Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 32 additions & 9 deletions pypdf/_font.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import unicodedata
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, ClassVar, cast

from pypdf.generic import (
ArrayObject,
Expand Down Expand Up @@ -60,6 +60,8 @@ class FontDescriptor:
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"
Expand All @@ -70,7 +72,7 @@ class FontDescriptor:
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] = field(default_factory=lambda: (-100.0, -200.0, 1000.0, 900.0))
bbox: tuple[float, float, float, float] = _DEFAULT_BBOX
font_file: StreamObject | None = None

def as_font_descriptor_resource(self) -> DictionaryObject:
Expand Down Expand Up @@ -267,6 +269,23 @@ def _add_space_width(

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.

Returns ``None`` when the value is not a sequence of exactly four
numbers, so 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] = {}
Expand All @@ -284,11 +303,13 @@ def _parse_font_descriptor(font_descriptor_obj: DictionaryObject) -> dict[str, A
]:
if source_key in font_descriptor_obj:
font_descriptor_kwargs[target_key] = font_descriptor_obj[source_key]
# Handle missing bbox gracefully - PDFs may have fonts without valid bounding boxes
# Handle missing or malformed bbox gracefully - PDFs may have fonts without valid bounding boxes
if "bbox" in font_descriptor_kwargs:
bbox_tuple = tuple(map(float, font_descriptor_kwargs["bbox"]))
assert len(bbox_tuple) == 4, bbox_tuple
font_descriptor_kwargs["bbox"] = bbox_tuple
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"]:
Expand Down Expand Up @@ -360,9 +381,11 @@ def from_font_resource(
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
bbox_tuple = tuple(map(float, cast(ArrayObject, pdf_font_dict["/FontBBox"])))
assert len(bbox_tuple) == 4, bbox_tuple
font_descriptor = FontDescriptor(name=name, bbox=bbox_tuple)
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
Expand Down
38 changes: 37 additions & 1 deletion tests/test_font.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from fontTools.ttLib import TTFont

from pypdf import PdfReader, PdfWriter
from pypdf._font import Font
from pypdf._font import Font, FontDescriptor
from pypdf.errors import PdfReadError
from pypdf.generic import (
ArrayObject,
Expand Down Expand Up @@ -77,6 +77,42 @@ def test_collect_cid_character_widths_truncated_w(w_array):
Font.from_font_resource(font_res)


@pytest.mark.parametrize("bbox", [
pytest.param(ArrayObject([NumberObject(0), NumberObject(0), NumberObject(100)]), id="too-short"),
pytest.param(ArrayObject(NumberObject(v) for v in range(6)), id="too-long"),
pytest.param(ArrayObject([NameObject("/x"), NumberObject(0), NumberObject(1), NumberObject(2)]), id="non-numeric"),
pytest.param(NumberObject(0), id="not-a-sequence"),
])
def test_font_descriptor_malformed_bbox(bbox):
# A /FontBBox that is not four numbers must fall back to the default
# bounding box instead of crashing text extraction.
font_res = DictionaryObject({
NameObject("/BaseFont"): NameObject("/Foo"),
NameObject("/Subtype"): NameObject("/Type1"),
NameObject("/FontDescriptor"): DictionaryObject({
NameObject("/FontBBox"): bbox,
}),
})
font = Font.from_font_resource(font_res)
assert font.font_descriptor.bbox == FontDescriptor._DEFAULT_BBOX


@pytest.mark.parametrize("bbox", [
pytest.param(ArrayObject([NumberObject(0), NumberObject(0)]), id="too-short"),
pytest.param(ArrayObject([NameObject("/x"), NumberObject(0), NumberObject(1), NumberObject(2)]), id="non-numeric"),
])
def test_type3_font_malformed_bbox(bbox):
# Type3 font without a /FontDescriptor but carrying a malformed /FontBBox.
font_res = DictionaryObject({
NameObject("/BaseFont"): NameObject("/Foo"),
NameObject("/Subtype"): NameObject("/Type3"),
NameObject("/ToUnicode"): NumberObject(0),
NameObject("/FontBBox"): bbox,
})
font = Font.from_font_resource(font_res)
assert font.font_descriptor.bbox == FontDescriptor._DEFAULT_BBOX


def test_font_file():
reader = PdfReader(RESOURCE_ROOT / "multilang.pdf")

Expand Down
Loading