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
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
1from __future__ import annotations
3import unicodedata
4from collections.abc import Sequence
5from dataclasses import dataclass, field
6from typing import TYPE_CHECKING, Any, ClassVar, cast
8from pypdf.generic import (
9 ArrayObject,
10 DictionaryObject,
11 FloatObject,
12 IndirectObject,
13 NameObject,
14 NumberObject,
15 PdfObject,
16 StreamObject,
17 TextStringObject,
18)
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
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
33 from .._writer import PdfWriter
35try:
36 from io import BytesIO
38 from fontTools.ttLib import TTFont
39 HAS_FONTTOOLS = True
40except ImportError:
41 HAS_FONTTOOLS = False
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
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
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
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.
72 The defaults are derived from the mean values of the 14 core fonts, rounded
73 to 100.
74 """
76 _DEFAULT_BBOX: ClassVar[tuple[float, float, float, float]] = (-100.0, -200.0, 1000.0, 900.0)
78 name: str = "Unknown"
79 family: str = "Unknown"
80 weight: str = "Unknown"
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
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 })
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
108 return font_descriptor_resource
111@dataclass(frozen=True)
112class CoreFontMetrics:
113 font_descriptor: FontDescriptor
114 character_widths: dict[str, float]
117@dataclass
118class Font:
119 """
120 A font object for use during text extraction and for producing
121 text appearance streams.
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.
135 """
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
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)
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 )
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}.")
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}.")
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 )
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)
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)
296 return space_char
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
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
310 current_widths["default"] = int(2 * current_widths[space_char])
311 return
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
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
327 if (flags & FontFlags.FIXED_PITCH) == FontFlags.FIXED_PITCH:
328 return character_widths["default"]
330 return character_widths["default"] // 2
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.
337 Args:
338 raw_bbox: The raw /FontBBox value read from the PDF.
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.
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]
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
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}")
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
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
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
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 )
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)
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))
474 if not font_descriptor:
475 font_descriptor = FontDescriptor(name=name)
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)
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 )
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.
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
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
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
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
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
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
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
585 return flags
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
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 )
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
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
629 # Get the font descriptor
630 font_descriptor_kwargs: dict[Any, Any] = {}
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
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))
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
652 font_descriptor_kwargs["flags"] = cls._get_font_flags_from_truetype_font_tables(
653 header_table,
654 postscript_table,
655 os2_table
656 )
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 )
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
671 font_descriptor = FontDescriptor(**font_descriptor_kwargs)
672 encoding = "utf_16_be" # Assume unicode
674 character_widths: dict[str, float] = {}
675 character_map: dict[str, str] = {}
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)
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 )
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 )
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
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
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"]
734 return font
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)
767 return reverse_cmap, encoding_cmap
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,))
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 )
790 return reverse_cmap, encoding_cmap
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
795 widths_list = []
796 unicode_map = []
797 bfchar_map: list[str] = []
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 )
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()
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}>")
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)])])
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")
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 )
856 return widths_list, to_unicode_stream
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()
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 })
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 })
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])])
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")
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 })
918 if differences_list:
919 _, to_unicode_stream = self._create_widths_list_and_unicode_stream()
920 simple_font[NameObject("/ToUnicode")] = to_unicode_stream
922 return simple_font
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"])
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
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
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 )
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)
969 if isinstance(self.encoding, dict):
970 supported_chars = set(self.encoding.values())
971 return all(char in supported_chars for char in text)
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