From b406e939f5d51a5787acfd8e8241da8cda420dd0 Mon Sep 17 00:00:00 2001 From: lllleolin-max <244385774+lllleolin-max@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:20:55 +0800 Subject: [PATCH 1/6] Escape backslashes when drawing PostScript text --- Tests/test_psdraw.py | 24 ++++++++++++++++++++++-- docs/releasenotes/13.0.0.rst | 7 +++++++ src/PIL/PSDraw.py | 4 ++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py index e017bc54e6a..4cbc3aa26c6 100644 --- a/Tests/test_psdraw.py +++ b/Tests/test_psdraw.py @@ -4,14 +4,14 @@ import sys from io import BytesIO +import pytest + from PIL import Image, PSDraw TYPE_CHECKING = False if TYPE_CHECKING: from pathlib import Path - import pytest - def _create_document(ps: PSDraw.PSDraw) -> None: title = "hopper" @@ -65,3 +65,23 @@ class MyStdOut: _create_document(ps) assert mystdout.buffer.getvalue() != b"" + + +@pytest.mark.parametrize( + "text, expected", + ( + (r"C:\temp\new", rb"C:\\temp\\new"), + ("\\", rb"\\"), + (r"\(", rb"\\\("), + (r"\)", rb"\\\)"), + (r"\\server\share", rb"\\\\server\\share"), + ("a(b)c", rb"a\(b\)c"), + ("café", b"caf\xe9"), + ("", b""), + ), +) +def test_text_escaping(text: str, expected: bytes) -> None: + with BytesIO() as buffer: + ps = PSDraw.PSDraw(buffer) + ps.text((10, 20), text) + assert buffer.getvalue() == b"10 20 M (" + expected + b") S\n" diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 46dbed3b58b..125e2bcdd75 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -135,6 +135,13 @@ immediately at the release of 3.15.0 final (2026-10-01, :pep:`790`). Pillow 13.0.0 now officially supports Python 3.15. +Fixed backslashes in PostScript text +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.PSDraw.PSDraw.text` now escapes backslashes in text strings, so +text such as Windows paths is preserved instead of being interpreted as +PostScript escape sequences. + Fixed ImageChops.offset() for 16-bit images ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/PIL/PSDraw.py b/src/PIL/PSDraw.py index d0e4d561a2a..c21760f490f 100644 --- a/src/PIL/PSDraw.py +++ b/src/PIL/PSDraw.py @@ -103,8 +103,8 @@ def text(self, xy: tuple[int, int], text: str) -> None: """ # The font is loaded as ISOLatin1Encoding, so use latin-1 here. text_bytes = bytes(text, "latin-1") - text_bytes = b"\\(".join(text_bytes.split(b"(")) - text_bytes = b"\\)".join(text_bytes.split(b")")) + for char in (b"\\", b"(", b")"): + text_bytes = text_bytes.replace(char, b"\\" + char) self.fp.write(b"%d %d M (%s) S\n" % (xy + (text_bytes,))) if TYPE_CHECKING: From b7dca8ff2f1a220d14fe4b7ad21f2b2593ae5d93 Mon Sep 17 00:00:00 2001 From: lllleolin-max <244385774+lllleolin-max@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:30:52 +0800 Subject: [PATCH 2/6] Make PostScript text backslash escaping opt-in --- Tests/test_psdraw.py | 59 +++++++++++++++++++++++++++++------- docs/reference/PSDraw.rst | 10 ++++++ docs/releasenotes/13.0.0.rst | 15 ++++----- src/PIL/PSDraw.py | 12 ++++++-- 4 files changed, 76 insertions(+), 20 deletions(-) diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py index 4cbc3aa26c6..614d6d157e4 100644 --- a/Tests/test_psdraw.py +++ b/Tests/test_psdraw.py @@ -67,21 +67,58 @@ class MyStdOut: assert mystdout.buffer.getvalue() != b"" +@pytest.mark.parametrize("escape", (None, False, True)) @pytest.mark.parametrize( - "text, expected", + "text, expected, escaped", ( - (r"C:\temp\new", rb"C:\\temp\\new"), - ("\\", rb"\\"), - (r"\(", rb"\\\("), - (r"\)", rb"\\\)"), - (r"\\server\share", rb"\\\\server\\share"), - ("a(b)c", rb"a\(b\)c"), - ("café", b"caf\xe9"), - ("", b""), + ("plain text", b"plain text", b"plain text"), + (r"C:\temp\new", rb"C:\temp\new", rb"C:\\temp\\new"), + ("\\", b"\\", rb"\\"), + ("trailing\\", b"trailing\\", rb"trailing\\"), + (r"\(", rb"\\(", rb"\\\("), + (r"\)", rb"\\)", rb"\\\)"), + (r"\\server\share", rb"\\server\share", rb"\\\\server\\share"), + ("a(b)c", rb"a\(b\)c", rb"a\(b\)c"), + (r"a\(b)\c", rb"a\\(b\)\c", rb"a\\\(b\)\\c"), + (r"\n\t\101", rb"\n\t\101", rb"\\n\\t\\101"), + (r"\\\\", rb"\\\\", rb"\\\\\\\\"), + ("line\\\nnext", b"line\\\nnext", b"line\\\\\nnext"), + ("line\\\rnext", b"line\\\rnext", b"line\\\\\rnext"), + ("line\\\r\nnext", b"line\\\r\nnext", b"line\\\\\r\nnext"), + ("\x00\t\n\r\r\n\b\f", b"\x00\t\n\r\r\n\b\f", b"\x00\t\n\r\r\n\b\f"), + ("café\xff", b"caf\xe9\xff", b"caf\xe9\xff"), + ("", b"", b""), ), ) -def test_text_escaping(text: str, expected: bytes) -> None: +def test_text_escaping( + text: str, expected: bytes, escaped: bytes, escape: bool | None +) -> None: with BytesIO() as buffer: ps = PSDraw.PSDraw(buffer) - ps.text((10, 20), text) + if escape is None: + ps.text((10, 20), text) + else: + ps.text((10, 20), text, escape=escape) + if escape: + expected = escaped assert buffer.getvalue() == b"10 20 M (" + expected + b") S\n" + + +@pytest.mark.parametrize("escape", (None, False, True)) +def test_text_encoding_error(escape: bool | None) -> None: + with BytesIO() as buffer: + ps = PSDraw.PSDraw(buffer) + with pytest.raises(UnicodeEncodeError): + if escape is None: + ps.text((10, 20), "\u0100") + else: + ps.text((10, 20), "\u0100", escape=escape) + assert buffer.getvalue() == b"" + + +def test_text_escape_keyword_only() -> None: + with BytesIO() as buffer: + ps = PSDraw.PSDraw(buffer) + with pytest.raises(TypeError): + ps.text((10, 20), "text", True) # type: ignore[call-arg] + assert buffer.getvalue() == b"" diff --git a/docs/reference/PSDraw.rst b/docs/reference/PSDraw.rst index 9eed775fc09..2a4b2d79da5 100644 --- a/docs/reference/PSDraw.rst +++ b/docs/reference/PSDraw.rst @@ -7,5 +7,15 @@ The :py:mod:`~PIL.PSDraw` module provides simple print support for PostScript printers. You can print text, graphics and images through this module. +By default, :py:meth:`~PIL.PSDraw.PSDraw.text` preserves PostScript backslash +escape sequences and escapes parentheses as before. Pass the keyword-only boolean +``escape=True`` to escape backslashes as well, for example when drawing a Windows +path:: + + ps.text((10, 20), r"C:\temp\new", escape=True) + +Text is encoded as Latin-1. PostScript normalizes literal CR and CRLF line endings +to LF, including when ``escape=True``. + .. autoclass:: PIL.PSDraw.PSDraw :members: diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 125e2bcdd75..36d14f8d02d 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -123,6 +123,14 @@ Two new filters are available for :py:meth:`~PIL.Image.Image.resize` and :py:meth:`~PIL.Image.Image.thumbnail`: ``Image.Resampling.MKS2013`` and ``Image.Resampling.MKS2021``. These are versions of the Magic Kernel Sharp filter. +Added ``escape`` argument to ``PSDraw.text()`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.PSDraw.PSDraw.text` now accepts a keyword-only boolean argument, +``escape``. Set it to ``True`` to escape backslashes, for example in Windows paths. +The default, ``False``, preserves the existing handling of PostScript escape +sequences and parentheses. + Other changes ============= @@ -135,13 +143,6 @@ immediately at the release of 3.15.0 final (2026-10-01, :pep:`790`). Pillow 13.0.0 now officially supports Python 3.15. -Fixed backslashes in PostScript text -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -:py:meth:`~PIL.PSDraw.PSDraw.text` now escapes backslashes in text strings, so -text such as Windows paths is preserved instead of being interpreted as -PostScript escape sequences. - Fixed ImageChops.offset() for 16-bit images ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/PIL/PSDraw.py b/src/PIL/PSDraw.py index c21760f490f..e3dd4e1bd59 100644 --- a/src/PIL/PSDraw.py +++ b/src/PIL/PSDraw.py @@ -96,14 +96,22 @@ def rectangle(self, box: tuple[int, int, int, int]) -> None: """ self.fp.write(b"%d %d M 0 %d %d Vr\n" % box) - def text(self, xy: tuple[int, int], text: str) -> None: + def text(self, xy: tuple[int, int], text: str, *, escape: bool = False) -> None: """ Draws text at the given position. You must use :py:meth:`~PIL.PSDraw.PSDraw.setfont` before calling this method. + + :param xy: The position, in PostScript point coordinates. + :param text: The text to draw, encoded as Latin-1. + :param escape: Whether to escape backslashes in the text. This keyword-only + boolean defaults to ``False``, preserving PostScript escape sequences. + Parentheses are always escaped. """ # The font is loaded as ISOLatin1Encoding, so use latin-1 here. text_bytes = bytes(text, "latin-1") - for char in (b"\\", b"(", b")"): + if escape: + text_bytes = text_bytes.replace(b"\\", b"\\\\") + for char in (b"(", b")"): text_bytes = text_bytes.replace(char, b"\\" + char) self.fp.write(b"%d %d M (%s) S\n" % (xy + (text_bytes,))) From c2173c4a232b046b97e5e291224867fb84d6d211 Mon Sep 17 00:00:00 2001 From: lllleolin-max <244385774+lllleolin-max@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:44:01 +0800 Subject: [PATCH 3/6] Clarify PSDraw text escaping documentation --- docs/reference/PSDraw.rst | 12 ++++++------ src/PIL/PSDraw.py | 6 ++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/reference/PSDraw.rst b/docs/reference/PSDraw.rst index 2a4b2d79da5..295b8aca804 100644 --- a/docs/reference/PSDraw.rst +++ b/docs/reference/PSDraw.rst @@ -7,15 +7,15 @@ The :py:mod:`~PIL.PSDraw` module provides simple print support for PostScript printers. You can print text, graphics and images through this module. -By default, :py:meth:`~PIL.PSDraw.PSDraw.text` preserves PostScript backslash -escape sequences and escapes parentheses as before. Pass the keyword-only boolean -``escape=True`` to escape backslashes as well, for example when drawing a Windows -path:: +By default, :py:meth:`~PIL.PSDraw.PSDraw.text` leaves backslashes unchanged for +backwards compatibility. Parentheses are always escaped. Pass the keyword-only +boolean ``escape=True`` to escape backslashes as well, for example when drawing a +Windows path:: ps.text((10, 20), r"C:\temp\new", escape=True) -Text is encoded as Latin-1. PostScript normalizes literal CR and CRLF line endings -to LF, including when ``escape=True``. +Text is encoded as Latin-1. PostScript normalizes unescaped CR and CRLF line +endings to LF, including when ``escape=True``. .. autoclass:: PIL.PSDraw.PSDraw :members: diff --git a/src/PIL/PSDraw.py b/src/PIL/PSDraw.py index e3dd4e1bd59..dd43c2445ff 100644 --- a/src/PIL/PSDraw.py +++ b/src/PIL/PSDraw.py @@ -104,8 +104,10 @@ def text(self, xy: tuple[int, int], text: str, *, escape: bool = False) -> None: :param xy: The position, in PostScript point coordinates. :param text: The text to draw, encoded as Latin-1. :param escape: Whether to escape backslashes in the text. This keyword-only - boolean defaults to ``False``, preserving PostScript escape sequences. - Parentheses are always escaped. + boolean defaults to ``False``, leaving backslashes unchanged for backwards + compatibility. Parentheses are always escaped. + + .. versionadded:: 13.0.0 """ # The font is loaded as ISOLatin1Encoding, so use latin-1 here. text_bytes = bytes(text, "latin-1") From bcdce51195f14067afb9372da7560c1df9c274d4 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 15 Sep 2026 20:14:31 +1000 Subject: [PATCH 4/6] Behaviour already described lower down the rendered docs page --- docs/reference/PSDraw.rst | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/docs/reference/PSDraw.rst b/docs/reference/PSDraw.rst index 295b8aca804..9eed775fc09 100644 --- a/docs/reference/PSDraw.rst +++ b/docs/reference/PSDraw.rst @@ -7,15 +7,5 @@ The :py:mod:`~PIL.PSDraw` module provides simple print support for PostScript printers. You can print text, graphics and images through this module. -By default, :py:meth:`~PIL.PSDraw.PSDraw.text` leaves backslashes unchanged for -backwards compatibility. Parentheses are always escaped. Pass the keyword-only -boolean ``escape=True`` to escape backslashes as well, for example when drawing a -Windows path:: - - ps.text((10, 20), r"C:\temp\new", escape=True) - -Text is encoded as Latin-1. PostScript normalizes unescaped CR and CRLF line -endings to LF, including when ``escape=True``. - .. autoclass:: PIL.PSDraw.PSDraw :members: From 6f9b4d3b7abfca3a84fc4d3b31e3c6177e636485 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 15 Sep 2026 20:16:34 +1000 Subject: [PATCH 5/6] Do not imply that escape=True changes how parentheses are handled --- docs/releasenotes/13.0.0.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 36d14f8d02d..e59941927c9 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -128,8 +128,8 @@ Added ``escape`` argument to ``PSDraw.text()`` :py:meth:`~PIL.PSDraw.PSDraw.text` now accepts a keyword-only boolean argument, ``escape``. Set it to ``True`` to escape backslashes, for example in Windows paths. -The default, ``False``, preserves the existing handling of PostScript escape -sequences and parentheses. +The default, ``False``, preserves the existing handling of backslashes and PostScript +escape sequences. Other changes ============= From c5249d906e7d86a7a0c832c281ad8a48b6ecb31e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 15 Sep 2026 20:38:50 +1000 Subject: [PATCH 6/6] Simplify tests --- Tests/test_psdraw.py | 34 ++++------------------------------ 1 file changed, 4 insertions(+), 30 deletions(-) diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py index 614d6d157e4..e06557c5107 100644 --- a/Tests/test_psdraw.py +++ b/Tests/test_psdraw.py @@ -72,27 +72,13 @@ class MyStdOut: "text, expected, escaped", ( ("plain text", b"plain text", b"plain text"), - (r"C:\temp\new", rb"C:\temp\new", rb"C:\\temp\\new"), - ("\\", b"\\", rb"\\"), - ("trailing\\", b"trailing\\", rb"trailing\\"), - (r"\(", rb"\\(", rb"\\\("), - (r"\)", rb"\\)", rb"\\\)"), - (r"\\server\share", rb"\\server\share", rb"\\\\server\\share"), ("a(b)c", rb"a\(b\)c", rb"a\(b\)c"), + (r"C:\temp\new", rb"C:\temp\new", rb"C:\\temp\\new"), (r"a\(b)\c", rb"a\\(b\)\c", rb"a\\\(b\)\\c"), (r"\n\t\101", rb"\n\t\101", rb"\\n\\t\\101"), - (r"\\\\", rb"\\\\", rb"\\\\\\\\"), - ("line\\\nnext", b"line\\\nnext", b"line\\\\\nnext"), - ("line\\\rnext", b"line\\\rnext", b"line\\\\\rnext"), - ("line\\\r\nnext", b"line\\\r\nnext", b"line\\\\\r\nnext"), - ("\x00\t\n\r\r\n\b\f", b"\x00\t\n\r\r\n\b\f", b"\x00\t\n\r\r\n\b\f"), - ("café\xff", b"caf\xe9\xff", b"caf\xe9\xff"), - ("", b"", b""), ), ) -def test_text_escaping( - text: str, expected: bytes, escaped: bytes, escape: bool | None -) -> None: +def test_text(text: str, expected: bytes, escaped: bytes, escape: bool | None) -> None: with BytesIO() as buffer: ps = PSDraw.PSDraw(buffer) if escape is None: @@ -104,21 +90,9 @@ def test_text_escaping( assert buffer.getvalue() == b"10 20 M (" + expected + b") S\n" -@pytest.mark.parametrize("escape", (None, False, True)) -def test_text_encoding_error(escape: bool | None) -> None: +def test_text_encoding_error() -> None: with BytesIO() as buffer: ps = PSDraw.PSDraw(buffer) with pytest.raises(UnicodeEncodeError): - if escape is None: - ps.text((10, 20), "\u0100") - else: - ps.text((10, 20), "\u0100", escape=escape) - assert buffer.getvalue() == b"" - - -def test_text_escape_keyword_only() -> None: - with BytesIO() as buffer: - ps = PSDraw.PSDraw(buffer) - with pytest.raises(TypeError): - ps.text((10, 20), "text", True) # type: ignore[call-arg] + ps.text((10, 20), "\u0100") assert buffer.getvalue() == b""