Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pypdf/generic/_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

456 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.DefaultTable import DefaultTable 

31 from fontTools.ttLib.tables.O_S_2f_2 import table_O_S_2f_2 

32 

33 from .._writer import PdfWriter 

34 

35try: 

36 from io import BytesIO 

37 

38 from fontTools.ttLib import TTFont 

39 HAS_FONTTOOLS = True 

40except ImportError: 

41 HAS_FONTTOOLS = False 

42 

43 

44# Limits. 

45MAX_CID_WIDTH_ENTRY_COUNT = 65_536 

46MAX_WIDTH_ENTRY_COUNT = 100_000 

47# For a simple font, character codes are one-byte values, 0-255, so /Widths can have at most 256 meaningful entries. 

48MAX_SIMPLE_FONT_CHARACTER_CODE = 255 

49 

50 

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

52HEADER_MACSTYLE_ITALIC = 0x02 

53OS2_FSSELECTION_ITALIC = 0x01 

54OS2_PANOSE_BFAMILYTYPE_SCRIPT = 3 

55OS2_PANOSE_BFAMILYTYPE_DECORATIVE = 4 

56OS2_PANOSE_BFAMILYTYPE_PICTORIAL = 5 

57OS2_PANOSE_BPROPORTION_MONOSPACED = 9 

58OS2_SFAMILYSCLASS_SCRIPTS = 10 

59OS2_SFAMILYSCLASS_SYMBOLIC = 12 

60 

61 

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

63CMAP_MAX_ENTRIES_PER_GROUP = 100 

64 

65 

66@dataclass(frozen=True) 

67class FontDescriptor: 

68 """ 

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

70 This contains both descriptive and metric information. 

71 

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

73 to 100. 

74 """ 

75 

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

77 

78 name: str = "Unknown" 

79 family: str = "Unknown" 

80 weight: str = "Unknown" 

81 

82 ascent: float = 700.0 

83 descent: float = -200.0 

84 cap_height: float = 600.0 

85 x_height: float = 500.0 

86 italic_angle: float = 0.0 # Non-italic 

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

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

89 font_file: StreamObject | None = None 

90 

91 def as_font_descriptor_resource(self) -> DictionaryObject: 

92 font_descriptor_resource = DictionaryObject({ 

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

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

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

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

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

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

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

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

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

102 }) 

103 

104 if self.font_file: 

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

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

107 

108 return font_descriptor_resource 

109 

110 

111@dataclass(frozen=True) 

112class CoreFontMetrics: 

113 font_descriptor: FontDescriptor 

114 character_widths: dict[str, float] 

115 

116 

117@dataclass 

118class Font: 

119 """ 

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

121 text appearance streams. 

122 

123 Attributes: 

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

125 character_map: The font's character map 

126 encoding: Font encoding. Must be a dict for a simple font, and string otherwise. 

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

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

129 character_widths: A mapping of characters to widths 

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

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

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

133 a ``/ToUnicode`` mapping. 

134 

135 """ 

136 

137 name: str 

138 encoding: str | dict[int, str] 

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

140 sub_type: str = "Unknown" 

141 font_descriptor: FontDescriptor = field(default_factory=FontDescriptor) 

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

143 space_width: float | int = 250 

144 space_char: str = " " 

145 interpretable: bool = True 

146 

147 @staticmethod 

148 def _collect_tt_t1_character_widths( 

149 pdf_font_dict: DictionaryObject, 

150 char_map: dict[Any, Any], 

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

152 current_widths: dict[str, float] 

153 ) -> None: 

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

155 widths_array = pdf_font_dict["/Widths"].get_object() 

156 if not isinstance(widths_array, ArrayObject): 

157 logger_warning( 

158 "Expected an array for /Widths, got %(widths_array)s. Ignoring it.", 

159 source=__name__, 

160 widths_array=widths_array, 

161 ) 

162 return 

163 Font.__check_entry_count( 

164 len(widths_array), MAX_SIMPLE_FONT_CHARACTER_CODE + 1 

165 ) 

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

167 if not isinstance(first_char, (int, float)): 

168 logger_warning( 

169 "Ignoring invalid /FirstChar %(code)s.", source=__name__, code=first_char 

170 ) 

171 return 

172 first_char = int(first_char) 

173 if first_char < 0: 

174 logger_warning( 

175 "Ignoring invalid /FirstChar %(code)d < 0.", source=__name__, code=first_char 

176 ) 

177 return 

178 for character_code, width in enumerate(widths_array, start=first_char): 

179 if character_code > MAX_SIMPLE_FONT_CHARACTER_CODE: 

180 logger_warning( 

181 "Ignoring invalid character codes > %(limit)d (starting at %(code)d).", 

182 source=__name__, 

183 code=character_code, 

184 limit=MAX_SIMPLE_FONT_CHARACTER_CODE 

185 ) 

186 break 

187 current_widths[chr(character_code)] = int(width) 

188 

189 @staticmethod 

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

191 if end < start: 

192 raise LimitReachedError( 

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

194 ) 

195 

196 count = end - start 

197 if count > MAX_CID_WIDTH_ENTRY_COUNT: 

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

199 

200 @staticmethod 

201 def __check_entry_count(count: int, limit: int = MAX_WIDTH_ENTRY_COUNT) -> None: 

202 if count > limit: 

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

204 

205 @staticmethod 

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

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

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

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

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

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

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

213 skip_count = 0 

214 entry_count = 0 

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

216 _w_length = len(_w) 

217 for idx, w_entry in enumerate(_w): 

218 if skip_count: 

219 skip_count -= 1 

220 continue 

221 w_entry = w_entry.get_object() 

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

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

224 # sometimes we do. 

225 logger_warning( 

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

227 source=__name__, 

228 w_entry=w_entry, 

229 ) 

230 continue 

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

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

233 if isinstance(w_next_entry, Sequence): 

234 start_idx, width_list = int(w_entry), w_next_entry 

235 stop_idx = start_idx + len(width_list) 

236 Font.__check_range_length(start_idx, stop_idx) 

237 entry_count += (stop_idx - start_idx) 

238 Font.__check_entry_count(entry_count) 

239 current_widths.update( 

240 { 

241 chr(_cidx): _width 

242 for _cidx, _width in zip( 

243 range(start_idx, stop_idx, 1), 

244 width_list, 

245 ) 

246 } 

247 ) 

248 skip_count = 1 

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

250 elif ( 

251 isinstance(w_next_entry, (int, float)) 

252 and idx + 2 < _w_length 

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

254 ): 

255 start_idx, stop_idx, const_width = ( 

256 int(w_entry), 

257 int(w_next_entry), 

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

259 ) 

260 Font.__check_range_length(start_idx, stop_idx + 1) 

261 entry_count += (stop_idx - start_idx + 1) 

262 Font.__check_entry_count(entry_count) 

263 current_widths.update( 

264 { 

265 chr(_cidx): const_width 

266 for _cidx in range( 

267 start_idx, stop_idx + 1, 1 

268 ) 

269 } 

270 ) 

271 skip_count = 2 

272 else: 

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

274 # while expecting more elements). 

275 logger_warning( 

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

277 source=__name__, 

278 w_entry=w_entry, 

279 ) 

280 

281 @staticmethod 

282 def _get_space_char( 

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

284 character_map: dict[Any, Any], 

285 ) -> str: 

286 space_char = " " 

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

288 if char_str == space_char: 

289 return str(glyph_id) 

290 

291 if isinstance(encoding, dict): 

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

293 if char_str == space_char: 

294 return chr(char_code) 

295 

296 return space_char 

297 

298 @staticmethod 

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

300 if not current_widths: 

301 current_widths["default"] = 500 

302 return 

303 

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

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

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

307 current_widths["default"] = current_widths[space_char] 

308 return 

309 

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

311 return 

312 

313 # Use the average width of existing glyph widths 

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

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

316 

317 @staticmethod 

318 def _add_space_width( 

319 character_widths: dict[str, float], 

320 flags: int, 

321 space_char: str 

322 ) -> float: 

323 space_width = character_widths.get(space_char, 0) 

324 if space_width != 0: 

325 return space_width 

326 

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

328 return character_widths["default"] 

329 

330 return character_widths["default"] // 2 

331 

332 @staticmethod 

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

334 """ 

335 Convert a raw /FontBBox value into four floats. 

336 

337 Args: 

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

339 

340 Returns: 

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

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

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

344 

345 """ 

346 try: 

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

348 except (TypeError, ValueError): 

349 return None 

350 if len(bbox) != 4: 

351 return None 

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

353 

354 @staticmethod 

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

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

357 for source_key, target_key in [ 

358 ("/FontName", "name"), 

359 ("/FontFamily", "family"), 

360 ("/FontWeight", "weight"), 

361 ("/Ascent", "ascent"), 

362 ("/Descent", "descent"), 

363 ("/CapHeight", "cap_height"), 

364 ("/XHeight", "x_height"), 

365 ("/ItalicAngle", "italic_angle"), 

366 ("/Flags", "flags"), 

367 ("/FontBBox", "bbox") 

368 ]: 

369 if source_key in font_descriptor_obj: 

370 font_descriptor_kwargs[target_key] = font_descriptor_obj[source_key] 

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

372 if "bbox" in font_descriptor_kwargs: 

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

374 if bbox is None: 

375 del font_descriptor_kwargs["bbox"] 

376 else: 

377 font_descriptor_kwargs["bbox"] = bbox 

378 

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

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

381 if source_key in font_descriptor_obj: 

382 if "font_file" in font_descriptor_kwargs: 

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

384 

385 try: 

386 font_file = font_descriptor_obj[source_key].get_object() 

387 font_descriptor_kwargs["font_file"] = font_file 

388 except PdfReadError as exception: 

389 logger_warning( 

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

391 source=__name__, 

392 source_key=source_key, 

393 font_descriptor_obj=font_descriptor_obj, 

394 error=exception, 

395 ) 

396 return font_descriptor_kwargs 

397 

398 @classmethod 

399 def from_font_resource( 

400 cls, 

401 pdf_font_dict: DictionaryObject, 

402 ) -> Font: 

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

404 

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

406 # A malformed resource may carry a non-string value here, so fall back 

407 # to "Unknown" rather than failing on it. 

408 base_font = pdf_font_dict.get("/BaseFont", "Unknown") 

409 name = base_font.removeprefix("/") if isinstance(base_font, str) else "Unknown" 

410 sub_type_value = pdf_font_dict.get("/Subtype", "Unknown") 

411 sub_type = sub_type_value.removeprefix("/") if isinstance(sub_type_value, str) else "Unknown" 

412 encoding, character_map = get_encoding(pdf_font_dict) 

413 font_descriptor = None 

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

415 interpretable = True 

416 

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

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

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

420 # reliably converted into character codes unless all named chars 

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

422 # PDF 1.7 standard. 

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

424 interpretable = all( 

425 cname in adobe_glyphs 

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

427 ) 

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

429 if "/Widths" in pdf_font_dict: 

430 cls._collect_tt_t1_character_widths( 

431 pdf_font_dict, character_map, encoding, character_widths 

432 ) 

433 

434 elif name in CORE_FONT_METRICS: 

435 font_descriptor = CORE_FONT_METRICS[name].font_descriptor 

436 for code, character in cast(dict[int, str], encoding).items(): 

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

438 if character in CORE_FONT_METRICS[name].character_widths: 

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

440 if "/FontDescriptor" in pdf_font_dict: 

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

442 if "/MissingWidth" in font_descriptor_obj: 

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

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

445 elif "/FontBBox" in pdf_font_dict: 

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

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

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

449 if bbox is not None: 

450 font_descriptor_kwargs["bbox"] = bbox 

451 font_descriptor = FontDescriptor(**font_descriptor_kwargs) 

452 

453 else: 

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

455 # to widths stashed in /DescendantFonts. 

456 descendant_fonts = pdf_font_dict.get("/DescendantFonts", ArrayObject()).get_object() 

457 if not isinstance(descendant_fonts, ArrayObject): 

458 logger_warning( 

459 "Expected an array for /DescendantFonts, got %(descendant_fonts)s. Ignoring it.", 

460 source=__name__, 

461 descendant_fonts=descendant_fonts, 

462 ) 

463 descendant_fonts = ArrayObject() 

464 d_font: DictionaryObject 

465 for d_font_idx, d_font in enumerate(descendant_fonts): 

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

467 descendant_fonts[d_font_idx] = d_font 

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

469 if "/DW" in d_font: 

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

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

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

473 

474 if not font_descriptor: 

475 font_descriptor = FontDescriptor(name=name) 

476 

477 space_char = cls._get_space_char(encoding, character_map) 

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

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

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

481 

482 return cls( 

483 name=name, 

484 sub_type=sub_type, 

485 encoding=encoding, 

486 font_descriptor=font_descriptor, 

487 character_map=character_map, 

488 character_widths=character_widths, 

489 space_width=space_width, 

490 space_char=space_char, 

491 interpretable=interpretable 

492 ) 

493 

494 @staticmethod 

495 def _load_fonttools_table(table_name: str, is_critical: bool, tt_font_object: TTFont) -> DefaultTable | None: 

496 """Once we have a TTFont object, we can try to get the necessary font tables from 

497 which we collect the information to instantiate a Font class. However, some of 

498 these tables might be missing from the embedded font file, and others might be 

499 corrupt in some form and then throw a struct.error on decompilation. We decompile 

500 all tables we need using this method to guard against fontTools exceptions. 

501 

502 This method distinguishes between optional tables and critical tables without which 

503 we cannot instantiate a Font. For critical tables, raises PdfReadError if missing 

504 OR if binary data is corrupt. For optional tables, it logs warnings instead. 

505 """ 

506 try: 

507 # tt_font.get() automatically triggers table.decompile() under the hood 

508 table = tt_font_object.get(table_name) 

509 if table is None: 

510 if is_critical: 

511 raise PdfReadError(f"Font file does not have a {table_name!r} table") 

512 logger_warning( 

513 "Optional font table %(table_name)r is missing from font file", 

514 source=__name__, 

515 table_name=table_name 

516 ) 

517 return None 

518 return table 

519 except Exception as exception: 

520 if is_critical: 

521 raise PdfReadError( 

522 f"Font table {table_name!r} is corrupt or truncated: {exception}" 

523 ) from exception 

524 logger_warning( 

525 "Optional font table %(table_name)r is corrupt and will be ignored: %(exception)s", 

526 source=__name__, 

527 table_name=table_name, 

528 exception=exception 

529 ) 

530 return None 

531 

532 @staticmethod 

533 def _get_font_flags_from_truetype_font_tables( 

534 header_table: table__h_e_a_d, 

535 postscript_table: table__p_o_s_t, 

536 os2_table: table_O_S_2f_2 

537 ) -> int: 

538 # Get the font flags 

539 if os2_table: 

540 panose = os2_table.panose 

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

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

543 family_class = os2_table.sFamilyClass >> 8 

544 flags: int = 0 

545 

546 # ITALIC 

547 if header_table.macStyle & HEADER_MACSTYLE_ITALIC or ( 

548 os2_table and os2_table.fsSelection & OS2_FSSELECTION_ITALIC 

549 ): 

550 flags |= FontFlags.ITALIC 

551 if postscript_table: 

552 italic_angle = postscript_table.italicAngle 

553 if italic_angle != 0.0: 

554 flags |= FontFlags.ITALIC 

555 

556 # FIXED_PITCH 

557 if ( 

558 (os2_table and panose.bProportion == OS2_PANOSE_BPROPORTION_MONOSPACED) or 

559 (postscript_table and postscript_table.isFixedPitch > 0) # Actually 1, but originally (older TTF spec. 

560 ): # version) any non-zero value signified monospace. 

561 flags |= FontFlags.FIXED_PITCH 

562 

563 # SCRIPT 

564 if os2_table and ( 

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

566 ): 

567 flags |= FontFlags.SCRIPT 

568 

569 # SERIF 

570 if os2_table and ( 

571 2 <= panose.bSerifStyle <= 10 

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

573 ): 

574 flags |= FontFlags.SERIF 

575 

576 # SYMBOLIC 

577 if os2_table and ( 

578 family_class == OS2_SFAMILYSCLASS_SYMBOLIC or 

579 panose.bFamilyType in {OS2_PANOSE_BFAMILYTYPE_DECORATIVE, OS2_PANOSE_BFAMILYTYPE_PICTORIAL} 

580 ): 

581 flags |= FontFlags.SYMBOLIC 

582 else: 

583 flags |= FontFlags.NONSYMBOLIC 

584 

585 return flags 

586 

587 @classmethod 

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

589 if not HAS_FONTTOOLS: 

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

591 try: 

592 tt_font_object = TTFont(font_file) 

593 except Exception as exception: 

594 raise PdfReadError(f"Could not open font file: {exception}") from exception 

595 with tt_font_object: 

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

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

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

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

600 

601 # Collect all font tables. 

602 ( 

603 cmap_table, 

604 header_table, 

605 metrics_table, 

606 horizontal_header_table, 

607 name_table, 

608 os2_table, 

609 postscript_table 

610 ) = ( 

611 cls._load_fonttools_table( 

612 table_name=table_name, 

613 is_critical=table_name in {"head", "hmtx", "cmap"}, 

614 tt_font_object=tt_font_object 

615 ) 

616 for table_name in ("cmap", "head", "hmtx", "hhea", "name", "OS/2", "post") 

617 ) 

618 

619 if TYPE_CHECKING: 

620 assert header_table is not None 

621 assert metrics_table is not None 

622 assert cmap_table is not None 

623 

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

625 if (units_per_em := header_table.unitsPerEm) <= 0: 

626 raise PdfReadError(f"Font file has an invalid unitsPerEm of {units_per_em}") 

627 scale_factor = 1000.0 / units_per_em 

628 

629 # Get the font descriptor 

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

631 

632 if name_table: 

633 for name, getter in ( 

634 ("name", name_table.getBestFullName), 

635 ("family", name_table.getBestFamilyName), 

636 ("weight", name_table.getBestSubFamilyName) 

637 ): 

638 if (value := getter()) is not None: 

639 font_descriptor_kwargs[name] = value 

640 

641 if horizontal_header_table: 

642 font_descriptor_kwargs["ascent"] = int(round(horizontal_header_table.ascent * scale_factor, 0)) 

643 font_descriptor_kwargs["descent"] = int(round(horizontal_header_table.descent * scale_factor, 0)) 

644 

645 if os2_table: 

646 try: 

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

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

649 except AttributeError: 

650 pass 

651 

652 font_descriptor_kwargs["flags"] = cls._get_font_flags_from_truetype_font_tables( 

653 header_table, 

654 postscript_table, 

655 os2_table 

656 ) 

657 

658 font_descriptor_kwargs["bbox"] = ( 

659 round(header_table.xMin * scale_factor, 0), 

660 round(header_table.yMin * scale_factor, 0), 

661 round(header_table.xMax * scale_factor, 0), 

662 round(header_table.yMax * scale_factor, 0) 

663 ) 

664 

665 font_file_data = StreamObject() 

666 font_file_raw_bytes = font_file.getvalue() 

667 font_file_data.set_data(font_file_raw_bytes) 

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

669 font_descriptor_kwargs["font_file"] = font_file_data 

670 

671 font_descriptor = FontDescriptor(**font_descriptor_kwargs) 

672 encoding = "utf_16_be" # Assume unicode 

673 

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

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

676 

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

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

679 try: 

680 reverse_cmap = cmap_table.buildReversedMin() 

681 except AttributeError: 

682 # Use buildReversed on fonttools < 4.57 and build a list of minimums from it 

683 reverse_cmap = {k: min(r) for k, r in cmap_table.buildReversed().items()} 

684 metrics = metrics_table.metrics 

685 for gid, glyph in enumerate(tt_font_object.getGlyphOrder()): 

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

687 gid_key_string = gid.to_bytes(2, "big").decode("utf-16-be", "surrogatepass") 

688 # Always map character width 

689 character_widths[gid_key_string] = round(metrics[glyph][0] * scale_factor) 

690 # Add GID to character_map when we can find it in the cmap 

691 if (char_code := reverse_cmap.get(glyph)) is not None: 

692 character_map[gid_key_string] = chr(char_code) 

693 

694 space_char = cls._get_space_char(encoding, character_map) 

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

696 space_width = cls._add_space_width( 

697 character_widths, font_descriptor_kwargs["flags"], space_char 

698 ) 

699 

700 return cls( 

701 name=font_descriptor.name, 

702 sub_type="Type0", 

703 encoding=encoding, 

704 font_descriptor=font_descriptor, 

705 character_map=character_map, 

706 character_widths=character_widths, 

707 space_width=space_width, 

708 space_char=space_char, 

709 interpretable=True 

710 ) 

711 

712 @classmethod 

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

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

715 

716 font_name = core_font_name.removeprefix("/") 

717 core_font_metrics = CORE_FONT_METRICS[font_name] 

718 win_ansi_encoding = encoding_dict_from_named_encoding("cp1252") # WinAnsiEncoding 

719 

720 font = cls( 

721 name=font_name, 

722 character_map={}, 

723 encoding=win_ansi_encoding, 

724 sub_type="Type1", 

725 font_descriptor=core_font_metrics.font_descriptor, 

726 character_widths={ 

727 char: core_font_metrics.character_widths[character] 

728 for code, character in win_ansi_encoding.items() 

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

730 } 

731 ) 

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

733 

734 return font 

735 

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

737 """ 

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

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

740 """ 

741 reverse_cmap = {} 

742 encoding_cmap = {} 

743 if ( 

744 HAS_FONTTOOLS 

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

746 and isinstance(self.encoding, str) 

747 and (font_file_data := cast(StreamObject, self.font_descriptor.font_file).get_data()) is not None 

748 ): 

749 try: 

750 tt_font_object = TTFont(BytesIO(font_file_data)) 

751 except Exception as exception: # Font file data is corrupt. 

752 logger_warning("Could not open font file: %(exception)s", source=__name__, exception=exception) 

753 else: 

754 with tt_font_object: 

755 # Read reverse_cmap and encoding_cmap from the font file if we can get a cmap 

756 tt_font_cmap_table = self._load_fonttools_table( 

757 table_name="cmap", is_critical=False, tt_font_object=tt_font_object 

758 ) 

759 if tt_font_cmap_table and (best_cmap := tt_font_cmap_table.getBestCmap()): 

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

761 gid = tt_font_object.getGlyphID(glyph_name) 

762 gid_key_string = gid.to_bytes(2, "big").decode("utf-16-be", "surrogatepass") 

763 unicode_char = chr(unicode_int) 

764 reverse_cmap[unicode_char] = gid_key_string 

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

766 

767 return reverse_cmap, encoding_cmap 

768 

769 if isinstance(self.encoding, str): 

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

771 glyph_id_str = str(glyph_id) 

772 reverse_cmap[unicode_char] = glyph_id_str 

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

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

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

776 character_str = chr(character_code) 

777 reverse_cmap[unicode_char] = character_str 

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

779 

780 unicode_to_bytes = { 

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

782 } 

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

784 reverse_cmap[unicode_char] = character_code_str 

785 encoding_cmap[character_code_str] = unicode_to_bytes.get( 

786 unicode_char, 

787 bytes((ord(character_code_str),)) 

788 ) 

789 

790 return reverse_cmap, encoding_cmap 

791 

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

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

794 

795 widths_list = [] 

796 unicode_map = [] 

797 bfchar_map: list[str] = [] 

798 

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

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

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

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

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

804 cid_system_info = ( 

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

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

807 ) 

808 

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

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

811 

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

813 # and actual_char is the actual character. 

814 for src_id, actual_char in mapping_source: 

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

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

817 # in multiple characters. 

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

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

820 cid_hex = src_hex_format.format(cid=cid) 

821 uni_hex = normalized_chars.encode("utf-16-be").hex().upper() 

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

823 

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

825 if self.name not in CORE_FONT_METRICS: 

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

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

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

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

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

831 

832 while partial_list := bfchar_map[:CMAP_MAX_ENTRIES_PER_GROUP]: 

833 del bfchar_map[:CMAP_MAX_ENTRIES_PER_GROUP] 

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

835 unicode_map.extend(partial_list) 

836 unicode_map.append("endbfchar") 

837 

838 # Create the /ToUnicode CMap Stream 

839 to_unicode_stream = StreamObject() 

840 to_unicode_stream.set_data( 

841 ( 

842 "/CIDInit /ProcSet findresource begin\n" 

843 "12 dict begin\n" 

844 "begincmap\n" 

845 f"{cid_system_info}" 

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

847 f"/CMapType 2 def\n" 

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

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

850 "endcmap\n" 

851 "CMapName currentdict /CMap defineresource pop\n" 

852 "end end" 

853 ).encode("ascii") 

854 ) 

855 

856 return widths_list, to_unicode_stream 

857 

858 def as_font_resource(self) -> DictionaryObject: 

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

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

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

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

863 # of the Type 0 font object). 

864 widths_list, to_unicode_stream = self._create_widths_list_and_unicode_stream() 

865 

866 # Create the descendant font object 

867 cid_font = DictionaryObject({ 

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

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

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

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

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

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

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

875 }), 

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

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

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

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

880 }) 

881 

882 # Create the Type 0 font object 

883 return DictionaryObject({ 

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

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

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

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

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

889 NameObject("/ToUnicode"): to_unicode_stream, 

890 }) 

891 

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

893 win_ansi_encoding = encoding_dict_from_named_encoding("cp1252") 

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

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

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

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

898 encoding_char = own_encoding.get(idx) 

899 if encoding_char and encoding_char != character_code: 

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

901 

902 if differences_list: 

903 encoding: DictionaryObject | NameObject = DictionaryObject({ 

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

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

906 }) 

907 else: 

908 encoding = NameObject("/WinAnsiEncoding") 

909 

910 simple_font = DictionaryObject({ 

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

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

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

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

915 NameObject("/Encoding"): encoding 

916 }) 

917 

918 if differences_list: 

919 _, to_unicode_stream = self._create_widths_list_and_unicode_stream() 

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

921 

922 return simple_font 

923 

924 def _add_to_writer( 

925 self, 

926 writer: PdfWriter, 

927 target_resource_dict: DictionaryObject, 

928 font_resource_name: NameObject 

929 ) -> IndirectObject: 

930 """ 

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

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

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

934 """ 

935 font_resource = self.as_font_resource() 

936 if "/ToUnicode" in font_resource: 

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

938 

939 if "/DescendantFonts" in font_resource: 

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

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

942 else: 

943 font_resource_dict = font_resource 

944 

945 if "/FontDescriptor" in font_resource_dict: 

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

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

948 if key in font_descriptor_obj: 

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

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

951 font_resource_dict["/FontDescriptor"] 

952 ) 

953 font_resource_ref = writer._add_object(font_resource) 

954 target_resource_dict[font_resource_name] = font_resource_ref 

955 return font_resource_ref 

956 

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

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

959 return sum( 

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

961 ) 

962 

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

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

965 if self.character_map: 

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

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

968 

969 if isinstance(self.encoding, dict): 

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

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

972 

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

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

975 return False