Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pypdf/_font.py: 22%

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

360 statements  

1from __future__ import annotations 

2 

3import unicodedata 

4from collections.abc import Sequence 

5from dataclasses import dataclass, field 

6from typing import TYPE_CHECKING, Any, cast 

7 

8from pypdf.generic import ( 

9 ArrayObject, 

10 DictionaryObject, 

11 FloatObject, 

12 IndirectObject, 

13 NameObject, 

14 NumberObject, 

15 PdfObject, 

16 StreamObject, 

17 TextStringObject, 

18) 

19 

20from ._cmap import get_encoding 

21from ._codecs.adobe_glyphs import adobe_glyphs 

22from ._utils import logger_warning 

23from .constants import FontFlags 

24from .errors import PdfReadError 

25 

26if TYPE_CHECKING: 

27 from fontTools.ttLib.tables._h_e_a_d import table__h_e_a_d 

28 from fontTools.ttLib.tables._p_o_s_t import table__p_o_s_t 

29 from fontTools.ttLib.tables.O_S_2f_2 import table_O_S_2f_2 

30 

31 from ._writer import PdfWriter 

32 

33try: 

34 from io import BytesIO 

35 

36 from fontTools.ttLib import TTFont, TTLibError 

37 HAS_FONTTOOLS = True 

38except ImportError: 

39 HAS_FONTTOOLS = False 

40 

41 

42# Some constants from truetype font tables that we use: 

43HEADER_MACSTYLE_ITALIC = 0x02 

44OS2_FSSELECTION_ITALIC = 0x01 

45OS2_PANOSE_BFAMILYTYPE_SCRIPT = 3 

46OS2_PANOSE_BFAMILYTYPE_DECORATIVE = 4 

47OS2_PANOSE_BFAMILYTYPE_PICTORIAL = 5 

48OS2_PANOSE_BPROPORTION_MONOSPACED = 9 

49OS2_SFAMILYSCLASS_SCRIPTS = 10 

50OS2_SFAMILYSCLASS_SYMBOLIC = 12 

51 

52 

53@dataclass(frozen=True) 

54class FontDescriptor: 

55 """ 

56 Represents the FontDescriptor dictionary as defined in the PDF specification. 

57 This contains both descriptive and metric information. 

58 

59 The defaults are derived from the mean values of the 14 core fonts, rounded 

60 to 100. 

61 """ 

62 

63 name: str = "Unknown" 

64 family: str = "Unknown" 

65 weight: str = "Unknown" 

66 

67 ascent: float = 700.0 

68 descent: float = -200.0 

69 cap_height: float = 600.0 

70 x_height: float = 500.0 

71 italic_angle: float = 0.0 # Non-italic 

72 flags: int = 32 # Non-serif, non-symbolic, not fixed width 

73 bbox: tuple[float, float, float, float] = field(default_factory=lambda: (-100.0, -200.0, 1000.0, 900.0)) 

74 font_file: StreamObject | None = None 

75 

76 def as_font_descriptor_resource(self) -> DictionaryObject: 

77 font_descriptor_resource = DictionaryObject({ 

78 NameObject("/Type"): NameObject("/FontDescriptor"), 

79 NameObject("/FontName"): NameObject(f"/{self.name}"), 

80 NameObject("/Flags"): NumberObject(self.flags), 

81 NameObject("/FontBBox"): ArrayObject([FloatObject(n) for n in self.bbox]), 

82 NameObject("/ItalicAngle"): FloatObject(self.italic_angle), 

83 NameObject("/Ascent"): FloatObject(self.ascent), 

84 NameObject("/Descent"): FloatObject(self.descent), 

85 NameObject("/CapHeight"): FloatObject(self.cap_height), 

86 NameObject("/XHeight"): FloatObject(self.x_height), 

87 }) 

88 

89 if self.font_file: 

90 # Add the stream. For now, we assume a TrueType font (FontFile2) 

91 font_descriptor_resource[NameObject("/FontFile2")] = self.font_file 

92 

93 return font_descriptor_resource 

94 

95 

96@dataclass(frozen=True) 

97class CoreFontMetrics: 

98 font_descriptor: FontDescriptor 

99 character_widths: dict[str, int] 

100 

101 

102@dataclass 

103class Font: 

104 """ 

105 A font object for use during text extraction and for producing 

106 text appearance streams. 

107 

108 Attributes: 

109 name: Font name, derived from font["/BaseFont"] 

110 character_map: The font's character map 

111 encoding: Font encoding 

112 sub_type: The font type, such as Type1, TrueType, or Type3. 

113 font_descriptor: Font metrics, including a mapping of characters to widths 

114 character_widths: A mapping of characters to widths 

115 space_width: The width of a space, or an approximation 

116 interpretable: Default True. If False, the font glyphs cannot 

117 be translated to characters, e.g. Type3 fonts that do not define 

118 a '/ToUnicode' mapping. 

119 

120 """ 

121 

122 name: str 

123 encoding: str | dict[int, str] 

124 character_map: dict[Any, Any] = field(default_factory=dict) 

125 sub_type: str = "Unknown" 

126 font_descriptor: FontDescriptor = field(default_factory=FontDescriptor) 

127 character_widths: dict[str, int] = field(default_factory=lambda: {"default": 500}) 

128 space_width: float | int = 250 

129 space_char: str = " " 

130 interpretable: bool = True 

131 

132 @staticmethod 

133 def _collect_tt_t1_character_widths( 

134 pdf_font_dict: DictionaryObject, 

135 char_map: dict[Any, Any], 

136 encoding: str | dict[int, str], 

137 current_widths: dict[str, int] 

138 ) -> None: 

139 """Parses a TrueType or Type1 font's /Widths array from a font dictionary and updates character widths""" 

140 widths_array = cast(ArrayObject, pdf_font_dict["/Widths"]) 

141 first_char = pdf_font_dict.get("/FirstChar", 0) 

142 for idx, width in enumerate(widths_array): 

143 current_widths[chr(idx + first_char)] = int(width) 

144 

145 @staticmethod 

146 def _collect_cid_character_widths( 

147 d_font: DictionaryObject, char_map: dict[Any, Any], current_widths: dict[str, int] 

148 ) -> None: 

149 """Parses the /W array from a DescendantFont dictionary and updates character widths.""" 

150 # /W width definitions have two valid formats which can be mixed and matched: 

151 # (1) A character start index followed by a list of widths, e.g. 

152 # `45 [500 600 700]` applies widths 500, 600, 700 to characters 45-47. 

153 # (2) A character start index, a character stop index, and a width, e.g. 

154 # `45 65 500` applies width 500 to characters 45-65. 

155 skip_count = 0 

156 _w = d_font.get("/W", ArrayObject()).get_object() 

157 _w_length = len(_w) 

158 for idx, w_entry in enumerate(_w): 

159 w_entry = w_entry.get_object() 

160 if skip_count: 

161 skip_count -= 1 

162 continue 

163 if not isinstance(w_entry, (int, float)): 

164 # We should never get here due to skip_count above. But 

165 # sometimes we do. 

166 logger_warning( 

167 "Expected numeric value for width, got %(w_entry)s. Ignoring it.", 

168 source=__name__, 

169 w_entry=w_entry, 

170 ) 

171 continue 

172 # check for format (1): `int [int int int int ...]` 

173 w_next_entry = _w[idx + 1].get_object() if idx + 1 < _w_length else None 

174 if isinstance(w_next_entry, Sequence): 

175 start_idx, width_list = w_entry, w_next_entry 

176 current_widths.update( 

177 { 

178 chr(_cidx): _width 

179 for _cidx, _width in zip( 

180 range( 

181 cast(int, start_idx), 

182 cast(int, start_idx) + len(width_list), 

183 1, 

184 ), 

185 width_list, 

186 ) 

187 } 

188 ) 

189 skip_count = 1 

190 # check for format (2): `int int int` 

191 elif ( 

192 isinstance(w_next_entry, (int, float)) 

193 and idx + 2 < _w_length 

194 and isinstance(_w[idx + 2].get_object(), (int, float)) 

195 ): 

196 start_idx, stop_idx, const_width = ( 

197 w_entry, 

198 w_next_entry, 

199 _w[idx + 2].get_object(), 

200 ) 

201 current_widths.update( 

202 { 

203 chr(_cidx): const_width 

204 for _cidx in range( 

205 cast(int, start_idx), cast(int, stop_idx + 1), 1 

206 ) 

207 } 

208 ) 

209 skip_count = 2 

210 else: 

211 # This handles the case of out of bounds (reaching the end of the width definitions 

212 # while expecting more elements). 

213 logger_warning( 

214 "Invalid font width definition. Last element: %(w_entry)s.", 

215 source=__name__, 

216 w_entry=w_entry, 

217 ) 

218 

219 @staticmethod 

220 def _get_space_char( 

221 encoding: str | dict[int, str], 

222 character_map: dict[Any, Any], 

223 ) -> str: 

224 space_char = " " 

225 if isinstance(encoding, dict): 

226 for char_code, char_str in encoding.items(): 

227 if char_str == space_char: 

228 return chr(char_code) 

229 

230 for glyph_id, char_str in character_map.items(): 

231 if char_str == space_char: 

232 return str(glyph_id) 

233 

234 return space_char 

235 

236 @staticmethod 

237 def _add_default_width(current_widths: dict[str, int], flags: int, space_char: str) -> None: 

238 if not current_widths: 

239 current_widths["default"] = 500 

240 return 

241 

242 if space_char in current_widths and current_widths[space_char] != 0: 

243 # Setting default to once or twice the space width, depending on fixed pitch 

244 if (flags & FontFlags.FIXED_PITCH) == FontFlags.FIXED_PITCH: 

245 current_widths["default"] = current_widths[space_char] 

246 return 

247 

248 current_widths["default"] = int(2 * current_widths[space_char]) 

249 return 

250 

251 # Use the average width of existing glyph widths 

252 valid_widths = [w for w in current_widths.values() if w > 0] 

253 current_widths["default"] = sum(valid_widths) // len(valid_widths) if valid_widths else 500 

254 

255 @staticmethod 

256 def _add_space_width( 

257 character_widths: dict[str, int], 

258 flags: int, 

259 space_char: str 

260 ) -> int: 

261 space_width = character_widths.get(space_char, 0) 

262 if space_width != 0: 

263 return space_width 

264 

265 if (flags & FontFlags.FIXED_PITCH) == FontFlags.FIXED_PITCH: 

266 return character_widths["default"] 

267 

268 return character_widths["default"] // 2 

269 

270 @staticmethod 

271 def _parse_font_descriptor(font_descriptor_obj: DictionaryObject) -> dict[str, Any]: 

272 font_descriptor_kwargs: dict[Any, Any] = {} 

273 for source_key, target_key in [ 

274 ("/FontName", "name"), 

275 ("/FontFamily", "family"), 

276 ("/FontWeight", "weight"), 

277 ("/Ascent", "ascent"), 

278 ("/Descent", "descent"), 

279 ("/CapHeight", "cap_height"), 

280 ("/XHeight", "x_height"), 

281 ("/ItalicAngle", "italic_angle"), 

282 ("/Flags", "flags"), 

283 ("/FontBBox", "bbox") 

284 ]: 

285 if source_key in font_descriptor_obj: 

286 font_descriptor_kwargs[target_key] = font_descriptor_obj[source_key] 

287 # Handle missing bbox gracefully - PDFs may have fonts without valid bounding boxes 

288 if "bbox" in font_descriptor_kwargs: 

289 bbox_tuple = tuple(map(float, font_descriptor_kwargs["bbox"])) 

290 assert len(bbox_tuple) == 4, bbox_tuple 

291 font_descriptor_kwargs["bbox"] = bbox_tuple 

292 

293 # Find the binary stream for this font if there is one 

294 for source_key in ["/FontFile", "/FontFile2", "/FontFile3"]: 

295 if source_key in font_descriptor_obj: 

296 if "font_file" in font_descriptor_kwargs: 

297 raise PdfReadError(f"More than one /FontFile found in {font_descriptor_obj}") 

298 

299 try: 

300 font_file = font_descriptor_obj[source_key].get_object() 

301 font_descriptor_kwargs["font_file"] = font_file 

302 except PdfReadError as e: 

303 logger_warning( 

304 "Failed to get %(source_key)r in %(font_descriptor_obj)s: %(error)s", 

305 source=__name__, 

306 source_key=source_key, 

307 font_descriptor_obj=font_descriptor_obj, 

308 error=e, 

309 ) 

310 return font_descriptor_kwargs 

311 

312 @classmethod 

313 def from_font_resource( 

314 cls, 

315 pdf_font_dict: DictionaryObject, 

316 ) -> Font: 

317 from pypdf._codecs.core_font_metrics import CORE_FONT_METRICS # noqa: PLC0415 

318 

319 # Can collect base_font, name and encoding directly from font resource 

320 name = pdf_font_dict.get("/BaseFont", "Unknown").removeprefix("/") 

321 sub_type = pdf_font_dict.get("/Subtype", "Unknown").removeprefix("/") 

322 encoding, character_map = get_encoding(pdf_font_dict) 

323 font_descriptor = None 

324 character_widths: dict[str, int] = {} 

325 interpretable = True 

326 

327 # Deal with fonts by type; Type1, TrueType and certain Type3 

328 if pdf_font_dict.get("/Subtype") in ("/Type1", "/MMType1", "/TrueType", "/Type3"): 

329 # Type3 fonts that do not specify a "/ToUnicode" mapping cannot be 

330 # reliably converted into character codes unless all named chars 

331 # in /CharProcs map to a standard adobe glyph. See §9.10.2 of the 

332 # PDF 1.7 standard. 

333 if sub_type == "Type3" and "/ToUnicode" not in pdf_font_dict: 

334 interpretable = all( 

335 cname in adobe_glyphs 

336 for cname in pdf_font_dict.get("/CharProcs") or [] 

337 ) 

338 if interpretable: # Save some overhead if font is not interpretable 

339 if "/Widths" in pdf_font_dict: 

340 cls._collect_tt_t1_character_widths( 

341 pdf_font_dict, character_map, encoding, character_widths 

342 ) 

343 

344 elif name in CORE_FONT_METRICS: 

345 font_descriptor = CORE_FONT_METRICS[name].font_descriptor 

346 if isinstance(encoding, dict): 

347 for code, character in encoding.items(): 

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

349 if character in CORE_FONT_METRICS[name].character_widths: 

350 character_widths[chr(code)] = CORE_FONT_METRICS[name].character_widths[character] 

351 else: 

352 for code in range(256): 

353 character = chr(code) 

354 if character in CORE_FONT_METRICS[name].character_widths: 

355 character_widths[character] = CORE_FONT_METRICS[name].character_widths[character] 

356 if "/FontDescriptor" in pdf_font_dict: 

357 font_descriptor_obj = pdf_font_dict.get("/FontDescriptor", DictionaryObject()).get_object() 

358 if "/MissingWidth" in font_descriptor_obj: 

359 character_widths["default"] = cast(int, font_descriptor_obj["/MissingWidth"].get_object()) 

360 font_descriptor = FontDescriptor(**cls._parse_font_descriptor(font_descriptor_obj)) 

361 elif "/FontBBox" in pdf_font_dict: 

362 # For Type3 without Font Descriptor but with FontBBox, see Table 110 in the PDF specification 2.0 

363 bbox_tuple = tuple(map(float, cast(ArrayObject, pdf_font_dict["/FontBBox"]))) 

364 assert len(bbox_tuple) == 4, bbox_tuple 

365 font_descriptor = FontDescriptor(name=name, bbox=bbox_tuple) 

366 

367 else: 

368 # Composite font or CID font - CID fonts have a /W array mapping character codes 

369 # to widths stashed in /DescendantFonts. No need to test for /DescendantFonts though, 

370 # because all other fonts have already been dealt with. 

371 d_font: DictionaryObject 

372 for d_font_idx, d_font in enumerate( 

373 cast(ArrayObject, pdf_font_dict["/DescendantFonts"]) 

374 ): 

375 d_font = cast(DictionaryObject, d_font.get_object()) 

376 cast(ArrayObject, pdf_font_dict["/DescendantFonts"])[d_font_idx] = d_font 

377 cls._collect_cid_character_widths( 

378 d_font, character_map, character_widths 

379 ) 

380 if "/DW" in d_font: 

381 character_widths["default"] = cast(int, d_font["/DW"].get_object()) 

382 font_descriptor_obj = d_font.get("/FontDescriptor", DictionaryObject()).get_object() 

383 font_descriptor = FontDescriptor(**cls._parse_font_descriptor(font_descriptor_obj)) 

384 

385 if not font_descriptor: 

386 font_descriptor = FontDescriptor(name=name) 

387 

388 space_char = cls._get_space_char(encoding, character_map) 

389 if character_widths.get("default", 0) == 0: 

390 cls._add_default_width(character_widths, font_descriptor.flags, space_char) 

391 space_width = cls._add_space_width(character_widths, font_descriptor.flags, space_char) 

392 

393 return cls( 

394 name=name, 

395 sub_type=sub_type, 

396 encoding=encoding, 

397 font_descriptor=font_descriptor, 

398 character_map=character_map, 

399 character_widths=character_widths, 

400 space_width=space_width, 

401 space_char=space_char, 

402 interpretable=interpretable 

403 ) 

404 

405 @staticmethod 

406 def _font_flags_from_truetype_font_tables( 

407 header: table__h_e_a_d, 

408 postscript: table__p_o_s_t, 

409 os2: table_O_S_2f_2 

410 ) -> int: 

411 # Get the font flags 

412 if os2: 

413 panose = os2.panose 

414 # sFamilyClass is a two-byte field. The high byte describes the family class, whereas the low 

415 # byte only describes the subclass. We only need the high byte, hence the bit shift below: 

416 family_class = os2.sFamilyClass >> 8 

417 flags: int = 0 

418 

419 # ITALIC 

420 if header.macStyle & HEADER_MACSTYLE_ITALIC or (os2 and os2.fsSelection & OS2_FSSELECTION_ITALIC): 

421 flags |= FontFlags.ITALIC 

422 if postscript: 

423 italic_angle = postscript.italicAngle 

424 if italic_angle != 0.0: 

425 flags |= FontFlags.ITALIC 

426 

427 # FIXED_PITCH 

428 if ( 

429 (os2 and panose.bProportion == OS2_PANOSE_BPROPORTION_MONOSPACED) or 

430 (postscript and postscript.isFixedPitch > 0) # Actually 1, but originally (older versions of the TTF 

431 ): # specification) any non-zero value signified monospace. 

432 flags |= FontFlags.FIXED_PITCH 

433 

434 # SCRIPT 

435 if os2 and ( 

436 family_class == OS2_SFAMILYSCLASS_SCRIPTS or panose.bFamilyType == OS2_PANOSE_BFAMILYTYPE_SCRIPT 

437 ): 

438 flags |= FontFlags.SCRIPT 

439 

440 # SERIF 

441 if os2 and ( 

442 2 <= panose.bSerifStyle <= 10 

443 or 1 <= family_class <= 5 or family_class == 7 # 6 is reserved, all 8 and above are not serif 

444 ): 

445 flags |= FontFlags.SERIF 

446 

447 # SYMBOLIC 

448 if os2 and ( 

449 family_class == OS2_SFAMILYSCLASS_SYMBOLIC or 

450 panose.bFamilyType in {OS2_PANOSE_BFAMILYTYPE_DECORATIVE, OS2_PANOSE_BFAMILYTYPE_PICTORIAL} 

451 ): 

452 flags |= FontFlags.SYMBOLIC 

453 else: 

454 flags |= FontFlags.NONSYMBOLIC 

455 

456 return flags 

457 

458 @classmethod 

459 def from_truetype_font_file(cls, font_file: BytesIO) -> Font: 

460 if not HAS_FONTTOOLS: 

461 raise ImportError("The 'fontTools' library is required to use 'from_truetype_font_file'") 

462 with TTFont(font_file) as tt_font_object: 

463 # See Chapter 6 of the TrueType reference manual for the definition of the head, OS/2 and post tables: 

464 # https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6head.html 

465 # https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6OS2.html 

466 # https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6post.html 

467 header = tt_font_object["head"] 

468 horizontal_header = tt_font_object["hhea"] 

469 metrics = tt_font_object["hmtx"].metrics 

470 

471 # Collect additional font tables to derive font information 

472 postscript = tt_font_object.get("post", None) 

473 os2 = tt_font_object.get("OS/2", None) 

474 

475 # Get the scaling factor to convert font file's units per em to PDF's 1000 units per em 

476 units_per_em = header.unitsPerEm 

477 if not units_per_em: 

478 raise PdfReadError("Font file has an invalid unitsPerEm of 0") 

479 scale_factor = 1000.0 / units_per_em 

480 

481 # Get the font descriptor 

482 font_descriptor_kwargs: dict[Any, Any] = {} 

483 names = tt_font_object.get("name", None) 

484 if names: 

485 font_descriptor_kwargs["name"] = names.getBestFullName() 

486 font_descriptor_kwargs["family"] = names.getBestFamilyName() 

487 font_descriptor_kwargs["weight"] = names.getBestSubFamilyName() 

488 font_descriptor_kwargs["ascent"] = int(round(horizontal_header.ascent * scale_factor, 0)) 

489 font_descriptor_kwargs["descent"] = int(round(horizontal_header.descent * scale_factor, 0)) 

490 if os2: 

491 try: 

492 font_descriptor_kwargs["cap_height"] = int(round(os2.sCapHeight * scale_factor, 0)) 

493 font_descriptor_kwargs["x_height"] = int(round(os2.sxHeight * scale_factor, 0)) 

494 except AttributeError: 

495 pass 

496 

497 font_descriptor_kwargs["flags"] = cls._font_flags_from_truetype_font_tables(header, postscript, os2) 

498 

499 font_descriptor_kwargs["bbox"] = ( 

500 round(header.xMin * scale_factor, 0), 

501 round(header.yMin * scale_factor, 0), 

502 round(header.xMax * scale_factor, 0), 

503 round(header.yMax * scale_factor, 0) 

504 ) 

505 

506 font_file_data = StreamObject() 

507 font_file_raw_bytes = font_file.getvalue() 

508 font_file_data.set_data(font_file_raw_bytes) 

509 font_file_data.update({NameObject("/Length1"): NumberObject(len(font_file_raw_bytes))}) 

510 font_descriptor_kwargs["font_file"] = font_file_data 

511 

512 font_descriptor = FontDescriptor(**font_descriptor_kwargs) 

513 encoding = "utf_16_be" # Assume unicode 

514 

515 character_widths: dict[str, int] = {} 

516 character_map: dict[str, str] = {} 

517 

518 glyph_order = tt_font_object.getGlyphOrder() 

519 # Note that one glyph can be mapped to multiple unicode code points. However, buildReversedMin() 

520 # creates a dictionary mapping glyphs to the minimum Unicode codepoint. 

521 tt_font_cmap_table = tt_font_object.get("cmap") 

522 if tt_font_cmap_table: 

523 reverse_cmap = tt_font_cmap_table.buildReversedMin() 

524 for gid, glyph in enumerate(glyph_order): 

525 char_code = reverse_cmap.get(glyph) 

526 if char_code is None: 

527 continue 

528 char = chr(char_code) 

529 gid = tt_font_object.getGlyphID(glyph) 

530 # The following is to comply with how font_glyph_byte_map works in _appearance_stream.py 

531 gid_bytes = gid.to_bytes(2, "big") 

532 gid_key_string = gid_bytes.decode("utf-16-be", "surrogatepass") 

533 character_map[gid_key_string] = char 

534 character_widths[gid_key_string] = int(round(metrics[glyph][0] * scale_factor, 0)) 

535 else: 

536 raise PdfReadError("Font file does not have a cmap table") 

537 

538 space_char = cls._get_space_char(encoding, character_map) 

539 cls._add_default_width(character_widths, font_descriptor_kwargs["flags"], space_char) 

540 space_width = cls._add_space_width( 

541 character_widths, font_descriptor_kwargs["flags"], space_char 

542 ) 

543 

544 return cls( 

545 name=font_descriptor.name, 

546 sub_type="TrueType", 

547 encoding=encoding, 

548 font_descriptor=font_descriptor, 

549 character_map=character_map, 

550 character_widths=character_widths, 

551 space_width=space_width, 

552 space_char=space_char, 

553 interpretable=True 

554 ) 

555 

556 def _get_typographic_maps(self) -> tuple[dict[str, str], dict[str, bytes]]: 

557 """ 

558 Generates maps to translate input unicode text to bytes in two steps: 

559 Unicode code point -> raw_character (reverse cmap) -> PDF bytes (encoding cmap). 

560 """ 

561 reverse_cmap = {} 

562 encoding_cmap = {} 

563 if ( 

564 HAS_FONTTOOLS 

565 and getattr(self.font_descriptor, "font_file", None) 

566 and isinstance(self.encoding, str) 

567 ): 

568 try: 

569 font_file_data = cast(StreamObject, self.font_descriptor.font_file).get_data() 

570 with TTFont(BytesIO(font_file_data)) as tt_font_object: 

571 tt_font_cmap_table = tt_font_object.get("cmap") 

572 best_cmap = tt_font_cmap_table.getBestCmap() 

573 for unicode_int, glyph_name in best_cmap.items(): 

574 gid = tt_font_object.getGlyphID(glyph_name) 

575 gid_bytes = gid.to_bytes(2, "big") 

576 gid_key_string = gid_bytes.decode("utf-16-be", "surrogatepass") 

577 unicode_char = chr(unicode_int) 

578 reverse_cmap[unicode_char] = gid_key_string 

579 encoding_cmap[gid_key_string] = gid_key_string.encode(self.encoding) 

580 

581 return reverse_cmap, encoding_cmap 

582 

583 except (AttributeError, TTLibError): # Cmap table is missing or the font is corrupt. 

584 reverse_cmap.clear() 

585 encoding_cmap.clear() 

586 

587 if isinstance(self.encoding, str): 

588 for glyph_id, unicode_char in self.character_map.items(): 

589 glyph_id_str = str(glyph_id) 

590 reverse_cmap[unicode_char] = glyph_id_str 

591 encoding_cmap[glyph_id_str] = glyph_id_str.encode(self.encoding) 

592 else: 

593 for character_code, unicode_char in self.encoding.items(): 

594 character_str = chr(character_code) 

595 reverse_cmap[unicode_char] = character_str 

596 encoding_cmap[character_str] = bytes((character_code,)) 

597 

598 unicode_to_bytes = { 

599 unicode_char: bytes((character_code,)) for character_code, unicode_char in self.encoding.items() 

600 } 

601 for glyph_id, unicode_char in self.character_map.items(): # This code is not covered nor tested 

602 reverse_cmap[unicode_char] = glyph_id 

603 encoding_cmap[glyph_id] = unicode_to_bytes.get(unicode_char, bytes((glyph_id,))) 

604 

605 return reverse_cmap, encoding_cmap 

606 

607 def _create_widths_list_and_unicode_stream(self) -> tuple[list[PdfObject], StreamObject]: 

608 widths_list = [] 

609 unicode_map = [] 

610 # In the loop, char is the decoded GID string (the reverse unicode hack) 

611 # and character_map[char] is the actual character. 

612 # The widths (/W) array can have two formats: 

613 # [first_cid [w1 w2 w3]] or [first last width] 

614 # Here we choose the first format and simply provide one array with one width for every cid. 

615 for gid_str, actual_char in self.character_map.items(): 

616 # Make sure that we do not include characters such as arabic presentation form characters. 

617 # Note that, in some cases, unicodedata.normalize() might split a ligature, resulting 

618 # in multiple characters. 

619 normalized_chars = unicodedata.normalize("NFKC", actual_char) 

620 uni_points = [ord(char) for char in normalized_chars] 

621 # Only deal with Basic Multilingual Plane characters. 

622 # TODO: Add all characters. 

623 if all(uni_point <= 0xFFFF for uni_point in uni_points): 

624 cid = ord(gid_str) 

625 cid_hex = f"{cid:04X}" 

626 uni_hex = "".join(f"{uni_point:04X}" for uni_point in uni_points) 

627 unicode_map.append(f"<{cid_hex}> <{uni_hex}>") 

628 

629 width = self.character_widths.get(gid_str, self.character_widths["default"]) 

630 widths_list.extend([NumberObject(cid), ArrayObject([NumberObject(width)])]) 

631 

632 # Create the /ToUnicode CMap Stream 

633 to_unicode_stream = StreamObject() 

634 to_unicode_stream.set_data( 

635 ( 

636 "/CIDInit /ProcSet findresource begin\n" 

637 "12 dict begin\n" 

638 "begincmap\n" 

639 "/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n" 

640 "/CMapName /Adobe-Identity-UCS def\n" 

641 "/CMapType 2 def\n" 

642 "1 begincodespacerange <0000> <FFFF> endcodespacerange\n" 

643 f"{len(unicode_map)} beginbfchar\n" 

644 + "\n".join(unicode_map) + "\n" 

645 "endbfchar\n" 

646 "endcmap\n" 

647 "CMapName currentdict /CMap defineresource pop\n" 

648 "end end" 

649 ).encode("ascii") 

650 ) 

651 

652 return widths_list, to_unicode_stream 

653 

654 def as_font_resource(self) -> DictionaryObject: 

655 # If we have an embedded Truetype font, we assume that we need to produce a Type 2 CID font resource. 

656 if self.font_descriptor.font_file and self.sub_type == "TrueType": 

657 # Begin with creating the widths array (part of the descendant font) and the unicode cmap (part 

658 # of the Type 0 font obect). 

659 widths_list, to_unicode_stream = self._create_widths_list_and_unicode_stream() 

660 

661 # Create the descendant font object 

662 cid_font = DictionaryObject({ 

663 NameObject("/Type"): NameObject("/Font"), 

664 NameObject("/Subtype"): NameObject("/CIDFontType2"), 

665 NameObject("/BaseFont"): NameObject(f"/{self.name}"), 

666 NameObject("/CIDSystemInfo"): DictionaryObject({ 

667 NameObject("/Registry"): TextStringObject("Adobe"), 

668 NameObject("/Ordering"): TextStringObject("Identity"), 

669 NameObject("/Supplement"): NumberObject(0) 

670 }), 

671 NameObject("/FontDescriptor"): self.font_descriptor.as_font_descriptor_resource(), 

672 NameObject("/W"): ArrayObject(widths_list), 

673 NameObject("/DW"): NumberObject(self.character_widths["default"]), 

674 NameObject("/CIDToGIDMap"): NameObject("/Identity") 

675 }) 

676 

677 # Create the Type 0 font object 

678 return DictionaryObject({ 

679 NameObject("/Type"): NameObject("/Font"), 

680 NameObject("/Subtype"): NameObject("/Type0"), 

681 NameObject("/BaseFont"): NameObject(f"/{self.name}"), 

682 NameObject("/Encoding"): NameObject("/Identity-H"), 

683 NameObject("/DescendantFonts"): ArrayObject([cid_font]), 

684 NameObject("/ToUnicode"): to_unicode_stream, 

685 }) 

686 

687 # Fallback: Return a font resource for one of the 14 Adobe Core fonts. 

688 return DictionaryObject({ 

689 NameObject("/Type"): NameObject("/Font"), 

690 NameObject("/Subtype"): NameObject("/Type1"), 

691 NameObject("/Name"): NameObject(f"/{self.name}"), 

692 NameObject("/BaseFont"): NameObject(f"/{self.name}"), 

693 NameObject("/Encoding"): NameObject("/WinAnsiEncoding") 

694 }) 

695 

696 def _add_to_writer( 

697 self, 

698 writer: PdfWriter, 

699 target_resource_dict: DictionaryObject, 

700 font_resource_name: NameObject 

701 ) -> IndirectObject: 

702 """ 

703 Some objects in a font resource need to be indirect objects. This method 

704 ensures that ToUnicode, FontDescriptor, FontFile, and, ultimately, the font 

705 resource itself, are registered with the PdfWriter instance as indirect objects. 

706 """ 

707 font_resource = self.as_font_resource() 

708 if "/ToUnicode" in font_resource: 

709 font_resource[NameObject("/ToUnicode")] = writer._add_object(font_resource["/ToUnicode"]) 

710 

711 if "/DescendantFonts" in font_resource: 

712 descendant_fonts = cast(ArrayObject, font_resource["/DescendantFonts"]) 

713 font_resource_dict = cast(DictionaryObject, descendant_fonts[0]) 

714 else: 

715 font_resource_dict = font_resource 

716 

717 if "/FontDescriptor" in font_resource_dict: 

718 font_descriptor_obj = cast(DictionaryObject, font_resource_dict["/FontDescriptor"]) 

719 for key in ["/FontFile", "/FontFile2", "/FontFile3"]: 

720 if key in font_descriptor_obj: 

721 font_descriptor_obj[NameObject(key)] = writer._add_object(font_descriptor_obj[key]) 

722 font_resource_dict[NameObject("/FontDescriptor")] = writer._add_object( 

723 font_resource_dict["/FontDescriptor"] 

724 ) 

725 font_resource_ref = writer._add_object(font_resource) 

726 target_resource_dict[font_resource_name] = font_resource_ref 

727 return font_resource_ref 

728 

729 def get_text_width(self, text: str = "") -> float: 

730 """Sum of character widths specified in PDF font for the supplied text.""" 

731 return sum( 

732 [self.character_widths.get(char, self.character_widths["default"]) for char in text], 0.0 

733 ) 

734 

735 def can_encode(self, text: str) -> bool: 

736 """Check whether the font is able to encode a text string.""" 

737 try: 

738 if self.character_map: 

739 supported_chars = set(self.character_map.values()) 

740 return all(char in supported_chars for char in text) 

741 if isinstance(self.encoding, str): 

742 text.encode(self.encoding, "surrogatepass") 

743 else: 

744 supported_chars = set(self.encoding.values()) 

745 return all(char in supported_chars for char in text) 

746 

747 except UnicodeEncodeError: 

748 return False 

749 

750 return True