1from __future__ import annotations
2
3import re
4from dataclasses import dataclass
5from enum import IntEnum
6from io import BytesIO
7from typing import TYPE_CHECKING, Any, cast
8
9from .._codecs import fill_from_encoding
10from .._codecs.core_font_metrics import CORE_FONT_METRICS
11from .._font import Font
12from .._utils import logger_warning
13from ..constants import AnnotationDictionaryAttributes, BorderStyles, FieldDictionaryAttributes, PageAttributes
14from ..errors import PdfReadError
15from ..generic import (
16 DecodedStreamObject,
17 DictionaryObject,
18 IndirectObject,
19 NameObject,
20 NumberObject,
21 RectangleObject,
22)
23from ..generic._base import ByteStringObject, TextStringObject
24
25if TYPE_CHECKING:
26 from pypdf._writer import PdfWriter
27
28 from .._page import PageObject
29
30try:
31 import arabic_reshaper
32 from bidi.algorithm import get_display
33 HAS_RTL_SUPPORT = True
34except ImportError:
35 HAS_RTL_SUPPORT = False
36
37DEFAULT_FONT_SIZE_IN_MULTILINE = 12
38
39
40@dataclass
41class BaseStreamConfig:
42 """A container representing the basic layout of an appearance stream."""
43 rectangle: RectangleObject | tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0)
44 border_width: int = 1 # The width of the border in points
45 border_style: str = BorderStyles.SOLID
46
47
48class BaseStreamAppearance(DecodedStreamObject):
49 """A class representing the very base of an appearance stream, that is, a rectangle and a border."""
50
51 def __init__(self, layout: BaseStreamConfig | None) -> None:
52 """
53 Takes the appearance stream layout as an argument.
54
55 Args:
56 layout: The basic layout parameters.
57 """
58 super().__init__()
59 self._layout = layout or BaseStreamConfig()
60 self[NameObject("/Type")] = NameObject("/XObject")
61 self[NameObject("/Subtype")] = NameObject("/Form")
62 self[NameObject("/BBox")] = RectangleObject(self._layout.rectangle)
63
64
65class TextAlignment(IntEnum):
66 """Defines the alignment options for text within a form field's appearance stream."""
67
68 LEFT = 0
69 CENTER = 1
70 RIGHT = 2
71
72
73class TextStreamAppearance(BaseStreamAppearance):
74 """
75 A class representing the appearance stream for a text-based form field.
76
77 This class generates the content stream (the `ap_stream_data`) that dictates
78 how text is rendered within a form field's bounding box. It handles properties
79 like font, font size, color, multiline text, and text selection highlighting.
80 """
81
82 def _scale_text(
83 self,
84 font: Font,
85 font_size: float,
86 leading_factor: float,
87 field_width: float,
88 field_height: float,
89 paragraphs: list[str],
90 min_font_size: float,
91 font_size_step: float = 0.2
92 ) -> tuple[list[tuple[float, str]], float]:
93 """
94 Takes a piece of text and scales it to field_width or field_height, given font_name
95 and font_size. Wraps text where necessary.
96
97 Args:
98 font: The font to be used.
99 font_size: The font size in points.
100 leading_factor: The line distance.
101 field_width: The width of the field in which to fit the text.
102 field_height: The height of the field in which to fit the text.
103 paragraphs: The text paragraphs to fit with the field.
104 min_font_size: The minimum font size at which to scale the text.
105 font_size_step: The amount by which to decrement font size per step while scaling.
106
107 Returns:
108 The text in the form of list of tuples, each tuple containing the length of a line
109 and its contents, and the font_size for these lines and lengths.
110 """
111 wrapped_lines = []
112 current_line_words: list[str] = []
113 current_line_width: float = 0
114 space_width = font.space_width * font_size / 1000
115 for paragraph in paragraphs:
116 if not paragraph.strip():
117 wrapped_lines.append((0.0, ""))
118 continue
119 words = paragraph.split(font.space_char)
120 for i, word in enumerate(words):
121 word_width = font.get_text_width(word) * font_size / 1000
122 test_width = current_line_width + word_width + (space_width if i else 0)
123 if test_width > field_width and current_line_words:
124 wrapped_lines.append((current_line_width, font.space_char.join(current_line_words)))
125 current_line_words = [word]
126 current_line_width = word_width
127 elif not current_line_words and word_width > field_width:
128 wrapped_lines.append((word_width, word))
129 current_line_words = []
130 current_line_width = 0
131 else:
132 if current_line_words:
133 current_line_width += space_width
134 current_line_words.append(word)
135 current_line_width += word_width
136 if current_line_words:
137 wrapped_lines.append((current_line_width, font.space_char.join(current_line_words)))
138 current_line_words = []
139 current_line_width = 0
140 # Estimate total height.
141 estimated_total_height = font_size + (len(wrapped_lines) - 1) * leading_factor * font_size
142 if estimated_total_height > field_height:
143 # Text overflows height; Retry with smaller font size.
144 new_font_size = font_size - font_size_step
145 if new_font_size >= min_font_size:
146 return self._scale_text(
147 font,
148 new_font_size,
149 leading_factor,
150 field_width,
151 field_height,
152 paragraphs,
153 min_font_size,
154 font_size_step
155 )
156 return wrapped_lines, round(font_size, 1)
157
158 def _generate_appearance_stream_data(
159 self,
160 text: str,
161 selection: list[str] | None ,
162 font: Font,
163 font_name: str = "/Helv",
164 font_size: float = 0.0,
165 font_color: str = "0 g",
166 is_multiline: bool = False,
167 alignment: TextAlignment = TextAlignment.LEFT,
168 is_comb: bool = False,
169 max_length: int | None = None
170 ) -> bytes:
171 """
172 Generates the raw bytes of the PDF appearance stream for a text field.
173
174 This private method assembles the PDF content stream operators to draw
175 the provided text within the specified rectangle. It handles text positioning,
176 font application, color, and special formatting like selected text.
177
178 Args:
179 text: The text to be rendered in the form field.
180 selection: An optional list of strings that should be highlighted as selected.
181 font: The font to use.
182 font_name: The name of the font resource to use (e.g., "/Helv").
183 font_size: The font size. If 0, it is automatically calculated
184 based on whether the field is multiline or not.
185 font_color: The color to apply to the font, represented as a PDF
186 graphics state string (e.g., "0 g" for black).
187 is_multiline: A boolean indicating if the text field is multiline.
188 alignment: Text alignment, can be TextAlignment.LEFT, .RIGHT, or .CENTER.
189 is_comb: Boolean that designates fixed-length fields, where every character
190 fills one "cell", such as in a postcode.
191 max_length: Used if is_comb is set. The maximum number of characters for a fixed-
192 length field.
193
194 Returns:
195 A byte string containing the PDF content stream data.
196
197 """
198 rectangle = self._layout.rectangle
199 if isinstance(rectangle, tuple):
200 rectangle = RectangleObject(rectangle)
201 leading_factor = (font.font_descriptor.bbox[3] - font.font_descriptor.bbox[1]) / 1000.0
202
203 # Set margins based on border width and style, but never less than 1 point
204 factor = 2 if self._layout.border_style in {"/B", "/I"} else 1
205 margin = max(self._layout.border_width * factor, 1)
206 field_height = rectangle.height - 2 * margin
207 field_width = rectangle.width - 4 * margin
208
209 reverse_cmap, encoding_cmap = font._get_typographic_maps()
210
211 def _unicode_to_glyph_id(text: str, reverse_cmap: dict[str, str]) -> str:
212 if HAS_RTL_SUPPORT:
213 # Use arabic-reshaper and python-bidi to rearrange and shape text for the PDF engine
214 reshaped_text = arabic_reshaper.reshape(text)
215 visual_text = get_display(reshaped_text, base_dir="L")
216 return "".join(reverse_cmap.get(char, char) for char in visual_text)
217
218 return "".join(reverse_cmap.get(char, char) for char in text)
219
220
221 def _glyph_id_to_bytes(glyphs: str, encoding_cmap: dict[str, bytes]) -> list[bytes]:
222 return [encoding_cmap.get(
223 glyph_id, bytes((ord(glyph_id),)) if ord(glyph_id) < 256 else b"?"
224 ) for glyph_id in glyphs]
225
226 # If font_size is 0, apply the logic for multiline or large-as-possible font
227 if font_size == 0:
228 min_font_size = 4.0 # The mininum font size
229 if selection: # Don't wrap text when dealing with a /Ch field, in order to prevent problems
230 is_multiline = False # with matching "selection" with "line" later on.
231 if is_multiline:
232 font_size = DEFAULT_FONT_SIZE_IN_MULTILINE
233 glyph_paragraphs = [
234 _unicode_to_glyph_id(paragraph, reverse_cmap) for paragraph in text.splitlines()
235 ]
236 lines, font_size = self._scale_text(
237 font,
238 font_size,
239 leading_factor,
240 field_width,
241 field_height,
242 glyph_paragraphs,
243 min_font_size
244 )
245 else:
246 max_vertical_size = field_height / leading_factor
247 glyphs = _unicode_to_glyph_id(text, reverse_cmap)
248 text_width_unscaled = font.get_text_width(glyphs) / 1000
249 max_horizontal_size = field_width / (text_width_unscaled or 1)
250 font_size = round(max(min(max_vertical_size, max_horizontal_size), min_font_size), 1)
251 lines = [(text_width_unscaled * font_size, glyphs)]
252 elif is_comb:
253 if max_length and len(text) > max_length:
254 logger_warning(
255 (
256 "Length of text %(text)s exceeds maximum length (%(max_length)d) "
257 "of field, input truncated."
258 ),
259 source=__name__,
260 text=text,
261 max_length=max_length,
262 )
263 # We act as if each character is one line, because we draw it separately later on
264 lines = []
265 for index, char in enumerate(text):
266 if index < (max_length or len(text)):
267 glyphs = _unicode_to_glyph_id(char, reverse_cmap)
268 lines.append((font.get_text_width(glyphs) * font_size / 1000, glyphs))
269 else:
270 lines = []
271 for line in text.splitlines():
272 glyphs = _unicode_to_glyph_id(line, reverse_cmap)
273 lines.append((font.get_text_width(glyphs) * font_size / 1000, glyphs))
274
275 # Set the vertical offset
276 if is_multiline:
277 y_offset = rectangle.height + margin - font.font_descriptor.bbox[3] * font_size / 1000.0
278 else:
279 y_offset = margin + ((field_height - font.font_descriptor.ascent * font_size / 1000) / 2)
280 default_appearance = f"{font_name} {font_size} Tf {font_color}"
281
282 ap_stream = (
283 f"q\n/Tx BMC \nq\n{2 * margin} {margin} {field_width} {field_height} "
284 f"re\nW\nBT\n{default_appearance}\n"
285 ).encode()
286 current_x_pos: float = 0 # Initial virtual position within the text object.
287
288 for line_number, (line_width, line) in enumerate(lines):
289 if selection and line in _unicode_to_glyph_id("".join(selection), reverse_cmap):
290 # Might be improved, but cannot find how to get fill working => replaced with lined box
291 ap_stream += (
292 f"1 {y_offset - (line_number * font_size * leading_factor) - 1} "
293 f"{rectangle.width - 2} {font_size + 2} re\n"
294 f"0.5 0.5 0.5 rg s\n{default_appearance}\n"
295 ).encode()
296
297 # Calculate the desired absolute starting X for the current line
298 desired_abs_x_start: float = 0
299 if is_comb and max_length:
300 # Calculate the width of a cell for one character
301 cell_width = rectangle.width / max_length
302 # Space from the left edge of the cell to the character's baseline start
303 # line_width here is the *actual* character width in points for the single character 'line'
304 centering_offset_in_cell = (cell_width - line_width) / 2
305 # Absolute start X = (Cell Index, i.e., line_number * Cell Width) + Centering Offset
306 desired_abs_x_start = (line_number * cell_width) + centering_offset_in_cell
307 elif alignment == TextAlignment.RIGHT:
308 desired_abs_x_start = rectangle.width - margin * 2 - line_width
309 elif alignment == TextAlignment.CENTER:
310 desired_abs_x_start = (rectangle.width - line_width) / 2
311 else: # Left aligned; default
312 desired_abs_x_start = margin * 2
313 # Calculate x_rel_offset: how much to move from the current_x_pos
314 # to reach the desired_abs_x_start.
315 x_rel_offset = desired_abs_x_start - current_x_pos
316
317 # Y-offset:
318 y_rel_offset: float = 0
319 if line_number == 0:
320 y_rel_offset = y_offset # Initial vertical position
321 elif is_comb:
322 y_rel_offset = 0.0 # DO NOT move vertically for subsequent characters
323 else:
324 y_rel_offset = - font_size * leading_factor # Move down by line height
325
326 # Td is a relative translation (Tx and Ty).
327 # It updates the current text position.
328 ap_stream += f"{x_rel_offset} {y_rel_offset} Td\n".encode()
329 # Update current_x_pos based on the Td operation for the next iteration.
330 # This is the X position where the *current line* will start.
331 current_x_pos = desired_abs_x_start
332
333 encoded_line = _glyph_id_to_bytes(line, encoding_cmap)
334 if any(len(c) >= 2 for c in encoded_line):
335 ap_stream += b"<" + (b"".join(encoded_line)).hex().encode() + b"> Tj\n"
336 else:
337 ap_stream += b"(" + b"".join(encoded_line) + b") Tj\n"
338 ap_stream += b"ET\nQ\nEMC\nQ\n"
339
340 return ap_stream
341
342 def __init__(
343 self,
344 layout: BaseStreamConfig | None = None,
345 text: str = "",
346 selection: list[str] | None = None,
347 font: Font | None = None,
348 font_resource: DictionaryObject | IndirectObject | None = None,
349 font_name: str = "/Helv",
350 font_size: float = 0.0,
351 font_color: str = "0 g",
352 is_multiline: bool = False,
353 alignment: TextAlignment = TextAlignment.LEFT,
354 is_comb: bool = False,
355 max_length: int | None = None
356 ) -> None:
357 """
358 Initializes a TextStreamAppearance object.
359
360 This constructor creates a new PDF stream object configured as an XObject
361 of subtype Form. It uses the `_appearance_stream_data` method to generate
362 the content for the stream.
363
364 Args:
365 layout: The basic layout parameters.
366 text: The text to be rendered in the form field.
367 selection: An optional list of strings that should be highlighted as selected.
368 font: A Font object. Falls back to Type 1 Helvetica if not given.
369 font_resource: An optional variable that represents a PDF font dictionary. Falls back
370 to Type 1 Helvetica if not given.
371 font_name: The name of the font resource, e.g., "/Helv".
372 font_size: The font size. If 0, it's auto-calculated.
373 font_color: The font color string.
374 is_multiline: A boolean indicating if the text field is multiline.
375 alignment: Text alignment, can be TextAlignment.LEFT, .RIGHT, or .CENTER.
376 is_comb: Boolean that designates fixed-length fields, where every character
377 fills one "cell", such as in a postcode.
378 max_length: Used if is_comb is set. The maximum number of characters for a fixed-
379 length field.
380
381 """
382 super().__init__(layout)
383
384 if not font or not font_resource:
385 font_name = "/Helv"
386 core_font_metrics = CORE_FONT_METRICS["Helvetica"]
387 win_ansi_encoding_list = fill_from_encoding("cp1252") # WinAnsiEncoding
388 font = Font(
389 name="Helvetica",
390 character_map={},
391 encoding=dict(zip(range(256), win_ansi_encoding_list)),
392 sub_type="Type1",
393 font_descriptor=core_font_metrics.font_descriptor,
394 character_widths={
395 chr(code): core_font_metrics.character_widths[value] for code, value in enumerate(
396 win_ansi_encoding_list
397 ) if value in core_font_metrics.character_widths
398 },
399 )
400 font.character_widths["default"] = core_font_metrics.character_widths["default"]
401 font_resource = font.as_font_resource()
402
403 ap_stream_data = self._generate_appearance_stream_data(
404 text,
405 selection,
406 font,
407 font_name=font_name,
408 font_size=font_size,
409 font_color=font_color,
410 is_multiline=is_multiline,
411 alignment=alignment,
412 is_comb=is_comb,
413 max_length=max_length
414 )
415
416 self.set_data(ByteStringObject(ap_stream_data))
417 self[NameObject("/Length")] = NumberObject(len(ap_stream_data))
418 # Update Resources with font information
419 self[NameObject("/Resources")] = DictionaryObject({
420 NameObject("/Font"): DictionaryObject({
421 NameObject(font_name): getattr(font_resource, "indirect_reference", font_resource)
422 })
423 })
424
425 @staticmethod
426 def _find_annotation_font_resource(
427 font_name: str,
428 annotation: DictionaryObject,
429 acro_form: DictionaryObject,
430 text: str
431 ) -> tuple[str, Font]:
432 # Try to find a resource dictionary for the font by examining the annotation and, if that fails,
433 # the AcroForm resources dictionary
434 acro_form_resources: Any = cast(
435 DictionaryObject,
436 annotation.get_inherited(
437 "/DR",
438 acro_form.get("/DR", DictionaryObject()),
439 ),
440 )
441 acro_form_font_resources = acro_form_resources.get("/Font", DictionaryObject())
442 font_resource = acro_form_font_resources.get(font_name, None)
443 if font_resource:
444 font = Font.from_font_resource(font_resource)
445 else:
446 # Normally, we should have found a font resource by now. However, when a user has provided a specific
447 # font name, we may not have found the associated font resource among the AcroForm resources. Also, in
448 # case of the 14 Adobe Core fonts, we may be expected to construct a font resource ourselves.
449 if font_name.removeprefix("/") not in CORE_FONT_METRICS:
450 # Default to Helvetica if we haven't found a font resource and cannot construct one ourselves.
451 logger_warning(
452 "Font dictionary for %(font_name)s not found; defaulting to Helvetica.",
453 source=__name__,
454 font_name=font_name,
455 )
456 font_name = "/Helvetica"
457 core_font_metrics = CORE_FONT_METRICS[font_name.removeprefix("/")]
458 font = Font(
459 name=font_name.removeprefix("/"),
460 character_map={},
461 encoding=dict(zip(range(256), fill_from_encoding("cp1252"))), # WinAnsiEncoding
462 sub_type="Type1",
463 font_descriptor=core_font_metrics.font_descriptor,
464 character_widths=core_font_metrics.character_widths
465 )
466
467 # If we have found a font resource, it still might not be able to encode the text value we received.
468 encodable = font.can_encode(text)
469
470 if not encodable:
471 # If we have a font file, we can try to produce a new font resource with an encoding
472 # that does include the necessary characters.
473 if font.font_descriptor.font_file and font.sub_type == "TrueType":
474 try:
475 font = font.from_truetype_font_file(BytesIO(font.font_descriptor.font_file.get_data()))
476 font_name = "/PYPDF1" # This means we most probably do not clash with an existing font name
477 encodable = font.can_encode(text)
478 except (ImportError, PdfReadError) as e:
479 logger_warning("Unable to use embedded font for encoding: %(e)s", source=__name__, e=e)
480
481 if not encodable:
482 logger_warning(
483 (
484 "Text string '%(text)s' contains characters not supported by font encoding. "
485 "This may result in text corruption. "
486 "Consider calling writer.update_page_form_field_values with auto_regenerate=True."
487 ),
488 source=__name__,
489 text=text,
490 )
491
492 return font_name, font
493
494 @staticmethod
495 def _sync_appearance_stream_font_resources(
496 writer: PdfWriter,
497 font_name: str,
498 font: Font,
499 target_resource_dict: DictionaryObject,
500 page: PageObject | None = None
501 ) -> IndirectObject:
502 """
503 Unified helper to sync fonts from an AP stream to a target resource dictionary (e.g., AcroForm /DR).
504 Will sync to page resources as well when page is added to the arguments.
505 """
506 target_fonts = target_resource_dict.setdefault(NameObject("/Font"), DictionaryObject()).get_object()
507 if font_name not in target_fonts:
508 font_resource_reference = font._add_to_writer(
509 writer,
510 target_fonts,
511 NameObject(font_name)
512 )
513 else:
514 font_resource_reference = target_fonts[font_name]
515
516 if page:
517 page_fonts_resource = cast(DictionaryObject, page[PageAttributes.RESOURCES]).setdefault(
518 NameObject("/Font"), DictionaryObject()
519 ).get_object()
520 if font_name not in page_fonts_resource:
521 page_fonts_resource[NameObject(font_name)] = getattr(
522 font_resource_reference, "indirect_reference", font_resource_reference
523 )
524
525 return font_resource_reference
526
527 @classmethod
528 def from_text_annotation(
529 cls,
530 writer: PdfWriter,
531 page: PageObject,
532 flatten: bool,
533 acro_form: DictionaryObject, # _root_object[CatalogDictionary.ACRO_FORM])
534 field: DictionaryObject,
535 annotation: DictionaryObject,
536 user_font_name: str = "",
537 user_font_size: float = -1,
538 ) -> TextStreamAppearance:
539 """
540 Creates a TextStreamAppearance object from a text field annotation.
541
542 This class method is a factory for creating a `TextStreamAppearance`
543 instance by extracting all necessary information (bounding box, font,
544 text content, etc.) from the PDF field and annotation dictionaries.
545 It respects inheritance for properties like default appearance (`/DA`).
546
547 Args:
548 writer: The PdfWriter instance that we are creating text stream appearances for.
549 page: The page that we are processing annotations for.
550 flatten: Whether we flatten text annotations or not. If true, add new font resource
551 to the page font resources. Otherwise, add them to the AcroForm resources.
552 acro_form: The root AcroForm dictionary from the PDF catalog.
553 field: The field dictionary object.
554 annotation: The widget annotation dictionary object associated with the field.
555 user_font_name: An optional user-provided font name to override the
556 default. Defaults to an empty string.
557 user_font_size: An optional user-provided font size to override the
558 default. A value of -1 indicates no override.
559
560 Returns:
561 A new `TextStreamAppearance` instance configured for the given field.
562
563 """
564 # Calculate rectangle dimensions
565 _rectangle = cast(RectangleObject, annotation[AnnotationDictionaryAttributes.Rect])
566 rectangle = RectangleObject((0, 0, abs(_rectangle[2] - _rectangle[0]), abs(_rectangle[3] - _rectangle[1])))
567
568 # Get default appearance dictionary from annotation
569 default_appearance = annotation.get_inherited(
570 AnnotationDictionaryAttributes.DA,
571 acro_form.get(AnnotationDictionaryAttributes.DA, None),
572 )
573 if not default_appearance:
574 # Create a default appearance if none was found in the annotation
575 default_appearance = TextStringObject("/Helv 0 Tf 0 g")
576 else:
577 default_appearance = default_appearance.get_object()
578
579 # Retrieve field text and selected values
580 field_flags = field.get(FieldDictionaryAttributes.Ff, 0)
581 if (
582 field.get(FieldDictionaryAttributes.FT, "/Tx") == "/Ch" and
583 field_flags & FieldDictionaryAttributes.FfBits.Combo == 0
584 ):
585 text = "\n".join(annotation.get_inherited(FieldDictionaryAttributes.Opt, []))
586 selection = field.get("/V", [])
587 if not isinstance(selection, list):
588 selection = [selection]
589 else: # /Tx
590 text = field.get("/V", "")
591 selection = []
592
593 # Escape parentheses (PDF 1.7 reference, table 3.2, Literal Strings)
594 text = text.replace("\\", "\\\\").replace("(", r"\(").replace(")", r"\)")
595
596 # Derive font name, size and color from the default appearance. Also set
597 # user-provided font name and font size in the default appearance, if given.
598 # For a font name, this presumes that we can find an associated font resource
599 # dictionary. Uses the variable font_properties as an intermediate.
600 # As per the PDF spec:
601 # "At a minimum, the string [that is, default_appearance] shall include a Tf (text
602 # font) operator along with its two operands, font and size" (Section 12.7.4.3
603 # "Variable text" of the PDF 2.0 specification).
604 font_properties = [prop for prop in re.split(r"\s", default_appearance) if prop]
605 da_font_name = font_properties.pop(font_properties.index("Tf") - 2)
606 font_size = float(font_properties.pop(font_properties.index("Tf") - 1))
607 font_properties.remove("Tf")
608 font_color = " ".join(font_properties)
609 # Determine the font name to use, prioritizing the user's input
610 if user_font_name:
611 font_name = user_font_name
612 else:
613 font_name = da_font_name
614 # Determine the font size to use, prioritizing the user's input
615 if user_font_size > 0:
616 font_size = user_font_size
617
618 font_name, font = cls._find_annotation_font_resource(font_name, annotation, acro_form, text)
619
620 # Change the /DA information if we changed the font name
621 if font_name != da_font_name:
622 annotation[NameObject("/DA")] = TextStringObject(default_appearance.replace(da_font_name, font_name))
623
624 # Synchronise font resources
625 font_resource_reference = cls._sync_appearance_stream_font_resources(
626 writer,
627 font_name,
628 font,
629 acro_form.setdefault(NameObject("/DR"), DictionaryObject()),
630 page if flatten else None,
631 )
632
633 # Retrieve formatting information
634 is_comb = False
635 max_length = None
636 if field_flags & FieldDictionaryAttributes.FfBits.Comb:
637 is_comb = True
638 max_length = annotation.get("/MaxLen")
639 is_multiline = False
640 if field_flags & FieldDictionaryAttributes.FfBits.Multiline:
641 is_multiline = True
642 alignment = field.get("/Q", TextAlignment.LEFT)
643 border_width = 1
644 border_style = BorderStyles.SOLID
645 if "/BS" in field:
646 border_width = cast(DictionaryObject, field["/BS"]).get("/W", border_width)
647 border_style = cast(DictionaryObject, field["/BS"]).get("/S", border_style)
648
649 # Create the TextStreamAppearance instance
650 layout = BaseStreamConfig(rectangle=rectangle, border_width=border_width, border_style=border_style)
651 new_appearance_stream = cls(
652 layout,
653 text,
654 selection,
655 font,
656 font_resource_reference,
657 font_name=font_name,
658 font_size=font_size,
659 font_color=font_color,
660 is_multiline=is_multiline,
661 alignment=alignment,
662 is_comb=is_comb,
663 max_length=max_length
664 )
665
666 if AnnotationDictionaryAttributes.AP in annotation:
667 for key, value in (
668 cast(DictionaryObject, annotation[AnnotationDictionaryAttributes.AP]).get("/N", {}).items()
669 ):
670 if key in {"/BBox", "/Length", "/Subtype", "/Type", "/Filter"}:
671 continue
672 # Don't overwrite font resources added by TextAppearanceStream.__init__
673 if key == "/Resources":
674 if "/Font" not in value:
675 value.get_object()[NameObject("/Font")] = DictionaryObject()
676 value["/Font"].get_object()[NameObject(font_name)] = getattr(
677 font_resource_reference, "indirect_reference", font_resource_reference
678 )
679 else:
680 new_appearance_stream[key] = value
681
682 return new_appearance_stream