1from __future__ import annotations
2
3import copy
4import re
5from dataclasses import dataclass, field
6from enum import IntEnum
7from io import BytesIO
8from operator import attrgetter
9from typing import TYPE_CHECKING, Any, NamedTuple, cast
10
11from .._codecs import encoding_dict_from_named_encoding
12from .._codecs.core_font_metrics import CORE_FONT_METRICS
13from .._font import Font
14from .._page import Transformation
15from .._utils import is_char_rtl, logger_warning
16from ..constants import AnnotationDictionaryAttributes, BorderStyles, FieldDictionaryAttributes, PageAttributes
17from ..errors import PdfReadError
18from ..generic import (
19 ArrayObject,
20 DecodedStreamObject,
21 DictionaryObject,
22 FloatObject,
23 IndirectObject,
24 NameObject,
25 NumberObject,
26 RectangleObject,
27 StreamObject,
28)
29from ..generic._base import ByteStringObject, TextStringObject
30
31if TYPE_CHECKING:
32 from pypdf._writer import PdfWriter
33
34 from .._page import PageObject
35
36try:
37 import arabic_reshaper
38 from bidi.algorithm import get_display
39 HAS_RTL_SUPPORT = True
40except ImportError:
41 HAS_RTL_SUPPORT = False
42
43DEFAULT_FONT_SIZE_IN_MULTILINE = 12
44
45# "The glyph widths shall be measured in units in which 1000 units correspond to 1 unit in text space"
46# (Table 111, PDF Specification 2.0)
47TEXT_SPACE_TO_GLYPH_SPACE_FACTOR = 1000
48
49
50@dataclass
51class BaseStreamConfig:
52 """A container representing the basic layout of an appearance stream."""
53 rectangle: RectangleObject = field(default_factory=lambda: RectangleObject((0.0, 0.0, 0.0, 0.0)))
54 border_width: int = 1 # The width of the border in points
55 border_style: str = BorderStyles.SOLID
56 rotation: int = 0
57
58
59class BaseStreamAppearance(DecodedStreamObject):
60 """A class representing the very base of an appearance stream, that is, a rectangle and a border."""
61
62 def _add_matrix(self, rotation: int) -> None:
63 # We need to rotate our rectangle while keeping its origin to (0, 0).
64 # Rotation goes counterclockwise. We want to know the furthest points to which we rotated left and down.
65 # These will serve as our X and Y offsets to translate the entire object origin back to (0, 0).
66 # If a corner rotates into negative space, that is our offset. If none does, the minimum is 0.0.
67 matrix = Transformation().rotate(rotation)
68 bottom_right_corner = (self._layout.rectangle.width, 0.0)
69 top_left_corner = (0.0, self._layout.rectangle.height)
70 top_right_corner = (self._layout.rectangle.width, self._layout.rectangle.height)
71 rotated_bottom_right_corner = matrix.apply_on(bottom_right_corner)
72 rotated_top_left_corner = matrix.apply_on(top_left_corner)
73 rotated_top_right_corner = matrix.apply_on(top_right_corner)
74 translation_x_offset = -min(
75 0.0, rotated_bottom_right_corner[0], rotated_top_left_corner[0], rotated_top_right_corner[0]
76 )
77 translation_y_offset = -min(
78 0.0, rotated_bottom_right_corner[1], rotated_top_left_corner[1], rotated_top_right_corner[1]
79 )
80 matrix = matrix.translate(translation_x_offset, translation_y_offset)
81 self[NameObject("/Matrix")] = ArrayObject([FloatObject(round(i, 3)) for i in matrix.ctm])
82
83 def __init__(self, layout: BaseStreamConfig | None) -> None:
84 """
85 Takes the appearance stream layout as an argument.
86
87 Args:
88 layout: The basic layout parameters.
89 """
90 super().__init__()
91 self._layout = layout or BaseStreamConfig()
92 self[NameObject("/Type")] = NameObject("/XObject")
93 self[NameObject("/Subtype")] = NameObject("/Form")
94 self[NameObject("/BBox")] = self._layout.rectangle
95
96 # Define the rotation matrix
97 rotation = self._layout.rotation % 360
98 if rotation:
99 self._add_matrix(rotation)
100
101
102class TextAlignment(IntEnum):
103 """Defines the alignment options for text within a form field's appearance stream."""
104
105 LEFT = 0
106 CENTER = 1
107 RIGHT = 2
108
109
110class WidthWordGlyphs(NamedTuple):
111 """A tuple of the unscaled width of a word (unencoded text) and the font-encoded glyphs that represent it."""
112
113 width: float
114 word: str
115 glyphs: str
116
117
118class TextStreamAppearance(BaseStreamAppearance):
119 """
120 A class representing the appearance stream for a text-based form field.
121
122 This class generates the content stream (the `ap_stream_data`) that dictates
123 how text is rendered within a form field's bounding box. It handles properties
124 like font, font size, color, multiline text, and text selection highlighting.
125 """
126
127 def _scale_text(
128 self,
129 font: Font,
130 font_size: float,
131 leading_factor: float,
132 field_width: float,
133 field_height: float,
134 paragraphs: list[list[WidthWordGlyphs]],
135 min_font_size: float,
136 font_size_step: float = 0.2
137 ) -> tuple[list[WidthWordGlyphs], float]:
138 """
139 Takes a piece of text and scales it to field_width or field_height, given font_name
140 and font_size. Wraps text where necessary.
141
142 Args:
143 font: The font to be used.
144 font_size: The font size in points.
145 leading_factor: The line distance.
146 field_width: The width of the field in which to fit the text.
147 field_height: The height of the field in which to fit the text.
148 paragraphs: The list of text paragraphs to fit with the field, where each paragraph is a list
149 of tuples of unscaled width, unencoded text, and glyphs (font-encoded text).
150 min_font_size: The minimum font size at which to scale the text.
151 font_size_step: The amount by which to decrement font size per step while scaling.
152
153 Returns:
154 The text in the form of list of tuples, each tuple containing the length of a line
155 and its contents, and the font_size for these lines and lengths.
156 """
157 wrapped_lines = []
158 current_line_words: list[WidthWordGlyphs] = []
159 current_line_width: float = 0
160 space_width = font.space_width * font_size / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR
161 for paragraph in paragraphs:
162 for i, width_word_glyphs in enumerate(paragraph):
163 word_width = width_word_glyphs.width * font_size
164 test_width = current_line_width + word_width + (space_width if i else 0)
165 if test_width > field_width and current_line_words:
166 wrapped_lines.append(
167 WidthWordGlyphs(
168 width=current_line_width,
169 word=" ".join(map(attrgetter("word"), current_line_words)),
170 glyphs=font.space_char.join(map(attrgetter("glyphs"), current_line_words))
171 )
172 )
173 current_line_words = [width_word_glyphs]
174 current_line_width = word_width
175 elif not current_line_words and word_width > field_width:
176 wrapped_lines.append(
177 WidthWordGlyphs(
178 width=word_width,
179 word=width_word_glyphs.word,
180 glyphs=width_word_glyphs.glyphs
181 )
182 )
183 current_line_words = []
184 current_line_width = 0
185 else:
186 if current_line_words:
187 current_line_width += space_width
188 current_line_words.append(width_word_glyphs)
189 current_line_width += word_width
190 if current_line_words:
191 wrapped_lines.append(WidthWordGlyphs(
192 width=current_line_width,
193 word=" ".join(map(attrgetter("word"), current_line_words)),
194 glyphs=font.space_char.join(map(attrgetter("glyphs"), current_line_words))
195 ))
196 current_line_words = []
197 current_line_width = 0
198 # Estimate total height.
199 estimated_total_height = font_size + (len(wrapped_lines) - 1) * leading_factor * font_size
200 if estimated_total_height > field_height:
201 # Text overflows height; Retry with smaller font size.
202 new_font_size = font_size - font_size_step
203 if new_font_size >= min_font_size:
204 return self._scale_text(
205 font,
206 new_font_size,
207 leading_factor,
208 field_width,
209 field_height,
210 paragraphs,
211 min_font_size,
212 font_size_step
213 )
214 return wrapped_lines, round(font_size, 1)
215
216 def _generate_appearance_stream_data(
217 self,
218 text: str,
219 selection: list[str] | None ,
220 font: Font,
221 font_name: str = "/Helv",
222 font_size: float = 0.0,
223 font_color: str = "0 g",
224 is_multiline: bool = False,
225 alignment: TextAlignment = TextAlignment.LEFT,
226 is_comb: bool = False,
227 max_length: int | None = None
228 ) -> bytes:
229 """
230 Generates the raw bytes of the PDF appearance stream for a text field.
231
232 This private method assembles the PDF content stream operators to draw
233 the provided text within the specified rectangle. It handles text positioning,
234 font application, color, and special formatting like selected text.
235
236 Args:
237 text: The text to be rendered in the form field.
238 selection: An optional list of strings that should be highlighted as selected.
239 font: The font to use.
240 font_name: The name of the font resource to use (e.g., "/Helv").
241 font_size: The font size. If 0, it is automatically calculated
242 based on whether the field is multiline or not.
243 font_color: The color to apply to the font, represented as a PDF
244 graphics state string (e.g., "0 g" for black).
245 is_multiline: A boolean indicating if the text field is multiline.
246 alignment: Text alignment, can be TextAlignment.LEFT, .RIGHT, or .CENTER.
247 is_comb: Boolean that designates fixed-length fields, where every character
248 fills one "cell", such as in a postcode.
249 max_length: Used if is_comb is set. The maximum number of characters for a fixed-
250 length field.
251
252 Returns:
253 A byte string containing the PDF content stream data.
254
255 """
256 rectangle = self._layout.rectangle
257 leading_factor = (
258 (font.font_descriptor.bbox[3] - font.font_descriptor.bbox[1]) / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR
259 )
260
261 # Set margins based on border width and style, but never less than 1 point
262 factor = 2 if self._layout.border_style in {"/B", "/I"} else 1
263 margin = max(self._layout.border_width * factor, 1)
264 field_height = rectangle.height - 2 * margin
265 field_width = rectangle.width - 4 * margin
266
267 reverse_cmap, encoding_cmap = font._get_typographic_maps()
268
269 def _unicode_to_glyph_id(text: str, reverse_cmap: dict[str, str]) -> str:
270 if HAS_RTL_SUPPORT:
271 # Use arabic-reshaper and python-bidi to rearrange and shape text for the PDF engine
272 reshaped_text = arabic_reshaper.reshape(text)
273 visual_text = get_display(reshaped_text, base_dir="L")
274 return "".join(reverse_cmap.get(char, char) for char in visual_text)
275
276 return "".join(reverse_cmap.get(char, char) for char in text)
277
278
279 def _glyph_id_to_bytes(glyphs: str, encoding_cmap: dict[str, bytes]) -> list[bytes]:
280 return [encoding_cmap.get(
281 glyph_id, bytes((ord(glyph_id),)) if ord(glyph_id) < 256 else b"?"
282 ) for glyph_id in glyphs]
283
284 # If font_size is 0, apply the logic for multiline or large-as-possible font
285 if font_size == 0:
286 min_font_size = 4.0 # The minimum font size
287 if selection: # Don't wrap text when dealing with a /Ch field, in order to prevent problems
288 is_multiline = False # with matching "selection" with "line" later on.
289 if is_multiline:
290 font_size = DEFAULT_FONT_SIZE_IN_MULTILINE
291 # We create a list of paragraphs, here each paragraph is a list of tuples, where each WidthWordGlyphs
292 # tuple signifies an unscaled word width, the word itself, and the glyphs that encode it.
293 paragraphs: list[list[WidthWordGlyphs]] = []
294 for line in text.splitlines():
295 if not line.strip():
296 paragraphs.append([WidthWordGlyphs(width=0.0, word="", glyphs="")])
297 continue
298 line_by_widths_words_glyphs: list[WidthWordGlyphs] = []
299 words = line.split(" ")
300 for word in words:
301 glyph_word = _unicode_to_glyph_id(word, reverse_cmap)
302 line_by_widths_words_glyphs.append(
303 WidthWordGlyphs(
304 width=font.get_text_width(word) / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR,
305 word=word,
306 glyphs=glyph_word
307 )
308 )
309 paragraphs.append(line_by_widths_words_glyphs)
310 lines, font_size = self._scale_text(
311 font,
312 font_size,
313 leading_factor,
314 field_width,
315 field_height,
316 paragraphs,
317 min_font_size
318 )
319 else:
320 max_vertical_size = field_height / leading_factor
321 glyphs = _unicode_to_glyph_id(text, reverse_cmap)
322 text_width_unscaled = font.get_text_width(glyphs) / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR
323 max_horizontal_size = field_width / (text_width_unscaled or 1)
324 font_size = round(max(min(max_vertical_size, max_horizontal_size), min_font_size), 1)
325 lines = [WidthWordGlyphs(width=text_width_unscaled * font_size, word=text, glyphs=glyphs)]
326 elif is_comb:
327 if max_length and len(text) > max_length:
328 logger_warning(
329 (
330 "Length of text %(text)s exceeds maximum length (%(max_length)d) "
331 "of field, input truncated."
332 ),
333 source=__name__,
334 text=text,
335 max_length=max_length,
336 )
337 # We act as if each character is one line, because we draw it separately later on
338 lines = []
339 for index, char in enumerate(text):
340 if index < (max_length or len(text)):
341 glyphs = _unicode_to_glyph_id(char, reverse_cmap)
342 lines.append(
343 WidthWordGlyphs(
344 width=font.get_text_width(glyphs) * font_size / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR,
345 word=char,
346 glyphs=glyphs
347 )
348 )
349 else:
350 lines = []
351 for line in text.splitlines():
352 glyphs = _unicode_to_glyph_id(line, reverse_cmap)
353 lines.append(
354 WidthWordGlyphs(
355 width=font.get_text_width(glyphs) * font_size / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR,
356 word=line,
357 glyphs=glyphs
358 )
359 )
360
361 # Set the vertical offset
362 if is_multiline:
363 y_offset = (
364 rectangle.height + margin - font.font_descriptor.bbox[3] * font_size / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR
365 )
366 else:
367 y_offset = margin + (
368 (field_height - font.font_descriptor.ascent * font_size / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR) / 2
369 )
370 default_appearance = f"{font_name} {font_size} Tf {font_color}"
371
372 ap_stream = (
373 f"q\n/Tx BMC \nq\n{2 * margin} {margin} {field_width} {field_height} "
374 f"re\nW\nBT\n{default_appearance}\n"
375 ).encode()
376 current_x_pos: float = 0 # Initial virtual position within the text object.
377
378 for line_number, (line_width, original_text, line) in enumerate(lines):
379 if selection and line in _unicode_to_glyph_id("".join(selection), reverse_cmap):
380 # Might be improved, but cannot find how to get fill working => replaced with lined box
381 ap_stream += (
382 f"1 {y_offset - (line_number * font_size * leading_factor) - 1} "
383 f"{rectangle.width - 2} {font_size + 2} re\n"
384 f"0.5 0.5 0.5 rg s\n{default_appearance}\n"
385 ).encode()
386
387 # Calculate the desired absolute starting X for the current line
388 desired_abs_x_start: float = 0
389 if is_comb and max_length:
390 # Calculate the width of a cell for one character
391 cell_width = rectangle.width / max_length
392 # Space from the left edge of the cell to the character's baseline start
393 # line_width here is the *actual* character width in points for the single character 'line'
394 centering_offset_in_cell = (cell_width - line_width) / 2
395 # Absolute start X = (Cell Index, i.e., line_number * Cell Width) + Centering Offset
396 desired_abs_x_start = (line_number * cell_width) + centering_offset_in_cell
397 elif alignment == TextAlignment.RIGHT:
398 desired_abs_x_start = rectangle.width - margin * 2 - line_width
399 elif alignment == TextAlignment.CENTER:
400 desired_abs_x_start = (rectangle.width - line_width) / 2
401 else: # Left aligned; default
402 desired_abs_x_start = margin * 2
403 # Calculate x_rel_offset: how much to move from the current_x_pos
404 # to reach the desired_abs_x_start.
405 x_rel_offset = desired_abs_x_start - current_x_pos
406
407 # Y-offset:
408 y_rel_offset: float = 0
409 if line_number == 0:
410 y_rel_offset = y_offset # Initial vertical position
411 elif is_comb:
412 y_rel_offset = 0.0 # DO NOT move vertically for subsequent characters
413 else:
414 y_rel_offset = - font_size * leading_factor # Move down by line height
415
416 # Td is a relative translation (Tx and Ty).
417 # It updates the current text position.
418 ap_stream += f"{x_rel_offset} {y_rel_offset} Td\n".encode()
419 # Update current_x_pos based on the Td operation for the next iteration.
420 # This is the X position where the *current line* will start.
421 current_x_pos = desired_abs_x_start
422
423 is_rtl = any(is_char_rtl(char) for char in line)
424
425 encoded_line = _glyph_id_to_bytes(line, encoding_cmap)
426 if is_rtl:
427 # Encode input text as UTF-16BE with a BOM, so that the input text is returned
428 # in the right direction when copying text from the resulting PDF
429 bom_text = b"\xfe\xff" + original_text.encode("utf-16-be")
430 hex_original_text = bom_text.hex().upper()
431 ap_stream += f"/Span << /ActualText <{hex_original_text}> >> BDC\n".encode()
432 if font.sub_type == "Type0": # 16-bit font
433 ap_stream += b"<" + (b"".join(encoded_line)).hex().encode() + b"> Tj\n"
434 else: # Simple font, 8-bit encoded.
435 # Escape parentheses (PDF 1.7 reference, table 3.2, Literal Strings)
436 line_as_bytes = (
437 b"".join(encoded_line)
438 .replace(b"\\", b"\\\\")
439 .replace(b"(", br"\(")
440 .replace(b")", br"\)")
441 )
442 ap_stream += b"(" + line_as_bytes + b") Tj\n"
443 if is_rtl:
444 ap_stream += b"EMC\n"
445 ap_stream += b"ET\nQ\nEMC\nQ\n"
446
447 return ap_stream
448
449 def __init__(
450 self,
451 layout: BaseStreamConfig | None = None,
452 text: str = "",
453 selection: list[str] | None = None,
454 font: Font | None = None,
455 font_resource: DictionaryObject | IndirectObject | None = None,
456 font_name: str = "/Helv",
457 font_size: float = 0.0,
458 font_color: str = "0 g",
459 is_multiline: bool = False,
460 alignment: TextAlignment = TextAlignment.LEFT,
461 is_comb: bool = False,
462 max_length: int | None = None
463 ) -> None:
464 """
465 Initializes a TextStreamAppearance object.
466
467 This constructor creates a new PDF stream object configured as an XObject
468 of subtype Form. It uses the `_appearance_stream_data` method to generate
469 the content for the stream.
470
471 Args:
472 layout: The basic layout parameters.
473 text: The text to be rendered in the form field.
474 selection: An optional list of strings that should be highlighted as selected.
475 font: A Font object. Falls back to Type 1 Helvetica if not given.
476 font_resource: An optional variable that represents a PDF font dictionary. Falls back
477 to Type 1 Helvetica if not given.
478 font_name: The name of the font resource, e.g., "/Helv".
479 font_size: The font size. If 0, it's auto-calculated.
480 font_color: The font color string.
481 is_multiline: A boolean indicating if the text field is multiline.
482 alignment: Text alignment, can be TextAlignment.LEFT, .RIGHT, or .CENTER.
483 is_comb: Boolean that designates fixed-length fields, where every character
484 fills one "cell", such as in a postcode.
485 max_length: Used if is_comb is set. The maximum number of characters for a fixed-
486 length field.
487
488 """
489 super().__init__(layout)
490
491 if not font or not font_resource:
492 font_name = "/Helv"
493 font = Font.from_core_font_name()
494 font_resource = font.as_font_resource()
495
496 ap_stream_data = self._generate_appearance_stream_data(
497 text,
498 selection,
499 font,
500 font_name=font_name,
501 font_size=font_size,
502 font_color=font_color,
503 is_multiline=is_multiline,
504 alignment=alignment,
505 is_comb=is_comb,
506 max_length=max_length
507 )
508
509 self.set_data(ByteStringObject(ap_stream_data))
510 self[NameObject("/Length")] = NumberObject(len(ap_stream_data))
511 # Update Resources with font information
512 self[NameObject("/Resources")] = DictionaryObject({
513 NameObject("/Font"): DictionaryObject({
514 NameObject(font_name): getattr(font_resource, "indirect_reference", font_resource)
515 })
516 })
517
518 @staticmethod
519 def _find_annotation_font_resource(
520 font_name: str,
521 annotation: DictionaryObject,
522 acro_form: DictionaryObject,
523 text: str
524 ) -> tuple[str, Font]:
525 # Try to find a resource dictionary for the font by examining the annotation and, if that fails,
526 # the AcroForm resources dictionary
527 acro_form_resources: Any = cast(
528 DictionaryObject,
529 annotation.get_inherited(
530 "/DR",
531 acro_form.get("/DR", DictionaryObject()),
532 ),
533 )
534 acro_form_font_resources = acro_form_resources.get("/Font", DictionaryObject())
535 font_resource = acro_form_font_resources.get(font_name, None)
536 if font_resource:
537 font = Font.from_font_resource(font_resource.get_object())
538 else:
539 # Normally, we should have found a font resource by now. However, when a user has provided a specific
540 # font name, we may not have found the associated font resource among the AcroForm resources. Also, in
541 # case of the 14 Adobe Core fonts, we may be expected to construct a font resource ourselves.
542 if font_name.removeprefix("/") not in CORE_FONT_METRICS:
543 # Default to Helvetica if we haven't found a font resource and cannot construct one ourselves.
544 logger_warning(
545 "Font dictionary for %(font_name)s not found; defaulting to Helvetica.",
546 source=__name__,
547 font_name=font_name,
548 )
549 font_name = "/Helvetica"
550 font = Font.from_core_font_name(font_name)
551
552 # If we have found a font resource, it still might not be able to encode the text value we received.
553 encodable = font.can_encode(text)
554
555 if not encodable:
556 # If we have a font file, we can try to produce a new font resource with an encoding
557 # that does include the necessary characters. We only try this for a TrueType font, meaning
558 # that, in PDF terms, it is a simple, 8-bit encoded font.
559 if font.font_descriptor.font_file and font.sub_type == "TrueType":
560 try:
561 font = font.from_truetype_font_file(BytesIO(font.font_descriptor.font_file.get_data()))
562 font_name = "/PYPDF1" # This means we most probably do not clash with an existing font name
563 encodable = font.can_encode(text)
564 except (ImportError, PdfReadError) as e:
565 logger_warning("Unable to use embedded font for encoding: %(e)s", source=__name__, e=e)
566
567 # If it's one of the unembedded 14 Adobe Core Fonts, we can test other supported encodings
568 elif font.sub_type == "Type1" and font.name in CORE_FONT_METRICS:
569 core_font_metrics = CORE_FONT_METRICS[font.name]
570 test_encodings = {
571 "cp1250", # Central / Eastern European
572 "cp1252", # Western European
573 "cp1254", # Turkish
574 "cp1257", # Baltic Rim
575 "iso8859_15" # Western European ISO Alternate
576 }
577 for encoding in test_encodings:
578 test_font = copy.copy(font)
579 test_font.encoding = encoding_dict_from_named_encoding(encoding)
580 encodable = test_font.can_encode(text)
581 if encodable:
582 font = test_font
583 font.character_widths.clear()
584 for code, character in test_font.encoding.items():
585 # Look up the width using the glyph name from the encoding
586 if character in core_font_metrics.character_widths:
587 font.character_widths[chr(code)] = core_font_metrics.character_widths[character]
588 font.character_widths["default"] = core_font_metrics.character_widths["default"]
589 font_name = "/PYPDF1" + encoding
590 break
591
592 if not encodable:
593 logger_warning(
594 (
595 "Text string '%(text)s' contains characters not supported by font encoding. "
596 "This may result in text corruption. "
597 "Consider calling writer.update_page_form_field_values with auto_regenerate=True."
598 ),
599 source=__name__,
600 text=text,
601 )
602
603 return font_name, font
604
605 @staticmethod
606 def _sync_appearance_stream_font_resources(
607 writer: PdfWriter,
608 font_name: str,
609 font: Font,
610 target_resource_dict: DictionaryObject,
611 page: PageObject | None = None
612 ) -> IndirectObject:
613 """
614 Unified helper to sync fonts from an AP stream to a target resource dictionary (e.g., AcroForm /DR).
615 Will sync to page resources as well when page is added to the arguments.
616 """
617 target_fonts = target_resource_dict.setdefault(NameObject("/Font"), DictionaryObject()).get_object()
618 if font_name not in target_fonts:
619 font_resource_reference = font._add_to_writer(
620 writer,
621 target_fonts,
622 NameObject(font_name)
623 )
624 else:
625 font_resource_reference = target_fonts[font_name]
626
627 if page:
628 page_fonts_resource = cast(DictionaryObject, page[PageAttributes.RESOURCES]).setdefault(
629 NameObject("/Font"), DictionaryObject()
630 ).get_object()
631 if font_name not in page_fonts_resource:
632 page_fonts_resource[NameObject(font_name)] = getattr(
633 font_resource_reference, "indirect_reference", font_resource_reference
634 )
635
636 return font_resource_reference
637
638 @classmethod
639 def from_text_annotation(
640 cls,
641 writer: PdfWriter,
642 page: PageObject,
643 flatten: bool,
644 acro_form: DictionaryObject, # _root_object[CatalogAttributes.ACRO_FORM])
645 field: DictionaryObject,
646 annotation: DictionaryObject,
647 user_font_name: str = "",
648 user_font_size: float = -1,
649 ) -> TextStreamAppearance:
650 """
651 Creates a TextStreamAppearance object from a text field annotation.
652
653 This class method is a factory for creating a `TextStreamAppearance`
654 instance by extracting all necessary information (bounding box, font,
655 text content, etc.) from the PDF field and annotation dictionaries.
656 It respects inheritance for properties like default appearance (`/DA`).
657
658 Args:
659 writer: The PdfWriter instance that we are creating text stream appearances for.
660 page: The page that we are processing annotations for.
661 flatten: Whether we flatten text annotations or not. If true, add new font resource
662 to the page font resources. Otherwise, add them to the AcroForm resources.
663 acro_form: The root AcroForm dictionary from the PDF catalog.
664 field: The field dictionary object.
665 annotation: The widget annotation dictionary object associated with the field.
666 user_font_name: An optional user-provided font name to override the
667 default. Defaults to an empty string.
668 user_font_size: An optional user-provided font size to override the
669 default. A value of -1 indicates no override.
670
671 Returns:
672 A new `TextStreamAppearance` instance configured for the given field.
673
674 """
675 # Calculate rectangle dimensions
676 _rectangle = cast(RectangleObject, annotation[AnnotationDictionaryAttributes.Rect])
677 # Normalize the rectangle, apply page rotation if applicable
678 if page.get_inherited("/Rotate") in {90, 270}:
679 rectangle = RectangleObject((0, 0, abs(_rectangle[3] - _rectangle[1]), abs(_rectangle[2] - _rectangle[0])))
680 else:
681 rectangle = RectangleObject((0, 0, abs(_rectangle[2] - _rectangle[0]), abs(_rectangle[3] - _rectangle[1])))
682
683 # Get default appearance dictionary from annotation
684 default_appearance = annotation.get_inherited(
685 AnnotationDictionaryAttributes.DA,
686 acro_form.get(AnnotationDictionaryAttributes.DA, None),
687 )
688 if not default_appearance:
689 # Create a default appearance if none was found in the annotation
690 default_appearance = TextStringObject("/Helv 0 Tf 0 g")
691 else:
692 default_appearance = default_appearance.get_object()
693
694 # Retrieve field text and selected values
695 field_flags = field.get(FieldDictionaryAttributes.Ff, 0)
696 if (
697 field.get(FieldDictionaryAttributes.FT, "/Tx") == "/Ch" and
698 field_flags & FieldDictionaryAttributes.FfBits.Combo == 0
699 ):
700 text = "\n".join(annotation.get_inherited(FieldDictionaryAttributes.Opt, []))
701 selection = field.get("/V", [])
702 if not isinstance(selection, list):
703 selection = [selection]
704 else: # /Tx
705 text = field.get("/V", "")
706 selection = []
707
708 # Derive font name, size and color from the default appearance. Also set
709 # user-provided font name and font size in the default appearance, if given.
710 # For a font name, this presumes that we can find an associated font resource
711 # dictionary. Uses the variable font_properties as an intermediate.
712 # As per the PDF spec:
713 # "At a minimum, the string [that is, default_appearance] shall include a Tf (text
714 # font) operator along with its two operands, font and size" (Section 12.7.4.3
715 # "Variable text" of the PDF 2.0 specification).
716 font_properties = [prop for prop in re.split(r"\s", default_appearance) if prop]
717 da_font_name = font_properties.pop(font_properties.index("Tf") - 2)
718 font_size = float(font_properties.pop(font_properties.index("Tf") - 1))
719 font_properties.remove("Tf")
720 font_color = " ".join(font_properties)
721 # Determine the font name to use, prioritizing the user's input
722 if user_font_name:
723 font_name = user_font_name
724 else:
725 font_name = da_font_name
726 # Determine the font size to use, prioritizing the user's input
727 if user_font_size > 0:
728 font_size = user_font_size
729
730 font_name, font = cls._find_annotation_font_resource(font_name, annotation, acro_form, text)
731
732 # Change the /DA information if we changed the font name
733 if font_name != da_font_name:
734 annotation[NameObject("/DA")] = TextStringObject(default_appearance.replace(da_font_name, font_name))
735
736 # Synchronise font resources
737 font_resource_reference = cls._sync_appearance_stream_font_resources(
738 writer,
739 font_name,
740 font,
741 acro_form.setdefault(NameObject("/DR"), DictionaryObject()),
742 page if flatten else None,
743 )
744
745 # Retrieve formatting information
746 is_comb = False
747 max_length = None
748 if field_flags & FieldDictionaryAttributes.FfBits.Comb:
749 is_comb = True
750 max_length = annotation.get("/MaxLen")
751 is_multiline = False
752 if field_flags & FieldDictionaryAttributes.FfBits.Multiline:
753 is_multiline = True
754 alignment = field.get("/Q", TextAlignment.LEFT)
755 border_width = 1
756 border_style = BorderStyles.SOLID
757 if "/BS" in field:
758 border_width = cast(DictionaryObject, field["/BS"]).get("/W", border_width)
759 border_style = cast(DictionaryObject, field["/BS"]).get("/S", border_style)
760
761 rotation = 0
762 appearance_characteristics = field.get_inherited("/MK", None)
763 if isinstance(appearance_characteristics, DictionaryObject):
764 rotation = int(appearance_characteristics.get("/R", 0))
765
766 # Create the TextStreamAppearance instance
767 layout = BaseStreamConfig(
768 rectangle=rectangle,
769 border_width=border_width,
770 border_style=border_style,
771 rotation=rotation
772 )
773
774 new_appearance_stream = cls(
775 layout,
776 text,
777 selection,
778 font,
779 font_resource_reference,
780 font_name=font_name,
781 font_size=font_size,
782 font_color=font_color,
783 is_multiline=is_multiline,
784 alignment=alignment,
785 is_comb=is_comb,
786 max_length=max_length
787 )
788
789 if AnnotationDictionaryAttributes.AP in annotation:
790 for key, value in (
791 cast(DictionaryObject, annotation[AnnotationDictionaryAttributes.AP]).get("/N", {}).items()
792 ):
793 if key in {"/BBox", "/Length", "/Subtype", "/Type", "/Filter"}:
794 continue
795 # Don't overwrite font resources added by TextAppearanceStream.__init__
796 if key == "/Resources":
797 if "/Font" not in value:
798 value.get_object()[NameObject("/Font")] = DictionaryObject()
799 value["/Font"].get_object()[NameObject(font_name)] = getattr(
800 font_resource_reference, "indirect_reference", font_resource_reference
801 )
802 else:
803 new_appearance_stream[key] = value
804
805 return new_appearance_stream
806
807
808def transform_annotation_appearance(annotation_obj: DictionaryObject, transformation: Transformation) -> None:
809 """
810 Compose `transformation` into an annotation's /AP /N appearance stream(s), in place.
811
812 Repositioning/resizing an annotation's /Rect alone is not enough: per
813 the appearance-stream algorithm (PDF 2.0, 12.5.5), a viewer fits the
814 appearance's /BBox (as mapped by its own /Matrix) into /Rect using an
815 axis-aligned scale, never a rotation. Left alone, rotating or shearing
816 a page therefore stretches/skews the untouched appearance content into
817 the new, differently-shaped /Rect instead of rotating it. Composing the
818 transform into /Matrix (rather than overwriting it) keeps the rendered
819 content consistent with the rest of the transformed page while
820 preserving whatever the annotation already had. Handles both a single
821 appearance stream and the multi-state /AP /N dict used by
822 checkbox/radio-button widgets.
823 """
824 if "/AP" not in annotation_obj:
825 return
826 ap = cast(DictionaryObject, annotation_obj["/AP"])
827 if "/N" not in ap:
828 return
829 # __getitem__ already resolves indirect references, so this is never None.
830 normal_ap = ap["/N"]
831 # /N is a single appearance stream for most annotations, but for
832 # widgets with multiple states (checkboxes, radio buttons) it is
833 # instead a dict of named sub-streams, one per state. Only a stream
834 # carries its own /BBox/Matrix.
835 state_aps = (
836 normal_ap.values()
837 if isinstance(normal_ap, DictionaryObject) and not isinstance(normal_ap, StreamObject)
838 else [normal_ap]
839 )
840 for state_ap in state_aps:
841 # get_object() is always safe to call: PdfObject.get_object() just
842 # returns self when the object is already resolved.
843 state_obj = state_ap.get_object()
844 if not isinstance(state_obj, StreamObject):
845 continue
846 old_matrix = tuple(state_obj.get("/Matrix", (1, 0, 0, 1, 0, 0)))
847 state_obj[NameObject("/Matrix")] = ArrayObject(
848 FloatObject(x) for x in Transformation(old_matrix).transform(transformation).ctm
849 )