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
86 changes: 81 additions & 5 deletions fpdf/fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,25 @@
from collections import defaultdict
from dataclasses import dataclass, replace
from functools import lru_cache
from typing import Optional, Tuple, Union
from typing import Optional, Tuple, Union, TYPE_CHECKING

from fontTools import ttLib
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.varLib import instancer

if TYPE_CHECKING: # Help static type checkers / language servers locate optional deps
# NOTE: this block exists purely for static type checkers / IDEs.
# It imports `uharfbuzz as hb` so tools like mypy/pyright and editors can
# resolve the `hb` symbol and provide completions/type information.
# The actual runtime import (and fallback to `hb = None`) is performed
# further down in the file inside a try/except. Keeping this separate
# avoids runtime side-effects during type-checking and makes the intent
# explicit to reviewers who may see what looks like a duplicate import.
try: # pragma: no cover - typing-only
import uharfbuzz as hb # type: ignore
except Exception:
hb = None # type: ignore

try:
import uharfbuzz as hb

Expand Down Expand Up @@ -295,9 +308,25 @@ def __init__(

# recalcTimestamp=False means that it doesn't modify the "modified" timestamp in head table
# if we leave recalcTimestamp=True the tests will break every time
self.ttfont = ttLib.TTFont(
self.ttffile, recalcTimestamp=False, fontNumber=0, lazy=True
)
try:
# Let fontTools handle a variety of container formats (TTF/OTF/WOFF/WOFF2).
# Note: WOFF2 support in fontTools requires a brotli backend (e.g. `brotli` or `brotlicffi`).
self.ttfont = ttLib.TTFont(
self.ttffile, recalcTimestamp=False, fontNumber=0, lazy=True
)
except (ImportError, RuntimeError) as exc: # pragma: no cover - defensive messaging
# If the user passed a WOFF2 file but brotli is not installed, fontTools
# raises an ImportError/RuntimeError during parsing. Provide a clearer hint
# only for that specific situation. Allow other exceptions (e.g. FileNotFoundError,
# OSError, parsing errors) to propagate normally so they aren't masked here.
Comment on lines +318 to +321
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you manage to create a unit test for this case, please?

fname_str = str(self.ttffile).lower()
if fname_str.endswith(".woff2"):
raise RuntimeError(
"Could not open WOFF2 font. WOFF2 support requires an external Brotli "
"library (install 'brotli' or 'brotlicffi'). Original error: "
f"{exc!s}"
) from exc
raise

if axes_dict is not None:
# Check if variable font.
Expand Down Expand Up @@ -438,7 +467,54 @@ def __init__(
@property
def hbfont(self):
if not self._hbfont:
self._hbfont = HarfBuzzFont(hb.Face(hb.Blob.from_file_path(self.ttffile)))
# Check if this is a WOFF/WOFF2 font that needs decompression
font_path_lower = str(self.ttffile).lower()
is_woff = font_path_lower.endswith(".woff") or font_path_lower.endswith(".woff2")

if is_woff:
# For WOFF/WOFF2, we need to decompress to SFNT format for HarfBuzz.
# HarfBuzz cannot load compressed WOFF/WOFF2 files directly, so we
# re-serialize the fontTools TTFont to a raw SFNT byte buffer.
from io import BytesIO

buf = BytesIO()
# Ensure we have the decompressed tables in memory
self.ttfont.save(buf)
buf.seek(0)
ttfont_bytes = buf.read()

# Try to create a HarfBuzz blob from bytes; if not available, write a
# temporary file as a last resort.
try:
blob = hb.Blob.from_bytes(ttfont_bytes)
face = hb.Face(blob)
except Exception:
import tempfile, os

tmp = tempfile.NamedTemporaryFile(suffix=".ttf", delete=False)
tmp_name = tmp.name
try:
tmp.write(ttfont_bytes)
tmp.flush()
tmp.close()
face = hb.Face(hb.Blob.from_file_path(tmp_name))
finally:
try:
os.unlink(tmp_name)
except Exception as cleanup_error:
# Log warning about failed cleanup - orphaned temp file may cause disk issues
LOGGER.warning(
"Failed to clean up temporary font file '%s': %s. "
"This may leave an orphaned file on disk.",
tmp_name,
cleanup_error,
)

self._hbfont = HarfBuzzFont(face)
else:
# For regular TTF/OTF fonts, load directly from file path (faster)
self._hbfont = HarfBuzzFont(hb.Face(hb.Blob.from_file_path(self.ttffile)))

return self._hbfont

def __repr__(self):
Expand Down
6 changes: 5 additions & 1 deletion fpdf/fpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
Iterator,
NamedTuple,
Optional,
TYPE_CHECKING,
Union,
)

Expand Down Expand Up @@ -2309,7 +2310,10 @@ def add_font(
raise ValueError('"fname" parameter is required')

ext = splitext(str(fname))[1].lower()
if ext not in (".otf", ".otc", ".ttf", ".ttc"):
# Accept web-font containers as well (WOFF / WOFF2). These will be
# transparently handled by fontTools (WOFF uses zlib; WOFF2 requires
# an optional brotli dependency for decompression).
if ext not in (".otf", ".otc", ".ttf", ".ttc", ".woff", ".woff2"):
raise ValueError(
f"Unsupported font file extension: {ext}."
" add_font() used to accept .pkl file as input, but for security reasons"
Expand Down