Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pypdf/generic/_appearance_stream.py: 15%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

364 statements  

1from __future__ import annotations 

2 

3import copy 

4from dataclasses import dataclass, field 

5from enum import IntEnum 

6from io import BytesIO 

7from operator import attrgetter 

8from typing import TYPE_CHECKING, Any, NamedTuple, cast 

9 

10from .._codecs import encoding_dict_from_named_encoding 

11from .._codecs.core_font_metrics import CORE_FONT_METRICS 

12from .._page import Transformation 

13from .._utils import is_char_rtl, logger_warning 

14from ..constants import AnnotationDictionaryAttributes, BorderStyles, FieldDictionaryAttributes, PageAttributes 

15from ..errors import PdfReadError 

16from ..generic import ( 

17 ArrayObject, 

18 ContentStream, 

19 DecodedStreamObject, 

20 DictionaryObject, 

21 FloatObject, 

22 IndirectObject, 

23 NameObject, 

24 NumberObject, 

25 RectangleObject, 

26 StreamObject, 

27) 

28from ..generic._base import ByteStringObject, TextStringObject 

29from ._color import Color, DeviceGray 

30from ._font import Font 

31 

32if TYPE_CHECKING: 

33 from pypdf._writer import PdfWriter 

34 

35 from .._page import PageObject 

36 

37try: 

38 import arabic_reshaper 

39 from bidi.algorithm import get_display 

40 HAS_RTL_SUPPORT = True 

41except ImportError: 

42 HAS_RTL_SUPPORT = False 

43 

44DEFAULT_FONT_SIZE_IN_MULTILINE = 12 

45 

46# "The glyph widths shall be measured in units in which 1000 units correspond to 1 unit in text space" 

47# (Table 111, PDF Specification 2.0) 

48TEXT_SPACE_TO_GLYPH_SPACE_FACTOR = 1000 

49 

50 

51@dataclass 

52class BaseStreamConfig: 

53 """A container representing the basic layout of an appearance stream.""" 

54 rectangle: RectangleObject = field(default_factory=lambda: RectangleObject((0.0, 0.0, 0.0, 0.0))) 

55 border_width: int = 1 # The width of the border in points 

56 border_style: str = BorderStyles.SOLID 

57 border_color: Color | None = None 

58 background_color: Color | None = None 

59 rotation: int = 0 

60 

61 

62class BaseStreamAppearance(DecodedStreamObject): 

63 """A class representing the very base of an appearance stream, that is, a rectangle and a border.""" 

64 

65 def _add_matrix(self, rotation: int) -> None: 

66 # We need to rotate our rectangle while keeping its origin to (0, 0). 

67 # Rotation goes counterclockwise. We want to know the furthest points to which we rotated left and down. 

68 # These will serve as our X and Y offsets to translate the entire object origin back to (0, 0). 

69 # If a corner rotates into negative space, that is our offset. If none does, the minimum is 0.0. 

70 matrix = Transformation().rotate(rotation) 

71 bottom_right_corner = (self._layout.rectangle.width, 0.0) 

72 top_left_corner = (0.0, self._layout.rectangle.height) 

73 top_right_corner = (self._layout.rectangle.width, self._layout.rectangle.height) 

74 rotated_bottom_right_corner = matrix.apply_on(bottom_right_corner) 

75 rotated_top_left_corner = matrix.apply_on(top_left_corner) 

76 rotated_top_right_corner = matrix.apply_on(top_right_corner) 

77 translation_x_offset = -min( 

78 0.0, rotated_bottom_right_corner[0], rotated_top_left_corner[0], rotated_top_right_corner[0] 

79 ) 

80 translation_y_offset = -min( 

81 0.0, rotated_bottom_right_corner[1], rotated_top_left_corner[1], rotated_top_right_corner[1] 

82 ) 

83 matrix = matrix.translate(translation_x_offset, translation_y_offset) 

84 self[NameObject("/Matrix")] = ArrayObject([FloatObject(round(i, 3)) for i in matrix.ctm]) 

85 

86 def __init__(self, layout: BaseStreamConfig | None) -> None: 

87 """ 

88 Takes the appearance stream layout as an argument. 

89 

90 Args: 

91 layout: The basic layout parameters. 

92 """ 

93 super().__init__() 

94 self._layout = layout or BaseStreamConfig() 

95 self[NameObject("/Type")] = NameObject("/XObject") 

96 self[NameObject("/Subtype")] = NameObject("/Form") 

97 self[NameObject("/BBox")] = self._layout.rectangle 

98 

99 # Define the rotation matrix 

100 rotation = self._layout.rotation % 360 

101 if rotation: 

102 self._add_matrix(rotation) 

103 

104 ap_stream_parts = [] 

105 # Only add a background color when color is defined and not transparent 

106 if isinstance(self._layout.background_color, Color): 

107 ap_stream_parts.append( 

108 f"0 0 {self._layout.rectangle.width} {self._layout.rectangle.height} re\n" 

109 f"{self._layout.background_color.as_operator()}\n" 

110 "f\n" 

111 ) 

112 # Only add a border when border width is larger than 0, border 

113 # color is defined, and border color is not set to transparent. 

114 if self._layout.border_width > 0: 

115 if isinstance(self._layout.border_color, Color): 

116 border_width_string = f"{self._layout.border_width} w\n" if self._layout.border_width != 1 else "" 

117 ap_stream_parts.append( 

118 f"{self._layout.border_width} " 

119 f"{self._layout.border_width} " 

120 f"{self._layout.rectangle.width - 2 * self._layout.border_width} " 

121 f"{self._layout.rectangle.height - 2 * self._layout.border_width} re\n" 

122 f"{border_width_string}" 

123 f"{self._layout.border_color.as_operator(stroke=True)}\n" 

124 "s\n" 

125 ) 

126 else: 

127 # If we aren't drawing a border, then set border_width to 0. This means less margin to 

128 # take into account and results in larger font sizes. 

129 self._layout.border_width = 0 

130 if ap_stream_parts: 

131 ap_stream_parts.insert(0, "q\n") 

132 ap_stream_parts.append("Q\n") 

133 self._ap_stream_data = "".join(ap_stream_parts).encode() 

134 

135 

136class TextAlignment(IntEnum): 

137 """Defines the alignment options for text within a form field's appearance stream.""" 

138 

139 LEFT = 0 

140 CENTER = 1 

141 RIGHT = 2 

142 

143 

144class WidthWordGlyphs(NamedTuple): 

145 """A tuple of the unscaled width of a word (unencoded text) and the font-encoded glyphs that represent it.""" 

146 

147 width: float 

148 word: str 

149 glyphs: str 

150 

151 

152class TextStreamAppearance(BaseStreamAppearance): 

153 """ 

154 A class representing the appearance stream for a text-based form field. 

155 

156 This class generates the content stream (the `ap_stream_data`) that dictates 

157 how text is rendered within a form field's bounding box. It handles properties 

158 like font, font size, color, multiline text, and text selection highlighting. 

159 """ 

160 

161 def _scale_text( 

162 self, 

163 font: Font, 

164 font_size: float, 

165 leading_factor: float, 

166 field_width: float, 

167 field_height: float, 

168 paragraphs: list[list[WidthWordGlyphs]], 

169 min_font_size: float, 

170 font_size_step: float = 0.2 

171 ) -> tuple[list[WidthWordGlyphs], float]: 

172 """ 

173 Takes a piece of text and scales it to field_width or field_height, given font_name 

174 and font_size. Wraps text where necessary. 

175 

176 Args: 

177 font: The font to be used. 

178 font_size: The font size in points. 

179 leading_factor: The line distance. 

180 field_width: The width of the field in which to fit the text. 

181 field_height: The height of the field in which to fit the text. 

182 paragraphs: The list of text paragraphs to fit with the field, where each paragraph is a list 

183 of tuples of unscaled width, unencoded text, and glyphs (font-encoded text). 

184 min_font_size: The minimum font size at which to scale the text. 

185 font_size_step: The amount by which to decrement font size per step while scaling. 

186 

187 Returns: 

188 The text in the form of list of tuples, each tuple containing the length of a line 

189 and its contents, and the font_size for these lines and lengths. 

190 """ 

191 wrapped_lines = [] 

192 current_line_words: list[WidthWordGlyphs] = [] 

193 current_line_width: float = 0 

194 space_width = font.space_width * font_size / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR 

195 for paragraph in paragraphs: 

196 for i, width_word_glyphs in enumerate(paragraph): 

197 word_width = width_word_glyphs.width * font_size 

198 test_width = current_line_width + word_width + (space_width if i else 0) 

199 if test_width > field_width and current_line_words: 

200 wrapped_lines.append( 

201 WidthWordGlyphs( 

202 width=current_line_width, 

203 word=" ".join(map(attrgetter("word"), current_line_words)), 

204 glyphs=font.space_char.join(map(attrgetter("glyphs"), current_line_words)) 

205 ) 

206 ) 

207 current_line_words = [width_word_glyphs] 

208 current_line_width = word_width 

209 elif not current_line_words and word_width > field_width: 

210 wrapped_lines.append( 

211 WidthWordGlyphs( 

212 width=word_width, 

213 word=width_word_glyphs.word, 

214 glyphs=width_word_glyphs.glyphs 

215 ) 

216 ) 

217 current_line_words = [] 

218 current_line_width = 0 

219 else: 

220 if current_line_words: 

221 current_line_width += space_width 

222 current_line_words.append(width_word_glyphs) 

223 current_line_width += word_width 

224 if current_line_words: 

225 wrapped_lines.append(WidthWordGlyphs( 

226 width=current_line_width, 

227 word=" ".join(map(attrgetter("word"), current_line_words)), 

228 glyphs=font.space_char.join(map(attrgetter("glyphs"), current_line_words)) 

229 )) 

230 current_line_words = [] 

231 current_line_width = 0 

232 # Estimate total height. 

233 estimated_total_height = font_size + (len(wrapped_lines) - 1) * leading_factor * font_size 

234 if estimated_total_height > field_height: 

235 # Text overflows height; Retry with smaller font size. 

236 new_font_size = font_size - font_size_step 

237 if new_font_size >= min_font_size: 

238 return self._scale_text( 

239 font, 

240 new_font_size, 

241 leading_factor, 

242 field_width, 

243 field_height, 

244 paragraphs, 

245 min_font_size, 

246 font_size_step 

247 ) 

248 return wrapped_lines, round(font_size, 1) 

249 

250 def _generate_appearance_stream_data( 

251 self, 

252 text: str, 

253 selection: list[str] | None , 

254 font: Font, 

255 font_name: str = "/Helv", 

256 font_size: float = 0.0, 

257 font_color: Color | None = None, 

258 is_multiline: bool = False, 

259 alignment: TextAlignment = TextAlignment.LEFT, 

260 is_comb: bool = False, 

261 max_length: int | None = None 

262 ) -> bytes: 

263 """ 

264 Generates the raw bytes of the PDF appearance stream for a text field. 

265 

266 This private method assembles the PDF content stream operators to draw 

267 the provided text within the specified rectangle. It handles text positioning, 

268 font application, color, and special formatting like selected text. 

269 

270 Args: 

271 text: The text to be rendered in the form field. 

272 selection: An optional list of strings that should be highlighted as selected. 

273 font: The font to use. 

274 font_name: The name of the font resource to use (e.g., "/Helv"). 

275 font_size: The font size. If 0, it is automatically calculated 

276 based on whether the field is multiline or not. 

277 font_color: The color to apply to the font, represented as a class Color 

278 is_multiline: A boolean indicating if the text field is multiline. 

279 alignment: Text alignment, can be TextAlignment.LEFT, .RIGHT, or .CENTER. 

280 is_comb: Boolean that designates fixed-length fields, where every character 

281 fills one "cell", such as in a postcode. 

282 max_length: Used if is_comb is set. The maximum number of characters for a fixed- 

283 length field. 

284 

285 Returns: 

286 A byte string containing the PDF content stream data. 

287 

288 """ 

289 font_color = font_color or DeviceGray() 

290 rectangle = self._layout.rectangle 

291 leading_factor = ( 

292 (font.font_descriptor.bbox[3] - font.font_descriptor.bbox[1]) / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR 

293 ) 

294 

295 # Set margins based on border width and style 

296 factor = 2 if self._layout.border_style in {"/B", "/I"} else 1 

297 margin = self._layout.border_width * factor 

298 field_height = rectangle.height - 2 * margin 

299 field_width = rectangle.width - 4 * max(margin, 1) 

300 

301 reverse_cmap, encoding_cmap = font._get_typographic_maps() 

302 

303 def _unicode_to_glyph_id(text: str, reverse_cmap: dict[str, str]) -> str: 

304 if HAS_RTL_SUPPORT: 

305 # Use arabic-reshaper and python-bidi to rearrange and shape text for the PDF engine 

306 reshaped_text = arabic_reshaper.reshape(text) 

307 visual_text = get_display(reshaped_text, base_dir="L") 

308 return "".join(reverse_cmap.get(char, char) for char in visual_text) 

309 

310 return "".join(reverse_cmap.get(char, char) for char in text) 

311 

312 

313 def _glyph_id_to_bytes(glyphs: str, encoding_cmap: dict[str, bytes]) -> list[bytes]: 

314 return [encoding_cmap.get( 

315 glyph_id, bytes((ord(glyph_id),)) if ord(glyph_id) < 256 else b"?" 

316 ) for glyph_id in glyphs] 

317 

318 # If font_size is 0, apply the logic for multiline or large-as-possible font 

319 if font_size == 0: 

320 min_font_size = 4.0 # The minimum font size 

321 if selection: # Don't wrap text when dealing with a /Ch field, in order to prevent problems 

322 is_multiline = False # with matching "selection" with "line" later on. 

323 if is_multiline: 

324 font_size = DEFAULT_FONT_SIZE_IN_MULTILINE 

325 # We create a list of paragraphs, here each paragraph is a list of tuples, where each WidthWordGlyphs 

326 # tuple signifies an unscaled word width, the word itself, and the glyphs that encode it. 

327 paragraphs: list[list[WidthWordGlyphs]] = [] 

328 for line in text.splitlines(): 

329 if not line.strip(): 

330 paragraphs.append([WidthWordGlyphs(width=0.0, word="", glyphs="")]) 

331 continue 

332 line_by_widths_words_glyphs: list[WidthWordGlyphs] = [] 

333 words = line.split(" ") 

334 for word in words: 

335 glyph_word = _unicode_to_glyph_id(word, reverse_cmap) 

336 line_by_widths_words_glyphs.append( 

337 WidthWordGlyphs( 

338 width=font.get_text_width(word) / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR, 

339 word=word, 

340 glyphs=glyph_word 

341 ) 

342 ) 

343 paragraphs.append(line_by_widths_words_glyphs) 

344 lines, font_size = self._scale_text( 

345 font, 

346 font_size, 

347 leading_factor, 

348 field_width, 

349 field_height, 

350 paragraphs, 

351 min_font_size 

352 ) 

353 else: 

354 max_vertical_size = field_height / leading_factor 

355 glyphs = _unicode_to_glyph_id(text, reverse_cmap) 

356 text_width_unscaled = font.get_text_width(glyphs) / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR 

357 max_horizontal_size = field_width / (text_width_unscaled or 1) 

358 font_size = round(max(min(max_vertical_size, max_horizontal_size), min_font_size), 1) 

359 lines = [WidthWordGlyphs(width=text_width_unscaled * font_size, word=text, glyphs=glyphs)] 

360 elif is_comb: 

361 if max_length and len(text) > max_length: 

362 logger_warning( 

363 ( 

364 "Length of text %(text)s exceeds maximum length (%(max_length)d) " 

365 "of field, input truncated." 

366 ), 

367 source=__name__, 

368 text=text, 

369 max_length=max_length, 

370 ) 

371 # We act as if each character is one line, because we draw it separately later on 

372 lines = [] 

373 for index, char in enumerate(text): 

374 if index < (max_length or len(text)): 

375 glyphs = _unicode_to_glyph_id(char, reverse_cmap) 

376 lines.append( 

377 WidthWordGlyphs( 

378 width=font.get_text_width(glyphs) * font_size / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR, 

379 word=char, 

380 glyphs=glyphs 

381 ) 

382 ) 

383 else: 

384 lines = [] 

385 for line in text.splitlines(): 

386 glyphs = _unicode_to_glyph_id(line, reverse_cmap) 

387 lines.append( 

388 WidthWordGlyphs( 

389 width=font.get_text_width(glyphs) * font_size / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR, 

390 word=line, 

391 glyphs=glyphs 

392 ) 

393 ) 

394 

395 # Set the vertical offset 

396 if is_multiline: 

397 y_offset = ( 

398 field_height + margin - font.font_descriptor.bbox[3] * font_size / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR 

399 ) 

400 else: 

401 y_offset = margin + ( 

402 (field_height - font.font_descriptor.ascent * font_size / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR) / 2 

403 ) 

404 default_appearance = f"{font_name} {font_size} Tf {font_color.as_operator()}" 

405 

406 ap_stream = bytearray() 

407 ap_stream += ( 

408 f"q\n/Tx BMC \nq\n" 

409 f"{2 * max(margin, 1)} {margin} " 

410 f"{round(field_width - 2 * max(margin, 1), 3)} {round(field_height - margin, 3)} re\n" 

411 f"W\nBT\n{default_appearance}\n" 

412 ).encode() 

413 current_x_pos: float = 0 # Initial virtual position within the text object. 

414 

415 selection_glyphs = _unicode_to_glyph_id("".join(selection), reverse_cmap) if selection else "" 

416 for line_number, (line_width, original_text, line) in enumerate(lines): 

417 if selection_glyphs and line in selection_glyphs: 

418 # Might be improved, but cannot find how to get fill working => replaced with lined box 

419 ap_stream += ( 

420 f"1 {round(y_offset - (line_number * font_size * leading_factor) - 1, 3)} " 

421 f"{rectangle.width - 2} {font_size + 2} re\n" 

422 f"0.5 0.5 0.5 rg s\n{default_appearance}\n" 

423 ).encode() 

424 

425 # Calculate the desired absolute starting X for the current line 

426 desired_abs_x_start: float = 0 

427 if is_comb and max_length: 

428 # Calculate the width of a cell for one character 

429 cell_width = rectangle.width / max_length 

430 # Space from the left edge of the cell to the character's baseline start 

431 # line_width here is the *actual* character width in points for the single character 'line' 

432 centering_offset_in_cell = (cell_width - line_width) / 2 

433 # Absolute start X = (Cell Index, i.e., line_number * Cell Width) + Centering Offset 

434 desired_abs_x_start = (line_number * cell_width) + centering_offset_in_cell 

435 elif alignment == TextAlignment.RIGHT: 

436 desired_abs_x_start = rectangle.width - max(margin, 1) * 2 - line_width 

437 elif alignment == TextAlignment.CENTER: 

438 desired_abs_x_start = (rectangle.width - line_width) / 2 

439 else: # Left aligned; default 

440 desired_abs_x_start = max(margin, 1) * 2 

441 # Calculate x_rel_offset: how much to move from the current_x_pos 

442 # to reach the desired_abs_x_start. 

443 x_rel_offset = desired_abs_x_start - current_x_pos 

444 

445 # Y-offset: 

446 y_rel_offset: float = 0 

447 if line_number == 0: 

448 y_rel_offset = y_offset # Initial vertical position 

449 elif is_comb: 

450 y_rel_offset = 0.0 # DO NOT move vertically for subsequent characters 

451 else: 

452 y_rel_offset = - font_size * leading_factor # Move down by line height 

453 

454 # Td is a relative translation (Tx and Ty). 

455 # It updates the current text position. 

456 ap_stream += f"{round(x_rel_offset, 3)} {round(y_rel_offset, 3)} Td\n".encode() 

457 # Update current_x_pos based on the Td operation for the next iteration. 

458 # This is the X position where the *current line* will start. 

459 current_x_pos = desired_abs_x_start 

460 

461 is_rtl = any(is_char_rtl(char) for char in line) 

462 

463 encoded_line = _glyph_id_to_bytes(line, encoding_cmap) 

464 if is_rtl: 

465 # Encode input text as UTF-16BE with a BOM, so that the input text is returned 

466 # in the right direction when copying text from the resulting PDF 

467 bom_text = b"\xfe\xff" + original_text.encode("utf-16-be") 

468 hex_original_text = bom_text.hex().upper() 

469 ap_stream += f"/Span << /ActualText <{hex_original_text}> >> BDC\n".encode() 

470 if font.sub_type == "Type0": # 16-bit font 

471 ap_stream += b"<" + (b"".join(encoded_line)).hex().encode() + b"> Tj\n" 

472 else: # Simple font, 8-bit encoded. 

473 # Escape parentheses (PDF 1.7 reference, table 3.2, Literal Strings) 

474 line_as_bytes = ( 

475 b"".join(encoded_line) 

476 .replace(b"\\", b"\\\\") 

477 .replace(b"(", br"\(") 

478 .replace(b")", br"\)") 

479 ) 

480 ap_stream += b"(" + line_as_bytes + b") Tj\n" 

481 if is_rtl: 

482 ap_stream += b"EMC\n" 

483 ap_stream += b"ET\nQ\nEMC\nQ\n" 

484 

485 return bytes(ap_stream) 

486 

487 def __init__( 

488 self, 

489 layout: BaseStreamConfig | None = None, 

490 text: str = "", 

491 selection: list[str] | None = None, 

492 font: Font | None = None, 

493 font_resource: DictionaryObject | IndirectObject | None = None, 

494 font_name: str = "/Helv", 

495 font_size: float = 0.0, 

496 font_color: Color | None = None, 

497 is_multiline: bool = False, 

498 alignment: TextAlignment = TextAlignment.LEFT, 

499 is_comb: bool = False, 

500 max_length: int | None = None 

501 ) -> None: 

502 """ 

503 Initializes a TextStreamAppearance object. 

504 

505 This constructor creates a new PDF stream object configured as an XObject 

506 of subtype Form. It uses the `_generate_appearance_stream_data` method to 

507 generate the content for the stream. 

508 

509 Args: 

510 layout: The basic layout parameters. 

511 text: The text to be rendered in the form field. 

512 selection: An optional list of strings that should be highlighted as selected. 

513 font: A Font object. Falls back to Type 1 Helvetica if not given. 

514 font_resource: An optional variable that represents a PDF font dictionary. Falls back 

515 to Type 1 Helvetica if not given. 

516 font_name: The name of the font resource, e.g., "/Helv". 

517 font_size: The font size. If 0, it's auto-calculated. 

518 font_color: The font color string. 

519 is_multiline: A boolean indicating if the text field is multiline. 

520 alignment: Text alignment, can be TextAlignment.LEFT, .RIGHT, or .CENTER. 

521 is_comb: Boolean that designates fixed-length fields, where every character 

522 fills one "cell", such as in a postcode. 

523 max_length: Used if is_comb is set. The maximum number of characters for a fixed- 

524 length field. 

525 

526 """ 

527 super().__init__(layout) 

528 

529 if not font or not font_resource: 

530 font_name = "/Helv" 

531 font = Font.from_core_font_name() 

532 font_resource = font.as_font_resource() 

533 

534 self._ap_stream_data += self._generate_appearance_stream_data( 

535 text, 

536 selection, 

537 font, 

538 font_name=font_name, 

539 font_size=font_size, 

540 font_color=font_color or DeviceGray(), 

541 is_multiline=is_multiline, 

542 alignment=alignment, 

543 is_comb=is_comb, 

544 max_length=max_length 

545 ) 

546 

547 self.set_data(ByteStringObject(self._ap_stream_data)) 

548 self[NameObject("/Length")] = NumberObject(len(self._ap_stream_data)) 

549 # Update Resources with font information 

550 self[NameObject("/Resources")] = DictionaryObject({ 

551 NameObject("/Font"): DictionaryObject({ 

552 NameObject(font_name): getattr(font_resource, "indirect_reference", font_resource) 

553 }) 

554 }) 

555 

556 @staticmethod 

557 def _find_annotation_font_resource( 

558 font_name: str, 

559 annotation: DictionaryObject, 

560 acro_form: DictionaryObject, 

561 text: str 

562 ) -> tuple[str, Font]: 

563 # Try to find a resource dictionary for the font by examining the annotation and, if that fails, 

564 # the AcroForm resources dictionary 

565 acro_form_resources: Any = cast( 

566 DictionaryObject, 

567 annotation.get_inherited( 

568 "/DR", 

569 acro_form.get("/DR", DictionaryObject()), 

570 ), 

571 ) 

572 acro_form_font_resources = acro_form_resources.get("/Font", DictionaryObject()) 

573 font_resource = acro_form_font_resources.get(font_name, None) 

574 if font_resource: 

575 font = Font.from_font_resource(font_resource.get_object()) 

576 else: 

577 # Normally, we should have found a font resource by now. However, when a user has provided a specific 

578 # font name, we may not have found the associated font resource among the AcroForm resources. Also, in 

579 # case of the 14 Adobe Core fonts, we may be expected to construct a font resource ourselves. 

580 if font_name.removeprefix("/") not in CORE_FONT_METRICS: 

581 # Default to Helvetica if we haven't found a font resource and cannot construct one ourselves. 

582 logger_warning( 

583 "Font dictionary for %(font_name)s not found; defaulting to Helvetica.", 

584 source=__name__, 

585 font_name=font_name, 

586 ) 

587 font_name = "/Helvetica" 

588 font = Font.from_core_font_name(font_name) 

589 

590 # If we have found a font resource, it still might not be able to encode the text value we received. 

591 encodable = font.can_encode(text) 

592 

593 if not encodable: 

594 # If we have a font file, we can try to produce a new font resource with an encoding 

595 # that does include the necessary characters. We only try this for a TrueType font, meaning 

596 # that, in PDF terms, it is a simple, 8-bit encoded font. 

597 if font.font_descriptor.font_file and font.sub_type == "TrueType": 

598 try: 

599 font = font.from_truetype_font_file(BytesIO(font.font_descriptor.font_file.get_data())) 

600 font_name = "/PYPDF1" # This means we most probably do not clash with an existing font name 

601 encodable = font.can_encode(text) 

602 except (ImportError, PdfReadError) as exception: 

603 logger_warning( 

604 "Unable to use embedded font for encoding: %(exception)s", 

605 source=__name__, 

606 exception=exception, 

607 ) 

608 

609 # If it's one of the unembedded 14 Adobe Core Fonts, we can test other supported encodings 

610 elif font.sub_type == "Type1" and font.name in CORE_FONT_METRICS: 

611 core_font_metrics = CORE_FONT_METRICS[font.name] 

612 test_encodings = { 

613 "cp1250", # Central / Eastern European 

614 "cp1252", # Western European 

615 "cp1254", # Turkish 

616 "cp1257", # Baltic Rim 

617 "iso8859_15" # Western European ISO Alternate 

618 } 

619 for encoding in test_encodings: 

620 test_font = copy.copy(font) 

621 test_font.encoding = encoding_dict_from_named_encoding(encoding) 

622 encodable = test_font.can_encode(text) 

623 if encodable: 

624 font = test_font 

625 font.character_widths.clear() 

626 for code, character in test_font.encoding.items(): 

627 # Look up the width using the glyph name from the encoding 

628 if character in core_font_metrics.character_widths: 

629 font.character_widths[chr(code)] = core_font_metrics.character_widths[character] 

630 font.character_widths["default"] = core_font_metrics.character_widths["default"] 

631 font_name = "/PYPDF1" + encoding 

632 break 

633 

634 if not encodable: 

635 logger_warning( 

636 ( 

637 "Text string '%(text)s' contains characters not supported by font encoding. " 

638 "This may result in text corruption. " 

639 "Consider calling writer.update_page_form_field_values with auto_regenerate=True." 

640 ), 

641 source=__name__, 

642 text=text, 

643 ) 

644 

645 return font_name, font 

646 

647 @staticmethod 

648 def _sync_appearance_stream_font_resources( 

649 writer: PdfWriter, 

650 font_name: str, 

651 font: Font, 

652 target_resource_dict: DictionaryObject, 

653 page: PageObject | None = None 

654 ) -> IndirectObject: 

655 """ 

656 Unified helper to sync fonts from an AP stream to a target resource dictionary (e.g., AcroForm /DR). 

657 Will sync to page resources as well when page is added to the arguments. 

658 """ 

659 target_fonts = target_resource_dict.setdefault(NameObject("/Font"), DictionaryObject()).get_object() 

660 if font_name not in target_fonts: 

661 font_resource_reference = font._add_to_writer( 

662 writer, 

663 target_fonts, 

664 NameObject(font_name) 

665 ) 

666 else: 

667 font_resource_reference = target_fonts[font_name] 

668 

669 if page: 

670 page_fonts_resource = cast(DictionaryObject, page[PageAttributes.RESOURCES]).setdefault( 

671 NameObject("/Font"), DictionaryObject() 

672 ).get_object() 

673 if font_name not in page_fonts_resource: 

674 page_fonts_resource[NameObject(font_name)] = getattr( 

675 font_resource_reference, "indirect_reference", font_resource_reference 

676 ) 

677 

678 return font_resource_reference 

679 

680 @staticmethod 

681 def _parse_default_appearance( 

682 default_appearance: TextStringObject | ByteStringObject, 

683 ) -> tuple[str | None, float, Color | None]: 

684 """ 

685 Read the font name, font size and font color from a default appearance string. 

686 

687 A default appearance is a content stream fragment of graphics and text state operators, such as 

688 `/a1.0 gs 0 0 0 rg /Helv 10 Tf` (Section 12.7.4.3 "Variable text" of the PDF 2.0 specification). 

689 Where an operator occurs more than once, the last one is in effect. Operators other than `Tf` and 

690 the non-stroking color operators are ignored. 

691 

692 Args: 

693 default_appearance: The /DA string of a variable text field. 

694 

695 Returns: 

696 The font name and font size of the last complete Tf operator, or None and 0 if there is none, 

697 and the color of the last complete color operator, or None if there is none. 

698 """ 

699 stream = ContentStream(None, None) 

700 stream.set_data(default_appearance.original_bytes) 

701 try: 

702 operations = stream.operations 

703 except PdfReadError: 

704 # Ignore a malformed default appearance altogether. 

705 return None, 0.0, None 

706 

707 font_name: str | None = None 

708 font_size = 0.0 

709 font_color: Color | None = None 

710 for operands, operator in operations: 

711 if operator == b"Tf": 

712 if len(operands) == 2 and isinstance(operands[0], NameObject) and isinstance(operands[1], (int, float)): 

713 font_name, font_size = str(operands[0]), float(operands[1]) 

714 elif (color := Color.from_normalized_values(operands, operator.decode("latin-1"))) is not None: 

715 font_color = color 

716 return font_name, font_size, font_color 

717 

718 @classmethod 

719 def from_text_annotation( 

720 cls, 

721 writer: PdfWriter, 

722 page: PageObject, 

723 flatten: bool, 

724 acro_form: DictionaryObject, # _root_object[CatalogAttributes.ACRO_FORM]) 

725 field: DictionaryObject, 

726 annotation: DictionaryObject, 

727 user_font_name: str = "", 

728 user_font_size: float = -1, 

729 ) -> TextStreamAppearance: 

730 """ 

731 Creates a TextStreamAppearance object from a text field annotation. 

732 

733 This class method is a factory for creating a `TextStreamAppearance` 

734 instance by extracting all necessary information (bounding box, font, 

735 text content, etc.) from the PDF field and annotation dictionaries. 

736 It respects inheritance for properties like default appearance (`/DA`). 

737 

738 Args: 

739 writer: The PdfWriter instance that we are creating text stream appearances for. 

740 page: The page that we are processing annotations for. 

741 flatten: Whether we flatten text annotations or not. If true, add new font resource 

742 to the page font resources. Otherwise, add them to the AcroForm resources. 

743 acro_form: The root AcroForm dictionary from the PDF catalog. 

744 field: The field dictionary object. 

745 annotation: The widget annotation dictionary object associated with the field. 

746 user_font_name: An optional user-provided font name to override the 

747 default. Defaults to an empty string. 

748 user_font_size: An optional user-provided font size to override the 

749 default. A value of -1 indicates no override. 

750 

751 Returns: 

752 A new `TextStreamAppearance` instance configured for the given field. 

753 

754 """ 

755 # Calculate rectangle dimensions 

756 _rectangle = cast(RectangleObject, annotation[AnnotationDictionaryAttributes.Rect]) 

757 # Normalize the rectangle, apply page rotation if applicable 

758 if page.get_inherited("/Rotate") in {90, 270}: 

759 rectangle = RectangleObject((0, 0, abs(_rectangle[3] - _rectangle[1]), abs(_rectangle[2] - _rectangle[0]))) 

760 else: 

761 rectangle = RectangleObject((0, 0, abs(_rectangle[2] - _rectangle[0]), abs(_rectangle[3] - _rectangle[1]))) 

762 

763 # Get default appearance dictionary from annotation 

764 default_appearance = annotation.get_inherited( 

765 AnnotationDictionaryAttributes.DA, 

766 acro_form.get(AnnotationDictionaryAttributes.DA, None), 

767 ) 

768 if not default_appearance: 

769 # Create a default appearance if none was found in the annotation 

770 default_appearance = TextStringObject("/Helv 0 Tf 0 g") 

771 else: 

772 default_appearance = default_appearance.get_object() 

773 

774 # Retrieve field text and selected values 

775 field_flags = field.get(FieldDictionaryAttributes.Ff, 0) 

776 if ( 

777 field.get(FieldDictionaryAttributes.FT, "/Tx") == "/Ch" and 

778 field_flags & FieldDictionaryAttributes.FfBits.Combo == 0 

779 ): 

780 text = "\n".join(annotation.get_inherited(FieldDictionaryAttributes.Opt, [])) 

781 selection = field.get("/V", []) 

782 if not isinstance(selection, list): 

783 selection = [selection] 

784 else: # /Tx 

785 text = field.get("/V", "") 

786 selection = [] 

787 

788 # Derive font name, size and color from the default appearance. Also set 

789 # user-provided font name and font size in the default appearance, if given. 

790 # For a font name, this presumes that we can find an associated font resource 

791 # dictionary. 

792 # As per the PDF spec: 

793 # "At a minimum, the string [that is, default_appearance] shall include a Tf (text 

794 # font) operator along with its two operands, font and size" (Section 12.7.4.3 

795 # "Variable text" of the PDF 2.0 specification). 

796 da_font_name, font_size, font_color = cls._parse_default_appearance(default_appearance) 

797 if da_font_name is None: 

798 logger_warning( 

799 ( 

800 "Could not read a complete Tf operator from the default appearance %(default_appearance)r; " 

801 "defaulting to /Helv 0 Tf." 

802 ), 

803 source=__name__, 

804 default_appearance=str(default_appearance), 

805 ) 

806 da_font_name, font_size = "/Helv", 0.0 

807 # Determine the font name to use, prioritizing the user's input 

808 if user_font_name: 

809 font_name = user_font_name 

810 else: 

811 font_name = da_font_name 

812 # Determine the font size to use, prioritizing the user's input 

813 if user_font_size > 0: 

814 font_size = user_font_size 

815 

816 font_name, font = cls._find_annotation_font_resource(font_name, annotation, acro_form, text) 

817 

818 # Change the /DA information if we changed the font name 

819 if font_name != da_font_name: 

820 annotation[NameObject("/DA")] = TextStringObject(default_appearance.replace(da_font_name, font_name)) 

821 

822 # Synchronise font resources 

823 font_resource_reference = cls._sync_appearance_stream_font_resources( 

824 writer, 

825 font_name, 

826 font, 

827 acro_form.setdefault(NameObject("/DR"), DictionaryObject()), 

828 page if flatten else None, 

829 ) 

830 

831 # Retrieve formatting information 

832 is_comb = False 

833 max_length = None 

834 if field_flags & FieldDictionaryAttributes.FfBits.Comb: 

835 is_comb = True 

836 max_length = annotation.get("/MaxLen") 

837 is_multiline = False 

838 if field_flags & FieldDictionaryAttributes.FfBits.Multiline: 

839 is_multiline = True 

840 alignment = field.get("/Q", TextAlignment.LEFT) 

841 border_width = 1 

842 border_style = BorderStyles.SOLID 

843 if "/BS" in field: 

844 border_width = cast(DictionaryObject, field["/BS"]).get("/W", border_width) 

845 border_style = cast(DictionaryObject, field["/BS"]).get("/S", border_style) 

846 

847 rotation = 0 

848 border_color: Color | None = None 

849 background_color: Color | None = None 

850 appearance_characteristics = field.get_inherited("/MK", None) 

851 if isinstance(appearance_characteristics, DictionaryObject): 

852 rotation = int(appearance_characteristics.get("/R", 0)) 

853 # Color.from_normalized_values([]) results in None for a "/BC" or "/BG" value of [] 

854 border_color = Color.from_normalized_values(appearance_characteristics.get("/BC")) 

855 background_color = Color.from_normalized_values(appearance_characteristics.get("/BG")) 

856 

857 # Create the TextStreamAppearance instance 

858 layout = BaseStreamConfig( 

859 rectangle=rectangle, 

860 border_width=border_width, 

861 border_style=border_style, 

862 border_color=border_color, 

863 background_color=background_color, 

864 rotation=rotation 

865 ) 

866 

867 new_appearance_stream = cls( 

868 layout, 

869 text, 

870 selection, 

871 font, 

872 font_resource_reference, 

873 font_name=font_name, 

874 font_size=font_size, 

875 font_color=font_color, 

876 is_multiline=is_multiline, 

877 alignment=alignment, 

878 is_comb=is_comb, 

879 max_length=max_length 

880 ) 

881 

882 if AnnotationDictionaryAttributes.AP in annotation: 

883 for key, value in ( 

884 cast(DictionaryObject, annotation[AnnotationDictionaryAttributes.AP]).get("/N", {}).items() 

885 ): 

886 if key in {"/BBox", "/Length", "/Subtype", "/Type", "/Filter"}: 

887 continue 

888 # Don't overwrite font resources added by TextAppearanceStream.__init__ 

889 if key == "/Resources": 

890 if "/Font" not in value: 

891 value.get_object()[NameObject("/Font")] = DictionaryObject() 

892 value["/Font"].get_object()[NameObject(font_name)] = getattr( 

893 font_resource_reference, "indirect_reference", font_resource_reference 

894 ) 

895 else: 

896 new_appearance_stream[key] = value 

897 

898 return new_appearance_stream 

899 

900 

901def transform_annotation_appearance(annotation_obj: DictionaryObject, transformation: Transformation) -> None: 

902 """ 

903 Compose `transformation` into an annotation's /AP /N appearance stream(s), in place. 

904 

905 Repositioning/resizing an annotation's /Rect alone is not enough: per 

906 the appearance-stream algorithm (PDF 2.0, 12.5.5), a viewer fits the 

907 appearance's /BBox (as mapped by its own /Matrix) into /Rect using an 

908 axis-aligned scale, never a rotation. Left alone, rotating or shearing 

909 a page therefore stretches/skews the untouched appearance content into 

910 the new, differently-shaped /Rect instead of rotating it. Composing the 

911 transform into /Matrix (rather than overwriting it) keeps the rendered 

912 content consistent with the rest of the transformed page while 

913 preserving whatever the annotation already had. Handles both a single 

914 appearance stream and the multi-state /AP /N dict used by 

915 checkbox/radio-button widgets. 

916 """ 

917 if "/AP" not in annotation_obj: 

918 return 

919 ap = cast(DictionaryObject, annotation_obj["/AP"]) 

920 if "/N" not in ap: 

921 return 

922 # __getitem__ already resolves indirect references, so this is never None. 

923 normal_ap = ap["/N"] 

924 # /N is a single appearance stream for most annotations, but for 

925 # widgets with multiple states (checkboxes, radio buttons) it is 

926 # instead a dict of named sub-streams, one per state. Only a stream 

927 # carries its own /BBox/Matrix. 

928 state_aps = ( 

929 normal_ap.values() 

930 if isinstance(normal_ap, DictionaryObject) and not isinstance(normal_ap, StreamObject) 

931 else [normal_ap] 

932 ) 

933 for state_ap in state_aps: 

934 # get_object() is always safe to call: PdfObject.get_object() just 

935 # returns self when the object is already resolved. 

936 state_obj = state_ap.get_object() 

937 if not isinstance(state_obj, StreamObject): 

938 continue 

939 old_matrix = tuple(state_obj.get("/Matrix", (1, 0, 0, 1, 0, 0))) 

940 state_obj[NameObject("/Matrix")] = ArrayObject( 

941 FloatObject(x) for x in Transformation(old_matrix).transform(transformation).ctm 

942 )