From 8edf5fb5c7950a299b197af877b50c1e0f7320f8 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:18:35 -0700 Subject: [PATCH 01/11] test(preflight): add font-glyph-missing regression fixture (#114) Embedded subset parses cleanly but lacks a shown glyph; font-integrity currently passes it clean. Expectation flips with the audit change. Co-Authored-By: Claude Sonnet 5.5 --- .../testdata/fixtures/font-glyph-missing.pdf | Bin 0 -> 22824 bytes .../testdata/fixtures/manifest.json | 11 ++ .../generate_font_glyph_coverage_fixtures.py | 96 ++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 loop-preflight/testdata/fixtures/font-glyph-missing.pdf create mode 100644 loop-preflight/tools/generate_font_glyph_coverage_fixtures.py diff --git a/loop-preflight/testdata/fixtures/font-glyph-missing.pdf b/loop-preflight/testdata/fixtures/font-glyph-missing.pdf new file mode 100644 index 0000000000000000000000000000000000000000..14eba6f0015f09653ea51f7116b6b0a688acd17e GIT binary patch literal 22824 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 literal 0 HcmV?d00001 diff --git a/loop-preflight/testdata/fixtures/manifest.json b/loop-preflight/testdata/fixtures/manifest.json index 503e700f2..f90a0a4ba 100644 --- a/loop-preflight/testdata/fixtures/manifest.json +++ b/loop-preflight/testdata/fixtures/manifest.json @@ -872,6 +872,17 @@ "source": "hand-built", "notes": "#668 corpus gap: embedded FontFile2 truncated so inspectPDFFontIntegrity reports TruncatedProgram." }, + { + "id": "font-glyph-missing", + "pdf": "font-glyph-missing.pdf", + "profile": "testdata/profiles/test-font-integrity.json", + "expect": { + "pass": false, + "check_ids": ["font-integrity"] + }, + "source": "hand-built", + "notes": "#114 regression: embedded TrueType subset parses cleanly but has no glyph for shown code 0xE9 in page content, a Form XObject and a Widget appearance stream. Passed clean before glyph coverage was audited." + }, { "id": "thin-parts-clear", "pdf": "thin-parts-clear.pdf", diff --git a/loop-preflight/tools/generate_font_glyph_coverage_fixtures.py b/loop-preflight/tools/generate_font_glyph_coverage_fixtures.py new file mode 100644 index 000000000..3042f8418 --- /dev/null +++ b/loop-preflight/tools/generate_font_glyph_coverage_fixtures.py @@ -0,0 +1,96 @@ +"""Generate the font-glyph-coverage regression fixture (issue #114). + +Standard library only. The base is font-embedded.pdf, whose embedded TrueType +subset parses cleanly but has a (1, 0) cmap that stops at code 127. The page +content is rewritten to also show code 0xE9 from that font, which the program +has no glyph for. Every table still parses, so font-integrity passed it clean +before glyph coverage was audited. + +The fixture carries the same embedded program in three shown-text locations: page +content, a Form XObject, and a Widget annotation appearance stream. +""" + +from __future__ import annotations + +import re +import sys +import zlib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUT = ROOT / "testdata" / "fixtures" +BASE = DEFAULT_OUT / "font-embedded.pdf" + +MISSING_CODE = b"\351" # octal 0xE9, outside the subset's 0..127 cmap + + +def _objects(data: bytes) -> dict[int, bytes]: + return { + int(m.group(1)): m.group(2) + for m in re.finditer(rb"(?:^|\n)(\d+) 0 obj\n(.*?)\nendobj", data, re.S) + } + + +def _stream(dictionary: bytes, payload: bytes) -> bytes: + body = zlib.compress(payload, 9) + return b"<< " + dictionary + b" /Length %d /Filter /FlateDecode >>\nstream\n" % len(body) + body + b"\nendstream" + + +def build() -> bytes: + objects = _objects(BASE.read_bytes()) + font_obj = objects[6] + if b"/F2+0 7 0 R" not in font_obj: + raise SystemExit("unexpected font-embedded.pdf layout") + + page_content = ( + b"1 0 0 1 0 0 cm 0 g\n" + b"BT 1 0 0 1 40 150 Tm /F2+0 18 Tf (Frisket fixture: embedded font ) Tj (" + MISSING_CODE + b") Tj ET\n" + b"/Fm0 Do\n" + ) + form_content = b"BT 1 0 0 1 40 120 Tm /F2+0 18 Tf (" + MISSING_CODE + b") Tj ET\n" + appearance = b"BT 1 0 0 1 2 2 Tm /F2+0 12 Tf (" + MISSING_CODE + b") Tj ET\n" + resources = b"/Resources << /Font 6 0 R >>" + + page = objects[4].replace( + b"/Resources << /Font 6 0 R /ProcSet", + b"/Annots [ 12 0 R ] /Resources << /Font 6 0 R /XObject << /Fm0 11 0 R >> /ProcSet", + ) + if page == objects[4]: + raise SystemExit("could not attach form and annotation to page") + + out: dict[int, bytes] = dict(objects) + out[4] = page + out[5] = _stream(b"", page_content) + out[11] = _stream(b"/Type /XObject /Subtype /Form /BBox [ 0 0 312 312 ] " + resources, form_content) + out[12] = ( + b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (glyph) /Rect [ 40 60 100 80 ] /F 4 " + b"/AP << /N 13 0 R >> >>" + ) + out[13] = _stream(b"/Type /XObject /Subtype /Form /BBox [ 0 0 60 20 ] " + resources, appearance) + + buffer = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n") + offsets: dict[int, int] = {} + for number in sorted(out): + offsets[number] = len(buffer) + buffer += b"%d 0 obj\n" % number + out[number] + b"\nendobj\n" + xref = len(buffer) + size = max(out) + 1 + buffer += b"xref\n0 %d\n0000000000 65535 f \n" % size + for number in range(1, size): + buffer += b"%010d 00000 n \n" % offsets[number] + buffer += b"trailer\n<< /Size %d /Root 1 0 R /Info 2 0 R /ID [ <66676c7970686d6973736e67> <66676c7970686d6973736e67> ] >>\n" % size + buffer += b"startxref\n%d\n%%%%EOF\n" % xref + return bytes(buffer) + + +def main(argv: list[str]) -> int: + out = Path(argv[1] if len(argv) > 1 else DEFAULT_OUT) + out.mkdir(parents=True, exist_ok=True) + target = out / "font-glyph-missing.pdf" + target.write_bytes(build()) + print(f"wrote {target.name}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From d7e34f21fafb1f601ab93620db0ca6c521ed620e Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:21:29 -0700 Subject: [PATCH 02/11] test(preflight): give each shown-text location its own missing code (#114) Reported codes then prove which of page content, Form XObject and annotation appearance were traversed. Co-Authored-By: Claude Sonnet 5.5 --- .../testdata/fixtures/font-glyph-missing.pdf | Bin 22824 -> 22824 bytes .../testdata/fixtures/manifest.json | 2 +- .../generate_font_glyph_coverage_fixtures.py | 22 +++++++++++------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/loop-preflight/testdata/fixtures/font-glyph-missing.pdf b/loop-preflight/testdata/fixtures/font-glyph-missing.pdf index 14eba6f0015f09653ea51f7116b6b0a688acd17e..b0af26d9af0379376dc509f5586a7c9caac691d8 100644 GIT binary patch delta 53 zcmV-50LuTUvH_^F0kA7p4$@Qz$x?6);Q|1^#tCwhaa9wuOjZ{G4%eCrAz2EpAzT2b L)d dict[int, bytes]: @@ -44,11 +48,11 @@ def build() -> bytes: page_content = ( b"1 0 0 1 0 0 cm 0 g\n" - b"BT 1 0 0 1 40 150 Tm /F2+0 18 Tf (Frisket fixture: embedded font ) Tj (" + MISSING_CODE + b") Tj ET\n" + b"BT 1 0 0 1 40 150 Tm /F2+0 18 Tf (Frisket fixture: embedded font ) Tj (" + PAGE_CODE + b") Tj ET\n" b"/Fm0 Do\n" ) - form_content = b"BT 1 0 0 1 40 120 Tm /F2+0 18 Tf (" + MISSING_CODE + b") Tj ET\n" - appearance = b"BT 1 0 0 1 2 2 Tm /F2+0 12 Tf (" + MISSING_CODE + b") Tj ET\n" + form_content = b"BT 1 0 0 1 40 120 Tm /F2+0 18 Tf (" + FORM_CODE + b") Tj ET\n" + appearance = b"BT 1 0 0 1 2 2 Tm /F2+0 12 Tf (" + ANNOTATION_CODE + b") Tj ET\n" resources = b"/Resources << /Font 6 0 R >>" page = objects[4].replace( From 43681a430f943f2d10375e00bf3ee66eb34b6c1b Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:24:26 -0700 Subject: [PATCH 03/11] feat(preflight): audit glyph coverage of shown codes in font-integrity (#114) Shown codes in page content, Form XObjects and annotation appearance streams must resolve to a real glyph; missing, .notdef and empty-outline glyphs are reported per page and font. Co-Authored-By: Claude Sonnet 5.5 --- LoopLibCore/sources/pdffont.cpp | 2 + LoopLibCore/sources/pdffont.h | 2 + LoopLibCore/sources/pdffontintegrity.cpp | 47 +++++++ LoopLibCore/sources/pdffontintegrity.h | 15 ++ LoopLibCore/sources/preflightengine.cpp | 160 ++++++++++++++++++++++ UnitTests/tst_preflightenginetest.cpp | 61 +++++++++ docs/preflight-check-catalog-overlay.json | 16 ++- loop-preflight/README.md | 9 ++ 8 files changed, 305 insertions(+), 7 deletions(-) diff --git a/LoopLibCore/sources/pdffont.cpp b/LoopLibCore/sources/pdffont.cpp index e1aff6460..caecd53f5 100644 --- a/LoopLibCore/sources/pdffont.cpp +++ b/LoopLibCore/sources/pdffont.cpp @@ -1217,6 +1217,7 @@ void PDFRealizedFontImpl::fillTextSequence(const QByteArray& byteArray, TextSequ { const Glyph& glyph = getGlyph(glyphIndex); textSequence.items.emplace_back(&glyph.glyph, font->getUnicode(cid), glyph.advance, cid); + textSequence.items.back().glyphIndex = glyphIndex; } else { @@ -1275,6 +1276,7 @@ void PDFRealizedFontImpl::fillTextSequence(const QByteArray& byteArray, TextSequ { const Glyph& glyph = getGlyph(*glyphIndex); textSequence.items.emplace_back(&glyph.glyph, character, glyph.advance, cid); + textSequence.items.back().glyphIndex = *glyphIndex; } else { diff --git a/LoopLibCore/sources/pdffont.h b/LoopLibCore/sources/pdffont.h index 4d6a56abe..47004021d 100644 --- a/LoopLibCore/sources/pdffont.h +++ b/LoopLibCore/sources/pdffont.h @@ -93,6 +93,8 @@ struct TextSequenceItem QChar character; PDFReal advance = 0; CID cid = 0; + /// Glyph index in the font program; 0 is .notdef. Zero also for advances and Type 3 glyphs. + GID glyphIndex = 0; }; struct TextSequence diff --git a/LoopLibCore/sources/pdffontintegrity.cpp b/LoopLibCore/sources/pdffontintegrity.cpp index 45e3570f7..b6387f00e 100644 --- a/LoopLibCore/sources/pdffontintegrity.cpp +++ b/LoopLibCore/sources/pdffontintegrity.cpp @@ -151,4 +151,51 @@ PDFFontIntegrityResult inspectPDFFontIntegrity(const PDFFont& font) return result; } +PDFShownGlyphDefect classifyShownGlyph(const TextSequenceItem& item) +{ + if (item.isContentStream()) + { + return PDFShownGlyphDefect::None; + } + + if (!item.glyph) + { + // The realized font emits a glyphless item with the code's width when the + // code resolves to nothing. TJ adjustments carry cid 0. + return (item.cid != 0 && item.advance != 0.0) ? PDFShownGlyphDefect::Unresolved + : PDFShownGlyphDefect::None; + } + + if (item.glyphIndex == 0) + { + return PDFShownGlyphDefect::Notdef; + } + + const bool visibleCharacter = !item.character.isNull() && !item.character.isSpace() + && item.character.category() != QChar::Other_Format + && item.character.category() != QChar::Other_Control; + if (visibleCharacter && item.glyph->isEmpty()) + { + return PDFShownGlyphDefect::EmptyOutline; + } + + return PDFShownGlyphDefect::None; +} + +QString shownGlyphDefectName(PDFShownGlyphDefect defect) +{ + switch (defect) + { + case PDFShownGlyphDefect::Unresolved: + return QStringLiteral("MissingGlyph"); + case PDFShownGlyphDefect::Notdef: + return QStringLiteral("NotdefGlyph"); + case PDFShownGlyphDefect::EmptyOutline: + return QStringLiteral("EmptyGlyph"); + case PDFShownGlyphDefect::None: + break; + } + return QString(); +} + } // namespace pdf diff --git a/LoopLibCore/sources/pdffontintegrity.h b/LoopLibCore/sources/pdffontintegrity.h index 63ed67116..d05cf5cba 100644 --- a/LoopLibCore/sources/pdffontintegrity.h +++ b/LoopLibCore/sources/pdffontintegrity.h @@ -31,6 +31,7 @@ namespace pdf { class PDFFont; +struct TextSequenceItem; struct LOOPLIBCORESHARED_EXPORT PDFFontIntegrityResult { @@ -46,6 +47,20 @@ struct LOOPLIBCORESHARED_EXPORT PDFFontIntegrityResult /// does not alter the existing embedded-fonts check contract. LOOPLIBCORESHARED_EXPORT PDFFontIntegrityResult inspectPDFFontIntegrity(const PDFFont& font); +enum class PDFShownGlyphDefect +{ + None, + Unresolved, ///< The shown code resolved to no glyph in the font program. + Notdef, ///< The shown code resolved to glyph 0 (.notdef). + EmptyOutline ///< The glyph exists but draws nothing for a visible character. +}; + +/// Classifies one shown character of a resolved text sequence. Advances (TJ +/// adjustments) and Type 3 glyph procedures are never defects here. +LOOPLIBCORESHARED_EXPORT PDFShownGlyphDefect classifyShownGlyph(const TextSequenceItem& item); + +LOOPLIBCORESHARED_EXPORT QString shownGlyphDefectName(PDFShownGlyphDefect defect); + } // namespace pdf #endif // PDFFONTINTEGRITY_H diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index d0d7dcc24..db7c98fe1 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -74,6 +74,7 @@ #include #include #include +#include #include namespace pdf @@ -5052,6 +5053,70 @@ void runEmbeddedFontsCheck(PDFDocumentSession* session, } } +struct ShownGlyphDefects +{ + QString subtype; + bool composite = false; + std::map> codesByDefect; +}; + +/// Records, per embedded font, the codes a page actually shows that do not +/// resolve to a usable glyph. Page content, Form XObjects and annotation +/// appearance streams all flow through the same text-sequence hook. +class ShownGlyphCoverageProcessor final : public PDFPageContentProcessor +{ +public: + using PDFPageContentProcessor::PDFPageContentProcessor; + + void processFormStream(const PDFStream* stream) + { + if (stream && !isContentSuppressed()) + { + processForm(stream); + } + } + + const std::map& defects() const { return m_defects; } + +protected: + bool isContentKindSuppressed(ContentKind kind) const override + { + return kind != ContentKind::Text && kind != ContentKind::Forms; + } + + void performProcessTextSequence(const TextSequence& textSequence, ProcessOrder order) override + { + if (order != ProcessOrder::BeforeOperation) + { + return; + } + + const PDFFontPointer font = getGraphicState()->getTextFont(); + if (!font || !font->getFontDescriptor() || !font->getFontDescriptor()->isEmbedded() + || font->getFontType() == FontType::Type3) + { + return; + } + + for (const TextSequenceItem& item : textSequence.items) + { + const PDFShownGlyphDefect defect = classifyShownGlyph(item); + if (defect == PDFShownGlyphDefect::None) + { + continue; + } + + ShownGlyphDefects& entry = m_defects[QString::fromLatin1(font->getFontId())]; + entry.subtype = QString::number(static_cast(font->getFontType())); + entry.composite = font->getFontType() == FontType::Type0; + entry.codesByDefect[shownGlyphDefectName(defect)].insert(item.cid); + } + } + +private: + std::map m_defects; +}; + // LOW CONFIDENCE NOTE: DPI calculation uses getCurrentTransformationMatrix() // from the PDFPageContentProcessor state, which is in PDF user space. // This matches the existing PDFImageCollectorProcessor pattern in @@ -5188,6 +5253,101 @@ void runFontIntegrityCheck(PDFDocumentSession* session, scanResources(page->getResources(), int(pageIndex + 1)); } } + + // Shown-glyph coverage: every code the pages, forms and annotation appearances + // actually show must resolve to a real glyph in the embedded program. + PDFOptionalContentActivity ocActivity(document, OCUsage::Export, nullptr); + PDFFontCache fontCache(DEFAULT_FONT_CACHE_LIMIT, DEFAULT_REALIZED_FONT_CACHE_LIMIT); + PDFModifiedDocument modifiedDocument(document, &ocActivity); + fontCache.setDocument(modifiedDocument); + fontCache.setCacheShrinkEnabled(nullptr, false); + PDFCMSManager cmsManager(nullptr); + cmsManager.setDocument(document); + PDFCMSPointer cms = cmsManager.getCurrentCMS(); + PDFMeshQualitySettings meshQuality; + + for (PDFInteger pageIndex = 0; pageIndex < pageCount; ++pageIndex) + { + const PDFPage* page = document->getCatalog()->getPage(pageIndex); + if (!page) + { + continue; + } + + const int pageNumber = int(pageIndex + 1); + std::map pageDefects; + bool incomplete = false; + QString incompleteReason; + try + { + ShownGlyphCoverageProcessor processor(page, document, &fontCache, cms.get(), &ocActivity, + QTransform(), meshQuality, session->getProcessingBudget()); + processor.processContents(); + processAnnotationAppearanceStreams(document, page, pageNumber, [&](const PDFPage*, const PDFStream* formStream) + { processor.processFormStream(formStream); }); + pageDefects = processor.defects(); + } + catch (const PDFException& exception) + { + incomplete = true; + incompleteReason = QString::fromUtf8(exception.what()); + } + + for (const auto& [fontName, shown] : pageDefects) + { + QStringList defectNames; + QStringList codeText; + QJsonArray missingCodes; + std::set allCodes; + for (const auto& [defectName, codes] : shown.codesByDefect) + { + defectNames.append(defectName); + allCodes.insert(codes.begin(), codes.end()); + } + for (unsigned int code : allCodes) + { + missingCodes.append(int(code)); + codeText.append(QString::number(code)); + } + + PreflightFinding finding; + finding.scope = QString::fromLatin1(PREFLIGHT_FINDING_SCOPE_PAGE); + finding.page = pageNumber; + finding.type = QStringLiteral("font-integrity"); + finding.checkId = check.id; + finding.severity = check.severity; + finding.message = PDFTranslationContext::tr("Font '%1' on page %2 has no usable glyph for shown %3: %4") + .arg(fontName) + .arg(pageNumber) + .arg(shown.composite ? QStringLiteral("CIDs") : QStringLiteral("character codes"), + codeText.join(QStringLiteral(", "))); + finding.evidence.insert(QStringLiteral("font_resource"), fontName); + finding.evidence.insert(QStringLiteral("font_subtype"), shown.subtype); + finding.evidence.insert(QStringLiteral("embedded"), true); + finding.evidence.insert(QStringLiteral("inspection_complete"), true); + finding.evidence.insert(QStringLiteral("code_kind"), + shown.composite ? QStringLiteral("cid") : QStringLiteral("character-code")); + finding.evidence.insert(QStringLiteral("missing_codes"), missingCodes); + finding.evidence.insert(QStringLiteral("defects"), QJsonArray::fromStringList(defectNames)); + pushPreflightFinding(finding, finding.severity, errors, warnings); + } + + if (incomplete) + { + PreflightFinding finding; + finding.scope = QString::fromLatin1(PREFLIGHT_FINDING_SCOPE_PAGE); + finding.page = pageNumber; + finding.type = QStringLiteral("font-integrity"); + finding.checkId = check.id; + finding.severity = QStringLiteral("error"); + finding.message = PDFTranslationContext::tr("Shown-glyph coverage could not be audited on page %1: %2") + .arg(pageNumber) + .arg(incompleteReason); + finding.evidence.insert(QStringLiteral("inspection_complete"), false); + finding.evidence.insert(QStringLiteral("defects"), QJsonArray{ QStringLiteral("GlyphCoverageIncomplete") }); + pushPreflightFinding(finding, finding.severity, errors, warnings); + } + } } PDFXRuleResult makePDFXRuleResult(const QString& ruleId, diff --git a/UnitTests/tst_preflightenginetest.cpp b/UnitTests/tst_preflightenginetest.cpp index a9fcd0294..661941532 100644 --- a/UnitTests/tst_preflightenginetest.cpp +++ b/UnitTests/tst_preflightenginetest.cpp @@ -32,6 +32,8 @@ #include "pdfimage.h" #include "pdfinkcoverageprobe.h" #include "pdffixupregistry.h" +#include "pdffont.h" +#include "pdffontintegrity.h" #include "pdfrepairoperation.h" #include "pdfobject.h" #include "pdfthinpartprobe.h" @@ -82,6 +84,8 @@ private slots: void thinPartProbe_reportsBoundedWidthAndPrecision(); void fontIntegrity_checkIsRegistered(); void run_fontIntegrity_keepsValidEmbeddedFixtureClean(); + void run_fontIntegrity_reportsShownGlyphsMissingFromSubset(); + void classifyShownGlyph_separatesDefectsFromAdvancesAndSpaces(); void hiddenContent_checksAreRegistered(); void run_offPageContent_detectsMarksOutsideToleratedBox(); void run_includesProfileFixups(); @@ -901,6 +905,63 @@ void PreflightEngineTest::run_fontIntegrity_keepsValidEmbeddedFixtureClean() QVERIFY(result.warnings.isEmpty()); } +void PreflightEngineTest::run_fontIntegrity_reportsShownGlyphsMissingFromSubset() +{ + // The subset parses cleanly but has no glyph for codes 0xE9 (page content), + // 0xEA (Form XObject) and 0xEB (annotation appearance stream). + const QString fixturePath = QStringLiteral(LOOP_PREFLIGHT_SOURCE_DIR "/testdata/fixtures/font-glyph-missing.pdf"); + QVERIFY(QFile::exists(fixturePath)); + + pdf::PDFDocumentReader reader(nullptr, [](bool*) + { return QString(); }, true, false); + pdf::PDFDocument document = reader.readFromFile(fixturePath); + QCOMPARE(reader.getReadingResult(), pdf::PDFDocumentReader::Result::OK); + + pdf::PDFDocumentSession session(&document); + pdf::PreflightEngine engine(&session); + const QJsonObject profile{ + { QStringLiteral("name"), QStringLiteral("Font integrity") }, + { QStringLiteral("checks"), QJsonArray{ + QJsonObject{ { QStringLiteral("id"), QStringLiteral("font-integrity") } } } } + }; + + const pdf::PreflightResult result = engine.run(profile); + QVERIFY(!result.pass); + QCOMPARE(result.errors.size(), 1); + + const pdf::PreflightFinding& finding = result.errors.first(); + QCOMPARE(finding.checkId, QStringLiteral("font-integrity")); + QCOMPARE(finding.page, 1); + QCOMPARE(finding.evidence.value(QStringLiteral("font_resource")).toString(), QStringLiteral("F2+0")); + QCOMPARE(finding.evidence.value(QStringLiteral("inspection_complete")).toBool(), true); + QCOMPARE(finding.evidence.value(QStringLiteral("code_kind")).toString(), QStringLiteral("character-code")); + QCOMPARE(finding.evidence.value(QStringLiteral("missing_codes")).toArray(), (QJsonArray{ 0xE9, 0xEA, 0xEB })); + QVERIFY(finding.evidence.value(QStringLiteral("defects")).toArray().contains(QStringLiteral("MissingGlyph"))); +} + +void PreflightEngineTest::classifyShownGlyph_separatesDefectsFromAdvancesAndSpaces() +{ + const QPainterPath empty; + QPainterPath outline; + outline.addRect(0, 0, 1, 1); + + const auto glyphItem = [](const QPainterPath* path, QChar character, pdf::GID gid) + { + pdf::TextSequenceItem item(path, character, 500.0, 7); + item.glyphIndex = gid; + return item; + }; + + QVERIFY(pdf::classifyShownGlyph(pdf::TextSequenceItem(-120.0)) == pdf::PDFShownGlyphDefect::None); + QVERIFY(pdf::classifyShownGlyph(pdf::TextSequenceItem(nullptr, QChar(), 500.0, 7)) == pdf::PDFShownGlyphDefect::Unresolved); + QVERIFY(pdf::classifyShownGlyph(pdf::TextSequenceItem(nullptr, QChar(), 500.0, 0)) == pdf::PDFShownGlyphDefect::None); + QVERIFY(pdf::classifyShownGlyph(glyphItem(&outline, QChar('A'), 0)) == pdf::PDFShownGlyphDefect::Notdef); + QVERIFY(pdf::classifyShownGlyph(glyphItem(&empty, QChar('A'), 12)) == pdf::PDFShownGlyphDefect::EmptyOutline); + QVERIFY(pdf::classifyShownGlyph(glyphItem(&empty, QChar(' '), 12)) == pdf::PDFShownGlyphDefect::None); + QVERIFY(pdf::classifyShownGlyph(glyphItem(&empty, QChar(), 12)) == pdf::PDFShownGlyphDefect::None); + QVERIFY(pdf::classifyShownGlyph(glyphItem(&outline, QChar('A'), 12)) == pdf::PDFShownGlyphDefect::None); +} + void PreflightEngineTest::hiddenContent_checksAreRegistered() { pdf::PreflightEngine engine(nullptr); diff --git a/docs/preflight-check-catalog-overlay.json b/docs/preflight-check-catalog-overlay.json index 916f07a9d..689837f53 100644 --- a/docs/preflight-check-catalog-overlay.json +++ b/docs/preflight-check-catalog-overlay.json @@ -517,8 +517,8 @@ "fixups": [] }, "font-integrity": { - "measures": "Embedded font bytes that fail to parse or cmap.", - "limitations": "Not a full glyph-coverage audit.", + "measures": "Embedded font bytes that fail to parse or cmap, and shown character codes or CIDs that resolve to no glyph, .notdef, or an empty outline.", + "limitations": "Glyph coverage audits codes shown in page content, Form XObjects and annotation appearance streams of embedded TrueType, Type 1 and CID fonts. A missing glyph with zero advance, simple-font code 0, and Type 3 glyph procedures are not reported; a page whose content cannot be processed is reported incomplete.", "coverage": "partial", "families": [ "sheetfed-offset", @@ -529,7 +529,7 @@ { "finding_type": "font-integrity", "severity": "error", - "condition": "an embedded font program fails to parse or its cmap cannot be read; a complete inspection keeps the check severity, an incomplete one is forced to error" + "condition": "an embedded font program fails to parse or its cmap cannot be read, or a shown code resolves to no usable glyph; a complete inspection keeps the check severity, an incomplete one is forced to error" } ], "evidence": [ @@ -544,7 +544,9 @@ "evidence.font_subtype", "evidence.embedded", "evidence.inspection_complete", - "evidence.defects" + "evidence.defects", + "evidence.code_kind", + "evidence.missing_codes" ], "fixups": [] }, @@ -1482,13 +1484,13 @@ { "id": "font-glyph-coverage", "priority": "P2", - "gap": "font-integrity parses the embedded program and its cmap but is not a glyph-coverage audit, so a missing glyph that still parses passes clean", + "gap": "shown character codes that resolve to no glyph, .notdef, or an empty outline in an embedded font that still parses were unchecked; font-integrity now audits them per page, form and annotation appearance (filed as #114)", "families": [ "sheetfed-offset", "digital" ], - "state": "open", - "closed_by": "#114", + "state": "landed", + "closed_by": "font-integrity", "deferral": null }, { diff --git a/loop-preflight/README.md b/loop-preflight/README.md index 961836f20..1baf6bc62 100644 --- a/loop-preflight/README.md +++ b/loop-preflight/README.md @@ -83,6 +83,14 @@ defects with the font resource and object reference, and marks unsupported formats as incomplete rather than clean. Existing `embedded-fonts` ids and severity behavior are unchanged. +It also audits glyph coverage: every character code or CID shown in page +content, Form XObjects and annotation appearance streams is resolved through +the font's encoding and cmap, and a code that resolves to no glyph, to +`.notdef`, or to an empty outline for a visible character is reported per page +and font with `evidence.missing_codes`. A zero-advance missing glyph, simple-font +code 0 and Type 3 glyph procedures are not reported; a page whose content cannot +be processed is reported with `inspection_complete: false`. + ## Hidden and non-printing content The detection-only checks `invisible-content`, `hidden-layers`, @@ -581,6 +589,7 @@ Corpus-gap fixtures added for #668 (each isolates one previously unexercised che | `obscured-content.pdf` | test-obscured-content | info | `obscured-content` | | `hidden-layers.pdf` | test-hidden-layers | warning | `hidden-layers` | | `font-integrity-corrupt.pdf` | test-font-integrity | fail | `font-integrity` | +| `font-glyph-missing.pdf` | test-font-integrity | fail | `font-integrity` (shown codes absent from the subset) | | `thin-parts-clear.pdf` | test-thin-parts-clear | fail | `thin-parts` (not near-threshold) | | `blank-page.pdf` | test-dieline-required | fail | `dieline` | | `blank-page.pdf` | test-processing-steps-required | fail | `processing-steps` | From 6d650a5f70535d199d5600f0ab42072e98ee11f0 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:27:32 -0700 Subject: [PATCH 04/11] fix(preflight): include QPainterPath for glyph classifier (#114) Co-Authored-By: Claude Sonnet 5.5 --- LoopLibCore/sources/pdffontintegrity.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/LoopLibCore/sources/pdffontintegrity.cpp b/LoopLibCore/sources/pdffontintegrity.cpp index b6387f00e..54527922a 100644 --- a/LoopLibCore/sources/pdffontintegrity.cpp +++ b/LoopLibCore/sources/pdffontintegrity.cpp @@ -24,6 +24,7 @@ #include "pdffont.h" +#include #include #include From 9462e6ffe05a8a43ad4e7f72818bb7772d48dbb2 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:28:31 -0700 Subject: [PATCH 05/11] test(preflight): disambiguate glyphless text items (#114) Co-Authored-By: Claude Sonnet 5.5 --- UnitTests/tst_preflightenginetest.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/UnitTests/tst_preflightenginetest.cpp b/UnitTests/tst_preflightenginetest.cpp index 661941532..39401c3f4 100644 --- a/UnitTests/tst_preflightenginetest.cpp +++ b/UnitTests/tst_preflightenginetest.cpp @@ -941,6 +941,7 @@ void PreflightEngineTest::run_fontIntegrity_reportsShownGlyphsMissingFromSubset( void PreflightEngineTest::classifyShownGlyph_separatesDefectsFromAdvancesAndSpaces() { + const QPainterPath* noGlyph = nullptr; const QPainterPath empty; QPainterPath outline; outline.addRect(0, 0, 1, 1); @@ -953,8 +954,8 @@ void PreflightEngineTest::classifyShownGlyph_separatesDefectsFromAdvancesAndSpac }; QVERIFY(pdf::classifyShownGlyph(pdf::TextSequenceItem(-120.0)) == pdf::PDFShownGlyphDefect::None); - QVERIFY(pdf::classifyShownGlyph(pdf::TextSequenceItem(nullptr, QChar(), 500.0, 7)) == pdf::PDFShownGlyphDefect::Unresolved); - QVERIFY(pdf::classifyShownGlyph(pdf::TextSequenceItem(nullptr, QChar(), 500.0, 0)) == pdf::PDFShownGlyphDefect::None); + QVERIFY(pdf::classifyShownGlyph(pdf::TextSequenceItem(noGlyph, QChar(), 500.0, 7)) == pdf::PDFShownGlyphDefect::Unresolved); + QVERIFY(pdf::classifyShownGlyph(pdf::TextSequenceItem(noGlyph, QChar(), 500.0, 0)) == pdf::PDFShownGlyphDefect::None); QVERIFY(pdf::classifyShownGlyph(glyphItem(&outline, QChar('A'), 0)) == pdf::PDFShownGlyphDefect::Notdef); QVERIFY(pdf::classifyShownGlyph(glyphItem(&empty, QChar('A'), 12)) == pdf::PDFShownGlyphDefect::EmptyOutline); QVERIFY(pdf::classifyShownGlyph(glyphItem(&empty, QChar(' '), 12)) == pdf::PDFShownGlyphDefect::None); From 1aea1911f781f0b7e9f90cd5501f5d8a23d98e76 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:30:57 -0700 Subject: [PATCH 06/11] fix(preflight): report unresolved shown codes regardless of advance (#114) A code outside /Widths resolves with zero advance and emitted no item, so the missing glyph was invisible to the audit. The text sequence now lists every unresolved code. Co-Authored-By: Claude Sonnet 5.5 --- LoopLibCore/sources/pdffont.cpp | 2 ++ LoopLibCore/sources/pdffont.h | 5 +++++ LoopLibCore/sources/pdffontintegrity.cpp | 6 ++---- LoopLibCore/sources/pdffontintegrity.h | 7 ++++--- LoopLibCore/sources/preflightengine.cpp | 8 ++++++++ UnitTests/tst_preflightenginetest.cpp | 3 +-- docs/preflight-check-catalog-overlay.json | 2 +- loop-preflight/README.md | 4 ++-- 8 files changed, 25 insertions(+), 12 deletions(-) diff --git a/LoopLibCore/sources/pdffont.cpp b/LoopLibCore/sources/pdffont.cpp index caecd53f5..282d4628f 100644 --- a/LoopLibCore/sources/pdffont.cpp +++ b/LoopLibCore/sources/pdffont.cpp @@ -1222,6 +1222,7 @@ void PDFRealizedFontImpl::fillTextSequence(const QByteArray& byteArray, TextSequ else { reporter->reportRenderError(RenderErrorType::Warning, PDFTranslationContext::tr("Glyph for simple font character code '%1' not found.").arg(cid)); + textSequence.unresolvedCodes.push_back(cid); if (glyphWidth > 0) { const QPainterPath* nullpath = nullptr; @@ -1284,6 +1285,7 @@ void PDFRealizedFontImpl::fillTextSequence(const QByteArray& byteArray, TextSequ { // Character with CID == 0 is treated as default whitespace, it hasn't glyph reporter->reportRenderError(RenderErrorType::Warning, PDFTranslationContext::tr("Glyph for composite font character with cid '%1' not found.").arg(cid)); + textSequence.unresolvedCodes.push_back(cid); } if (glyphWidth > 0) diff --git a/LoopLibCore/sources/pdffont.h b/LoopLibCore/sources/pdffont.h index 47004021d..13968b991 100644 --- a/LoopLibCore/sources/pdffont.h +++ b/LoopLibCore/sources/pdffont.h @@ -100,6 +100,11 @@ struct TextSequenceItem struct TextSequence { std::vector items; + + /// Shown codes (character codes of simple fonts, CIDs of composite fonts) that + /// resolved to no glyph, whatever their advance. Composite CID 0 is the default + /// whitespace and is never listed. + std::vector unresolvedCodes; }; constexpr bool isTextRenderingModeFilled(TextRenderingMode mode) diff --git a/LoopLibCore/sources/pdffontintegrity.cpp b/LoopLibCore/sources/pdffontintegrity.cpp index 54527922a..cb438429e 100644 --- a/LoopLibCore/sources/pdffontintegrity.cpp +++ b/LoopLibCore/sources/pdffontintegrity.cpp @@ -161,10 +161,8 @@ PDFShownGlyphDefect classifyShownGlyph(const TextSequenceItem& item) if (!item.glyph) { - // The realized font emits a glyphless item with the code's width when the - // code resolves to nothing. TJ adjustments carry cid 0. - return (item.cid != 0 && item.advance != 0.0) ? PDFShownGlyphDefect::Unresolved - : PDFShownGlyphDefect::None; + // Codes that resolve to nothing are listed in TextSequence::unresolvedCodes. + return PDFShownGlyphDefect::None; } if (item.glyphIndex == 0) diff --git a/LoopLibCore/sources/pdffontintegrity.h b/LoopLibCore/sources/pdffontintegrity.h index d05cf5cba..c5d213f17 100644 --- a/LoopLibCore/sources/pdffontintegrity.h +++ b/LoopLibCore/sources/pdffontintegrity.h @@ -50,13 +50,14 @@ LOOPLIBCORESHARED_EXPORT PDFFontIntegrityResult inspectPDFFontIntegrity(const PD enum class PDFShownGlyphDefect { None, - Unresolved, ///< The shown code resolved to no glyph in the font program. + Unresolved, ///< The shown code resolved to no glyph (TextSequence::unresolvedCodes). Notdef, ///< The shown code resolved to glyph 0 (.notdef). EmptyOutline ///< The glyph exists but draws nothing for a visible character. }; -/// Classifies one shown character of a resolved text sequence. Advances (TJ -/// adjustments) and Type 3 glyph procedures are never defects here. +/// Classifies one resolved glyph item of a text sequence. Advances (TJ +/// adjustments), glyphless items and Type 3 glyph procedures are never defects +/// here; codes that resolved to nothing are reported by the text sequence. LOOPLIBCORESHARED_EXPORT PDFShownGlyphDefect classifyShownGlyph(const TextSequenceItem& item); LOOPLIBCORESHARED_EXPORT QString shownGlyphDefectName(PDFShownGlyphDefect defect); diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index db7c98fe1..e66d24abd 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -5098,6 +5098,14 @@ class ShownGlyphCoverageProcessor final : public PDFPageContentProcessor return; } + for (const CID code : textSequence.unresolvedCodes) + { + ShownGlyphDefects& entry = m_defects[QString::fromLatin1(font->getFontId())]; + entry.subtype = QString::number(static_cast(font->getFontType())); + entry.composite = font->getFontType() == FontType::Type0; + entry.codesByDefect[shownGlyphDefectName(PDFShownGlyphDefect::Unresolved)].insert(code); + } + for (const TextSequenceItem& item : textSequence.items) { const PDFShownGlyphDefect defect = classifyShownGlyph(item); diff --git a/UnitTests/tst_preflightenginetest.cpp b/UnitTests/tst_preflightenginetest.cpp index 39401c3f4..94582cbee 100644 --- a/UnitTests/tst_preflightenginetest.cpp +++ b/UnitTests/tst_preflightenginetest.cpp @@ -954,8 +954,7 @@ void PreflightEngineTest::classifyShownGlyph_separatesDefectsFromAdvancesAndSpac }; QVERIFY(pdf::classifyShownGlyph(pdf::TextSequenceItem(-120.0)) == pdf::PDFShownGlyphDefect::None); - QVERIFY(pdf::classifyShownGlyph(pdf::TextSequenceItem(noGlyph, QChar(), 500.0, 7)) == pdf::PDFShownGlyphDefect::Unresolved); - QVERIFY(pdf::classifyShownGlyph(pdf::TextSequenceItem(noGlyph, QChar(), 500.0, 0)) == pdf::PDFShownGlyphDefect::None); + QVERIFY(pdf::classifyShownGlyph(pdf::TextSequenceItem(noGlyph, QChar(), 500.0, 7)) == pdf::PDFShownGlyphDefect::None); QVERIFY(pdf::classifyShownGlyph(glyphItem(&outline, QChar('A'), 0)) == pdf::PDFShownGlyphDefect::Notdef); QVERIFY(pdf::classifyShownGlyph(glyphItem(&empty, QChar('A'), 12)) == pdf::PDFShownGlyphDefect::EmptyOutline); QVERIFY(pdf::classifyShownGlyph(glyphItem(&empty, QChar(' '), 12)) == pdf::PDFShownGlyphDefect::None); diff --git a/docs/preflight-check-catalog-overlay.json b/docs/preflight-check-catalog-overlay.json index 689837f53..1b2f6e26e 100644 --- a/docs/preflight-check-catalog-overlay.json +++ b/docs/preflight-check-catalog-overlay.json @@ -518,7 +518,7 @@ }, "font-integrity": { "measures": "Embedded font bytes that fail to parse or cmap, and shown character codes or CIDs that resolve to no glyph, .notdef, or an empty outline.", - "limitations": "Glyph coverage audits codes shown in page content, Form XObjects and annotation appearance streams of embedded TrueType, Type 1 and CID fonts. A missing glyph with zero advance, simple-font code 0, and Type 3 glyph procedures are not reported; a page whose content cannot be processed is reported incomplete.", + "limitations": "Glyph coverage audits codes shown in page content, Form XObjects and annotation appearance streams of embedded TrueType, Type 1 and CID fonts. Composite CID 0 (default whitespace) and Type 3 glyph procedures are not reported; a page whose content cannot be processed is reported incomplete.", "coverage": "partial", "families": [ "sheetfed-offset", diff --git a/loop-preflight/README.md b/loop-preflight/README.md index 1baf6bc62..0e94e8b8b 100644 --- a/loop-preflight/README.md +++ b/loop-preflight/README.md @@ -87,8 +87,8 @@ It also audits glyph coverage: every character code or CID shown in page content, Form XObjects and annotation appearance streams is resolved through the font's encoding and cmap, and a code that resolves to no glyph, to `.notdef`, or to an empty outline for a visible character is reported per page -and font with `evidence.missing_codes`. A zero-advance missing glyph, simple-font -code 0 and Type 3 glyph procedures are not reported; a page whose content cannot +and font with `evidence.missing_codes`. Composite CID 0 (default whitespace) and +Type 3 glyph procedures are not reported; a page whose content cannot be processed is reported with `inspection_complete: false`. ## Hidden and non-printing content From 63fef66bc4e2503f64cdadd70c185da672be0b37 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:34:58 -0700 Subject: [PATCH 07/11] fix(preflight): surface unresolved codes for strings with no drawable items (#114) drawText returned early for an empty sequence, before any hook ran. A new performTextGlyphsUnresolved hook fires first; the editor processor is unaffected. Co-Authored-By: Claude Sonnet 5.5 --- .../sources/pdfpagecontentprocessor.cpp | 10 ++++ LoopLibCore/sources/pdfpagecontentprocessor.h | 4 ++ LoopLibCore/sources/preflightengine.cpp | 49 +++++++++++++------ 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/LoopLibCore/sources/pdfpagecontentprocessor.cpp b/LoopLibCore/sources/pdfpagecontentprocessor.cpp index e8c55e476..1cda52c4c 100644 --- a/LoopLibCore/sources/pdfpagecontentprocessor.cpp +++ b/LoopLibCore/sources/pdfpagecontentprocessor.cpp @@ -540,6 +540,11 @@ void PDFPageContentProcessor::performProcessTextSequence(const TextSequence& tex Q_UNUSED(order); } +void PDFPageContentProcessor::performTextGlyphsUnresolved(const TextSequence& textSequence) +{ + Q_UNUSED(textSequence); +} + bool PDFPageContentProcessor::isContentKindSuppressed(ContentKind kind) const { Q_UNUSED(kind); @@ -3406,6 +3411,11 @@ void PDFPageContentProcessor::operatorCompatibilityEnd() void PDFPageContentProcessor::drawText(const TextSequence& textSequence) { + if (!textSequence.unresolvedCodes.empty()) + { + performTextGlyphsUnresolved(textSequence); + } + if (textSequence.items.empty()) { // Do not display empty text diff --git a/LoopLibCore/sources/pdfpagecontentprocessor.h b/LoopLibCore/sources/pdfpagecontentprocessor.h index af1ab4703..700a86429 100644 --- a/LoopLibCore/sources/pdfpagecontentprocessor.h +++ b/LoopLibCore/sources/pdfpagecontentprocessor.h @@ -753,6 +753,10 @@ class LOOPLIBCORESHARED_EXPORT PDFPageContentProcessor : public PDFRenderErrorRe /// Implement to respond to text sequence processing virtual void performProcessTextSequence(const TextSequence& textSequence, ProcessOrder order); + /// Called once per shown string for which at least one code resolved to no + /// glyph, including strings that produce no drawable items at all. + virtual void performTextGlyphsUnresolved(const TextSequence& textSequence); + enum class ContentKind { Shapes, ///< General shapes (they can be also shaded / tiled) diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index e66d24abd..c833aa9da 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -5084,26 +5084,31 @@ class ShownGlyphCoverageProcessor final : public PDFPageContentProcessor return kind != ContentKind::Text && kind != ContentKind::Forms; } - void performProcessTextSequence(const TextSequence& textSequence, ProcessOrder order) override + void performTextGlyphsUnresolved(const TextSequence& textSequence) override { - if (order != ProcessOrder::BeforeOperation) + const PDFFontPointer font = embeddedShownFont(); + if (!font) { return; } - const PDFFontPointer font = getGraphicState()->getTextFont(); - if (!font || !font->getFontDescriptor() || !font->getFontDescriptor()->isEmbedded() - || font->getFontType() == FontType::Type3) + for (const CID code : textSequence.unresolvedCodes) + { + record(*font, PDFShownGlyphDefect::Unresolved, code); + } + } + + void performProcessTextSequence(const TextSequence& textSequence, ProcessOrder order) override + { + if (order != ProcessOrder::BeforeOperation) { return; } - for (const CID code : textSequence.unresolvedCodes) + const PDFFontPointer font = embeddedShownFont(); + if (!font) { - ShownGlyphDefects& entry = m_defects[QString::fromLatin1(font->getFontId())]; - entry.subtype = QString::number(static_cast(font->getFontType())); - entry.composite = font->getFontType() == FontType::Type0; - entry.codesByDefect[shownGlyphDefectName(PDFShownGlyphDefect::Unresolved)].insert(code); + return; } for (const TextSequenceItem& item : textSequence.items) @@ -5114,14 +5119,30 @@ class ShownGlyphCoverageProcessor final : public PDFPageContentProcessor continue; } - ShownGlyphDefects& entry = m_defects[QString::fromLatin1(font->getFontId())]; - entry.subtype = QString::number(static_cast(font->getFontType())); - entry.composite = font->getFontType() == FontType::Type0; - entry.codesByDefect[shownGlyphDefectName(defect)].insert(item.cid); + record(*font, defect, item.cid); } } private: + PDFFontPointer embeddedShownFont() const + { + const PDFFontPointer font = getGraphicState()->getTextFont(); + if (!font || !font->getFontDescriptor() || !font->getFontDescriptor()->isEmbedded() + || font->getFontType() == FontType::Type3) + { + return nullptr; + } + return font; + } + + void record(const PDFFont& font, PDFShownGlyphDefect defect, CID code) + { + ShownGlyphDefects& entry = m_defects[QString::fromLatin1(font.getFontId())]; + entry.subtype = QString::number(static_cast(font.getFontType())); + entry.composite = font.getFontType() == FontType::Type0; + entry.codesByDefect[shownGlyphDefectName(defect)].insert(code); + } + std::map m_defects; }; From 266553f7de3cfbc36231844bb14ec031aabfde42 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:40:40 -0700 Subject: [PATCH 08/11] docs(preflight): land font-glyph-coverage with snapshot and catalogs (#114) Adds the font-glyph-missing snapshot and regenerates the check catalog, corpus-coverage map and backlog. Golden corpus snapshots are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- LoopLibCore/sources/preflightengine.cpp | 3 +- docs/generated/preflight-check-catalog.json | 10 ++-- docs/generated/preflight-corpus-coverage.json | 3 +- .../generated/preflight-coverage-backlog.json | 6 +-- .../snapshots/font-glyph-missing.json | 52 +++++++++++++++++++ 5 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 loop-preflight/testdata/snapshots/font-glyph-missing.json diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index c833aa9da..00711741b 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -5127,8 +5127,7 @@ class ShownGlyphCoverageProcessor final : public PDFPageContentProcessor PDFFontPointer embeddedShownFont() const { const PDFFontPointer font = getGraphicState()->getTextFont(); - if (!font || !font->getFontDescriptor() || !font->getFontDescriptor()->isEmbedded() - || font->getFontType() == FontType::Type3) + if (!font || !font->getFontDescriptor() || !font->getFontDescriptor()->isEmbedded() || font->getFontType() == FontType::Type3) { return nullptr; } diff --git a/docs/generated/preflight-check-catalog.json b/docs/generated/preflight-check-catalog.json index 700197bf2..66105fe4e 100644 --- a/docs/generated/preflight-check-catalog.json +++ b/docs/generated/preflight-check-catalog.json @@ -378,19 +378,21 @@ "evidence.font_subtype", "evidence.embedded", "evidence.inspection_complete", - "evidence.defects" + "evidence.defects", + "evidence.code_kind", + "evidence.missing_codes" ], "families": [ "sheetfed-offset", "digital" ], "fixups": [], - "limitations": "Not a full glyph-coverage audit.", - "measures": "Embedded font bytes that fail to parse or cmap.", + "limitations": "Glyph coverage audits codes shown in page content, Form XObjects and annotation appearance streams of embedded TrueType, Type 1 and CID fonts. Composite CID 0 (default whitespace) and Type 3 glyph procedures are not reported; a page whose content cannot be processed is reported incomplete.", + "measures": "Embedded font bytes that fail to parse or cmap, and shown character codes or CIDs that resolve to no glyph, .notdef, or an empty outline.", "parameters": [], "severity": [ { - "condition": "an embedded font program fails to parse or its cmap cannot be read; a complete inspection keeps the check severity, an incomplete one is forced to error", + "condition": "an embedded font program fails to parse or its cmap cannot be read, or a shown code resolves to no usable glyph; a complete inspection keeps the check severity, an incomplete one is forced to error", "finding_type": "font-integrity", "severity": "error" } diff --git a/docs/generated/preflight-corpus-coverage.json b/docs/generated/preflight-corpus-coverage.json index c261ad5f0..2d90965ee 100644 --- a/docs/generated/preflight-corpus-coverage.json +++ b/docs/generated/preflight-corpus-coverage.json @@ -120,6 +120,7 @@ "corpus_gap": null, "coverage": "partial", "finding_fixtures": [ + "font-glyph-missing", "font-integrity-corrupt" ], "uninspected_fixtures": [] @@ -272,6 +273,6 @@ } }, "snapshot_dir": "loop-preflight/testdata/snapshots", - "source_fixtures": 76, + "source_fixtures": 77, "unattributed_findings": 1 } diff --git a/docs/generated/preflight-coverage-backlog.json b/docs/generated/preflight-coverage-backlog.json index 8c428da56..92020d5fc 100644 --- a/docs/generated/preflight-coverage-backlog.json +++ b/docs/generated/preflight-coverage-backlog.json @@ -287,16 +287,16 @@ "state": "open" }, { - "closed_by": "#114", + "closed_by": "font-integrity", "deferral": null, "families": [ "sheetfed-offset", "digital" ], - "gap": "font-integrity parses the embedded program and its cmap but is not a glyph-coverage audit, so a missing glyph that still parses passes clean", + "gap": "shown character codes that resolve to no glyph, .notdef, or an empty outline in an embedded font that still parses were unchecked; font-integrity now audits them per page, form and annotation appearance (filed as #114)", "id": "font-glyph-coverage", "priority": "P2", - "state": "open" + "state": "landed" }, { "closed_by": "thin-strokes", diff --git a/loop-preflight/testdata/snapshots/font-glyph-missing.json b/loop-preflight/testdata/snapshots/font-glyph-missing.json new file mode 100644 index 000000000..566001e62 --- /dev/null +++ b/loop-preflight/testdata/snapshots/font-glyph-missing.json @@ -0,0 +1,52 @@ +{ + "checks": [ + { + "id": "font-integrity", + "status": "failed" + } + ], + "errors": [ + { + "check_id": "font-integrity", + "evidence": { + "code_kind": "character-code", + "defects": [ + "MissingGlyph" + ], + "embedded": true, + "font_resource": "F2+0", + "font_subtype": "4", + "inspection_complete": true, + "missing_codes": [ + 233, + 234, + 235 + ] + }, + "message": "Font 'F2+0' on page 1 has no usable glyph for shown character codes: 233, 234, 235", + "page": 1, + "scope": "page", + "severity": "error", + "type": "font-integrity" + } + ], + "fixups_available": [ + ], + "inspection_complete": true, + "pass": false, + "profile": "Loop Test - Font Integrity", + "schema_kind": "preflight-report", + "schema_version": 4, + "verdict": { + "blocking_finding_ids": [ + "a1974367f8e8fab6" + ], + "reason": "One or more blocking findings require resolution or an active disposition.", + "reason_code": "blocking-findings", + "state": "fail", + "waived_finding_ids": [ + ] + }, + "warnings": [ + ] +} From 7a7435492eee95796bd4816f27ca2bb5373398cd Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:41:18 -0700 Subject: [PATCH 09/11] chore(preflight): changelog, evidence and formatting for glyph coverage (#114) Co-Authored-By: Claude Sonnet 5.5 --- LoopLibCore/sources/pdffont.h | 47 ++++++++++++++----- LoopLibCore/sources/pdffontintegrity.cpp | 25 ++++------ LoopLibCore/sources/pdffontintegrity.h | 10 ++-- .../cc-issue-114-glyph-coverage.evidence.yaml | 17 +++++++ changes/cc-issue-114-glyph-coverage.md | 6 +++ 5 files changed, 72 insertions(+), 33 deletions(-) create mode 100644 changes/cc-issue-114-glyph-coverage.evidence.yaml create mode 100644 changes/cc-issue-114-glyph-coverage.md diff --git a/LoopLibCore/sources/pdffont.h b/LoopLibCore/sources/pdffont.h index 13968b991..bf1f25acb 100644 --- a/LoopLibCore/sources/pdffont.h +++ b/LoopLibCore/sources/pdffont.h @@ -79,9 +79,25 @@ class ITreeFactory struct TextSequenceItem { inline explicit TextSequenceItem() = default; - inline explicit TextSequenceItem(const QPainterPath* glyph, QChar character, PDFReal advance, CID cid) : glyph(glyph), character(character), advance(advance), cid(cid) { } - inline explicit TextSequenceItem(PDFReal advance) : character(), advance(advance) { } - inline explicit TextSequenceItem(const QByteArray* characterContentStream, QChar character, PDFReal advance, uint cid) : characterContentStream(characterContentStream), character(character), advance(advance), cid(cid) { } + inline explicit TextSequenceItem(const QPainterPath* glyph, QChar character, PDFReal advance, CID cid) : + glyph(glyph), + character(character), + advance(advance), + cid(cid) + { + } + inline explicit TextSequenceItem(PDFReal advance) : + character(), + advance(advance) + { + } + inline explicit TextSequenceItem(const QByteArray* characterContentStream, QChar character, PDFReal advance, uint cid) : + characterContentStream(characterContentStream), + character(character), + advance(advance), + cid(cid) + { + } inline bool isContentStream() const { return characterContentStream; } inline bool isCharacter() const { return glyph; } @@ -302,7 +318,10 @@ class LOOPLIBCORESHARED_EXPORT PDFRealizedFont private: /// Constructs new realized font - explicit PDFRealizedFont(IRealizedFontImpl* impl) : m_impl(impl) { } + explicit PDFRealizedFont(IRealizedFontImpl* impl) : + m_impl(impl) + { + } IRealizedFontImpl* m_impl; }; @@ -424,7 +443,7 @@ class LOOPLIBCORESHARED_EXPORT PDFSimpleFont : public PDFFont bool m_hasToUnicode; GlyphIndices m_glyphIndices; GlyphNames m_glyphNames; - StandardFontType m_standardFontType; ///< Type of the standard font (or invalid, if it is not a standard font) + StandardFontType m_standardFontType; ///< Type of the standard font (or invalid, if it is not a standard font) }; class PDFType1Font : public PDFSimpleFont @@ -475,7 +494,6 @@ class LOOPLIBCORESHARED_EXPORT PDFFontCache m_realizedFontCacheLimit(realizedFontCacheLimit), m_document(nullptr) { - } ~PDFFontCache(); @@ -550,7 +568,10 @@ class LOOPLIBCORESHARED_EXPORT PDFFontCache class PDFCIDtoGIDMapper { public: - explicit inline PDFCIDtoGIDMapper(QByteArray&& mapping) : m_mapping(qMove(mapping)) { } + explicit inline PDFCIDtoGIDMapper(QByteArray&& mapping) : + m_mapping(qMove(mapping)) + { + } /// Maps CID to GID (glyph identifier). Nullopt means no valid mapping exists. std::optional tryMap(CID cid) const @@ -676,11 +697,16 @@ class LOOPLIBCORESHARED_EXPORT PDFFontCMap bool containsCode(unsigned int code, unsigned int byteCount) const; private: - struct Entry { constexpr explicit inline Entry() = default; - constexpr explicit inline Entry(unsigned int from, unsigned int to, unsigned int byteCount, CID cid) : from(from), to(to), byteCount(byteCount), cid(cid) { } + constexpr explicit inline Entry(unsigned int from, unsigned int to, unsigned int byteCount, CID cid) : + from(from), + to(to), + byteCount(byteCount), + cid(cid) + { + } unsigned int from = 0; unsigned int to = 0; @@ -784,7 +810,6 @@ class LOOPLIBCORESHARED_EXPORT PDFType0Font : public PDFFont m_defaultAdvance(defaultAdvance), m_advances(qMove(advances)) { - } virtual ~PDFType0Font() = default; @@ -852,4 +877,4 @@ class LOOPLIBCORESHARED_EXPORT PDFSystemFont } // namespace pdf -#endif // PDFFONT_H +#endif // PDFFONT_H diff --git a/LoopLibCore/sources/pdffontintegrity.cpp b/LoopLibCore/sources/pdffontintegrity.cpp index cb438429e..dd4b36ee3 100644 --- a/LoopLibCore/sources/pdffontintegrity.cpp +++ b/LoopLibCore/sources/pdffontintegrity.cpp @@ -42,10 +42,7 @@ quint16 readU16(const QByteArray& data, int offset) quint32 readU32(const QByteArray& data, int offset) { - return (quint32(uchar(data.at(offset))) << 24) - | (quint32(uchar(data.at(offset + 1))) << 16) - | (quint32(uchar(data.at(offset + 2))) << 8) - | quint32(uchar(data.at(offset + 3))); + return (quint32(uchar(data.at(offset))) << 24) | (quint32(uchar(data.at(offset + 1))) << 16) | (quint32(uchar(data.at(offset + 2))) << 8) | quint32(uchar(data.at(offset + 3))); } void inspectTrueType(const QByteArray& program, FontType fontType, QStringList& defects) @@ -57,8 +54,7 @@ void inspectTrueType(const QByteArray& program, FontType fontType, QStringList& } const QByteArray magic = program.left(4); - if (magic != QByteArrayLiteral("OTTO") - && magic != QByteArray::fromHex("00010000")) + if (magic != QByteArrayLiteral("OTTO") && magic != QByteArray::fromHex("00010000")) { defects.append(QStringLiteral("UnreadableTableDirectory")); return; @@ -80,8 +76,7 @@ void inspectTrueType(const QByteArray& program, FontType fontType, QStringList& tables.insert(tag); const quint32 length = readU32(program, offset + 12); const quint32 tableOffset = readU32(program, offset + 8); - if (tableOffset > quint32(program.size()) - || length > quint32(program.size()) - tableOffset) + if (tableOffset > quint32(program.size()) || length > quint32(program.size()) - tableOffset) { defects.append(QStringLiteral("TruncatedProgram")); continue; @@ -97,14 +92,13 @@ void inspectTrueType(const QByteArray& program, FontType fontType, QStringList& } } - if (fontType == FontType::TrueType && tables.contains(QByteArrayLiteral("glyf")) - && !tables.contains(QByteArrayLiteral("loca"))) + if (fontType == FontType::TrueType && tables.contains(QByteArrayLiteral("glyf")) && !tables.contains(QByteArrayLiteral("loca"))) { defects.append(QStringLiteral("GlyfLocaInconsistent")); } } -} // namespace +} // namespace PDFFontIntegrityResult inspectPDFFontIntegrity(const PDFFont& font) { @@ -131,8 +125,7 @@ PDFFontIntegrityResult inspectPDFFontIntegrity(const PDFFont& font) break; case FontType::Type1: case FontType::MMType1: - if (!program->startsWith("%!") - && !(program->size() >= 2 && uchar(program->at(0)) == 0x80 && uchar(program->at(1)) == 0x01)) + if (!program->startsWith("%!") && !(program->size() >= 2 && uchar(program->at(0)) == 0x80 && uchar(program->at(1)) == 0x01)) { result.defects.append(QStringLiteral("UnreadableType1Program")); } @@ -170,9 +163,7 @@ PDFShownGlyphDefect classifyShownGlyph(const TextSequenceItem& item) return PDFShownGlyphDefect::Notdef; } - const bool visibleCharacter = !item.character.isNull() && !item.character.isSpace() - && item.character.category() != QChar::Other_Format - && item.character.category() != QChar::Other_Control; + const bool visibleCharacter = !item.character.isNull() && !item.character.isSpace() && item.character.category() != QChar::Other_Format && item.character.category() != QChar::Other_Control; if (visibleCharacter && item.glyph->isEmpty()) { return PDFShownGlyphDefect::EmptyOutline; @@ -197,4 +188,4 @@ QString shownGlyphDefectName(PDFShownGlyphDefect defect) return QString(); } -} // namespace pdf +} // namespace pdf diff --git a/LoopLibCore/sources/pdffontintegrity.h b/LoopLibCore/sources/pdffontintegrity.h index c5d213f17..10f48e555 100644 --- a/LoopLibCore/sources/pdffontintegrity.h +++ b/LoopLibCore/sources/pdffontintegrity.h @@ -50,9 +50,9 @@ LOOPLIBCORESHARED_EXPORT PDFFontIntegrityResult inspectPDFFontIntegrity(const PD enum class PDFShownGlyphDefect { None, - Unresolved, ///< The shown code resolved to no glyph (TextSequence::unresolvedCodes). - Notdef, ///< The shown code resolved to glyph 0 (.notdef). - EmptyOutline ///< The glyph exists but draws nothing for a visible character. + Unresolved, ///< The shown code resolved to no glyph (TextSequence::unresolvedCodes). + Notdef, ///< The shown code resolved to glyph 0 (.notdef). + EmptyOutline ///< The glyph exists but draws nothing for a visible character. }; /// Classifies one resolved glyph item of a text sequence. Advances (TJ @@ -62,6 +62,6 @@ LOOPLIBCORESHARED_EXPORT PDFShownGlyphDefect classifyShownGlyph(const TextSequen LOOPLIBCORESHARED_EXPORT QString shownGlyphDefectName(PDFShownGlyphDefect defect); -} // namespace pdf +} // namespace pdf -#endif // PDFFONTINTEGRITY_H +#endif // PDFFONTINTEGRITY_H diff --git a/changes/cc-issue-114-glyph-coverage.evidence.yaml b/changes/cc-issue-114-glyph-coverage.evidence.yaml new file mode 100644 index 000000000..8a3c99bf6 --- /dev/null +++ b/changes/cc-issue-114-glyph-coverage.evidence.yaml @@ -0,0 +1,17 @@ +format_version: 1 +kind: evidence +claims: + - id: font-glyph-coverage-regression-fixture + evidence: + - unit:agent-policy:preflight + - differential:UnitTestsPreflightCorpus + - architecture:docs/generated/preflight-corpus-coverage.json + - id: shown-code-glyph-audit + evidence: + - unit:agent-policy:core + - unit:UnitTestsPreflightEngine + - architecture:scripts/generate-architecture-catalogs.py +unresolved: + - core:scripts/ci/check_independent_validation_gate.py + - Only UnitTestsPreflightEngine and UnitTestsPreflightCorpus were built and run locally; the linux-build and windows-build CI lanes run the remaining mapped targets. + - Composite CID 0 (default whitespace) and Type 3 glyph procedures are not audited; Type 1 and CFF glyph-name coverage relies on the realized font's name lookup. diff --git a/changes/cc-issue-114-glyph-coverage.md b/changes/cc-issue-114-glyph-coverage.md new file mode 100644 index 000000000..fc8666720 --- /dev/null +++ b/changes/cc-issue-114-glyph-coverage.md @@ -0,0 +1,6 @@ +# Audit glyph coverage of shown codes in embedded fonts (#114) + +Category: fixed +Audience: prepress operators and preflight maintainers relying on font-integrity +Breaking-Change: no +Summary: font-integrity now resolves every character code or CID shown in page content, Form XObjects and annotation appearance streams and reports a finding, naming page, font and missing codes, when one maps to no glyph, .notdef, or an empty outline, so an embedded subset that parses but lacks a used glyph no longer passes clean. Adds the font-glyph-missing regression fixture and lands the font-glyph-coverage backlog row. From ed9266439919a23e4709c2ff7b0a2ecf4b0d28fc Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:41:38 -0700 Subject: [PATCH 10/11] chore(preflight): declare mapped proof lanes for glyph coverage (#114) Co-Authored-By: Claude Sonnet 5.5 --- .../cc-issue-114-glyph-coverage.evidence.yaml | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/changes/cc-issue-114-glyph-coverage.evidence.yaml b/changes/cc-issue-114-glyph-coverage.evidence.yaml index 8a3c99bf6..378dffa97 100644 --- a/changes/cc-issue-114-glyph-coverage.evidence.yaml +++ b/changes/cc-issue-114-glyph-coverage.evidence.yaml @@ -3,15 +3,27 @@ kind: evidence claims: - id: font-glyph-coverage-regression-fixture evidence: - - unit:agent-policy:preflight - - differential:UnitTestsPreflightCorpus + - integration:UnitTestsPreflightCorpus + - architecture:loop-preflight/testdata/fixtures + - architecture:docs/generated/preflight-check-catalog.json + - architecture:docs/generated/architecture-catalog.json - architecture:docs/generated/preflight-corpus-coverage.json - id: shown-code-glyph-audit evidence: - unit:agent-policy:core + - unit:agent-policy:preflight + - unit:UnitTestsPreflightChecks - unit:UnitTestsPreflightEngine - - architecture:scripts/generate-architecture-catalogs.py + - unit:UnitTestsPreflightInteraction + - unit:UnitTestsPreflightVerdict + - unit:UnitTestsPreflightProfileResolver + - unit:UnitTestsProfileIdentity + - unit:UnitTestsOperatorAcceptance + - integration:UnitTestsPreflightWorkflowAcceptance + - differential:UnitTestsStandardOracle + - differential:UnitTestsConversionOracle + - security:scripts/ci/check_source_integrity.py unresolved: - core:scripts/ci/check_independent_validation_gate.py - - Only UnitTestsPreflightEngine and UnitTestsPreflightCorpus were built and run locally; the linux-build and windows-build CI lanes run the remaining mapped targets. + - Only UnitTestsPreflightEngine and UnitTestsPreflightCorpus were built and run locally; the other listed unit, integration and differential lanes are proven by the linux-build and windows-build CI lanes, not by this worktree. - Composite CID 0 (default whitespace) and Type 3 glyph procedures are not audited; Type 1 and CFF glyph-name coverage relies on the realized font's name lookup. From 53b6e02d04a446ee60c0c4793b3e198aca53f7e0 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:22:36 -0700 Subject: [PATCH 11/11] fix(preflight): skip invisible text and key glyph defects by font Shown-glyph coverage ignored the text rendering mode, so Tr 3 OCR layers drawn with glyphless fonts were reported as empty glyphs. Defects are now recorded per font object instead of per resource name, and budget exhaustion propagates to the engine instead of becoming per-page errors. Co-Authored-By: Claude Opus 5.5 --- LoopLibCore/sources/preflightengine.cpp | 49 +++++++++++++++++++------ 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index 00711741b..b2b01945f 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -5055,6 +5055,8 @@ void runEmbeddedFontsCheck(PDFDocumentSession* session, struct ShownGlyphDefects { + PDFFontPointer font; + QString fontName; QString subtype; bool composite = false; std::map> codesByDefect; @@ -5076,7 +5078,7 @@ class ShownGlyphCoverageProcessor final : public PDFPageContentProcessor } } - const std::map& defects() const { return m_defects; } + const std::vector& defects() const { return m_defects; } protected: bool isContentKindSuppressed(ContentKind kind) const override @@ -5094,7 +5096,7 @@ class ShownGlyphCoverageProcessor final : public PDFPageContentProcessor for (const CID code : textSequence.unresolvedCodes) { - record(*font, PDFShownGlyphDefect::Unresolved, code); + record(font, PDFShownGlyphDefect::Unresolved, code); } } @@ -5119,13 +5121,21 @@ class ShownGlyphCoverageProcessor final : public PDFPageContentProcessor continue; } - record(*font, defect, item.cid); + record(font, defect, item.cid); } } private: + /// Invisible (Tr 3) and clip-only (Tr 7) text shows nothing, so OCR text layers + /// drawn with glyphless fonts are not audited. PDFFontPointer embeddedShownFont() const { + const TextRenderingMode mode = getGraphicState()->getTextRenderingMode(); + if (!isTextRenderingModeFilled(mode) && !isTextRenderingModeStroked(mode)) + { + return nullptr; + } + const PDFFontPointer font = getGraphicState()->getTextFont(); if (!font || !font->getFontDescriptor() || !font->getFontDescriptor()->isEmbedded() || font->getFontType() == FontType::Type3) { @@ -5134,15 +5144,25 @@ class ShownGlyphCoverageProcessor final : public PDFPageContentProcessor return font; } - void record(const PDFFont& font, PDFShownGlyphDefect defect, CID code) + /// Entries are per font object, not per resource name: page and form resources + /// may bind the same name to different fonts. + void record(const PDFFontPointer& font, PDFShownGlyphDefect defect, CID code) { - ShownGlyphDefects& entry = m_defects[QString::fromLatin1(font.getFontId())]; - entry.subtype = QString::number(static_cast(font.getFontType())); - entry.composite = font.getFontType() == FontType::Type0; - entry.codesByDefect[shownGlyphDefectName(defect)].insert(code); + auto entry = std::find_if(m_defects.begin(), m_defects.end(), [&font](const ShownGlyphDefects& defects) + { return defects.font == font; }); + if (entry == m_defects.end()) + { + ShownGlyphDefects defects; + defects.font = font; + defects.fontName = QString::fromLatin1(font->getFontId()); + defects.subtype = QString::number(static_cast(font->getFontType())); + defects.composite = font->getFontType() == FontType::Type0; + entry = m_defects.insert(m_defects.end(), std::move(defects)); + } + entry->codesByDefect[shownGlyphDefectName(defect)].insert(code); } - std::map m_defects; + std::vector m_defects; }; // LOW CONFIDENCE NOTE: DPI calculation uses getCurrentTransformationMatrix() @@ -5303,7 +5323,7 @@ void runFontIntegrityCheck(PDFDocumentSession* session, } const int pageNumber = int(pageIndex + 1); - std::map pageDefects; + std::vector pageDefects; bool incomplete = false; QString incompleteReason; try @@ -5314,6 +5334,12 @@ void runFontIntegrityCheck(PDFDocumentSession* session, processAnnotationAppearanceStreams(document, page, pageNumber, [&](const PDFPage*, const PDFStream* formStream) { processor.processFormStream(formStream); }); pageDefects = processor.defects(); + std::stable_sort(pageDefects.begin(), pageDefects.end(), [](const ShownGlyphDefects& left, const ShownGlyphDefects& right) + { return left.fontName < right.fontName; }); + } + catch (const PDFBudgetExceededException&) + { + throw; } catch (const PDFException& exception) { @@ -5321,8 +5347,9 @@ void runFontIntegrityCheck(PDFDocumentSession* session, incompleteReason = QString::fromUtf8(exception.what()); } - for (const auto& [fontName, shown] : pageDefects) + for (const ShownGlyphDefects& shown : pageDefects) { + const QString& fontName = shown.fontName; QStringList defectNames; QStringList codeText; QJsonArray missingCodes;