_font.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. from collections.abc import Sequence
  2. from dataclasses import dataclass, field
  3. from typing import Any, Union, cast
  4. from pypdf.generic import ArrayObject, DictionaryObject, NameObject
  5. from ._cmap import get_encoding
  6. from ._codecs.adobe_glyphs import adobe_glyphs
  7. from ._utils import logger_warning
  8. from .constants import FontFlags
  9. @dataclass(frozen=True)
  10. class FontDescriptor:
  11. """
  12. Represents the FontDescriptor dictionary as defined in the PDF specification.
  13. This contains both descriptive and metric information.
  14. The defaults are derived from the mean values of the 14 core fonts, rounded
  15. to 100.
  16. """
  17. name: str = "Unknown"
  18. family: str = "Unknown"
  19. weight: str = "Unknown"
  20. ascent: float = 700.0
  21. descent: float = -200.0
  22. cap_height: float = 600.0
  23. x_height: float = 500.0
  24. italic_angle: float = 0.0 # Non-italic
  25. flags: int = 32 # Non-serif, non-symbolic, not fixed width
  26. bbox: tuple[float, float, float, float] = field(default_factory=lambda: (-100.0, -200.0, 1000.0, 900.0))
  27. @dataclass(frozen=True)
  28. class CoreFontMetrics:
  29. font_descriptor: FontDescriptor
  30. character_widths: dict[str, int]
  31. @dataclass
  32. class Font:
  33. """
  34. A font object for use during text extraction and for producing
  35. text appearance streams.
  36. Attributes:
  37. name: Font name, derived from font["/BaseFont"]
  38. character_map: The font's character map
  39. encoding: Font encoding
  40. sub_type: The font type, such as Type1, TrueType, or Type3.
  41. font_descriptor: Font metrics, including a mapping of characters to widths
  42. character_widths: A mapping of characters to widths
  43. space_width: The width of a space, or an approximation
  44. interpretable: Default True. If False, the font glyphs cannot
  45. be translated to characters, e.g. Type3 fonts that do not define
  46. a '/ToUnicode' mapping.
  47. """
  48. name: str
  49. encoding: Union[str, dict[int, str]]
  50. character_map: dict[Any, Any] = field(default_factory=dict)
  51. sub_type: str = "Unknown"
  52. font_descriptor: FontDescriptor = field(default_factory=FontDescriptor)
  53. character_widths: dict[str, int] = field(default_factory=lambda: {"default": 500})
  54. space_width: Union[float, int] = 250
  55. interpretable: bool = True
  56. @staticmethod
  57. def _collect_tt_t1_character_widths(
  58. pdf_font_dict: DictionaryObject,
  59. char_map: dict[Any, Any],
  60. encoding: Union[str, dict[int, str]],
  61. current_widths: dict[str, int]
  62. ) -> None:
  63. """Parses a TrueType or Type1 font's /Widths array from a font dictionary and updates character widths"""
  64. widths_array = cast(ArrayObject, pdf_font_dict["/Widths"])
  65. first_char = pdf_font_dict.get("/FirstChar", 0)
  66. if not isinstance(encoding, str):
  67. # This means that encoding is a dict
  68. current_widths.update({
  69. encoding.get(idx + first_char, chr(idx + first_char)): width
  70. for idx, width in enumerate(widths_array)
  71. })
  72. return
  73. # We map the character code directly to the character
  74. # using the string encoding
  75. for idx, width in enumerate(widths_array):
  76. # Often "idx == 0" will denote the .notdef character, but we add it anyway
  77. char_code = idx + first_char # This is a raw code
  78. # Get the "raw" character or byte representation
  79. raw_char = bytes([char_code]).decode(encoding, "surrogatepass")
  80. # Translate raw_char to the REAL Unicode character using the char_map
  81. unicode_char = char_map.get(raw_char)
  82. if unicode_char:
  83. current_widths[unicode_char] = int(width)
  84. else:
  85. current_widths[raw_char] = int(width)
  86. @staticmethod
  87. def _collect_cid_character_widths(
  88. d_font: DictionaryObject, char_map: dict[Any, Any], current_widths: dict[str, int]
  89. ) -> None:
  90. """Parses the /W array from a DescendantFont dictionary and updates character widths."""
  91. ord_map = {
  92. ord(_target): _surrogate
  93. for _target, _surrogate in char_map.items()
  94. if isinstance(_target, str)
  95. }
  96. # /W width definitions have two valid formats which can be mixed and matched:
  97. # (1) A character start index followed by a list of widths, e.g.
  98. # `45 [500 600 700]` applies widths 500, 600, 700 to characters 45-47.
  99. # (2) A character start index, a character stop index, and a width, e.g.
  100. # `45 65 500` applies width 500 to characters 45-65.
  101. skip_count = 0
  102. _w = d_font.get("/W", [])
  103. for idx, w_entry in enumerate(_w):
  104. w_entry = w_entry.get_object()
  105. if skip_count:
  106. skip_count -= 1
  107. continue
  108. if not isinstance(w_entry, (int, float)):
  109. # We should never get here due to skip_count above. But
  110. # sometimes we do.
  111. logger_warning(f"Expected numeric value for width, got {w_entry}. Ignoring it.", __name__)
  112. continue
  113. # check for format (1): `int [int int int int ...]`
  114. w_next_entry = _w[idx + 1].get_object()
  115. if isinstance(w_next_entry, Sequence):
  116. start_idx, width_list = w_entry, w_next_entry
  117. current_widths.update(
  118. {
  119. ord_map[_cidx]: _width
  120. for _cidx, _width in zip(
  121. range(
  122. cast(int, start_idx),
  123. cast(int, start_idx) + len(width_list),
  124. 1,
  125. ),
  126. width_list,
  127. )
  128. if _cidx in ord_map
  129. }
  130. )
  131. skip_count = 1
  132. # check for format (2): `int int int`
  133. elif isinstance(w_next_entry, (int, float)) and isinstance(
  134. _w[idx + 2].get_object(), (int, float)
  135. ):
  136. start_idx, stop_idx, const_width = (
  137. w_entry,
  138. w_next_entry,
  139. _w[idx + 2].get_object(),
  140. )
  141. current_widths.update(
  142. {
  143. ord_map[_cidx]: const_width
  144. for _cidx in range(
  145. cast(int, start_idx), cast(int, stop_idx + 1), 1
  146. )
  147. if _cidx in ord_map
  148. }
  149. )
  150. skip_count = 2
  151. else:
  152. # This handles the case of out of bounds (reaching the end of the width definitions
  153. # while expecting more elements).
  154. logger_warning(
  155. f"Invalid font width definition. Last element: {w_entry}.",
  156. __name__
  157. )
  158. @staticmethod
  159. def _add_default_width(current_widths: dict[str, int], flags: int) -> None:
  160. if not current_widths:
  161. current_widths["default"] = 500
  162. return
  163. if " " in current_widths and current_widths[" "] != 0:
  164. # Setting default to once or twice the space width, depending on fixed pitch
  165. if (flags & FontFlags.FIXED_PITCH) == FontFlags.FIXED_PITCH:
  166. current_widths["default"] = current_widths[" "]
  167. return
  168. current_widths["default"] = int(2 * current_widths[" "])
  169. return
  170. # Use the average width of existing glyph widths
  171. valid_widths = [w for w in current_widths.values() if w > 0]
  172. current_widths["default"] = sum(valid_widths) // len(valid_widths) if valid_widths else 500
  173. @staticmethod
  174. def _parse_font_descriptor(font_descriptor_obj: DictionaryObject) -> dict[str, Any]:
  175. font_descriptor_kwargs: dict[Any, Any] = {}
  176. for source_key, target_key in [
  177. ("/FontName", "name"),
  178. ("/FontFamily", "family"),
  179. ("/FontWeight", "weight"),
  180. ("/Ascent", "ascent"),
  181. ("/Descent", "descent"),
  182. ("/CapHeight", "cap_height"),
  183. ("/XHeight", "x_height"),
  184. ("/ItalicAngle", "italic_angle"),
  185. ("/Flags", "flags"),
  186. ("/FontBBox", "bbox")
  187. ]:
  188. if source_key in font_descriptor_obj:
  189. font_descriptor_kwargs[target_key] = font_descriptor_obj[source_key]
  190. # Handle missing bbox gracefully - PDFs may have fonts without valid bounding boxes
  191. if "bbox" in font_descriptor_kwargs:
  192. bbox_tuple = tuple(map(float, font_descriptor_kwargs["bbox"]))
  193. assert len(bbox_tuple) == 4, bbox_tuple
  194. font_descriptor_kwargs["bbox"] = bbox_tuple
  195. return font_descriptor_kwargs
  196. @classmethod
  197. def from_font_resource(
  198. cls,
  199. pdf_font_dict: DictionaryObject,
  200. ) -> "Font":
  201. from pypdf._codecs.core_font_metrics import CORE_FONT_METRICS # noqa: PLC0415
  202. # Can collect base_font, name and encoding directly from font resource
  203. name = pdf_font_dict.get("/BaseFont", "Unknown").removeprefix("/")
  204. sub_type = pdf_font_dict.get("/Subtype", "Unknown").removeprefix("/")
  205. encoding, character_map = get_encoding(pdf_font_dict)
  206. font_descriptor = None
  207. character_widths: dict[str, int] = {}
  208. interpretable = True
  209. # Deal with fonts by type; Type1, TrueType and certain Type3
  210. if pdf_font_dict.get("/Subtype") in ("/Type1", "/MMType1", "/TrueType", "/Type3"):
  211. # Type3 fonts that do not specify a "/ToUnicode" mapping cannot be
  212. # reliably converted into character codes unless all named chars
  213. # in /CharProcs map to a standard adobe glyph. See §9.10.2 of the
  214. # PDF 1.7 standard.
  215. if sub_type == "Type3" and "/ToUnicode" not in pdf_font_dict:
  216. interpretable = all(
  217. cname in adobe_glyphs
  218. for cname in pdf_font_dict.get("/CharProcs") or []
  219. )
  220. if interpretable: # Save some overhead if font is not interpretable
  221. if "/Widths" in pdf_font_dict:
  222. cls._collect_tt_t1_character_widths(
  223. pdf_font_dict, character_map, encoding, character_widths
  224. )
  225. elif name in CORE_FONT_METRICS:
  226. font_descriptor = CORE_FONT_METRICS[name].font_descriptor
  227. character_widths = CORE_FONT_METRICS[name].character_widths
  228. if "/FontDescriptor" in pdf_font_dict:
  229. font_descriptor_obj = pdf_font_dict.get("/FontDescriptor", DictionaryObject()).get_object()
  230. if "/MissingWidth" in font_descriptor_obj:
  231. character_widths["default"] = cast(int, font_descriptor_obj["/MissingWidth"].get_object())
  232. font_descriptor = FontDescriptor(**cls._parse_font_descriptor(font_descriptor_obj))
  233. elif "/FontBBox" in pdf_font_dict:
  234. # For Type3 without Font Descriptor but with FontBBox, see Table 110 in the PDF specification 2.0
  235. bbox_tuple = tuple(map(float, cast(ArrayObject, pdf_font_dict["/FontBBox"])))
  236. assert len(bbox_tuple) == 4, bbox_tuple
  237. font_descriptor = FontDescriptor(name=name, bbox=bbox_tuple)
  238. else:
  239. # Composite font or CID font - CID fonts have a /W array mapping character codes
  240. # to widths stashed in /DescendantFonts. No need to test for /DescendantFonts though,
  241. # because all other fonts have already been dealt with.
  242. d_font: DictionaryObject
  243. for d_font_idx, d_font in enumerate(
  244. cast(ArrayObject, pdf_font_dict["/DescendantFonts"])
  245. ):
  246. d_font = cast(DictionaryObject, d_font.get_object())
  247. cast(ArrayObject, pdf_font_dict["/DescendantFonts"])[d_font_idx] = d_font
  248. cls._collect_cid_character_widths(
  249. d_font, character_map, character_widths
  250. )
  251. if "/DW" in d_font:
  252. character_widths["default"] = cast(int, d_font["/DW"].get_object())
  253. font_descriptor_obj = d_font.get("/FontDescriptor", DictionaryObject()).get_object()
  254. font_descriptor = FontDescriptor(**cls._parse_font_descriptor(font_descriptor_obj))
  255. if not font_descriptor:
  256. font_descriptor = FontDescriptor(name=name)
  257. if character_widths.get("default", 0) == 0:
  258. cls._add_default_width(character_widths, font_descriptor.flags)
  259. space_width = character_widths.get(" ", 0)
  260. if space_width == 0:
  261. if (font_descriptor.flags & FontFlags.FIXED_PITCH) == FontFlags.FIXED_PITCH:
  262. space_width = character_widths["default"]
  263. else:
  264. space_width = character_widths["default"] // 2
  265. return cls(
  266. name=name,
  267. sub_type=sub_type,
  268. encoding=encoding,
  269. font_descriptor=font_descriptor,
  270. character_map=character_map,
  271. character_widths=character_widths,
  272. space_width=space_width,
  273. interpretable=interpretable
  274. )
  275. def as_font_resource(self) -> DictionaryObject:
  276. # For now, this returns a font resource that only works with the 14 Adobe Core fonts.
  277. return (
  278. DictionaryObject({
  279. NameObject("/Subtype"): NameObject("/Type1"),
  280. NameObject("/Name"): NameObject(f"/{self.name}"),
  281. NameObject("/Type"): NameObject("/Font"),
  282. NameObject("/BaseFont"): NameObject(f"/{self.name}"),
  283. NameObject("/Encoding"): NameObject("/WinAnsiEncoding")
  284. })
  285. )
  286. def text_width(self, text: str = "") -> float:
  287. """Sum of character widths specified in PDF font for the supplied text."""
  288. return sum(
  289. [self.character_widths.get(char, self.character_widths["default"]) for char in text], 0.0
  290. )