feat(reader): add PDF reading support with outline chapters, CLI blocks, and noise filtering (task 7)
This commit is contained in:
Vendored
+884
@@ -0,0 +1,884 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
||||
|
||||
from pypdf.generic import (
|
||||
ArrayObject,
|
||||
DictionaryObject,
|
||||
FloatObject,
|
||||
IndirectObject,
|
||||
NameObject,
|
||||
NumberObject,
|
||||
PdfObject,
|
||||
StreamObject,
|
||||
TextStringObject,
|
||||
)
|
||||
|
||||
from ._cmap import get_encoding
|
||||
from ._codecs import encoding_dict_from_named_encoding
|
||||
from ._codecs.adobe_glyphs import adobe_glyphs
|
||||
from ._utils import logger_warning
|
||||
from .constants import FontFlags
|
||||
from .errors import LimitReachedError, PdfReadError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fontTools.ttLib.tables._h_e_a_d import table__h_e_a_d
|
||||
from fontTools.ttLib.tables._p_o_s_t import table__p_o_s_t
|
||||
from fontTools.ttLib.tables.O_S_2f_2 import table_O_S_2f_2
|
||||
|
||||
from ._writer import PdfWriter
|
||||
|
||||
try:
|
||||
from io import BytesIO
|
||||
|
||||
from fontTools.ttLib import TTFont, TTLibError
|
||||
HAS_FONTTOOLS = True
|
||||
except ImportError:
|
||||
HAS_FONTTOOLS = False
|
||||
|
||||
|
||||
# Limits.
|
||||
MAX_CID_WIDTH_ENTRY_COUNT = 65_536
|
||||
MAX_WIDTH_ENTRY_COUNT = 100_000
|
||||
# For a simple font, character codes are one-byte values, 0-255, so /Widths can have at most 256 meaningful entries.
|
||||
MAX_SIMPLE_FONT_WIDTH_ENTRY_COUNT = 256
|
||||
|
||||
|
||||
# Some constants from truetype font tables that we use:
|
||||
HEADER_MACSTYLE_ITALIC = 0x02
|
||||
OS2_FSSELECTION_ITALIC = 0x01
|
||||
OS2_PANOSE_BFAMILYTYPE_SCRIPT = 3
|
||||
OS2_PANOSE_BFAMILYTYPE_DECORATIVE = 4
|
||||
OS2_PANOSE_BFAMILYTYPE_PICTORIAL = 5
|
||||
OS2_PANOSE_BPROPORTION_MONOSPACED = 9
|
||||
OS2_SFAMILYSCLASS_SCRIPTS = 10
|
||||
OS2_SFAMILYSCLASS_SYMBOLIC = 12
|
||||
|
||||
|
||||
# Groups of CMap mappings cannot exceed 100 entries (Adobe CMap and CIDFont Files Specification 1.0, p. 49).
|
||||
CMAP_MAX_ENTRIES_PER_GROUP = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FontDescriptor:
|
||||
"""
|
||||
Represents the FontDescriptor dictionary as defined in the PDF specification.
|
||||
This contains both descriptive and metric information.
|
||||
|
||||
The defaults are derived from the mean values of the 14 core fonts, rounded
|
||||
to 100.
|
||||
"""
|
||||
|
||||
_DEFAULT_BBOX: ClassVar[tuple[float, float, float, float]] = (-100.0, -200.0, 1000.0, 900.0)
|
||||
|
||||
name: str = "Unknown"
|
||||
family: str = "Unknown"
|
||||
weight: str = "Unknown"
|
||||
|
||||
ascent: float = 700.0
|
||||
descent: float = -200.0
|
||||
cap_height: float = 600.0
|
||||
x_height: float = 500.0
|
||||
italic_angle: float = 0.0 # Non-italic
|
||||
flags: int = 32 # Non-serif, non-symbolic, not fixed width
|
||||
bbox: tuple[float, float, float, float] = _DEFAULT_BBOX
|
||||
font_file: StreamObject | None = None
|
||||
|
||||
def as_font_descriptor_resource(self) -> DictionaryObject:
|
||||
font_descriptor_resource = DictionaryObject({
|
||||
NameObject("/Type"): NameObject("/FontDescriptor"),
|
||||
NameObject("/FontName"): NameObject(f"/{self.name}"),
|
||||
NameObject("/Flags"): NumberObject(self.flags),
|
||||
NameObject("/FontBBox"): ArrayObject([FloatObject(n) for n in self.bbox]),
|
||||
NameObject("/ItalicAngle"): FloatObject(self.italic_angle),
|
||||
NameObject("/Ascent"): FloatObject(self.ascent),
|
||||
NameObject("/Descent"): FloatObject(self.descent),
|
||||
NameObject("/CapHeight"): FloatObject(self.cap_height),
|
||||
NameObject("/XHeight"): FloatObject(self.x_height),
|
||||
})
|
||||
|
||||
if self.font_file:
|
||||
# Add the stream. For now, we assume a TrueType font (FontFile2)
|
||||
font_descriptor_resource[NameObject("/FontFile2")] = self.font_file
|
||||
|
||||
return font_descriptor_resource
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CoreFontMetrics:
|
||||
font_descriptor: FontDescriptor
|
||||
character_widths: dict[str, float]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Font:
|
||||
"""
|
||||
A font object for use during text extraction and for producing
|
||||
text appearance streams.
|
||||
|
||||
Attributes:
|
||||
name: Font name, derived from ``font["/BaseFont"]``
|
||||
character_map: The font's character map
|
||||
encoding: Font encoding
|
||||
sub_type: The font type, such as Type1, TrueType, or Type3.
|
||||
font_descriptor: Font metrics, including a mapping of characters to widths
|
||||
character_widths: A mapping of characters to widths
|
||||
space_width: The width of a space, or an approximation
|
||||
interpretable: Default True. If False, the font glyphs cannot
|
||||
be translated to characters, e.g. Type3 fonts that do not define
|
||||
a ``/ToUnicode`` mapping.
|
||||
|
||||
"""
|
||||
|
||||
name: str
|
||||
encoding: str | dict[int, str]
|
||||
character_map: dict[Any, Any] = field(default_factory=dict)
|
||||
sub_type: str = "Unknown"
|
||||
font_descriptor: FontDescriptor = field(default_factory=FontDescriptor)
|
||||
character_widths: dict[str, float] = field(default_factory=lambda: {"default": 500})
|
||||
space_width: float | int = 250
|
||||
space_char: str = " "
|
||||
interpretable: bool = True
|
||||
|
||||
@staticmethod
|
||||
def _collect_tt_t1_character_widths(
|
||||
pdf_font_dict: DictionaryObject,
|
||||
char_map: dict[Any, Any],
|
||||
encoding: str | dict[int, str],
|
||||
current_widths: dict[str, float]
|
||||
) -> None:
|
||||
"""Parses a TrueType or Type1 font's /Widths array from a font dictionary and updates character widths"""
|
||||
widths_array = cast(ArrayObject, pdf_font_dict["/Widths"])
|
||||
Font.__check_entry_count(
|
||||
len(widths_array), MAX_SIMPLE_FONT_WIDTH_ENTRY_COUNT
|
||||
)
|
||||
first_char = pdf_font_dict.get("/FirstChar", 0)
|
||||
for idx, width in enumerate(widths_array):
|
||||
current_widths[chr(idx + first_char)] = int(width)
|
||||
|
||||
@staticmethod
|
||||
def __check_range_length(start: int, end: int) -> None:
|
||||
if end < start:
|
||||
raise LimitReachedError(
|
||||
f"Invalid CID width range: {start}..{end}."
|
||||
)
|
||||
|
||||
count = end - start
|
||||
if count > MAX_CID_WIDTH_ENTRY_COUNT:
|
||||
raise LimitReachedError(f"CID width range too large: {count} > {MAX_CID_WIDTH_ENTRY_COUNT}.")
|
||||
|
||||
@staticmethod
|
||||
def __check_entry_count(count: int, limit: int = MAX_WIDTH_ENTRY_COUNT) -> None:
|
||||
if count > limit:
|
||||
raise LimitReachedError(f"Too many character widths: {count} > {limit}.")
|
||||
|
||||
@staticmethod
|
||||
def _collect_cid_character_widths(d_font: DictionaryObject, current_widths: dict[str, float]) -> None:
|
||||
"""Parses the /W array from a DescendantFont dictionary and updates character widths."""
|
||||
# /W width definitions have two valid formats which can be mixed and matched:
|
||||
# (1) A character start index followed by a list of widths, e.g.
|
||||
# `45 [500 600 700]` applies widths 500, 600, 700 to characters 45-47.
|
||||
# (2) A character start index, a character stop index, and a width, e.g.
|
||||
# `45 65 500` applies width 500 to characters 45-65.
|
||||
skip_count = 0
|
||||
entry_count = 0
|
||||
_w = d_font.get("/W", ArrayObject()).get_object()
|
||||
_w_length = len(_w)
|
||||
for idx, w_entry in enumerate(_w):
|
||||
if skip_count:
|
||||
skip_count -= 1
|
||||
continue
|
||||
w_entry = w_entry.get_object()
|
||||
if not isinstance(w_entry, (int, float)):
|
||||
# We should never get here due to skip_count above. But
|
||||
# sometimes we do.
|
||||
logger_warning(
|
||||
"Expected numeric value for width, got %(w_entry)s. Ignoring it.",
|
||||
source=__name__,
|
||||
w_entry=w_entry,
|
||||
)
|
||||
continue
|
||||
# check for format (1): `int [int int int int ...]`
|
||||
w_next_entry = _w[idx + 1].get_object() if idx + 1 < _w_length else None
|
||||
if isinstance(w_next_entry, Sequence):
|
||||
start_idx, width_list = int(w_entry), w_next_entry
|
||||
stop_idx = start_idx + len(width_list)
|
||||
Font.__check_range_length(start_idx, stop_idx)
|
||||
entry_count += (stop_idx - start_idx)
|
||||
Font.__check_entry_count(entry_count)
|
||||
current_widths.update(
|
||||
{
|
||||
chr(_cidx): _width
|
||||
for _cidx, _width in zip(
|
||||
range(start_idx, stop_idx, 1),
|
||||
width_list,
|
||||
)
|
||||
}
|
||||
)
|
||||
skip_count = 1
|
||||
# check for format (2): `int int int`
|
||||
elif (
|
||||
isinstance(w_next_entry, (int, float))
|
||||
and idx + 2 < _w_length
|
||||
and isinstance(_w[idx + 2].get_object(), (int, float))
|
||||
):
|
||||
start_idx, stop_idx, const_width = (
|
||||
int(w_entry),
|
||||
int(w_next_entry),
|
||||
_w[idx + 2].get_object(),
|
||||
)
|
||||
Font.__check_range_length(start_idx, stop_idx + 1)
|
||||
entry_count += (stop_idx - start_idx + 1)
|
||||
Font.__check_entry_count(entry_count)
|
||||
current_widths.update(
|
||||
{
|
||||
chr(_cidx): const_width
|
||||
for _cidx in range(
|
||||
start_idx, stop_idx + 1, 1
|
||||
)
|
||||
}
|
||||
)
|
||||
skip_count = 2
|
||||
else:
|
||||
# This handles the case of out of bounds (reaching the end of the width definitions
|
||||
# while expecting more elements).
|
||||
logger_warning(
|
||||
"Invalid font width definition. Last element: %(w_entry)s.",
|
||||
source=__name__,
|
||||
w_entry=w_entry,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_space_char(
|
||||
encoding: str | dict[int, str],
|
||||
character_map: dict[Any, Any],
|
||||
) -> str:
|
||||
space_char = " "
|
||||
if isinstance(encoding, dict):
|
||||
for char_code, char_str in encoding.items():
|
||||
if char_str == space_char:
|
||||
return chr(char_code)
|
||||
|
||||
for glyph_id, char_str in character_map.items():
|
||||
if char_str == space_char:
|
||||
return str(glyph_id)
|
||||
|
||||
return space_char
|
||||
|
||||
@staticmethod
|
||||
def _add_default_width(current_widths: dict[str, float], flags: int, space_char: str) -> None:
|
||||
if not current_widths:
|
||||
current_widths["default"] = 500
|
||||
return
|
||||
|
||||
if space_char in current_widths and current_widths[space_char] != 0:
|
||||
# Setting default to once or twice the space width, depending on fixed pitch
|
||||
if (flags & FontFlags.FIXED_PITCH) == FontFlags.FIXED_PITCH:
|
||||
current_widths["default"] = current_widths[space_char]
|
||||
return
|
||||
|
||||
current_widths["default"] = int(2 * current_widths[space_char])
|
||||
return
|
||||
|
||||
# Use the average width of existing glyph widths
|
||||
valid_widths = [w for w in current_widths.values() if w > 0]
|
||||
current_widths["default"] = sum(valid_widths) // len(valid_widths) if valid_widths else 500
|
||||
|
||||
@staticmethod
|
||||
def _add_space_width(
|
||||
character_widths: dict[str, float],
|
||||
flags: int,
|
||||
space_char: str
|
||||
) -> float:
|
||||
space_width = character_widths.get(space_char, 0)
|
||||
if space_width != 0:
|
||||
return space_width
|
||||
|
||||
if (flags & FontFlags.FIXED_PITCH) == FontFlags.FIXED_PITCH:
|
||||
return character_widths["default"]
|
||||
|
||||
return character_widths["default"] // 2
|
||||
|
||||
@staticmethod
|
||||
def _parse_bbox(raw_bbox: Any) -> tuple[float, float, float, float] | None:
|
||||
"""
|
||||
Convert a raw /FontBBox value into four floats.
|
||||
|
||||
Args:
|
||||
raw_bbox: The raw /FontBBox value read from the PDF.
|
||||
|
||||
Returns:
|
||||
The four bounding box values, or ``None`` when the value is not
|
||||
a sequence of exactly four numbers, so that a malformed entry
|
||||
falls back to the default bounding box rather than raising.
|
||||
|
||||
"""
|
||||
try:
|
||||
bbox = [float(value) for value in raw_bbox]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if len(bbox) != 4:
|
||||
return None
|
||||
return bbox[0], bbox[1], bbox[2], bbox[3]
|
||||
|
||||
@staticmethod
|
||||
def _parse_font_descriptor(font_descriptor_obj: DictionaryObject) -> dict[str, Any]:
|
||||
font_descriptor_kwargs: dict[Any, Any] = {}
|
||||
for source_key, target_key in [
|
||||
("/FontName", "name"),
|
||||
("/FontFamily", "family"),
|
||||
("/FontWeight", "weight"),
|
||||
("/Ascent", "ascent"),
|
||||
("/Descent", "descent"),
|
||||
("/CapHeight", "cap_height"),
|
||||
("/XHeight", "x_height"),
|
||||
("/ItalicAngle", "italic_angle"),
|
||||
("/Flags", "flags"),
|
||||
("/FontBBox", "bbox")
|
||||
]:
|
||||
if source_key in font_descriptor_obj:
|
||||
font_descriptor_kwargs[target_key] = font_descriptor_obj[source_key]
|
||||
# Handle missing or malformed bbox gracefully - PDFs may have fonts without valid bounding boxes
|
||||
if "bbox" in font_descriptor_kwargs:
|
||||
bbox = Font._parse_bbox(font_descriptor_kwargs["bbox"])
|
||||
if bbox is None:
|
||||
del font_descriptor_kwargs["bbox"]
|
||||
else:
|
||||
font_descriptor_kwargs["bbox"] = bbox
|
||||
|
||||
# Find the binary stream for this font if there is one
|
||||
for source_key in ["/FontFile", "/FontFile2", "/FontFile3"]:
|
||||
if source_key in font_descriptor_obj:
|
||||
if "font_file" in font_descriptor_kwargs:
|
||||
raise PdfReadError(f"More than one /FontFile found in {font_descriptor_obj}")
|
||||
|
||||
try:
|
||||
font_file = font_descriptor_obj[source_key].get_object()
|
||||
font_descriptor_kwargs["font_file"] = font_file
|
||||
except PdfReadError as e:
|
||||
logger_warning(
|
||||
"Failed to get %(source_key)r in %(font_descriptor_obj)s: %(error)s",
|
||||
source=__name__,
|
||||
source_key=source_key,
|
||||
font_descriptor_obj=font_descriptor_obj,
|
||||
error=e,
|
||||
)
|
||||
return font_descriptor_kwargs
|
||||
|
||||
@classmethod
|
||||
def from_font_resource(
|
||||
cls,
|
||||
pdf_font_dict: DictionaryObject,
|
||||
) -> Font:
|
||||
from pypdf._codecs.core_font_metrics import CORE_FONT_METRICS # noqa: PLC0415
|
||||
|
||||
# Can collect base_font, name and encoding directly from font resource
|
||||
name = pdf_font_dict.get("/BaseFont", "Unknown").removeprefix("/")
|
||||
sub_type = pdf_font_dict.get("/Subtype", "Unknown").removeprefix("/")
|
||||
encoding, character_map = get_encoding(pdf_font_dict)
|
||||
font_descriptor = None
|
||||
character_widths: dict[str, float] = {}
|
||||
interpretable = True
|
||||
|
||||
# Deal with fonts by type; Type1, TrueType and certain Type3
|
||||
if pdf_font_dict.get("/Subtype") in ("/Type1", "/MMType1", "/TrueType", "/Type3"):
|
||||
# Type3 fonts that do not specify a "/ToUnicode" mapping cannot be
|
||||
# reliably converted into character codes unless all named chars
|
||||
# in /CharProcs map to a standard adobe glyph. See §9.10.2 of the
|
||||
# PDF 1.7 standard.
|
||||
if sub_type == "Type3" and "/ToUnicode" not in pdf_font_dict:
|
||||
interpretable = all(
|
||||
cname in adobe_glyphs
|
||||
for cname in pdf_font_dict.get("/CharProcs") or []
|
||||
)
|
||||
if interpretable: # Save some overhead if font is not interpretable
|
||||
if "/Widths" in pdf_font_dict:
|
||||
cls._collect_tt_t1_character_widths(
|
||||
pdf_font_dict, character_map, encoding, character_widths
|
||||
)
|
||||
|
||||
elif name in CORE_FONT_METRICS:
|
||||
font_descriptor = CORE_FONT_METRICS[name].font_descriptor
|
||||
if isinstance(encoding, dict):
|
||||
for code, character in encoding.items():
|
||||
# Look up the width using the glyph name from the encoding
|
||||
if character in CORE_FONT_METRICS[name].character_widths:
|
||||
character_widths[chr(code)] = CORE_FONT_METRICS[name].character_widths[character]
|
||||
else:
|
||||
for code in range(256):
|
||||
character = chr(code)
|
||||
if character in CORE_FONT_METRICS[name].character_widths:
|
||||
character_widths[character] = CORE_FONT_METRICS[name].character_widths[character]
|
||||
if "/FontDescriptor" in pdf_font_dict:
|
||||
font_descriptor_obj = pdf_font_dict.get("/FontDescriptor", DictionaryObject()).get_object()
|
||||
if "/MissingWidth" in font_descriptor_obj:
|
||||
character_widths["default"] = cast(int, font_descriptor_obj["/MissingWidth"].get_object())
|
||||
font_descriptor = FontDescriptor(**cls._parse_font_descriptor(font_descriptor_obj))
|
||||
elif "/FontBBox" in pdf_font_dict:
|
||||
# For Type3 without Font Descriptor but with FontBBox, see Table 110 in the PDF specification 2.0
|
||||
font_descriptor_kwargs: dict[str, Any] = {"name": name}
|
||||
bbox = cls._parse_bbox(pdf_font_dict["/FontBBox"])
|
||||
if bbox is not None:
|
||||
font_descriptor_kwargs["bbox"] = bbox
|
||||
font_descriptor = FontDescriptor(**font_descriptor_kwargs)
|
||||
|
||||
else:
|
||||
# Composite font or CID font - CID fonts have a /W array mapping character codes
|
||||
# to widths stashed in /DescendantFonts. No need to test for /DescendantFonts though,
|
||||
# because all other fonts have already been dealt with.
|
||||
d_font: DictionaryObject
|
||||
for d_font_idx, d_font in enumerate(
|
||||
cast(ArrayObject, pdf_font_dict["/DescendantFonts"])
|
||||
):
|
||||
d_font = cast(DictionaryObject, d_font.get_object())
|
||||
cast(ArrayObject, pdf_font_dict["/DescendantFonts"])[d_font_idx] = d_font
|
||||
cls._collect_cid_character_widths(d_font=d_font, current_widths=character_widths)
|
||||
if "/DW" in d_font:
|
||||
character_widths["default"] = cast(int, d_font["/DW"].get_object())
|
||||
font_descriptor_obj = d_font.get("/FontDescriptor", DictionaryObject()).get_object()
|
||||
font_descriptor = FontDescriptor(**cls._parse_font_descriptor(font_descriptor_obj))
|
||||
|
||||
if not font_descriptor:
|
||||
font_descriptor = FontDescriptor(name=name)
|
||||
|
||||
space_char = cls._get_space_char(encoding, character_map)
|
||||
if character_widths.get("default", 0) == 0:
|
||||
cls._add_default_width(character_widths, font_descriptor.flags, space_char)
|
||||
space_width = cls._add_space_width(character_widths, font_descriptor.flags, space_char)
|
||||
|
||||
return cls(
|
||||
name=name,
|
||||
sub_type=sub_type,
|
||||
encoding=encoding,
|
||||
font_descriptor=font_descriptor,
|
||||
character_map=character_map,
|
||||
character_widths=character_widths,
|
||||
space_width=space_width,
|
||||
space_char=space_char,
|
||||
interpretable=interpretable
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _font_flags_from_truetype_font_tables(
|
||||
header: table__h_e_a_d,
|
||||
postscript: table__p_o_s_t,
|
||||
os2: table_O_S_2f_2
|
||||
) -> int:
|
||||
# Get the font flags
|
||||
if os2:
|
||||
panose = os2.panose
|
||||
# sFamilyClass is a two-byte field. The high byte describes the family class, whereas the low
|
||||
# byte only describes the subclass. We only need the high byte, hence the bit shift below:
|
||||
family_class = os2.sFamilyClass >> 8
|
||||
flags: int = 0
|
||||
|
||||
# ITALIC
|
||||
if header.macStyle & HEADER_MACSTYLE_ITALIC or (os2 and os2.fsSelection & OS2_FSSELECTION_ITALIC):
|
||||
flags |= FontFlags.ITALIC
|
||||
if postscript:
|
||||
italic_angle = postscript.italicAngle
|
||||
if italic_angle != 0.0:
|
||||
flags |= FontFlags.ITALIC
|
||||
|
||||
# FIXED_PITCH
|
||||
if (
|
||||
(os2 and panose.bProportion == OS2_PANOSE_BPROPORTION_MONOSPACED) or
|
||||
(postscript and postscript.isFixedPitch > 0) # Actually 1, but originally (older versions of the TTF
|
||||
): # specification) any non-zero value signified monospace.
|
||||
flags |= FontFlags.FIXED_PITCH
|
||||
|
||||
# SCRIPT
|
||||
if os2 and (
|
||||
family_class == OS2_SFAMILYSCLASS_SCRIPTS or panose.bFamilyType == OS2_PANOSE_BFAMILYTYPE_SCRIPT
|
||||
):
|
||||
flags |= FontFlags.SCRIPT
|
||||
|
||||
# SERIF
|
||||
if os2 and (
|
||||
2 <= panose.bSerifStyle <= 10
|
||||
or 1 <= family_class <= 5 or family_class == 7 # 6 is reserved, all 8 and above are not serif
|
||||
):
|
||||
flags |= FontFlags.SERIF
|
||||
|
||||
# SYMBOLIC
|
||||
if os2 and (
|
||||
family_class == OS2_SFAMILYSCLASS_SYMBOLIC or
|
||||
panose.bFamilyType in {OS2_PANOSE_BFAMILYTYPE_DECORATIVE, OS2_PANOSE_BFAMILYTYPE_PICTORIAL}
|
||||
):
|
||||
flags |= FontFlags.SYMBOLIC
|
||||
else:
|
||||
flags |= FontFlags.NONSYMBOLIC
|
||||
|
||||
return flags
|
||||
|
||||
@classmethod
|
||||
def from_truetype_font_file(cls, font_file: BytesIO) -> Font:
|
||||
if not HAS_FONTTOOLS:
|
||||
raise ImportError("The 'fontTools' library is required to use 'from_truetype_font_file'")
|
||||
with TTFont(font_file) as tt_font_object:
|
||||
# See Chapter 6 of the TrueType reference manual for the definition of the head, OS/2 and post tables:
|
||||
# https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6head.html
|
||||
# https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6OS2.html
|
||||
# https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6post.html
|
||||
header = tt_font_object["head"]
|
||||
horizontal_header = tt_font_object["hhea"]
|
||||
metrics = tt_font_object["hmtx"].metrics
|
||||
|
||||
# Collect additional font tables to derive font information
|
||||
postscript = tt_font_object.get("post", None)
|
||||
os2 = tt_font_object.get("OS/2", None)
|
||||
|
||||
# Get the scaling factor to convert font file's units per em to PDF's 1000 units per em
|
||||
units_per_em = header.unitsPerEm
|
||||
if not units_per_em:
|
||||
raise PdfReadError("Font file has an invalid unitsPerEm of 0")
|
||||
scale_factor = 1000.0 / units_per_em
|
||||
|
||||
# Get the font descriptor
|
||||
font_descriptor_kwargs: dict[Any, Any] = {}
|
||||
names = tt_font_object.get("name", None)
|
||||
if names:
|
||||
font_descriptor_kwargs["name"] = names.getBestFullName()
|
||||
font_descriptor_kwargs["family"] = names.getBestFamilyName()
|
||||
font_descriptor_kwargs["weight"] = names.getBestSubFamilyName()
|
||||
font_descriptor_kwargs["ascent"] = int(round(horizontal_header.ascent * scale_factor, 0))
|
||||
font_descriptor_kwargs["descent"] = int(round(horizontal_header.descent * scale_factor, 0))
|
||||
if os2:
|
||||
try:
|
||||
font_descriptor_kwargs["cap_height"] = int(round(os2.sCapHeight * scale_factor, 0))
|
||||
font_descriptor_kwargs["x_height"] = int(round(os2.sxHeight * scale_factor, 0))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
font_descriptor_kwargs["flags"] = cls._font_flags_from_truetype_font_tables(header, postscript, os2)
|
||||
|
||||
font_descriptor_kwargs["bbox"] = (
|
||||
round(header.xMin * scale_factor, 0),
|
||||
round(header.yMin * scale_factor, 0),
|
||||
round(header.xMax * scale_factor, 0),
|
||||
round(header.yMax * scale_factor, 0)
|
||||
)
|
||||
|
||||
font_file_data = StreamObject()
|
||||
font_file_raw_bytes = font_file.getvalue()
|
||||
font_file_data.set_data(font_file_raw_bytes)
|
||||
font_file_data.update({NameObject("/Length1"): NumberObject(len(font_file_raw_bytes))})
|
||||
font_descriptor_kwargs["font_file"] = font_file_data
|
||||
|
||||
font_descriptor = FontDescriptor(**font_descriptor_kwargs)
|
||||
encoding = "utf_16_be" # Assume unicode
|
||||
|
||||
character_widths: dict[str, float] = {}
|
||||
character_map: dict[str, str] = {}
|
||||
|
||||
glyph_order = tt_font_object.getGlyphOrder()
|
||||
# Note that one glyph can be mapped to multiple unicode code points. However, buildReversedMin()
|
||||
# creates a dictionary mapping glyphs to the minimum Unicode codepoint.
|
||||
tt_font_cmap_table = tt_font_object.get("cmap")
|
||||
if tt_font_cmap_table:
|
||||
try:
|
||||
reverse_cmap = tt_font_cmap_table.buildReversedMin()
|
||||
except AttributeError:
|
||||
# use buildReversed on fonttools < 4.57 and build a list of minimums from it
|
||||
reverse_cmap = {k: min(r) for k, r in tt_font_cmap_table.buildReversed().items()}
|
||||
for gid, glyph in enumerate(glyph_order):
|
||||
char_code = reverse_cmap.get(glyph)
|
||||
if char_code is None:
|
||||
continue
|
||||
char = chr(char_code)
|
||||
gid = tt_font_object.getGlyphID(glyph)
|
||||
# The following is to comply with how font_glyph_byte_map works in _appearance_stream.py
|
||||
gid_bytes = gid.to_bytes(2, "big")
|
||||
gid_key_string = gid_bytes.decode("utf-16-be", "surrogatepass")
|
||||
character_map[gid_key_string] = char
|
||||
character_widths[gid_key_string] = int(round(metrics[glyph][0] * scale_factor, 0))
|
||||
else:
|
||||
raise PdfReadError("Font file does not have a cmap table")
|
||||
|
||||
space_char = cls._get_space_char(encoding, character_map)
|
||||
cls._add_default_width(character_widths, font_descriptor_kwargs["flags"], space_char)
|
||||
space_width = cls._add_space_width(
|
||||
character_widths, font_descriptor_kwargs["flags"], space_char
|
||||
)
|
||||
|
||||
return cls(
|
||||
name=font_descriptor.name,
|
||||
sub_type="Type0",
|
||||
encoding=encoding,
|
||||
font_descriptor=font_descriptor,
|
||||
character_map=character_map,
|
||||
character_widths=character_widths,
|
||||
space_width=space_width,
|
||||
space_char=space_char,
|
||||
interpretable=True
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_core_font_name(cls, core_font_name: str = "/Helvetica") -> Font:
|
||||
from pypdf._codecs.core_font_metrics import CORE_FONT_METRICS # noqa: PLC0415
|
||||
|
||||
font_name = core_font_name.removeprefix("/")
|
||||
core_font_metrics = CORE_FONT_METRICS[font_name]
|
||||
win_ansi_encoding = encoding_dict_from_named_encoding("cp1252") # WinAnsiEncoding
|
||||
|
||||
font = cls(
|
||||
name=font_name,
|
||||
character_map={},
|
||||
encoding=win_ansi_encoding,
|
||||
sub_type="Type1",
|
||||
font_descriptor=core_font_metrics.font_descriptor,
|
||||
character_widths={
|
||||
char: core_font_metrics.character_widths[character]
|
||||
for code, character in win_ansi_encoding.items()
|
||||
if (char := chr(code)) in core_font_metrics.character_widths
|
||||
}
|
||||
)
|
||||
font.character_widths["default"] = core_font_metrics.character_widths["default"]
|
||||
|
||||
return font
|
||||
|
||||
def _get_typographic_maps(self) -> tuple[dict[str, str], dict[str, bytes]]:
|
||||
"""
|
||||
Generates maps to translate input unicode text to bytes in two steps:
|
||||
Unicode code point -> raw_character (reverse cmap) -> PDF bytes (encoding cmap).
|
||||
"""
|
||||
reverse_cmap = {}
|
||||
encoding_cmap = {}
|
||||
if (
|
||||
HAS_FONTTOOLS
|
||||
and getattr(self.font_descriptor, "font_file", None)
|
||||
and isinstance(self.encoding, str)
|
||||
):
|
||||
try:
|
||||
font_file_data = cast(StreamObject, self.font_descriptor.font_file).get_data()
|
||||
with TTFont(BytesIO(font_file_data)) as tt_font_object:
|
||||
tt_font_cmap_table = tt_font_object.get("cmap")
|
||||
best_cmap = tt_font_cmap_table.getBestCmap()
|
||||
for unicode_int, glyph_name in best_cmap.items():
|
||||
gid = tt_font_object.getGlyphID(glyph_name)
|
||||
gid_bytes = gid.to_bytes(2, "big")
|
||||
gid_key_string = gid_bytes.decode("utf-16-be", "surrogatepass")
|
||||
unicode_char = chr(unicode_int)
|
||||
reverse_cmap[unicode_char] = gid_key_string
|
||||
encoding_cmap[gid_key_string] = gid_key_string.encode(self.encoding)
|
||||
|
||||
return reverse_cmap, encoding_cmap
|
||||
|
||||
except (AttributeError, TTLibError): # Cmap table is missing or the font is corrupt.
|
||||
reverse_cmap.clear()
|
||||
encoding_cmap.clear()
|
||||
|
||||
if isinstance(self.encoding, str):
|
||||
for glyph_id, unicode_char in self.character_map.items():
|
||||
glyph_id_str = str(glyph_id)
|
||||
reverse_cmap[unicode_char] = glyph_id_str
|
||||
encoding_cmap[glyph_id_str] = glyph_id_str.encode(self.encoding)
|
||||
else: # Encoding is a dict, which means we are dealing with a simple font
|
||||
for character_code, unicode_char in self.encoding.items():
|
||||
character_str = chr(character_code)
|
||||
reverse_cmap[unicode_char] = character_str
|
||||
encoding_cmap[character_str] = bytes((character_code,))
|
||||
|
||||
unicode_to_bytes = {
|
||||
unicode_char: bytes((character_code,)) for character_code, unicode_char in self.encoding.items()
|
||||
}
|
||||
for character_code_str, unicode_char in self.character_map.items():
|
||||
reverse_cmap[unicode_char] = character_code_str
|
||||
encoding_cmap[character_code_str] = unicode_to_bytes.get(
|
||||
unicode_char,
|
||||
bytes((ord(character_code_str),))
|
||||
)
|
||||
|
||||
return reverse_cmap, encoding_cmap
|
||||
|
||||
def _create_widths_list_and_unicode_stream(self) -> tuple[list[PdfObject], StreamObject]:
|
||||
from pypdf._codecs.core_font_metrics import CORE_FONT_METRICS # noqa: PLC0415
|
||||
|
||||
widths_list = []
|
||||
unicode_map = []
|
||||
bfchar_map: list[str] = []
|
||||
|
||||
# Composite/CID fonts use 4-hex digits, simple fonts use 2-hex digits
|
||||
src_hex_format = "{cid:04X}" if self.sub_type == "Type0" else "{cid:02X}"
|
||||
codespace_min = "0000" if self.sub_type == "Type0" else "00"
|
||||
codespace_max = "FFFF" if self.sub_type == "Type0" else "FF"
|
||||
cmap_name = "Adobe-Identity-UCS" if self.sub_type == "Type0" else "Custom-Simple-8Bit"
|
||||
cid_system_info = (
|
||||
"/CIDSystemInfo <<\n/Registry (Adobe)\n/Ordering (UCS)\n/Supplement 0\n>> def\n"
|
||||
if self.sub_type == "Type0" else ""
|
||||
)
|
||||
|
||||
# If we have self.character_map then use that. Otherwise fall back to self.encoding.
|
||||
mapping_source = (self.character_map or cast(dict[int, str], self.encoding)).items()
|
||||
|
||||
# In the loop, src_id is the decoded GID string (the reverse unicode hack) or the character code
|
||||
# and actual_char is the actual character.
|
||||
for src_id, actual_char in mapping_source:
|
||||
# Make sure that we do not include characters such as arabic presentation form characters.
|
||||
# Note that, in some cases, unicodedata.normalize() might split a ligature, resulting
|
||||
# in multiple characters.
|
||||
normalized_chars = unicodedata.normalize("NFKC", actual_char)
|
||||
uni_points = [ord(char) for char in normalized_chars]
|
||||
# Only deal with Basic Multilingual Plane characters.
|
||||
# TODO: Add all characters.
|
||||
if all(uni_point <= 0xFFFF for uni_point in uni_points):
|
||||
cid = ord(src_id) if isinstance(src_id, str) else src_id
|
||||
cid_hex = src_hex_format.format(cid=cid)
|
||||
uni_hex = "".join(f"{uni_point:04X}" for uni_point in uni_points)
|
||||
bfchar_map.append(f"<{cid_hex}> <{uni_hex}>")
|
||||
|
||||
# Width mapping, but not for the 14 Adobe code fonts, which are dealt with elsewhere.
|
||||
if self.name not in CORE_FONT_METRICS:
|
||||
# The widths (/W) array can have two formats:
|
||||
# [first_cid [w1 w2 w3]] or [first last width]
|
||||
# Here we choose the first format and simply provide one array with one width for every cid.
|
||||
width = self.character_widths.get(cast(str, src_id), self.character_widths["default"])
|
||||
widths_list.extend([NumberObject(cid), ArrayObject([NumberObject(width)])])
|
||||
|
||||
while partial_list := bfchar_map[:CMAP_MAX_ENTRIES_PER_GROUP]:
|
||||
del bfchar_map[:CMAP_MAX_ENTRIES_PER_GROUP]
|
||||
unicode_map.append(f"{len(partial_list)} beginbfchar")
|
||||
unicode_map.extend(partial_list)
|
||||
unicode_map.append("endbfchar")
|
||||
|
||||
# Create the /ToUnicode CMap Stream
|
||||
to_unicode_stream = StreamObject()
|
||||
to_unicode_stream.set_data(
|
||||
(
|
||||
"/CIDInit /ProcSet findresource begin\n"
|
||||
"12 dict begin\n"
|
||||
"begincmap\n"
|
||||
f"{cid_system_info}"
|
||||
f"/CMapName /{cmap_name} def\n"
|
||||
f"/CMapType 2 def\n"
|
||||
f"1 begincodespacerange <{codespace_min}> <{codespace_max}> endcodespacerange\n"
|
||||
+ "\n".join(unicode_map) + "\n"
|
||||
"endcmap\n"
|
||||
"CMapName currentdict /CMap defineresource pop\n"
|
||||
"end end"
|
||||
).encode("ascii")
|
||||
)
|
||||
|
||||
return widths_list, to_unicode_stream
|
||||
|
||||
def as_font_resource(self) -> DictionaryObject:
|
||||
# If we have an embedded Truetype font, we assume that we need to produce a Type 2 CID font resource.
|
||||
# We check that we are 16-bit encoded, that is, Type0.
|
||||
if self.font_descriptor.font_file and self.sub_type == "Type0":
|
||||
# Begin with creating the widths array (part of the descendant font) and the unicode cmap (part
|
||||
# of the Type 0 font object).
|
||||
widths_list, to_unicode_stream = self._create_widths_list_and_unicode_stream()
|
||||
|
||||
# Create the descendant font object
|
||||
cid_font = DictionaryObject({
|
||||
NameObject("/Type"): NameObject("/Font"),
|
||||
NameObject("/Subtype"): NameObject("/CIDFontType2"),
|
||||
NameObject("/BaseFont"): NameObject(f"/{self.name}"),
|
||||
NameObject("/CIDSystemInfo"): DictionaryObject({
|
||||
NameObject("/Registry"): TextStringObject("Adobe"),
|
||||
NameObject("/Ordering"): TextStringObject("Identity"),
|
||||
NameObject("/Supplement"): NumberObject(0)
|
||||
}),
|
||||
NameObject("/FontDescriptor"): self.font_descriptor.as_font_descriptor_resource(),
|
||||
NameObject("/W"): ArrayObject(widths_list),
|
||||
NameObject("/DW"): NumberObject(self.character_widths["default"]),
|
||||
NameObject("/CIDToGIDMap"): NameObject("/Identity")
|
||||
})
|
||||
|
||||
# Create the Type 0 font object
|
||||
return DictionaryObject({
|
||||
NameObject("/Type"): NameObject("/Font"),
|
||||
NameObject("/Subtype"): NameObject("/Type0"),
|
||||
NameObject("/BaseFont"): NameObject(f"/{self.name}"),
|
||||
NameObject("/Encoding"): NameObject("/Identity-H"),
|
||||
NameObject("/DescendantFonts"): ArrayObject([cid_font]),
|
||||
NameObject("/ToUnicode"): to_unicode_stream,
|
||||
})
|
||||
|
||||
# Fallback: Return a font resource for one of the 14 Adobe Core fonts.
|
||||
win_ansi_encoding = encoding_dict_from_named_encoding("cp1252")
|
||||
differences_list: list[NumberObject | NameObject] = []
|
||||
reverse_adobe_glyphs = {value: key for key, value in adobe_glyphs.items()}
|
||||
own_encoding: dict[int, str] = cast(dict[int, str], self.encoding)
|
||||
for idx, character_code in win_ansi_encoding.items():
|
||||
encoding_char = own_encoding.get(idx)
|
||||
if encoding_char and encoding_char != character_code:
|
||||
differences_list.extend([NumberObject(idx), NameObject(reverse_adobe_glyphs[encoding_char])])
|
||||
|
||||
if differences_list:
|
||||
encoding: DictionaryObject | NameObject = DictionaryObject({
|
||||
NameObject("/BaseEncoding"): NameObject("/WinAnsiEncoding"),
|
||||
NameObject("/Differences"): ArrayObject(differences_list),
|
||||
})
|
||||
else:
|
||||
encoding = NameObject("/WinAnsiEncoding")
|
||||
|
||||
simple_font = DictionaryObject({
|
||||
NameObject("/Type"): NameObject("/Font"),
|
||||
NameObject("/Subtype"): NameObject("/Type1"),
|
||||
NameObject("/Name"): NameObject(f"/{self.name}"),
|
||||
NameObject("/BaseFont"): NameObject(f"/{self.name}"),
|
||||
NameObject("/Encoding"): encoding
|
||||
})
|
||||
|
||||
if differences_list:
|
||||
_, to_unicode_stream = self._create_widths_list_and_unicode_stream()
|
||||
simple_font[NameObject("/ToUnicode")] = to_unicode_stream
|
||||
|
||||
return simple_font
|
||||
|
||||
def _add_to_writer(
|
||||
self,
|
||||
writer: PdfWriter,
|
||||
target_resource_dict: DictionaryObject,
|
||||
font_resource_name: NameObject
|
||||
) -> IndirectObject:
|
||||
"""
|
||||
Some objects in a font resource need to be indirect objects. This method
|
||||
ensures that ToUnicode, FontDescriptor, FontFile, and, ultimately, the font
|
||||
resource itself, are registered with the PdfWriter instance as indirect objects.
|
||||
"""
|
||||
font_resource = self.as_font_resource()
|
||||
if "/ToUnicode" in font_resource:
|
||||
font_resource[NameObject("/ToUnicode")] = writer._add_object(font_resource["/ToUnicode"])
|
||||
|
||||
if "/DescendantFonts" in font_resource:
|
||||
descendant_fonts = cast(ArrayObject, font_resource["/DescendantFonts"])
|
||||
font_resource_dict = cast(DictionaryObject, descendant_fonts[0])
|
||||
else:
|
||||
font_resource_dict = font_resource
|
||||
|
||||
if "/FontDescriptor" in font_resource_dict:
|
||||
font_descriptor_obj = cast(DictionaryObject, font_resource_dict["/FontDescriptor"])
|
||||
for key in ["/FontFile", "/FontFile2", "/FontFile3"]:
|
||||
if key in font_descriptor_obj:
|
||||
font_descriptor_obj[NameObject(key)] = writer._add_object(font_descriptor_obj[key])
|
||||
font_resource_dict[NameObject("/FontDescriptor")] = writer._add_object(
|
||||
font_resource_dict["/FontDescriptor"]
|
||||
)
|
||||
font_resource_ref = writer._add_object(font_resource)
|
||||
target_resource_dict[font_resource_name] = font_resource_ref
|
||||
return font_resource_ref
|
||||
|
||||
def get_text_width(self, text: str = "") -> float:
|
||||
"""Sum of character widths specified in PDF font for the supplied text."""
|
||||
return sum(
|
||||
[self.character_widths.get(char, self.character_widths["default"]) for char in text], 0.0
|
||||
)
|
||||
|
||||
def can_encode(self, text: str) -> bool:
|
||||
"""Check whether the font is able to encode a text string."""
|
||||
if self.character_map:
|
||||
supported_chars = set(self.character_map.values())
|
||||
return all(char in supported_chars for char in text)
|
||||
|
||||
if isinstance(self.encoding, dict):
|
||||
supported_chars = set(self.encoding.values())
|
||||
return all(char in supported_chars for char in text)
|
||||
|
||||
# Not a simple font (encoding is not a dict), and missing ToUnicode cmap (no character_map).
|
||||
# Assume we cannot use this font for text encoding.
|
||||
return False
|
||||
Reference in New Issue
Block a user