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