From b2865bda9e32309b5f4aaff6e15f3e02793f58d5 Mon Sep 17 00:00:00 2001 From: BharathPESU Date: Tue, 4 Nov 2025 22:40:48 +0530 Subject: [PATCH 1/3] issue fixed --- fpdf/fonts.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++---- fpdf/fpdf.py | 18 ++++++++++++-- 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/fpdf/fonts.py b/fpdf/fonts.py index 5f910d5d2..a34bdc45a 100644 --- a/fpdf/fonts.py +++ b/fpdf/fonts.py @@ -17,12 +17,18 @@ 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 + try: # pragma: no cover - typing-only + import uharfbuzz as hb # type: ignore + except Exception: + hb = None # type: ignore + try: import uharfbuzz as hb @@ -295,9 +301,23 @@ 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 Exception as exc: # pragma: no cover - defensive messaging + # If the user passed a WOFF2 file but brotli is not installed, fontTools + # raises an error during parsing. Provide a clearer hint. + 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. @@ -438,7 +458,45 @@ def __init__( @property def hbfont(self): if not self._hbfont: - self._hbfont = HarfBuzzFont(hb.Face(hb.Blob.from_file_path(self.ttffile))) + # HarfBuzz expects an SFNT (TTF/OTF) blob. For safety and for + # WOFF/WOFF2 input we re-serialize the fontTools TTFont to a + # raw SFNT byte buffer and hand that to HarfBuzz. This avoids + # relying on harfbuzz to accept compressed web font containers. + try: + 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() + except Exception: + # Fallback: attempt to let HarfBuzz load from file path directly + self._hbfont = HarfBuzzFont(hb.Face(hb.Blob.from_file_path(self.ttffile))) + return self._hbfont + + # 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) + 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: + # Best-effort cleanup; if it fails, leave the temp file. + pass + + self._hbfont = HarfBuzzFont(face) return self._hbfont def __repr__(self): diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index b0680dc7b..aa428d90f 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -34,6 +34,7 @@ Iterator, NamedTuple, Optional, + TYPE_CHECKING, Union, ) @@ -44,6 +45,16 @@ except ImportError: pkcs12, signer = None, None +if TYPE_CHECKING: # Help static type checkers / language servers locate optional deps + try: # pragma: no cover - typing-only + import endesive # type: ignore + except Exception: + pass + try: # pragma: no cover - typing-only + import uharfbuzz # type: ignore + except Exception: + pass + try: from PIL.Image import Image except ImportError: @@ -2309,7 +2320,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" @@ -5949,7 +5963,7 @@ def use_font_face(self, font_face: FontFace): @check_page @contextmanager - def table(self, *args: Any, **kwargs: Any) -> ContextManager[Table]: + def table(self, *args: Any, **kwargs: Any) -> Iterator[Table]: """ Inserts a table, that can be built using the `fpdf.table.Table` object yield. Detailed usage documentation: https://py-pdf.github.io/fpdf2/Tables.html From a5446bff37aaeda756ad29ffb03002518fa4f879 Mon Sep 17 00:00:00 2001 From: BharathPESU Date: Tue, 4 Nov 2025 22:52:52 +0530 Subject: [PATCH 2/3] Fix: optional imports and generator contextmanager typing in fpdf.py --- fpdf/fonts.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fpdf/fonts.py b/fpdf/fonts.py index a34bdc45a..4ac1e9f63 100644 --- a/fpdf/fonts.py +++ b/fpdf/fonts.py @@ -307,9 +307,11 @@ def __init__( self.ttfont = ttLib.TTFont( self.ttffile, recalcTimestamp=False, fontNumber=0, lazy=True ) - except Exception as exc: # pragma: no cover - defensive messaging + 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 error during parsing. Provide a clearer hint. + # 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. fname_str = str(self.ttffile).lower() if fname_str.endswith(".woff2"): raise RuntimeError( From db7bd56ac7dcacc1ddea5e66cdd22796f8580425 Mon Sep 17 00:00:00 2001 From: BharathPESU Date: Wed, 5 Nov 2025 22:34:26 +0530 Subject: [PATCH 3/3] 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] --- fpdf/fonts.py | 74 +++++++++++++++++++++++++++++++-------------------- fpdf/fpdf.py | 12 +-------- 2 files changed, 46 insertions(+), 40 deletions(-) diff --git a/fpdf/fonts.py b/fpdf/fonts.py index 4ac1e9f63..1259553c5 100644 --- a/fpdf/fonts.py +++ b/fpdf/fonts.py @@ -24,6 +24,13 @@ 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: @@ -460,11 +467,14 @@ def __init__( @property def hbfont(self): if not self._hbfont: - # HarfBuzz expects an SFNT (TTF/OTF) blob. For safety and for - # WOFF/WOFF2 input we re-serialize the fontTools TTFont to a - # raw SFNT byte buffer and hand that to HarfBuzz. This avoids - # relying on harfbuzz to accept compressed web font containers. - try: + # 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() @@ -472,33 +482,39 @@ def hbfont(self): self.ttfont.save(buf) buf.seek(0) ttfont_bytes = buf.read() - except Exception: - # Fallback: attempt to let HarfBuzz load from file path directly - self._hbfont = HarfBuzzFont(hb.Face(hb.Blob.from_file_path(self.ttffile))) - return self._hbfont - - # 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) + # Try to create a HarfBuzz blob from bytes; if not available, write a + # temporary file as a last resort. 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: - # Best-effort cleanup; if it fails, leave the temp file. - pass + blob = hb.Blob.from_bytes(ttfont_bytes) + face = hb.Face(blob) + except Exception: + import tempfile, os - self._hbfont = HarfBuzzFont(face) + 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): diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index aa428d90f..e1a9a1c06 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -45,16 +45,6 @@ except ImportError: pkcs12, signer = None, None -if TYPE_CHECKING: # Help static type checkers / language servers locate optional deps - try: # pragma: no cover - typing-only - import endesive # type: ignore - except Exception: - pass - try: # pragma: no cover - typing-only - import uharfbuzz # type: ignore - except Exception: - pass - try: from PIL.Image import Image except ImportError: @@ -5963,7 +5953,7 @@ def use_font_face(self, font_face: FontFace): @check_page @contextmanager - def table(self, *args: Any, **kwargs: Any) -> Iterator[Table]: + def table(self, *args: Any, **kwargs: Any) -> ContextManager[Table]: """ Inserts a table, that can be built using the `fpdf.table.Table` object yield. Detailed usage documentation: https://py-pdf.github.io/fpdf2/Tables.html