From 92a2fe880af08785354858d5e42a0d38b885df8f Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:56:07 -0700 Subject: [PATCH 1/2] Guard find_nonembedded_cid_fonts against non-dictionary resources A malformed PDF can store a non-dictionary object under a page's /Font or /XObject resource. find_nonembedded_cid_fonts() iterated .values() on that object outside the per-entry try/except, so scanning such a page raised (TypeError/AttributeError depending on the pikepdf version) instead of producing output. This surfaced as a PDF/A conversion crash. Route both resource lookups through a small helper that returns an empty list when the resource is missing or not a dictionary, so a garbage entry is simply treated as having no fonts. Add a regression test covering a non-dictionary /Font and /XObject. --- src/ocrmypdf/pdfa.py | 41 ++++++++++++++++++++++++++++------------- tests/test_pdfa.py | 16 ++++++++++++++++ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/ocrmypdf/pdfa.py b/src/ocrmypdf/pdfa.py index 6d79722a..264b16fe 100644 --- a/src/ocrmypdf/pdfa.py +++ b/src/ocrmypdf/pdfa.py @@ -148,6 +148,23 @@ def _cid_font_is_embedded(type0_font: Dictionary) -> bool: return False +def _dict_entries(resource): + """Return the values of a PDF resource sub-dictionary, tolerating garbage. + + A ``/Font`` or ``/XObject`` resource is expected to be a dictionary, but a + malformed PDF -- common in OCR workloads -- may store a non-dictionary + object (an array, a name, an empty value) there. Calling ``.values()`` on a + non-dictionary raises, so return an empty list in that case rather than + letting the scan crash (issue #1713). + """ + if resource is None: + return [] + try: + return list(resource.values()) + except (AttributeError, TypeError): + return [] + + def find_nonembedded_cid_fonts(pdf: Pdf) -> set[str]: """Find CID-keyed (Type0) fonts that lack embedded glyph data. @@ -175,21 +192,19 @@ def find_nonembedded_cid_fonts(pdf: Pdf) -> set[str]: if resources is None or depth > 10: return fonts = resources.get(Name.Font, None) - if fonts is not None: - for font in fonts.values(): - try: - if font.get(Name.Subtype) != Name.Type0: - continue - if not _cid_font_is_embedded(font): - basefont = str(font.get(Name.BaseFont, '/(unnamed)')) - found.add(basefont.lstrip('/')) - except (AttributeError, TypeError, KeyError): + for font in _dict_entries(fonts): + try: + if font.get(Name.Subtype) != Name.Type0: continue + if not _cid_font_is_embedded(font): + basefont = str(font.get(Name.BaseFont, '/(unnamed)')) + found.add(basefont.lstrip('/')) + except (AttributeError, TypeError, KeyError): + continue xobjects = resources.get(Name.XObject, None) - if xobjects is not None: - for xobj in xobjects.values(): - if xobj.get(Name.Subtype) == Name.Form and Name.Resources in xobj: - scan_resources(xobj[Name.Resources], depth + 1) + for xobj in _dict_entries(xobjects): + if xobj.get(Name.Subtype) == Name.Form and Name.Resources in xobj: + scan_resources(xobj[Name.Resources], depth + 1) for page in pdf.pages: scan_resources(page.get(Name.Resources, None)) diff --git a/tests/test_pdfa.py b/tests/test_pdfa.py index e5ee6ccb..0f984650 100644 --- a/tests/test_pdfa.py +++ b/tests/test_pdfa.py @@ -94,6 +94,22 @@ class TestFindNonembeddedCidFonts: with pikepdf.open(path) as pdf: assert find_nonembedded_cid_fonts(pdf) == {'ZZZ+Hidden'} + def test_non_dictionary_font_and_xobject_resources_are_ignored(self, tmp_path): + # A malformed PDF may carry a /Font or /XObject resource that is not a + # dictionary (an array, a name, an empty value). Scanning must skip it + # rather than raise when iterating its values (regression test for the + # crash reported in issue #1713). + path = tmp_path / 'malformed_resources.pdf' + with pikepdf.new() as pdf: + page = pdf.add_blank_page() + page.Resources = pikepdf.Dictionary( + Font=pikepdf.Array([]), + XObject=pikepdf.Array([]), + ) + pdf.save(path) + with pikepdf.open(path) as pdf: + assert find_nonembedded_cid_fonts(pdf) == set() + @pytest.fixture def nonembedded_cid_pdf(tmp_path): From 640b3062b2749838e79a6e379ac8083f114dfe51 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Thu, 16 Jul 2026 23:42:50 -0700 Subject: [PATCH 2/2] Refine non-dict resource guard: prefer isinstance, cover FontDescriptor Follow-on to the non-dictionary /Font and /XObject guard. Replace the _dict_entries() helper with an isinstance(..., pikepdf.Dictionary) check at each resource lookup, which pikepdf's metaclass supports directly and which reads as exactly the invariant being enforced. Iterate via as_dict().values() so the values are typed and mypy stays clean once the Any from the untyped resources argument is narrowed away. Also guard _cid_font_is_embedded against a non-dictionary /FontDescriptor: `key in descriptor` raises ValueError on a non-dict, which the caller's except (AttributeError, TypeError, KeyError) does not catch. Such a font now counts as non-embedded and is reported, so PDF/A conversion is refused rather than risking Ghostscript corrupting a pre-existing CID text layer. Adds a regression test for the FontDescriptor case (issue #1713). --- src/ocrmypdf/pdfa.py | 53 ++++++++++++++++++-------------------------- tests/test_pdfa.py | 30 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/src/ocrmypdf/pdfa.py b/src/ocrmypdf/pdfa.py index 264b16fe..a4cd30b6 100644 --- a/src/ocrmypdf/pdfa.py +++ b/src/ocrmypdf/pdfa.py @@ -12,7 +12,7 @@ from importlib.resources import files as package_files from pathlib import Path import pikepdf -from pikepdf import Array, Dictionary, Name, Pdf, Stream +from pikepdf import Array, Dictionary, Name, Object, Pdf, Stream log = logging.getLogger(__name__) @@ -137,34 +137,19 @@ def file_claims_pdfa(filename: Path): return pdfa_dict -def _cid_font_is_embedded(type0_font: Dictionary) -> bool: +def _cid_font_is_embedded(type0_font: Object) -> bool: """Return True if a Type0 font's CID descendant carries embedded glyphs.""" for descendant in type0_font.get(Name.DescendantFonts, []): descriptor = descendant.get(Name.FontDescriptor, None) - if descriptor is not None and any( + # A malformed PDF may store a non-dictionary here; `key in descriptor` + # raises on those, so require a real dictionary before probing it. + if isinstance(descriptor, Dictionary) and any( key in descriptor for key in (Name.FontFile, Name.FontFile2, Name.FontFile3) ): return True return False -def _dict_entries(resource): - """Return the values of a PDF resource sub-dictionary, tolerating garbage. - - A ``/Font`` or ``/XObject`` resource is expected to be a dictionary, but a - malformed PDF -- common in OCR workloads -- may store a non-dictionary - object (an array, a name, an empty value) there. Calling ``.values()`` on a - non-dictionary raises, so return an empty list in that case rather than - letting the scan crash (issue #1713). - """ - if resource is None: - return [] - try: - return list(resource.values()) - except (AttributeError, TypeError): - return [] - - def find_nonembedded_cid_fonts(pdf: Pdf) -> set[str]: """Find CID-keyed (Type0) fonts that lack embedded glyph data. @@ -191,20 +176,26 @@ def find_nonembedded_cid_fonts(pdf: Pdf) -> set[str]: def scan_resources(resources, depth: int = 0) -> None: if resources is None or depth > 10: return + # A well-formed PDF stores dictionaries under /Font and /XObject, but a + # malformed one (common in OCR workloads) may store an array, a name, or + # another non-dictionary object. Only such dictionaries have .values(), + # so guard with isinstance rather than let the scan crash (issue #1713). fonts = resources.get(Name.Font, None) - for font in _dict_entries(fonts): - try: - if font.get(Name.Subtype) != Name.Type0: + if isinstance(fonts, Dictionary): + for font in fonts.as_dict().values(): + try: + if font.get(Name.Subtype) != Name.Type0: + continue + if not _cid_font_is_embedded(font): + basefont = str(font.get(Name.BaseFont, '/(unnamed)')) + found.add(basefont.lstrip('/')) + except (AttributeError, TypeError, KeyError): continue - if not _cid_font_is_embedded(font): - basefont = str(font.get(Name.BaseFont, '/(unnamed)')) - found.add(basefont.lstrip('/')) - except (AttributeError, TypeError, KeyError): - continue xobjects = resources.get(Name.XObject, None) - for xobj in _dict_entries(xobjects): - if xobj.get(Name.Subtype) == Name.Form and Name.Resources in xobj: - scan_resources(xobj[Name.Resources], depth + 1) + if isinstance(xobjects, Dictionary): + for xobj in xobjects.as_dict().values(): + if xobj.get(Name.Subtype) == Name.Form and Name.Resources in xobj: + scan_resources(xobj[Name.Resources], depth + 1) for page in pdf.pages: scan_resources(page.get(Name.Resources, None)) diff --git a/tests/test_pdfa.py b/tests/test_pdfa.py index 0f984650..a544f9f8 100644 --- a/tests/test_pdfa.py +++ b/tests/test_pdfa.py @@ -110,6 +110,36 @@ class TestFindNonembeddedCidFonts: with pikepdf.open(path) as pdf: assert find_nonembedded_cid_fonts(pdf) == set() + def test_non_dictionary_font_descriptor_is_reported(self, tmp_path): + # A Type0 font whose descendant carries a non-dictionary /FontDescriptor + # has no embedded glyph data, so it must be reported -- not crash. This + # is the same malformed-resource bug class as #1713, one level deeper: + # `key in descriptor` raises ValueError on a non-dictionary. + path = tmp_path / 'bad_descriptor.pdf' + with pikepdf.new() as pdf: + page = pdf.add_blank_page() + cidfont = pdf.make_indirect( + pikepdf.Dictionary( + Type=Name.Font, + Subtype=Name.CIDFontType2, + BaseFont=Name('/BOGUS+CID'), + FontDescriptor=Name.NotADictionary, + ) + ) + type0 = pdf.make_indirect( + pikepdf.Dictionary( + Type=Name.Font, + Subtype=Name.Type0, + BaseFont=Name('/BOGUS+CID'), + Encoding=Name.Identity_H, + DescendantFonts=pikepdf.Array([cidfont]), + ) + ) + page.Resources = pikepdf.Dictionary(Font=pikepdf.Dictionary(F0=type0)) + pdf.save(path) + with pikepdf.open(path) as pdf: + assert find_nonembedded_cid_fonts(pdf) == {'BOGUS+CID'} + @pytest.fixture def nonembedded_cid_pdf(tmp_path):