Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 48 additions & 7 deletions pypdf/generic/_appearance_stream.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
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

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,
Expand All @@ -40,9 +43,10 @@
@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
rotation: int = 0


class BaseStreamAppearance(DecodedStreamObject):
Expand All @@ -59,7 +63,30 @@ 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

# 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):
Expand Down Expand Up @@ -196,8 +223,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
Expand Down Expand Up @@ -563,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(
Expand Down Expand Up @@ -646,8 +675,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,
Expand Down
14 changes: 7 additions & 7 deletions tests/test_appearance_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
)
Expand All @@ -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
Expand Down
Loading