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
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.O_S_2f_2 import table_O_S_2f_2
32 from ._writer import PdfWriter
34try:
35 from io import BytesIO
37 from fontTools.ttLib import TTFont, TTLibError
38 HAS_FONTTOOLS = True
39except ImportError:
40 HAS_FONTTOOLS = False
43# Limits.
44MAX_CID_WIDTH_ENTRY_COUNT = 65_536
45MAX_WIDTH_ENTRY_COUNT = 100_000
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
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
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.
69 The defaults are derived from the mean values of the 14 core fonts, rounded
70 to 100.
71 """
73 _DEFAULT_BBOX: ClassVar[tuple[float, float, float, float]] = (-100.0, -200.0, 1000.0, 900.0)
75 name: str = "Unknown"
76 family: str = "Unknown"
77 weight: str = "Unknown"
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
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 })
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
105 return font_descriptor_resource
108@dataclass(frozen=True)
109class CoreFontMetrics:
110 font_descriptor: FontDescriptor
111 character_widths: dict[str, float]
114@dataclass
115class Font:
116 """
117 A font object for use during text extraction and for producing
118 text appearance streams.
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.
132 """
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
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)
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 )
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}.")
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}.")
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 )
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)
260 for glyph_id, char_str in character_map.items():
261 if char_str == space_char:
262 return str(glyph_id)
264 return space_char
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
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
278 current_widths["default"] = int(2 * current_widths[space_char])
279 return
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
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
295 if (flags & FontFlags.FIXED_PITCH) == FontFlags.FIXED_PITCH:
296 return character_widths["default"]
298 return character_widths["default"] // 2
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.
305 Args:
306 raw_bbox: The raw /FontBBox value read from the PDF.
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.
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]
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
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}")
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
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
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
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 )
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)
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))
439 if not font_descriptor:
440 font_descriptor = FontDescriptor(name=name)
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)
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 )
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
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
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
488 # SCRIPT
489 if os2 and (
490 family_class == OS2_SFAMILYSCLASS_SCRIPTS or panose.bFamilyType == OS2_PANOSE_BFAMILYTYPE_SCRIPT
491 ):
492 flags |= FontFlags.SCRIPT
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
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
510 return flags
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
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)
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
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
551 font_descriptor_kwargs["flags"] = cls._font_flags_from_truetype_font_tables(header, postscript, os2)
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 )
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
566 font_descriptor = FontDescriptor(**font_descriptor_kwargs)
567 encoding = "utf_16_be" # Assume unicode
569 character_widths: dict[str, float] = {}
570 character_map: dict[str, str] = {}
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")
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 )
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 )
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
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
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"]
632 return font
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)
659 return reverse_cmap, encoding_cmap
661 except (AttributeError, TTLibError): # Cmap table is missing or the font is corrupt.
662 reverse_cmap.clear()
663 encoding_cmap.clear()
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,))
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 )
686 return reverse_cmap, encoding_cmap
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
691 widths_list = []
692 unicode_map = []
693 bfchar_map: list[str] = []
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 )
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()
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}>")
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)])])
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")
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 )
756 return widths_list, to_unicode_stream
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()
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 })
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 })
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])])
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")
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 })
818 if differences_list:
819 _, to_unicode_stream = self._create_widths_list_and_unicode_stream()
820 simple_font[NameObject("/ToUnicode")] = to_unicode_stream
822 return simple_font
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"])
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
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
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 )
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)
869 if isinstance(self.encoding, dict):
870 supported_chars = set(self.encoding.values())
871 return all(char in supported_chars for char in text)
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