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

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

326 statements  

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 )