Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pypdf/_font.py: 22%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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, 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.adobe_glyphs import adobe_glyphs
22from ._utils import logger_warning
23from .constants import FontFlags
24from .errors import PdfReadError
26if TYPE_CHECKING:
27 from fontTools.ttLib.tables._h_e_a_d import table__h_e_a_d
28 from fontTools.ttLib.tables._p_o_s_t import table__p_o_s_t
29 from fontTools.ttLib.tables.O_S_2f_2 import table_O_S_2f_2
31 from ._writer import PdfWriter
33try:
34 from io import BytesIO
36 from fontTools.ttLib import TTFont, TTLibError
37 HAS_FONTTOOLS = True
38except ImportError:
39 HAS_FONTTOOLS = False
42# Some constants from truetype font tables that we use:
43HEADER_MACSTYLE_ITALIC = 0x02
44OS2_FSSELECTION_ITALIC = 0x01
45OS2_PANOSE_BFAMILYTYPE_SCRIPT = 3
46OS2_PANOSE_BFAMILYTYPE_DECORATIVE = 4
47OS2_PANOSE_BFAMILYTYPE_PICTORIAL = 5
48OS2_PANOSE_BPROPORTION_MONOSPACED = 9
49OS2_SFAMILYSCLASS_SCRIPTS = 10
50OS2_SFAMILYSCLASS_SYMBOLIC = 12
53@dataclass(frozen=True)
54class FontDescriptor:
55 """
56 Represents the FontDescriptor dictionary as defined in the PDF specification.
57 This contains both descriptive and metric information.
59 The defaults are derived from the mean values of the 14 core fonts, rounded
60 to 100.
61 """
63 name: str = "Unknown"
64 family: str = "Unknown"
65 weight: str = "Unknown"
67 ascent: float = 700.0
68 descent: float = -200.0
69 cap_height: float = 600.0
70 x_height: float = 500.0
71 italic_angle: float = 0.0 # Non-italic
72 flags: int = 32 # Non-serif, non-symbolic, not fixed width
73 bbox: tuple[float, float, float, float] = field(default_factory=lambda: (-100.0, -200.0, 1000.0, 900.0))
74 font_file: StreamObject | None = None
76 def as_font_descriptor_resource(self) -> DictionaryObject:
77 font_descriptor_resource = DictionaryObject({
78 NameObject("/Type"): NameObject("/FontDescriptor"),
79 NameObject("/FontName"): NameObject(f"/{self.name}"),
80 NameObject("/Flags"): NumberObject(self.flags),
81 NameObject("/FontBBox"): ArrayObject([FloatObject(n) for n in self.bbox]),
82 NameObject("/ItalicAngle"): FloatObject(self.italic_angle),
83 NameObject("/Ascent"): FloatObject(self.ascent),
84 NameObject("/Descent"): FloatObject(self.descent),
85 NameObject("/CapHeight"): FloatObject(self.cap_height),
86 NameObject("/XHeight"): FloatObject(self.x_height),
87 })
89 if self.font_file:
90 # Add the stream. For now, we assume a TrueType font (FontFile2)
91 font_descriptor_resource[NameObject("/FontFile2")] = self.font_file
93 return font_descriptor_resource
96@dataclass(frozen=True)
97class CoreFontMetrics:
98 font_descriptor: FontDescriptor
99 character_widths: dict[str, int]
102@dataclass
103class Font:
104 """
105 A font object for use during text extraction and for producing
106 text appearance streams.
108 Attributes:
109 name: Font name, derived from font["/BaseFont"]
110 character_map: The font's character map
111 encoding: Font encoding
112 sub_type: The font type, such as Type1, TrueType, or Type3.
113 font_descriptor: Font metrics, including a mapping of characters to widths
114 character_widths: A mapping of characters to widths
115 space_width: The width of a space, or an approximation
116 interpretable: Default True. If False, the font glyphs cannot
117 be translated to characters, e.g. Type3 fonts that do not define
118 a '/ToUnicode' mapping.
120 """
122 name: str
123 encoding: str | dict[int, str]
124 character_map: dict[Any, Any] = field(default_factory=dict)
125 sub_type: str = "Unknown"
126 font_descriptor: FontDescriptor = field(default_factory=FontDescriptor)
127 character_widths: dict[str, int] = field(default_factory=lambda: {"default": 500})
128 space_width: float | int = 250
129 space_char: str = " "
130 interpretable: bool = True
132 @staticmethod
133 def _collect_tt_t1_character_widths(
134 pdf_font_dict: DictionaryObject,
135 char_map: dict[Any, Any],
136 encoding: str | dict[int, str],
137 current_widths: dict[str, int]
138 ) -> None:
139 """Parses a TrueType or Type1 font's /Widths array from a font dictionary and updates character widths"""
140 widths_array = cast(ArrayObject, pdf_font_dict["/Widths"])
141 first_char = pdf_font_dict.get("/FirstChar", 0)
142 for idx, width in enumerate(widths_array):
143 current_widths[chr(idx + first_char)] = int(width)
145 @staticmethod
146 def _collect_cid_character_widths(
147 d_font: DictionaryObject, char_map: dict[Any, Any], current_widths: dict[str, int]
148 ) -> None:
149 """Parses the /W array from a DescendantFont dictionary and updates character widths."""
150 # /W width definitions have two valid formats which can be mixed and matched:
151 # (1) A character start index followed by a list of widths, e.g.
152 # `45 [500 600 700]` applies widths 500, 600, 700 to characters 45-47.
153 # (2) A character start index, a character stop index, and a width, e.g.
154 # `45 65 500` applies width 500 to characters 45-65.
155 skip_count = 0
156 _w = d_font.get("/W", ArrayObject()).get_object()
157 _w_length = len(_w)
158 for idx, w_entry in enumerate(_w):
159 w_entry = w_entry.get_object()
160 if skip_count:
161 skip_count -= 1
162 continue
163 if not isinstance(w_entry, (int, float)):
164 # We should never get here due to skip_count above. But
165 # sometimes we do.
166 logger_warning(
167 "Expected numeric value for width, got %(w_entry)s. Ignoring it.",
168 source=__name__,
169 w_entry=w_entry,
170 )
171 continue
172 # check for format (1): `int [int int int int ...]`
173 w_next_entry = _w[idx + 1].get_object() if idx + 1 < _w_length else None
174 if isinstance(w_next_entry, Sequence):
175 start_idx, width_list = w_entry, w_next_entry
176 current_widths.update(
177 {
178 chr(_cidx): _width
179 for _cidx, _width in zip(
180 range(
181 cast(int, start_idx),
182 cast(int, start_idx) + len(width_list),
183 1,
184 ),
185 width_list,
186 )
187 }
188 )
189 skip_count = 1
190 # check for format (2): `int int int`
191 elif (
192 isinstance(w_next_entry, (int, float))
193 and idx + 2 < _w_length
194 and isinstance(_w[idx + 2].get_object(), (int, float))
195 ):
196 start_idx, stop_idx, const_width = (
197 w_entry,
198 w_next_entry,
199 _w[idx + 2].get_object(),
200 )
201 current_widths.update(
202 {
203 chr(_cidx): const_width
204 for _cidx in range(
205 cast(int, start_idx), cast(int, stop_idx + 1), 1
206 )
207 }
208 )
209 skip_count = 2
210 else:
211 # This handles the case of out of bounds (reaching the end of the width definitions
212 # while expecting more elements).
213 logger_warning(
214 "Invalid font width definition. Last element: %(w_entry)s.",
215 source=__name__,
216 w_entry=w_entry,
217 )
219 @staticmethod
220 def _get_space_char(
221 encoding: str | dict[int, str],
222 character_map: dict[Any, Any],
223 ) -> str:
224 space_char = " "
225 if isinstance(encoding, dict):
226 for char_code, char_str in encoding.items():
227 if char_str == space_char:
228 return chr(char_code)
230 for glyph_id, char_str in character_map.items():
231 if char_str == space_char:
232 return str(glyph_id)
234 return space_char
236 @staticmethod
237 def _add_default_width(current_widths: dict[str, int], flags: int, space_char: str) -> None:
238 if not current_widths:
239 current_widths["default"] = 500
240 return
242 if space_char in current_widths and current_widths[space_char] != 0:
243 # Setting default to once or twice the space width, depending on fixed pitch
244 if (flags & FontFlags.FIXED_PITCH) == FontFlags.FIXED_PITCH:
245 current_widths["default"] = current_widths[space_char]
246 return
248 current_widths["default"] = int(2 * current_widths[space_char])
249 return
251 # Use the average width of existing glyph widths
252 valid_widths = [w for w in current_widths.values() if w > 0]
253 current_widths["default"] = sum(valid_widths) // len(valid_widths) if valid_widths else 500
255 @staticmethod
256 def _add_space_width(
257 character_widths: dict[str, int],
258 flags: int,
259 space_char: str
260 ) -> int:
261 space_width = character_widths.get(space_char, 0)
262 if space_width != 0:
263 return space_width
265 if (flags & FontFlags.FIXED_PITCH) == FontFlags.FIXED_PITCH:
266 return character_widths["default"]
268 return character_widths["default"] // 2
270 @staticmethod
271 def _parse_font_descriptor(font_descriptor_obj: DictionaryObject) -> dict[str, Any]:
272 font_descriptor_kwargs: dict[Any, Any] = {}
273 for source_key, target_key in [
274 ("/FontName", "name"),
275 ("/FontFamily", "family"),
276 ("/FontWeight", "weight"),
277 ("/Ascent", "ascent"),
278 ("/Descent", "descent"),
279 ("/CapHeight", "cap_height"),
280 ("/XHeight", "x_height"),
281 ("/ItalicAngle", "italic_angle"),
282 ("/Flags", "flags"),
283 ("/FontBBox", "bbox")
284 ]:
285 if source_key in font_descriptor_obj:
286 font_descriptor_kwargs[target_key] = font_descriptor_obj[source_key]
287 # Handle missing bbox gracefully - PDFs may have fonts without valid bounding boxes
288 if "bbox" in font_descriptor_kwargs:
289 bbox_tuple = tuple(map(float, font_descriptor_kwargs["bbox"]))
290 assert len(bbox_tuple) == 4, bbox_tuple
291 font_descriptor_kwargs["bbox"] = bbox_tuple
293 # Find the binary stream for this font if there is one
294 for source_key in ["/FontFile", "/FontFile2", "/FontFile3"]:
295 if source_key in font_descriptor_obj:
296 if "font_file" in font_descriptor_kwargs:
297 raise PdfReadError(f"More than one /FontFile found in {font_descriptor_obj}")
299 try:
300 font_file = font_descriptor_obj[source_key].get_object()
301 font_descriptor_kwargs["font_file"] = font_file
302 except PdfReadError as e:
303 logger_warning(
304 "Failed to get %(source_key)r in %(font_descriptor_obj)s: %(error)s",
305 source=__name__,
306 source_key=source_key,
307 font_descriptor_obj=font_descriptor_obj,
308 error=e,
309 )
310 return font_descriptor_kwargs
312 @classmethod
313 def from_font_resource(
314 cls,
315 pdf_font_dict: DictionaryObject,
316 ) -> Font:
317 from pypdf._codecs.core_font_metrics import CORE_FONT_METRICS # noqa: PLC0415
319 # Can collect base_font, name and encoding directly from font resource
320 name = pdf_font_dict.get("/BaseFont", "Unknown").removeprefix("/")
321 sub_type = pdf_font_dict.get("/Subtype", "Unknown").removeprefix("/")
322 encoding, character_map = get_encoding(pdf_font_dict)
323 font_descriptor = None
324 character_widths: dict[str, int] = {}
325 interpretable = True
327 # Deal with fonts by type; Type1, TrueType and certain Type3
328 if pdf_font_dict.get("/Subtype") in ("/Type1", "/MMType1", "/TrueType", "/Type3"):
329 # Type3 fonts that do not specify a "/ToUnicode" mapping cannot be
330 # reliably converted into character codes unless all named chars
331 # in /CharProcs map to a standard adobe glyph. See §9.10.2 of the
332 # PDF 1.7 standard.
333 if sub_type == "Type3" and "/ToUnicode" not in pdf_font_dict:
334 interpretable = all(
335 cname in adobe_glyphs
336 for cname in pdf_font_dict.get("/CharProcs") or []
337 )
338 if interpretable: # Save some overhead if font is not interpretable
339 if "/Widths" in pdf_font_dict:
340 cls._collect_tt_t1_character_widths(
341 pdf_font_dict, character_map, encoding, character_widths
342 )
344 elif name in CORE_FONT_METRICS:
345 font_descriptor = CORE_FONT_METRICS[name].font_descriptor
346 if isinstance(encoding, dict):
347 for code, character in encoding.items():
348 # Look up the width using the glyph name from the encoding
349 if character in CORE_FONT_METRICS[name].character_widths:
350 character_widths[chr(code)] = CORE_FONT_METRICS[name].character_widths[character]
351 else:
352 for code in range(256):
353 character = chr(code)
354 if character in CORE_FONT_METRICS[name].character_widths:
355 character_widths[character] = CORE_FONT_METRICS[name].character_widths[character]
356 if "/FontDescriptor" in pdf_font_dict:
357 font_descriptor_obj = pdf_font_dict.get("/FontDescriptor", DictionaryObject()).get_object()
358 if "/MissingWidth" in font_descriptor_obj:
359 character_widths["default"] = cast(int, font_descriptor_obj["/MissingWidth"].get_object())
360 font_descriptor = FontDescriptor(**cls._parse_font_descriptor(font_descriptor_obj))
361 elif "/FontBBox" in pdf_font_dict:
362 # For Type3 without Font Descriptor but with FontBBox, see Table 110 in the PDF specification 2.0
363 bbox_tuple = tuple(map(float, cast(ArrayObject, pdf_font_dict["/FontBBox"])))
364 assert len(bbox_tuple) == 4, bbox_tuple
365 font_descriptor = FontDescriptor(name=name, bbox=bbox_tuple)
367 else:
368 # Composite font or CID font - CID fonts have a /W array mapping character codes
369 # to widths stashed in /DescendantFonts. No need to test for /DescendantFonts though,
370 # because all other fonts have already been dealt with.
371 d_font: DictionaryObject
372 for d_font_idx, d_font in enumerate(
373 cast(ArrayObject, pdf_font_dict["/DescendantFonts"])
374 ):
375 d_font = cast(DictionaryObject, d_font.get_object())
376 cast(ArrayObject, pdf_font_dict["/DescendantFonts"])[d_font_idx] = d_font
377 cls._collect_cid_character_widths(
378 d_font, character_map, character_widths
379 )
380 if "/DW" in d_font:
381 character_widths["default"] = cast(int, d_font["/DW"].get_object())
382 font_descriptor_obj = d_font.get("/FontDescriptor", DictionaryObject()).get_object()
383 font_descriptor = FontDescriptor(**cls._parse_font_descriptor(font_descriptor_obj))
385 if not font_descriptor:
386 font_descriptor = FontDescriptor(name=name)
388 space_char = cls._get_space_char(encoding, character_map)
389 if character_widths.get("default", 0) == 0:
390 cls._add_default_width(character_widths, font_descriptor.flags, space_char)
391 space_width = cls._add_space_width(character_widths, font_descriptor.flags, space_char)
393 return cls(
394 name=name,
395 sub_type=sub_type,
396 encoding=encoding,
397 font_descriptor=font_descriptor,
398 character_map=character_map,
399 character_widths=character_widths,
400 space_width=space_width,
401 space_char=space_char,
402 interpretable=interpretable
403 )
405 @staticmethod
406 def _font_flags_from_truetype_font_tables(
407 header: table__h_e_a_d,
408 postscript: table__p_o_s_t,
409 os2: table_O_S_2f_2
410 ) -> int:
411 # Get the font flags
412 if os2:
413 panose = os2.panose
414 # sFamilyClass is a two-byte field. The high byte describes the family class, whereas the low
415 # byte only describes the subclass. We only need the high byte, hence the bit shift below:
416 family_class = os2.sFamilyClass >> 8
417 flags: int = 0
419 # ITALIC
420 if header.macStyle & HEADER_MACSTYLE_ITALIC or (os2 and os2.fsSelection & OS2_FSSELECTION_ITALIC):
421 flags |= FontFlags.ITALIC
422 if postscript:
423 italic_angle = postscript.italicAngle
424 if italic_angle != 0.0:
425 flags |= FontFlags.ITALIC
427 # FIXED_PITCH
428 if (
429 (os2 and panose.bProportion == OS2_PANOSE_BPROPORTION_MONOSPACED) or
430 (postscript and postscript.isFixedPitch > 0) # Actually 1, but originally (older versions of the TTF
431 ): # specification) any non-zero value signified monospace.
432 flags |= FontFlags.FIXED_PITCH
434 # SCRIPT
435 if os2 and (
436 family_class == OS2_SFAMILYSCLASS_SCRIPTS or panose.bFamilyType == OS2_PANOSE_BFAMILYTYPE_SCRIPT
437 ):
438 flags |= FontFlags.SCRIPT
440 # SERIF
441 if os2 and (
442 2 <= panose.bSerifStyle <= 10
443 or 1 <= family_class <= 5 or family_class == 7 # 6 is reserved, all 8 and above are not serif
444 ):
445 flags |= FontFlags.SERIF
447 # SYMBOLIC
448 if os2 and (
449 family_class == OS2_SFAMILYSCLASS_SYMBOLIC or
450 panose.bFamilyType in {OS2_PANOSE_BFAMILYTYPE_DECORATIVE, OS2_PANOSE_BFAMILYTYPE_PICTORIAL}
451 ):
452 flags |= FontFlags.SYMBOLIC
453 else:
454 flags |= FontFlags.NONSYMBOLIC
456 return flags
458 @classmethod
459 def from_truetype_font_file(cls, font_file: BytesIO) -> Font:
460 if not HAS_FONTTOOLS:
461 raise ImportError("The 'fontTools' library is required to use 'from_truetype_font_file'")
462 with TTFont(font_file) as tt_font_object:
463 # See Chapter 6 of the TrueType reference manual for the definition of the head, OS/2 and post tables:
464 # https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6head.html
465 # https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6OS2.html
466 # https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6post.html
467 header = tt_font_object["head"]
468 horizontal_header = tt_font_object["hhea"]
469 metrics = tt_font_object["hmtx"].metrics
471 # Collect additional font tables to derive font information
472 postscript = tt_font_object.get("post", None)
473 os2 = tt_font_object.get("OS/2", None)
475 # Get the scaling factor to convert font file's units per em to PDF's 1000 units per em
476 units_per_em = header.unitsPerEm
477 if not units_per_em:
478 raise PdfReadError("Font file has an invalid unitsPerEm of 0")
479 scale_factor = 1000.0 / units_per_em
481 # Get the font descriptor
482 font_descriptor_kwargs: dict[Any, Any] = {}
483 names = tt_font_object.get("name", None)
484 if names:
485 font_descriptor_kwargs["name"] = names.getBestFullName()
486 font_descriptor_kwargs["family"] = names.getBestFamilyName()
487 font_descriptor_kwargs["weight"] = names.getBestSubFamilyName()
488 font_descriptor_kwargs["ascent"] = int(round(horizontal_header.ascent * scale_factor, 0))
489 font_descriptor_kwargs["descent"] = int(round(horizontal_header.descent * scale_factor, 0))
490 if os2:
491 try:
492 font_descriptor_kwargs["cap_height"] = int(round(os2.sCapHeight * scale_factor, 0))
493 font_descriptor_kwargs["x_height"] = int(round(os2.sxHeight * scale_factor, 0))
494 except AttributeError:
495 pass
497 font_descriptor_kwargs["flags"] = cls._font_flags_from_truetype_font_tables(header, postscript, os2)
499 font_descriptor_kwargs["bbox"] = (
500 round(header.xMin * scale_factor, 0),
501 round(header.yMin * scale_factor, 0),
502 round(header.xMax * scale_factor, 0),
503 round(header.yMax * scale_factor, 0)
504 )
506 font_file_data = StreamObject()
507 font_file_raw_bytes = font_file.getvalue()
508 font_file_data.set_data(font_file_raw_bytes)
509 font_file_data.update({NameObject("/Length1"): NumberObject(len(font_file_raw_bytes))})
510 font_descriptor_kwargs["font_file"] = font_file_data
512 font_descriptor = FontDescriptor(**font_descriptor_kwargs)
513 encoding = "utf_16_be" # Assume unicode
515 character_widths: dict[str, int] = {}
516 character_map: dict[str, str] = {}
518 glyph_order = tt_font_object.getGlyphOrder()
519 # Note that one glyph can be mapped to multiple unicode code points. However, buildReversedMin()
520 # creates a dictionary mapping glyphs to the minimum Unicode codepoint.
521 tt_font_cmap_table = tt_font_object.get("cmap")
522 if tt_font_cmap_table:
523 reverse_cmap = tt_font_cmap_table.buildReversedMin()
524 for gid, glyph in enumerate(glyph_order):
525 char_code = reverse_cmap.get(glyph)
526 if char_code is None:
527 continue
528 char = chr(char_code)
529 gid = tt_font_object.getGlyphID(glyph)
530 # The following is to comply with how font_glyph_byte_map works in _appearance_stream.py
531 gid_bytes = gid.to_bytes(2, "big")
532 gid_key_string = gid_bytes.decode("utf-16-be", "surrogatepass")
533 character_map[gid_key_string] = char
534 character_widths[gid_key_string] = int(round(metrics[glyph][0] * scale_factor, 0))
535 else:
536 raise PdfReadError("Font file does not have a cmap table")
538 space_char = cls._get_space_char(encoding, character_map)
539 cls._add_default_width(character_widths, font_descriptor_kwargs["flags"], space_char)
540 space_width = cls._add_space_width(
541 character_widths, font_descriptor_kwargs["flags"], space_char
542 )
544 return cls(
545 name=font_descriptor.name,
546 sub_type="TrueType",
547 encoding=encoding,
548 font_descriptor=font_descriptor,
549 character_map=character_map,
550 character_widths=character_widths,
551 space_width=space_width,
552 space_char=space_char,
553 interpretable=True
554 )
556 def _get_typographic_maps(self) -> tuple[dict[str, str], dict[str, bytes]]:
557 """
558 Generates maps to translate input unicode text to bytes in two steps:
559 Unicode code point -> raw_character (reverse cmap) -> PDF bytes (encoding cmap).
560 """
561 reverse_cmap = {}
562 encoding_cmap = {}
563 if (
564 HAS_FONTTOOLS
565 and getattr(self.font_descriptor, "font_file", None)
566 and isinstance(self.encoding, str)
567 ):
568 try:
569 font_file_data = cast(StreamObject, self.font_descriptor.font_file).get_data()
570 with TTFont(BytesIO(font_file_data)) as tt_font_object:
571 tt_font_cmap_table = tt_font_object.get("cmap")
572 best_cmap = tt_font_cmap_table.getBestCmap()
573 for unicode_int, glyph_name in best_cmap.items():
574 gid = tt_font_object.getGlyphID(glyph_name)
575 gid_bytes = gid.to_bytes(2, "big")
576 gid_key_string = gid_bytes.decode("utf-16-be", "surrogatepass")
577 unicode_char = chr(unicode_int)
578 reverse_cmap[unicode_char] = gid_key_string
579 encoding_cmap[gid_key_string] = gid_key_string.encode(self.encoding)
581 return reverse_cmap, encoding_cmap
583 except (AttributeError, TTLibError): # Cmap table is missing or the font is corrupt.
584 reverse_cmap.clear()
585 encoding_cmap.clear()
587 if isinstance(self.encoding, str):
588 for glyph_id, unicode_char in self.character_map.items():
589 glyph_id_str = str(glyph_id)
590 reverse_cmap[unicode_char] = glyph_id_str
591 encoding_cmap[glyph_id_str] = glyph_id_str.encode(self.encoding)
592 else:
593 for character_code, unicode_char in self.encoding.items():
594 character_str = chr(character_code)
595 reverse_cmap[unicode_char] = character_str
596 encoding_cmap[character_str] = bytes((character_code,))
598 unicode_to_bytes = {
599 unicode_char: bytes((character_code,)) for character_code, unicode_char in self.encoding.items()
600 }
601 for glyph_id, unicode_char in self.character_map.items(): # This code is not covered nor tested
602 reverse_cmap[unicode_char] = glyph_id
603 encoding_cmap[glyph_id] = unicode_to_bytes.get(unicode_char, bytes((glyph_id,)))
605 return reverse_cmap, encoding_cmap
607 def _create_widths_list_and_unicode_stream(self) -> tuple[list[PdfObject], StreamObject]:
608 widths_list = []
609 unicode_map = []
610 # In the loop, char is the decoded GID string (the reverse unicode hack)
611 # and character_map[char] is the actual character.
612 # The widths (/W) array can have two formats:
613 # [first_cid [w1 w2 w3]] or [first last width]
614 # Here we choose the first format and simply provide one array with one width for every cid.
615 for gid_str, actual_char in self.character_map.items():
616 # Make sure that we do not include characters such as arabic presentation form characters.
617 # Note that, in some cases, unicodedata.normalize() might split a ligature, resulting
618 # in multiple characters.
619 normalized_chars = unicodedata.normalize("NFKC", actual_char)
620 uni_points = [ord(char) for char in normalized_chars]
621 # Only deal with Basic Multilingual Plane characters.
622 # TODO: Add all characters.
623 if all(uni_point <= 0xFFFF for uni_point in uni_points):
624 cid = ord(gid_str)
625 cid_hex = f"{cid:04X}"
626 uni_hex = "".join(f"{uni_point:04X}" for uni_point in uni_points)
627 unicode_map.append(f"<{cid_hex}> <{uni_hex}>")
629 width = self.character_widths.get(gid_str, self.character_widths["default"])
630 widths_list.extend([NumberObject(cid), ArrayObject([NumberObject(width)])])
632 # Create the /ToUnicode CMap Stream
633 to_unicode_stream = StreamObject()
634 to_unicode_stream.set_data(
635 (
636 "/CIDInit /ProcSet findresource begin\n"
637 "12 dict begin\n"
638 "begincmap\n"
639 "/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n"
640 "/CMapName /Adobe-Identity-UCS def\n"
641 "/CMapType 2 def\n"
642 "1 begincodespacerange <0000> <FFFF> endcodespacerange\n"
643 f"{len(unicode_map)} beginbfchar\n"
644 + "\n".join(unicode_map) + "\n"
645 "endbfchar\n"
646 "endcmap\n"
647 "CMapName currentdict /CMap defineresource pop\n"
648 "end end"
649 ).encode("ascii")
650 )
652 return widths_list, to_unicode_stream
654 def as_font_resource(self) -> DictionaryObject:
655 # If we have an embedded Truetype font, we assume that we need to produce a Type 2 CID font resource.
656 if self.font_descriptor.font_file and self.sub_type == "TrueType":
657 # Begin with creating the widths array (part of the descendant font) and the unicode cmap (part
658 # of the Type 0 font obect).
659 widths_list, to_unicode_stream = self._create_widths_list_and_unicode_stream()
661 # Create the descendant font object
662 cid_font = DictionaryObject({
663 NameObject("/Type"): NameObject("/Font"),
664 NameObject("/Subtype"): NameObject("/CIDFontType2"),
665 NameObject("/BaseFont"): NameObject(f"/{self.name}"),
666 NameObject("/CIDSystemInfo"): DictionaryObject({
667 NameObject("/Registry"): TextStringObject("Adobe"),
668 NameObject("/Ordering"): TextStringObject("Identity"),
669 NameObject("/Supplement"): NumberObject(0)
670 }),
671 NameObject("/FontDescriptor"): self.font_descriptor.as_font_descriptor_resource(),
672 NameObject("/W"): ArrayObject(widths_list),
673 NameObject("/DW"): NumberObject(self.character_widths["default"]),
674 NameObject("/CIDToGIDMap"): NameObject("/Identity")
675 })
677 # Create the Type 0 font object
678 return DictionaryObject({
679 NameObject("/Type"): NameObject("/Font"),
680 NameObject("/Subtype"): NameObject("/Type0"),
681 NameObject("/BaseFont"): NameObject(f"/{self.name}"),
682 NameObject("/Encoding"): NameObject("/Identity-H"),
683 NameObject("/DescendantFonts"): ArrayObject([cid_font]),
684 NameObject("/ToUnicode"): to_unicode_stream,
685 })
687 # Fallback: Return a font resource for one of the 14 Adobe Core fonts.
688 return DictionaryObject({
689 NameObject("/Type"): NameObject("/Font"),
690 NameObject("/Subtype"): NameObject("/Type1"),
691 NameObject("/Name"): NameObject(f"/{self.name}"),
692 NameObject("/BaseFont"): NameObject(f"/{self.name}"),
693 NameObject("/Encoding"): NameObject("/WinAnsiEncoding")
694 })
696 def _add_to_writer(
697 self,
698 writer: PdfWriter,
699 target_resource_dict: DictionaryObject,
700 font_resource_name: NameObject
701 ) -> IndirectObject:
702 """
703 Some objects in a font resource need to be indirect objects. This method
704 ensures that ToUnicode, FontDescriptor, FontFile, and, ultimately, the font
705 resource itself, are registered with the PdfWriter instance as indirect objects.
706 """
707 font_resource = self.as_font_resource()
708 if "/ToUnicode" in font_resource:
709 font_resource[NameObject("/ToUnicode")] = writer._add_object(font_resource["/ToUnicode"])
711 if "/DescendantFonts" in font_resource:
712 descendant_fonts = cast(ArrayObject, font_resource["/DescendantFonts"])
713 font_resource_dict = cast(DictionaryObject, descendant_fonts[0])
714 else:
715 font_resource_dict = font_resource
717 if "/FontDescriptor" in font_resource_dict:
718 font_descriptor_obj = cast(DictionaryObject, font_resource_dict["/FontDescriptor"])
719 for key in ["/FontFile", "/FontFile2", "/FontFile3"]:
720 if key in font_descriptor_obj:
721 font_descriptor_obj[NameObject(key)] = writer._add_object(font_descriptor_obj[key])
722 font_resource_dict[NameObject("/FontDescriptor")] = writer._add_object(
723 font_resource_dict["/FontDescriptor"]
724 )
725 font_resource_ref = writer._add_object(font_resource)
726 target_resource_dict[font_resource_name] = font_resource_ref
727 return font_resource_ref
729 def get_text_width(self, text: str = "") -> float:
730 """Sum of character widths specified in PDF font for the supplied text."""
731 return sum(
732 [self.character_widths.get(char, self.character_widths["default"]) for char in text], 0.0
733 )
735 def can_encode(self, text: str) -> bool:
736 """Check whether the font is able to encode a text string."""
737 try:
738 if self.character_map:
739 supported_chars = set(self.character_map.values())
740 return all(char in supported_chars for char in text)
741 if isinstance(self.encoding, str):
742 text.encode(self.encoding, "surrogatepass")
743 else:
744 supported_chars = set(self.encoding.values())
745 return all(char in supported_chars for char in text)
747 except UnicodeEncodeError:
748 return False
750 return True