Skip to content

Commit 893ed2d

Browse files
Support WOFF and WOFF2 fonts (#1660)
* issue fixed * Fix: optional imports and generator contextmanager typing in fpdf.py * Fix type checking and performance issues in fonts.py and fpdf.py - fpdf/fonts.py: * Added explanatory comment for TYPE_CHECKING block clarifying it's for static type checkers only * Optimized hbfont property to avoid performance hit for non-WOFF fonts by checking file extension * For WOFF/WOFF2: uses byte buffer decompression (required for HarfBuzz) * For TTF/OTF: loads directly from file path (faster, no extra serialization) * Removed invalid fallback for WOFF/WOFF2 that would fail anyway * Added logging for failed temp file cleanup to track orphaned files - fpdf/fpdf.py: * Removed unnecessary TYPE_CHECKING block for optional dependencies * Fixed table() method return type from Iterator[Table] to ContextManager[Table] * support woff and woff2 * copy is_compressed attribute * docs and changelog * remove some comments --------- Co-authored-by: Anderson Herzogenrath da Costa <[email protected]>
1 parent ebe3055 commit 893ed2d

File tree

12 files changed

+129
-7
lines changed

12 files changed

+129
-7
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ This can also be enabled programmatically with `warnings.simplefilter('default',
2020
### Added
2121
* support for SVG `<linearGradient>` and `<radialGradient>` elements - _cf._ [issue #1580](https://github.com/py-pdf/fpdf2/issues/1580) - thanks to @Ani07-05
2222
* mypy and pyright checks in the CI pipeline to enforce strict typing
23+
* support WOFF and WOFF2 fonts - thanks to @BharathPESU
2324
### Fixed
2425
* the `A5` value that could be specified as page `format` to the `FPDF` constructor was slightly incorrect, and the corresponding page dimensions have been fixed. This could lead to a minor change in your documents dimensions if you used this `A5` page format. - _cf._ [issue #1699](https://github.com/py-pdf/fpdf2/issues/1699)
2526
* a bug when rendering empty tables with `INTERNAL` layout, that caused an extra border to be rendered due to an erroneous use of `list.index()` - _cf._ [issue #1669](https://github.com/py-pdf/fpdf2/issues/1669)

docs/Unicode.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ FreeSerif, FreeMono
1818

1919
To use a Unicode font in your program, use the [`add_font()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.add_font), and then the [`set_font()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.set_font) method calls.
2020

21+
### Web fonts (WOFF and WOFF2) ###
22+
23+
WOFF and WOFF2 are web-optimized, compressed containers for TrueType and OpenType fonts, designed to reduce download size for browsers. `fpdf2` supports these formats by decompressing them before embedding the resulting font data into the generated PDF.
24+
2125

2226
### Built-in Fonts vs. Unicode Fonts ###
2327

fpdf/fonts.py

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from copy import deepcopy
2121
from dataclasses import dataclass, replace
2222
from functools import cache
23+
from io import BytesIO
2324
from pathlib import Path
2425
from typing import (
2526
TYPE_CHECKING,
@@ -341,6 +342,7 @@ class TTFFont:
341342
"color_font",
342343
"unicode_range",
343344
"palette_index",
345+
"is_compressed",
344346
)
345347

346348
def __init__(
@@ -356,18 +358,35 @@ def __init__(
356358
self.i = len(fpdf.fonts) + 1
357359
self.type = "TTF"
358360
self.ttffile = font_file_path
361+
self.is_compressed = str(self.ttffile).lower().endswith((".woff", ".woff2"))
359362
self._hbfont: Optional["HarfBuzzFont"] = None
360363
self.fontkey = fontkey
361364
self.biggest_size_pt: float = 0
362365

363366
# recalcTimestamp=False means that it doesn't modify the "modified" timestamp in head table
364367
# if we leave recalcTimestamp=True the tests will break every time
365-
self.ttfont = ttLib.TTFont(
366-
self.ttffile, recalcTimestamp=False, fontNumber=0, lazy=True
367-
)
368+
try:
369+
self.ttfont = ttLib.TTFont(
370+
self.ttffile, recalcTimestamp=False, fontNumber=0, lazy=True
371+
)
372+
except (
373+
ImportError,
374+
RuntimeError,
375+
) as exc: # pragma: no cover - defensive messaging
376+
# If the user passed a WOFF2 file but brotli is not installed, fontTools
377+
# raises an ImportError/RuntimeError during parsing. Provide a clearer hint
378+
# only for that specific situation. Allow other exceptions (e.g. FileNotFoundError,
379+
# OSError, parsing errors) to propagate normally so they aren't masked here.
380+
fname_str = str(self.ttffile).lower()
381+
if fname_str.endswith(".woff2"):
382+
raise RuntimeError(
383+
"Could not open WOFF2 font. WOFF2 support requires an external Brotli "
384+
"library (install 'brotli' or 'brotlicffi'). Original error: "
385+
f"{exc!s}"
386+
) from exc
387+
raise
368388

369389
if axes_dict is not None:
370-
# Check if variable font.
371390
if "fvar" not in self.ttfont:
372391
raise AttributeError(f"{self.ttffile} is not a variable font")
373392

@@ -377,6 +396,9 @@ def __init__(
377396
inplace=True,
378397
static=True,
379398
)
399+
if self.is_compressed:
400+
# Normalize to SFNT output for embedding and HarfBuzz.
401+
self.ttfont.flavor = None
380402
upem: float = float(self.ttfont["head"].unitsPerEm)
381403
self.scale: float = 1000 / upem
382404

@@ -516,7 +538,50 @@ def __init__(
516538
@property
517539
def hbfont(self) -> "HarfBuzzFont":
518540
if not self._hbfont:
519-
self._hbfont = HarfBuzzFont(hb.Face(hb.Blob.from_file_path(self.ttffile)))
541+
if self.is_compressed:
542+
# HarfBuzz cannot load compressed WOFF/WOFF2 files directly, so we
543+
# re-serialize the fontTools TTFont to a raw SFNT byte buffer.
544+
buf = BytesIO()
545+
self.ttfont.save(buf)
546+
buf.seek(0)
547+
ttfont_bytes = buf.read()
548+
549+
# Try to create a HarfBuzz blob from bytes; if not available, write a
550+
# temporary file as a last resort.
551+
try:
552+
blob = hb.Blob.from_bytes(ttfont_bytes)
553+
face = hb.Face(blob)
554+
except (AttributeError, RuntimeError):
555+
import tempfile, os # pylint: disable=import-outside-toplevel
556+
557+
tmp_name = None
558+
try:
559+
with tempfile.NamedTemporaryFile(
560+
suffix=".ttf", delete=False
561+
) as tmp:
562+
tmp_name = tmp.name
563+
tmp.write(ttfont_bytes)
564+
tmp.flush()
565+
face = hb.Face(hb.Blob.from_file_path(tmp_name))
566+
finally:
567+
if tmp_name:
568+
try:
569+
os.unlink(tmp_name)
570+
except OSError as cleanup_error:
571+
# Log warning about failed cleanup - orphaned temp file may cause disk issues
572+
LOGGER.warning(
573+
"Failed to clean up temporary font file '%s': %s. This may leave an orphaned file on disk.",
574+
tmp_name,
575+
cleanup_error,
576+
)
577+
578+
self._hbfont = HarfBuzzFont(face)
579+
else:
580+
# For regular TTF/OTF fonts, load directly from file path (faster)
581+
self._hbfont = HarfBuzzFont(
582+
hb.Face(hb.Blob.from_file_path(self.ttffile))
583+
)
584+
520585
return self._hbfont
521586

522587
def __repr__(self) -> str:
@@ -542,6 +607,7 @@ def __deepcopy__(self: "TTFFont", memo: dict[int, Any]) -> "TTFFont":
542607
copy.sp = self.sp
543608
copy.ss = self.ss
544609
copy.emphasis = self.emphasis
610+
copy.is_compressed = self.is_compressed
545611
# Attributes shared, to improve FPDFRecorder performances:
546612
copy.ttfont = self.ttfont
547613
copy.cmap = self.cmap

fpdf/fpdf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2513,7 +2513,7 @@ def add_font(
25132513
raise ValueError('"fname" parameter is required')
25142514

25152515
ext = splitext(str(fname))[1].lower()
2516-
if ext not in (".otf", ".otc", ".ttf", ".ttc"):
2516+
if ext not in (".otf", ".otc", ".ttf", ".ttc", ".woff", ".woff2"):
25172517
raise ValueError(
25182518
f"Unsupported font file extension: {ext}."
25192519
" add_font() used to accept .pkl file as input, but for security reasons"

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ docs = [
7171
"pdoc3",
7272
]
7373
test = [
74+
"brotli",
7475
"camelot-py[base]",
7576
"endesive[full]",
7677
"pytest",

test/fonts/font_woff.pdf

6.77 KB
Binary file not shown.

test/fonts/font_woff2.pdf

6.77 KB
Binary file not shown.

test/fonts/font_woff2_hb.pdf

7.6 KB
Binary file not shown.

test/fonts/font_woff_hb.pdf

7.6 KB
Binary file not shown.
15.9 KB
Binary file not shown.

0 commit comments

Comments
 (0)