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

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

430 statements  

1from __future__ import annotations 

2 

3import unicodedata 

4from collections.abc import Sequence 

5from dataclasses import dataclass, field 

6from typing import TYPE_CHECKING, Any, ClassVar, 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 import encoding_dict_from_named_encoding 

22from ._codecs.adobe_glyphs import adobe_glyphs 

23from ._utils import logger_warning 

24from .constants import FontFlags 

25from .errors import LimitReachedError, PdfReadError 

26 

27if TYPE_CHECKING: 

28 from fontTools.ttLib.tables._h_e_a_d import table__h_e_a_d 

29 from fontTools.ttLib.tables._p_o_s_t import table__p_o_s_t 

30 from fontTools.ttLib.tables.O_S_2f_2 import table_O_S_2f_2 

31 

32 from ._writer import PdfWriter 

33 

34try: 

35 from io import BytesIO 

36 

37 from fontTools.ttLib import TTFont, TTLibError 

38 HAS_FONTTOOLS = True 

39except ImportError: 

40 HAS_FONTTOOLS = False 

41 

42 

43# Limits. 

44MAX_CID_WIDTH_ENTRY_COUNT = 65_536 

45MAX_WIDTH_ENTRY_COUNT = 100_000 

46 

47 

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

49HEADER_MACSTYLE_ITALIC = 0x02 

50OS2_FSSELECTION_ITALIC = 0x01 

51OS2_PANOSE_BFAMILYTYPE_SCRIPT = 3 

52OS2_PANOSE_BFAMILYTYPE_DECORATIVE = 4 

53OS2_PANOSE_BFAMILYTYPE_PICTORIAL = 5 

54OS2_PANOSE_BPROPORTION_MONOSPACED = 9 

55OS2_SFAMILYSCLASS_SCRIPTS = 10 

56OS2_SFAMILYSCLASS_SYMBOLIC = 12 

57 

58 

59# Groups of CMap mappings cannot exceed 100 entries (Adobe CMap and CIDFont Files Specification 1.0, p. 49). 

60CMAP_MAX_ENTRIES_PER_GROUP = 100 

61 

62 

63@dataclass(frozen=True) 

64class FontDescriptor: 

65 """ 

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

67 This contains both descriptive and metric information. 

68 

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

70 to 100. 

71 """ 

72 

73 _DEFAULT_BBOX: ClassVar[tuple[float, float, float, float]] = (-100.0, -200.0, 1000.0, 900.0) 

74 

75 name: str = "Unknown" 

76 family: str = "Unknown" 

77 weight: str = "Unknown" 

78 

79 ascent: float = 700.0 

80 descent: float = -200.0 

81 cap_height: float = 600.0 

82 x_height: float = 500.0 

83 italic_angle: float = 0.0 # Non-italic 

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

85 bbox: tuple[float, float, float, float] = _DEFAULT_BBOX 

86 font_file: StreamObject | None = None 

87 

88 def as_font_descriptor_resource(self) -> DictionaryObject: 

89 font_descriptor_resource = DictionaryObject({ 

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

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

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

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

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

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

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

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

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

99 }) 

100 

101 if self.font_file: 

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

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

104 

105 return font_descriptor_resource 

106 

107 

108@dataclass(frozen=True) 

109class CoreFontMetrics: 

110 font_descriptor: FontDescriptor 

111 character_widths: dict[str, float] 

112 

113 

114@dataclass 

115class Font: 

116 """ 

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

118 text appearance streams. 

119 

120 Attributes: 

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

122 character_map: The font's character map 

123 encoding: Font encoding 

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

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

126 character_widths: A mapping of characters to widths 

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

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

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

130 a '/ToUnicode' mapping. 

131 

132 """ 

133 

134 name: str 

135 encoding: str | dict[int, str] 

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

137 sub_type: str = "Unknown" 

138 font_descriptor: FontDescriptor = field(default_factory=FontDescriptor) 

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

140 space_width: float | int = 250 

141 space_char: str = " " 

142 interpretable: bool = True 

143 

144 @staticmethod 

145 def _collect_tt_t1_character_widths( 

146 pdf_font_dict: DictionaryObject, 

147 char_map: dict[Any, Any], 

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

149 current_widths: dict[str, float] 

150 ) -> None: 

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

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

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

154 for idx, width in enumerate(widths_array): 

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

156 

157 @staticmethod 

158 def __check_range_length(start: int, end: int) -> None: 

159 if end < start: 

160 raise LimitReachedError( 

161 f"Invalid CID width range: {start}..{end}." 

162 ) 

163 

164 count = end - start 

165 if count > MAX_CID_WIDTH_ENTRY_COUNT: 

166 raise LimitReachedError(f"CID width range too large: {count} > {MAX_CID_WIDTH_ENTRY_COUNT}.") 

167 

168 @staticmethod 

169 def __check_entry_count(count: int) -> None: 

170 if count > MAX_WIDTH_ENTRY_COUNT: 

171 raise LimitReachedError(f"Too many character widths: {count} > {MAX_WIDTH_ENTRY_COUNT}.") 

172 

173 @staticmethod 

174 def _collect_cid_character_widths(d_font: DictionaryObject, current_widths: dict[str, float]) -> None: 

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

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

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

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

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

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

181 skip_count = 0 

182 entry_count = 0 

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

184 _w_length = len(_w) 

185 for idx, w_entry in enumerate(_w): 

186 if skip_count: 

187 skip_count -= 1 

188 continue 

189 w_entry = w_entry.get_object() 

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

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

192 # sometimes we do. 

193 logger_warning( 

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

195 source=__name__, 

196 w_entry=w_entry, 

197 ) 

198 continue 

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

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

201 if isinstance(w_next_entry, Sequence): 

202 start_idx, width_list = int(w_entry), w_next_entry 

203 stop_idx = start_idx + len(width_list) 

204 Font.__check_range_length(start_idx, stop_idx) 

205 entry_count += (stop_idx - start_idx) 

206 Font.__check_entry_count(entry_count) 

207 current_widths.update( 

208 { 

209 chr(_cidx): _width 

210 for _cidx, _width in zip( 

211 range(start_idx, stop_idx, 1), 

212 width_list, 

213 ) 

214 } 

215 ) 

216 skip_count = 1 

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

218 elif ( 

219 isinstance(w_next_entry, (int, float)) 

220 and idx + 2 < _w_length 

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

222 ): 

223 start_idx, stop_idx, const_width = ( 

224 int(w_entry), 

225 int(w_next_entry), 

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

227 ) 

228 Font.__check_range_length(start_idx, stop_idx + 1) 

229 entry_count += (stop_idx - start_idx + 1) 

230 Font.__check_entry_count(entry_count) 

231 current_widths.update( 

232 { 

233 chr(_cidx): const_width 

234 for _cidx in range( 

235 start_idx, stop_idx + 1, 1 

236 ) 

237 } 

238 ) 

239 skip_count = 2 

240 else: 

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

242 # while expecting more elements). 

243 logger_warning( 

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

245 source=__name__, 

246 w_entry=w_entry, 

247 ) 

248 

249 @staticmethod 

250 def _get_space_char( 

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

252 character_map: dict[Any, Any], 

253 ) -> str: 

254 space_char = " " 

255 if isinstance(encoding, dict): 

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

257 if char_str == space_char: 

258 return chr(char_code) 

259 

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

261 if char_str == space_char: 

262 return str(glyph_id) 

263 

264 return space_char 

265 

266 @staticmethod 

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

268 if not current_widths: 

269 current_widths["default"] = 500 

270 return 

271 

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

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

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

275 current_widths["default"] = current_widths[space_char] 

276 return 

277 

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

279 return 

280 

281 # Use the average width of existing glyph widths 

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

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

284 

285 @staticmethod 

286 def _add_space_width( 

287 character_widths: dict[str, float], 

288 flags: int, 

289 space_char: str 

290 ) -> float: 

291 space_width = character_widths.get(space_char, 0) 

292 if space_width != 0: 

293 return space_width 

294 

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

296 return character_widths["default"] 

297 

298 return character_widths["default"] // 2 

299 

300 @staticmethod 

301 def _parse_bbox(raw_bbox: Any) -> tuple[float, float, float, float] | None: 

302 """ 

303 Convert a raw /FontBBox value into four floats. 

304 

305 Args: 

306 raw_bbox: The raw /FontBBox value read from the PDF. 

307 

308 Returns: 

309 The four bounding box values, or ``None`` when the value is not 

310 a sequence of exactly four numbers, so that a malformed entry 

311 falls back to the default bounding box rather than raising. 

312 

313 """ 

314 try: 

315 bbox = [float(value) for value in raw_bbox] 

316 except (TypeError, ValueError): 

317 return None 

318 if len(bbox) != 4: 

319 return None 

320 return bbox[0], bbox[1], bbox[2], bbox[3] 

321 

322 @staticmethod 

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

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

325 for source_key, target_key in [ 

326 ("/FontName", "name"), 

327 ("/FontFamily", "family"), 

328 ("/FontWeight", "weight"), 

329 ("/Ascent", "ascent"), 

330 ("/Descent", "descent"), 

331 ("/CapHeight", "cap_height"), 

332 ("/XHeight", "x_height"), 

333 ("/ItalicAngle", "italic_angle"), 

334 ("/Flags", "flags"), 

335 ("/FontBBox", "bbox") 

336 ]: 

337 if source_key in font_descriptor_obj: 

338 font_descriptor_kwargs[target_key] = font_descriptor_obj[source_key] 

339 # Handle missing or malformed bbox gracefully - PDFs may have fonts without valid bounding boxes 

340 if "bbox" in font_descriptor_kwargs: 

341 bbox = Font._parse_bbox(font_descriptor_kwargs["bbox"]) 

342 if bbox is None: 

343 del font_descriptor_kwargs["bbox"] 

344 else: 

345 font_descriptor_kwargs["bbox"] = bbox 

346 

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

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

349 if source_key in font_descriptor_obj: 

350 if "font_file" in font_descriptor_kwargs: 

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

352 

353 try: 

354 font_file = font_descriptor_obj[source_key].get_object() 

355 font_descriptor_kwargs["font_file"] = font_file 

356 except PdfReadError as e: 

357 logger_warning( 

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

359 source=__name__, 

360 source_key=source_key, 

361 font_descriptor_obj=font_descriptor_obj, 

362 error=e, 

363 ) 

364 return font_descriptor_kwargs 

365 

366 @classmethod 

367 def from_font_resource( 

368 cls, 

369 pdf_font_dict: DictionaryObject, 

370 ) -> Font: 

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

372 

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

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

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

376 encoding, character_map = get_encoding(pdf_font_dict) 

377 font_descriptor = None 

378 character_widths: dict[str, float] = {} 

379 interpretable = True 

380 

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

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

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

384 # reliably converted into character codes unless all named chars 

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

386 # PDF 1.7 standard. 

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

388 interpretable = all( 

389 cname in adobe_glyphs 

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

391 ) 

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

393 if "/Widths" in pdf_font_dict: 

394 cls._collect_tt_t1_character_widths( 

395 pdf_font_dict, character_map, encoding, character_widths 

396 ) 

397 

398 elif name in CORE_FONT_METRICS: 

399 font_descriptor = CORE_FONT_METRICS[name].font_descriptor 

400 if isinstance(encoding, dict): 

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

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

403 if character in CORE_FONT_METRICS[name].character_widths: 

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

405 else: 

406 for code in range(256): 

407 character = chr(code) 

408 if character in CORE_FONT_METRICS[name].character_widths: 

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

410 if "/FontDescriptor" in pdf_font_dict: 

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

412 if "/MissingWidth" in font_descriptor_obj: 

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

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

415 elif "/FontBBox" in pdf_font_dict: 

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

417 font_descriptor_kwargs: dict[str, Any] = {"name": name} 

418 bbox = cls._parse_bbox(pdf_font_dict["/FontBBox"]) 

419 if bbox is not None: 

420 font_descriptor_kwargs["bbox"] = bbox 

421 font_descriptor = FontDescriptor(**font_descriptor_kwargs) 

422 

423 else: 

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

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

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

427 d_font: DictionaryObject 

428 for d_font_idx, d_font in enumerate( 

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

430 ): 

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

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

433 cls._collect_cid_character_widths(d_font=d_font, current_widths=character_widths) 

434 if "/DW" in d_font: 

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

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

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

438 

439 if not font_descriptor: 

440 font_descriptor = FontDescriptor(name=name) 

441 

442 space_char = cls._get_space_char(encoding, character_map) 

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

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

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

446 

447 return cls( 

448 name=name, 

449 sub_type=sub_type, 

450 encoding=encoding, 

451 font_descriptor=font_descriptor, 

452 character_map=character_map, 

453 character_widths=character_widths, 

454 space_width=space_width, 

455 space_char=space_char, 

456 interpretable=interpretable 

457 ) 

458 

459 @staticmethod 

460 def _font_flags_from_truetype_font_tables( 

461 header: table__h_e_a_d, 

462 postscript: table__p_o_s_t, 

463 os2: table_O_S_2f_2 

464 ) -> int: 

465 # Get the font flags 

466 if os2: 

467 panose = os2.panose 

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

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

470 family_class = os2.sFamilyClass >> 8 

471 flags: int = 0 

472 

473 # ITALIC 

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

475 flags |= FontFlags.ITALIC 

476 if postscript: 

477 italic_angle = postscript.italicAngle 

478 if italic_angle != 0.0: 

479 flags |= FontFlags.ITALIC 

480 

481 # FIXED_PITCH 

482 if ( 

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

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

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

486 flags |= FontFlags.FIXED_PITCH 

487 

488 # SCRIPT 

489 if os2 and ( 

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

491 ): 

492 flags |= FontFlags.SCRIPT 

493 

494 # SERIF 

495 if os2 and ( 

496 2 <= panose.bSerifStyle <= 10 

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

498 ): 

499 flags |= FontFlags.SERIF 

500 

501 # SYMBOLIC 

502 if os2 and ( 

503 family_class == OS2_SFAMILYSCLASS_SYMBOLIC or 

504 panose.bFamilyType in {OS2_PANOSE_BFAMILYTYPE_DECORATIVE, OS2_PANOSE_BFAMILYTYPE_PICTORIAL} 

505 ): 

506 flags |= FontFlags.SYMBOLIC 

507 else: 

508 flags |= FontFlags.NONSYMBOLIC 

509 

510 return flags 

511 

512 @classmethod 

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

514 if not HAS_FONTTOOLS: 

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

516 with TTFont(font_file) as tt_font_object: 

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

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

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

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

521 header = tt_font_object["head"] 

522 horizontal_header = tt_font_object["hhea"] 

523 metrics = tt_font_object["hmtx"].metrics 

524 

525 # Collect additional font tables to derive font information 

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

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

528 

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

530 units_per_em = header.unitsPerEm 

531 if not units_per_em: 

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

533 scale_factor = 1000.0 / units_per_em 

534 

535 # Get the font descriptor 

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

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

538 if names: 

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

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

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

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

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

544 if os2: 

545 try: 

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

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

548 except AttributeError: 

549 pass 

550 

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

552 

553 font_descriptor_kwargs["bbox"] = ( 

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

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

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

557 round(header.yMax * scale_factor, 0) 

558 ) 

559 

560 font_file_data = StreamObject() 

561 font_file_raw_bytes = font_file.getvalue() 

562 font_file_data.set_data(font_file_raw_bytes) 

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

564 font_descriptor_kwargs["font_file"] = font_file_data 

565 

566 font_descriptor = FontDescriptor(**font_descriptor_kwargs) 

567 encoding = "utf_16_be" # Assume unicode 

568 

569 character_widths: dict[str, float] = {} 

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

571 

572 glyph_order = tt_font_object.getGlyphOrder() 

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

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

575 tt_font_cmap_table = tt_font_object.get("cmap") 

576 if tt_font_cmap_table: 

577 reverse_cmap = tt_font_cmap_table.buildReversedMin() 

578 for gid, glyph in enumerate(glyph_order): 

579 char_code = reverse_cmap.get(glyph) 

580 if char_code is None: 

581 continue 

582 char = chr(char_code) 

583 gid = tt_font_object.getGlyphID(glyph) 

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

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

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

587 character_map[gid_key_string] = char 

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

589 else: 

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

591 

592 space_char = cls._get_space_char(encoding, character_map) 

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

594 space_width = cls._add_space_width( 

595 character_widths, font_descriptor_kwargs["flags"], space_char 

596 ) 

597 

598 return cls( 

599 name=font_descriptor.name, 

600 sub_type="Type0", 

601 encoding=encoding, 

602 font_descriptor=font_descriptor, 

603 character_map=character_map, 

604 character_widths=character_widths, 

605 space_width=space_width, 

606 space_char=space_char, 

607 interpretable=True 

608 ) 

609 

610 @classmethod 

611 def from_core_font_name(cls, core_font_name: str = "/Helvetica") -> Font: 

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

613 

614 font_name = core_font_name.removeprefix("/") 

615 core_font_metrics = CORE_FONT_METRICS[font_name] 

616 win_ansi_encoding = encoding_dict_from_named_encoding("cp1252") # WinAnsiEncoding 

617 

618 font = cls( 

619 name=font_name, 

620 character_map={}, 

621 encoding=win_ansi_encoding, 

622 sub_type="Type1", 

623 font_descriptor=core_font_metrics.font_descriptor, 

624 character_widths={ 

625 char: core_font_metrics.character_widths[character] 

626 for code, character in win_ansi_encoding.items() 

627 if (char := chr(code)) in core_font_metrics.character_widths 

628 } 

629 ) 

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

631 

632 return font 

633 

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

635 """ 

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

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

638 """ 

639 reverse_cmap = {} 

640 encoding_cmap = {} 

641 if ( 

642 HAS_FONTTOOLS 

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

644 and isinstance(self.encoding, str) 

645 ): 

646 try: 

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

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

649 tt_font_cmap_table = tt_font_object.get("cmap") 

650 best_cmap = tt_font_cmap_table.getBestCmap() 

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

652 gid = tt_font_object.getGlyphID(glyph_name) 

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

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

655 unicode_char = chr(unicode_int) 

656 reverse_cmap[unicode_char] = gid_key_string 

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

658 

659 return reverse_cmap, encoding_cmap 

660 

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

662 reverse_cmap.clear() 

663 encoding_cmap.clear() 

664 

665 if isinstance(self.encoding, str): 

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

667 glyph_id_str = str(glyph_id) 

668 reverse_cmap[unicode_char] = glyph_id_str 

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

670 else: # Encoding is a dict, which means we are dealing with a simple font 

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

672 character_str = chr(character_code) 

673 reverse_cmap[unicode_char] = character_str 

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

675 

676 unicode_to_bytes = { 

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

678 } 

679 for character_code_str, unicode_char in self.character_map.items(): 

680 reverse_cmap[unicode_char] = character_code_str 

681 encoding_cmap[character_code_str] = unicode_to_bytes.get( 

682 unicode_char, 

683 bytes((ord(character_code_str),)) 

684 ) 

685 

686 return reverse_cmap, encoding_cmap 

687 

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

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

690 

691 widths_list = [] 

692 unicode_map = [] 

693 bfchar_map: list[str] = [] 

694 

695 # Composite/CID fonts use 4-hex digits, simple fonts use 2-hex digits 

696 src_hex_format = "{cid:04X}" if self.sub_type == "Type0" else "{cid:02X}" 

697 codespace_min = "0000" if self.sub_type == "Type0" else "00" 

698 codespace_max = "FFFF" if self.sub_type == "Type0" else "FF" 

699 cmap_name = "Adobe-Identity-UCS" if self.sub_type == "Type0" else "Custom-Simple-8Bit" 

700 cid_system_info = ( 

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

702 if self.sub_type == "Type0" else "" 

703 ) 

704 

705 # If we have self.character_map then use that. Otherwise fall back to self.encoding. 

706 mapping_source = (self.character_map or cast(dict[int, str], self.encoding)).items() 

707 

708 # In the loop, src_id is the decoded GID string (the reverse unicode hack) or the character code 

709 # and actual_char is the actual character. 

710 for src_id, actual_char in mapping_source: 

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

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

713 # in multiple characters. 

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

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

716 # Only deal with Basic Multilingual Plane characters. 

717 # TODO: Add all characters. 

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

719 cid = ord(src_id) if isinstance(src_id, str) else src_id 

720 cid_hex = src_hex_format.format(cid=cid) 

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

722 bfchar_map.append(f"<{cid_hex}> <{uni_hex}>") 

723 

724 # Width mapping, but not for the 14 Adobe code fonts, which are dealt with elsewhere. 

725 if self.name not in CORE_FONT_METRICS: 

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

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

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

729 width = self.character_widths.get(cast(str, src_id), self.character_widths["default"]) 

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

731 

732 while partial_list := bfchar_map[:CMAP_MAX_ENTRIES_PER_GROUP]: 

733 del bfchar_map[:CMAP_MAX_ENTRIES_PER_GROUP] 

734 unicode_map.append(f"{len(partial_list)} beginbfchar") 

735 unicode_map.extend(partial_list) 

736 unicode_map.append("endbfchar") 

737 

738 # Create the /ToUnicode CMap Stream 

739 to_unicode_stream = StreamObject() 

740 to_unicode_stream.set_data( 

741 ( 

742 "/CIDInit /ProcSet findresource begin\n" 

743 "12 dict begin\n" 

744 "begincmap\n" 

745 f"{cid_system_info}" 

746 f"/CMapName /{cmap_name} def\n" 

747 f"/CMapType 2 def\n" 

748 f"1 begincodespacerange <{codespace_min}> <{codespace_max}> endcodespacerange\n" 

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

750 "endcmap\n" 

751 "CMapName currentdict /CMap defineresource pop\n" 

752 "end end" 

753 ).encode("ascii") 

754 ) 

755 

756 return widths_list, to_unicode_stream 

757 

758 def as_font_resource(self) -> DictionaryObject: 

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

760 # We check that we are 16-bit encoded, that is, Type0. 

761 if self.font_descriptor.font_file and self.sub_type == "Type0": 

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

763 # of the Type 0 font object). 

764 widths_list, to_unicode_stream = self._create_widths_list_and_unicode_stream() 

765 

766 # Create the descendant font object 

767 cid_font = DictionaryObject({ 

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

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

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

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

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

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

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

775 }), 

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

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

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

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

780 }) 

781 

782 # Create the Type 0 font object 

783 return DictionaryObject({ 

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

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

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

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

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

789 NameObject("/ToUnicode"): to_unicode_stream, 

790 }) 

791 

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

793 win_ansi_encoding = encoding_dict_from_named_encoding("cp1252") 

794 differences_list: list[NumberObject | NameObject] = [] 

795 reverse_adobe_glyphs = {value: key for key, value in adobe_glyphs.items()} 

796 own_encoding: dict[int, str] = cast(dict[int, str], self.encoding) 

797 for idx, character_code in win_ansi_encoding.items(): 

798 encoding_char = own_encoding.get(idx) 

799 if encoding_char and encoding_char != character_code: 

800 differences_list.extend([NumberObject(idx), NameObject(reverse_adobe_glyphs[encoding_char])]) 

801 

802 if differences_list: 

803 encoding: DictionaryObject | NameObject = DictionaryObject({ 

804 NameObject("/BaseEncoding"): NameObject("/WinAnsiEncoding"), 

805 NameObject("/Differences"): ArrayObject(differences_list), 

806 }) 

807 else: 

808 encoding = NameObject("/WinAnsiEncoding") 

809 

810 simple_font = DictionaryObject({ 

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

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

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

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

815 NameObject("/Encoding"): encoding 

816 }) 

817 

818 if differences_list: 

819 _, to_unicode_stream = self._create_widths_list_and_unicode_stream() 

820 simple_font[NameObject("/ToUnicode")] = to_unicode_stream 

821 

822 return simple_font 

823 

824 def _add_to_writer( 

825 self, 

826 writer: PdfWriter, 

827 target_resource_dict: DictionaryObject, 

828 font_resource_name: NameObject 

829 ) -> IndirectObject: 

830 """ 

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

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

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

834 """ 

835 font_resource = self.as_font_resource() 

836 if "/ToUnicode" in font_resource: 

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

838 

839 if "/DescendantFonts" in font_resource: 

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

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

842 else: 

843 font_resource_dict = font_resource 

844 

845 if "/FontDescriptor" in font_resource_dict: 

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

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

848 if key in font_descriptor_obj: 

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

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

851 font_resource_dict["/FontDescriptor"] 

852 ) 

853 font_resource_ref = writer._add_object(font_resource) 

854 target_resource_dict[font_resource_name] = font_resource_ref 

855 return font_resource_ref 

856 

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

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

859 return sum( 

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

861 ) 

862 

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

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

865 if self.character_map: 

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

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

868 

869 if isinstance(self.encoding, dict): 

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

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

872 

873 # Not a simple font (encoding is not a dict), and missing ToUnicode cmap (no character_map). 

874 # Assume we cannot use this font for text encoding. 

875 return False