From da2d01d07a942a4a0ec98215eceaf12d5feaaadd Mon Sep 17 00:00:00 2001 From: PJBrs Date: Tue, 14 Jul 2026 08:24:22 +0200 Subject: [PATCH 1/4] ENH: BaseStreamConfig: Let rectangle always be a RectangleObject Now that we need BaseStreamConfig.rectangle in more places, just have it always be a RectangleObject. This is more accurate and it prevents code coverage / mypy problems where lines needed to check whether rectangle is a RectangleObject are either detected as missing by mypy or untested by coverage. --- pypdf/generic/_appearance_stream.py | 8 +++----- tests/test_appearance_stream.py | 14 +++++++------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/pypdf/generic/_appearance_stream.py b/pypdf/generic/_appearance_stream.py index 268a1726d..64365545c 100644 --- a/pypdf/generic/_appearance_stream.py +++ b/pypdf/generic/_appearance_stream.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import IntEnum from io import BytesIO from typing import TYPE_CHECKING, Any, cast @@ -40,7 +40,7 @@ @dataclass class BaseStreamConfig: """A container representing the basic layout of an appearance stream.""" - rectangle: RectangleObject | tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0) + rectangle: RectangleObject = field(default_factory=lambda: RectangleObject((0.0, 0.0, 0.0, 0.0))) border_width: int = 1 # The width of the border in points border_style: str = BorderStyles.SOLID @@ -59,7 +59,7 @@ def __init__(self, layout: BaseStreamConfig | None) -> None: self._layout = layout or BaseStreamConfig() self[NameObject("/Type")] = NameObject("/XObject") self[NameObject("/Subtype")] = NameObject("/Form") - self[NameObject("/BBox")] = RectangleObject(self._layout.rectangle) + self[NameObject("/BBox")] = self._layout.rectangle class TextAlignment(IntEnum): @@ -196,8 +196,6 @@ def _generate_appearance_stream_data( """ rectangle = self._layout.rectangle - if isinstance(rectangle, tuple): - rectangle = RectangleObject(rectangle) leading_factor = (font.font_descriptor.bbox[3] - font.font_descriptor.bbox[1]) / 1000.0 # Set margins based on border width and style, but never less than 1 point diff --git a/tests/test_appearance_stream.py b/tests/test_appearance_stream.py index a73eaaaa2..382fb2f8b 100644 --- a/tests/test_appearance_stream.py +++ b/tests/test_appearance_stream.py @@ -16,7 +16,7 @@ def test_comb(): - layout=BaseStreamConfig(rectangle=(0.0, 0.0, 197.285, 18.455)) + layout=BaseStreamConfig(rectangle=RectangleObject((0.0, 0.0, 197.285, 18.455))) font_size = 10.0 text = "01234567" max_length = 10 @@ -36,7 +36,7 @@ def test_comb(): b"19.728499999999997 0.0 Td\n(7) Tj\nET\nQ\nEMC\nQ\n" ) - layout.rectangle = (0.0, 0.0, 20.852, 20.84) + layout.rectangle = RectangleObject((0.0, 0.0, 20.852, 20.84)) text = "AA" max_length = 1 appearance_stream = TextStreamAppearance( @@ -48,7 +48,7 @@ def test_comb(): def test_scale_text(): - layout=BaseStreamConfig(rectangle=(0, 0, 9.1, 55.4)) + layout=BaseStreamConfig(rectangle=RectangleObject((0, 0, 9.1, 55.4))) font_size = 10.1 text = "Hello World" is_multiline = False @@ -64,7 +64,7 @@ def test_scale_text(): ) assert b"4.0 Tf" in appearance_stream.get_data() - layout.rectangle = (0, 0, 160, 360) + layout.rectangle = RectangleObject((0, 0, 160, 360)) font_size = 0.0 text = """Welcome to pypdf pypdf is a free and open source pure-python PDF library capable of splitting, merging, cropping, and @@ -80,13 +80,13 @@ def test_scale_text(): assert b"12 Tf" in appearance_stream.get_data() assert b"pypdf is a free and open" in appearance_stream.get_data() - layout.rectangle = (0, 0, 160, 160) + layout.rectangle = RectangleObject((0, 0, 160, 160)) appearance_stream = TextStreamAppearance( layout=layout, text=text, font_size=font_size, is_multiline=is_multiline ) assert b"9.8 Tf" in appearance_stream.get_data() - layout.rectangle = (0, 0, 160, 12) + layout.rectangle = RectangleObject((0, 0, 160, 12)) appearance_stream = TextStreamAppearance( layout=layout, text=text, font_size=font_size, is_multiline=is_multiline ) @@ -104,7 +104,7 @@ def test_scale_text(): ) assert b"7.3 Tf" in appearance_stream.get_data() - layout.rectangle = (0, 0, 10, 100) + layout.rectangle = RectangleObject((0, 0, 10, 100)) text = "OneWord" appearance_stream = TextStreamAppearance( layout=layout, text=text, font_size=font_size, is_multiline=is_multiline From 138e8a51906ebf0df40944c96bb3def45e9981f7 Mon Sep 17 00:00:00 2001 From: PJBrs Date: Mon, 13 Jul 2026 22:32:08 +0200 Subject: [PATCH 2/4] ENH: AppearanceStream: Add rotation to BaseStreamAppearance This patch adds rotation to BaseStreamConfig and applies it to BaseStreamAppearance by adding a "/Matrix" key when rotation is 90, 180 or 270 degrees. --- pypdf/generic/_appearance_stream.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/pypdf/generic/_appearance_stream.py b/pypdf/generic/_appearance_stream.py index 64365545c..6b7bff7af 100644 --- a/pypdf/generic/_appearance_stream.py +++ b/pypdf/generic/_appearance_stream.py @@ -9,12 +9,15 @@ from .._codecs import fill_from_encoding from .._codecs.core_font_metrics import CORE_FONT_METRICS from .._font import Font +from .._page import Transformation from .._utils import logger_warning from ..constants import AnnotationDictionaryAttributes, BorderStyles, FieldDictionaryAttributes, PageAttributes from ..errors import PdfReadError from ..generic import ( + ArrayObject, DecodedStreamObject, DictionaryObject, + FloatObject, IndirectObject, NameObject, NumberObject, @@ -43,6 +46,7 @@ class BaseStreamConfig: rectangle: RectangleObject = field(default_factory=lambda: RectangleObject((0.0, 0.0, 0.0, 0.0))) border_width: int = 1 # The width of the border in points border_style: str = BorderStyles.SOLID + rotation: int = 0 class BaseStreamAppearance(DecodedStreamObject): @@ -61,6 +65,29 @@ def __init__(self, layout: BaseStreamConfig | None) -> None: self[NameObject("/Subtype")] = NameObject("/Form") self[NameObject("/BBox")] = self._layout.rectangle + # Define the rotation matrix + rotation = self._layout.rotation % 360 + if rotation: + matrix = Transformation().rotate(rotation) + + # Rotation goes counterclockwise. We want to know the furthest points to which we rotated left and down. + # These will serve as our X and Y offsets to translate the entire object origin back to (0, 0). + # If a corner rotates into negative space, that is our offset. If none does, the minimum is 0.0. + bottom_right_corner = (self._layout.rectangle.width, 0.0) + top_left_corner = (0.0, self._layout.rectangle.height) + top_right_corner = (self._layout.rectangle.width, self._layout.rectangle.height) + rotated_bottom_right_corner = matrix.apply_on(bottom_right_corner) + rotated_top_left_corner = matrix.apply_on(top_left_corner) + rotated_top_right_corner = matrix.apply_on(top_right_corner) + translation_x_offset = -min( + 0.0, rotated_bottom_right_corner[0], rotated_top_left_corner[0], rotated_top_right_corner[0] + ) + translation_y_offset = -min( + 0.0, rotated_bottom_right_corner[1], rotated_top_left_corner[1], rotated_top_right_corner[1] + ) + matrix = matrix.translate(translation_x_offset, translation_y_offset) + self[NameObject("/Matrix")] = ArrayObject([FloatObject(round(i, 3)) for i in matrix.ctm]) + class TextAlignment(IntEnum): """Defines the alignment options for text within a form field's appearance stream.""" From b63a31a42ff96e90b0929f26927e3e14c242388f Mon Sep 17 00:00:00 2001 From: PJBrs Date: Mon, 13 Jul 2026 22:33:49 +0200 Subject: [PATCH 3/4] ENH: AppearanceStream: Parse rotation for annotations When an annotation includes a ["/MK"]["/R"] field for rotation, detect it and rotate the appearance stream accordingly. --- pypdf/generic/_appearance_stream.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pypdf/generic/_appearance_stream.py b/pypdf/generic/_appearance_stream.py index 6b7bff7af..ce2efb77b 100644 --- a/pypdf/generic/_appearance_stream.py +++ b/pypdf/generic/_appearance_stream.py @@ -671,8 +671,20 @@ def from_text_annotation( border_width = cast(DictionaryObject, field["/BS"]).get("/W", border_width) border_style = cast(DictionaryObject, field["/BS"]).get("/S", border_style) + rotation = 0 + if "/MK" in field: + appearance_characteristics = cast(DictionaryObject, field["/MK"]) + if "/R" in appearance_characteristics: + rotation = cast(int, appearance_characteristics.get("/R", 0)) + # Create the TextStreamAppearance instance - layout = BaseStreamConfig(rectangle=rectangle, border_width=border_width, border_style=border_style) + layout = BaseStreamConfig( + rectangle=rectangle, + border_width=border_width, + border_style=border_style, + rotation=rotation + ) + new_appearance_stream = cls( layout, text, From b3505aa8c391061b4501a4d82510d21af86943bd Mon Sep 17 00:00:00 2001 From: PJBrs Date: Mon, 13 Jul 2026 18:15:04 +0200 Subject: [PATCH 4/4] ENH: AppearanceStream: Address page rotation for annotations When a page has a rotation set of 90 or 270 degrees (for instance, portrait to landscape), also rotate the axes of the annotation's appearance stream rectangle. --- pypdf/generic/_appearance_stream.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pypdf/generic/_appearance_stream.py b/pypdf/generic/_appearance_stream.py index ce2efb77b..97358a874 100644 --- a/pypdf/generic/_appearance_stream.py +++ b/pypdf/generic/_appearance_stream.py @@ -588,7 +588,11 @@ def from_text_annotation( """ # Calculate rectangle dimensions _rectangle = cast(RectangleObject, annotation[AnnotationDictionaryAttributes.Rect]) - rectangle = RectangleObject((0, 0, abs(_rectangle[2] - _rectangle[0]), abs(_rectangle[3] - _rectangle[1]))) + # Normalize the rectangle, apply page rotation if applicable + if page.get_inherited("/Rotate") in {90, 270}: + rectangle = RectangleObject((0, 0, abs(_rectangle[3] - _rectangle[1]), abs(_rectangle[2] - _rectangle[0]))) + else: + rectangle = RectangleObject((0, 0, abs(_rectangle[2] - _rectangle[0]), abs(_rectangle[3] - _rectangle[1]))) # Get default appearance dictionary from annotation default_appearance = annotation.get_inherited(