From 38d7af0862b5ebd00c1983fe0f5f41a3ac2d4eb5 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:21:44 -0700 Subject: [PATCH 1/6] test(preflight): add ocmd-print-divergence regression fixture (#115) Five membership dictionaries over groups with opposite View and Print usage; hidden-layers currently passes it without a finding. Co-Authored-By: Claude Sonnet 5.5 --- .../testdata/fixtures/manifest.json | 11 +++ .../fixtures/ocmd-print-divergence.pdf | Bin 0 -> 1975 bytes .../tools/generate_ocmd_divergence_fixture.py | 90 ++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 loop-preflight/testdata/fixtures/ocmd-print-divergence.pdf create mode 100644 loop-preflight/tools/generate_ocmd_divergence_fixture.py diff --git a/loop-preflight/testdata/fixtures/manifest.json b/loop-preflight/testdata/fixtures/manifest.json index 503e700f2..86e2fa7b3 100644 --- a/loop-preflight/testdata/fixtures/manifest.json +++ b/loop-preflight/testdata/fixtures/manifest.json @@ -861,6 +861,17 @@ "source": "hand-built", "notes": "#668 corpus gap: optional-content group OFF by default with OC-marked content." }, + { + "id": "ocmd-print-divergence", + "pdf": "ocmd-print-divergence.pdf", + "profile": "testdata/profiles/test-hidden-layers.json", + "expect": { + "pass": true, + "check_ids": ["hidden-layers"] + }, + "source": "hand-built", + "notes": "#115 regression: five OCMDs (AllOn, AnyOn, AllOff, AnyOff, VE) over groups with opposite View and Print usage, on Form XObjects and marked content. Screen and print show different content; the scan passed it clean before membership dictionaries were evaluated for both usages." + }, { "id": "font-integrity-corrupt", "pdf": "font-integrity-corrupt.pdf", diff --git a/loop-preflight/testdata/fixtures/ocmd-print-divergence.pdf b/loop-preflight/testdata/fixtures/ocmd-print-divergence.pdf new file mode 100644 index 0000000000000000000000000000000000000000..cd23612d07b0fa773b325221b03ee011414ffbb9 GIT binary patch literal 1975 zcmb7F+iuf95PkPo%u6JApzHNDk*Z3K6C(tPLjn~N>Vw;Cf~&-a^%fd_k_Y|(euSB| zo%q(OIB{&v&Y8>EGn>I;629VRzA^ao_t$TOg9+KqopF8+?8D)!02ah5-ep^0i+C$? zu+U)%ED9Df`zoYL&_>Qt@H&Uj;Ob>h|6T)|=(Ta2iSfm2&x>#r2Bf%nFA78tmZ=x?9=5iulx z_bGq3@<(4H4gE*`{zXdDL((O^ zoHR_V7LuYh{Z=i8VIiZM9EgZV6KnAE@)n^VOFZx7^?UNpCan=dxh;TQ?sidHE1B4B zmt2D$2b`ekp#dcc_Cc?vG{*Bxm4^gPTs1JLk$zBP+E1l!Hg$0K(29auvBjBGg0a{p zxI5<6xUkjzjnZden8`h`aqq06jEQGF6U#BN^+`}lPfr3a(;VOkvqAlHiik@G0fuyp6l8!4)oE+F*03{9&OeT32xbJ2Y0Mx zqeyL=?;x`+zoCcHY}rP`j*XjLEaxWbwu$B3?P4vC8gBVnLzHOC=2&=4RyHN$WGAHl z=UgQ}1#$&!nW5ZCp12!tCaC{&Z{ykC+H>$T@;%S>Ezie4uYR*0kzB=6Y5uwEyT)KJ HjY8ue=PdM6 literal 0 HcmV?d00001 diff --git a/loop-preflight/tools/generate_ocmd_divergence_fixture.py b/loop-preflight/tools/generate_ocmd_divergence_fixture.py new file mode 100644 index 000000000..6beafff73 --- /dev/null +++ b/loop-preflight/tools/generate_ocmd_divergence_fixture.py @@ -0,0 +1,90 @@ +"""Generate the ocmd-print-divergence regression fixture (issue #115). + +Standard library only. Two optional-content groups carry opposite View and Print +usage states and the default configuration's /AS array applies both events: + + A visible on screen, hidden in print + B hidden on screen, visible in print + +Five membership dictionaries exercise every /P policy and a /VE expression. Two +govern Form XObjects painted with Do (/OC in the XObject dictionary), three +govern marked content (BDC). None of them is a plain group, and none of them is +hidden by the print activity the shared hidden-content scan used to evaluate in +a way it reported, so the scan passed this file clean although screen and print +show different content. + + M1 AllOn [A] Form XObject screen ON, print OFF + M2 AnyOn [B] BDC screen OFF, print ON + M3 VE [Not A] BDC screen OFF, print ON + M4 AllOff [A] Form XObject screen OFF, print ON + M5 AnyOff [A] BDC screen OFF, print ON +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUT = ROOT / "testdata" / "fixtures" + + +def build() -> bytes: + def stream(dictionary: bytes, payload: bytes) -> bytes: + return b"<< " + dictionary + b" /Length %d >>\nstream\n" % len(payload) + payload + b"\nendstream" + + def square(x: int, rgb: str) -> bytes: + return b"q " + rgb.encode() + b" rg " + b"%d 20 30 30 re f Q\n" % x + + form = b"/Type /XObject /Subtype /Form /BBox [ 0 0 200 200 ] " + content = ( + b"/Fm1 Do\n/Fm4 Do\n" + + b"/OC /M2 BDC " + square(10, "0 0.6 0") + b"EMC\n" + + b"/OC /M3 BDC " + square(50, "0 0 0.8") + b"EMC\n" + + b"/OC /M5 BDC " + square(90, "0.8 0 0") + b"EMC\n" + ) + objects: dict[int, bytes] = { + 1: b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [ 5 0 R 6 0 R ] /D << /BaseState /ON " + b"/ON [ 5 0 R 6 0 R ] /AS [ << /Event /View /Category [ /View ] /OCGs [ 5 0 R 6 0 R ] >> " + b"<< /Event /Print /Category [ /Print ] /OCGs [ 5 0 R 6 0 R ] >> ] >> >> >>", + 2: b"<< /Type /Pages /Kids [ 3 0 R ] /Count 1 >>", + 3: b"<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 200 200 ] /Contents 4 0 R /Resources << " + b"/Properties << /M2 8 0 R /M3 9 0 R /M5 11 0 R >> /XObject << /Fm1 12 0 R /Fm4 13 0 R >> >> >>", + 4: stream(b"", content), + 5: b"<< /Type /OCG /Name (ScreenOnly) /Usage << /View << /ViewState /ON >> /Print << /PrintState /OFF >> >> >>", + 6: b"<< /Type /OCG /Name (PrintOnly) /Usage << /View << /ViewState /OFF >> /Print << /PrintState /ON >> >> >>", + 7: b"<< /Type /OCMD /OCGs [ 5 0 R ] /P /AllOn >>", + 8: b"<< /Type /OCMD /OCGs [ 6 0 R ] /P /AnyOn >>", + 9: b"<< /Type /OCMD /VE [ /Not 5 0 R ] >>", + 10: b"<< /Type /OCMD /OCGs [ 5 0 R ] /P /AllOff >>", + 11: b"<< /Type /OCMD /OCGs [ 5 0 R ] /P /AnyOff >>", + 12: stream(form + b"/OC 7 0 R", square(130, "0 0 0")), + 13: stream(form + b"/OC 10 0 R", square(170, "0.5 0.5 0.5")), + } + + buffer = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n") + offsets: dict[int, int] = {} + for number in sorted(objects): + offsets[number] = len(buffer) + buffer += b"%d 0 obj\n" % number + objects[number] + b"\nendobj\n" + xref = len(buffer) + size = max(objects) + 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 /ID [ <6f636d64646976657267> <6f636d64646976657267> ] >>\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 / "ocmd-print-divergence.pdf" + target.write_bytes(build()) + print(f"wrote {target.name}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 2b52af9c8f742bb51b7648e7368e17691db03030 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:23:11 -0700 Subject: [PATCH 2/6] feat(preflight): evaluate optional-content membership for print and screen (#115) hidden-layers now evaluates every governor it meets (marked content, Form XObject /OC, annotation /OC, inline OCMDs) under the View and Print usages and reports divergence, print-hidden membership dictionaries, and unevaluable ones as incomplete. Co-Authored-By: Claude Sonnet 5.5 --- LoopLibCore/sources/preflightengine.cpp | 263 +++++++++++++++++++++++- UnitTests/tst_preflightenginetest.cpp | 114 ++++++++++ 2 files changed, 376 insertions(+), 1 deletion(-) diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index d0d7dcc24..f99feceea 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -4644,6 +4644,136 @@ struct HiddenContentFinding bool heuristic = false; }; +/// What a single optional-content governor (an OCG or a membership dictionary) +/// resolves to under the view and print usages, with the default configuration's +/// /AS events applied for each. +struct OptionalContentGovernorReport +{ + QString source; // xobject, marked-content or annotation + QString kind; // ocg or ocmd + QString policy; // AnyOn, AllOn, AnyOff, AllOff or VE; empty for a group + QStringList groups; + OCState viewState = OCState::Unknown; + OCState printState = OCState::Unknown; + bool incomplete = false; + QString reason; +}; + +QString optionalContentGroupLabel(const PDFOptionalContentProperties* properties, const PDFObjectReference& reference) +{ + if (properties && properties->hasOptionalContentGroup(reference)) + { + const QString name = properties->getOptionalContentGroup(reference).getName(); + if (!name.isEmpty()) + { + return name; + } + } + return QStringLiteral("object %1 %2").arg(reference.objectNumber).arg(reference.generation); +} + +OptionalContentGovernorReport evaluateOptionalContentGovernor(const PDFDocument* document, + const PDFObject& governor, + const PDFOptionalContentActivity& viewActivity, + const PDFOptionalContentActivity& printActivity) +{ + OptionalContentGovernorReport report; + const PDFOptionalContentProperties* properties = printActivity.getProperties(); + + if (governor.isReference() && properties && properties->hasOptionalContentGroup(governor.getReference())) + { + const PDFObjectReference reference = governor.getReference(); + report.kind = QStringLiteral("ocg"); + report.groups = QStringList{ optionalContentGroupLabel(properties, reference) }; + report.viewState = viewActivity.getState(reference); + report.printState = printActivity.getState(reference); + report.incomplete = report.viewState == OCState::Unknown || report.printState == OCState::Unknown; + if (report.incomplete) + { + report.reason = QStringLiteral("group state is unknown"); + } + return report; + } + + report.kind = QStringLiteral("ocmd"); + const PDFObject resolved = document->getObject(governor); + if (!resolved.isDictionary()) + { + report.incomplete = true; + report.reason = QStringLiteral("optional-content governor is not a group or membership dictionary"); + return report; + } + + const PDFDictionary* dictionary = resolved.getDictionary(); + const bool hasExpression = dictionary->hasKey("VE"); + const PDFObject policyObject = document->getObject(dictionary->get("P")); + report.policy = hasExpression ? QStringLiteral("VE") + : (policyObject.isName() ? QString::fromLatin1(policyObject.getString()) + : QStringLiteral("AnyOn")); + + // Collect the groups the membership names, through /OCGs or the expression tree. + std::function collect = [&](const PDFObject& node, int depth) + { + if (depth > 32) + { + return; + } + if (node.isReference() && properties && properties->hasOptionalContentGroup(node.getReference())) + { + const QString label = optionalContentGroupLabel(properties, node.getReference()); + if (!report.groups.contains(label)) + { + report.groups.append(label); + } + return; + } + const PDFObject value = document->getObject(node); + if (value.isArray()) + { + for (size_t index = 0; index < value.getArray()->getCount(); ++index) + { + collect(value.getArray()->getItem(index), depth + 1); + } + } + }; + collect(dictionary->get(hasExpression ? "VE" : "OCGs"), 0); + + PDFOptionalContentMembershipObject membership; + try + { + membership = PDFOptionalContentMembershipObject::create(document, governor); + } + catch (const PDFException& exception) + { + report.incomplete = true; + report.reason = QString::fromUtf8(exception.what()); + return report; + } + + if (!membership.isValid()) + { + // A membership dictionary naming no groups and no expression shows its content. + if (!hasExpression && report.groups.isEmpty() && !dictionary->hasKey("OCGs")) + { + report.viewState = OCState::ON; + report.printState = OCState::ON; + return report; + } + report.incomplete = true; + report.reason = QStringLiteral("membership dictionary could not be parsed"); + return report; + } + + report.viewState = membership.evaluate(&viewActivity); + report.printState = membership.evaluate(&printActivity); + report.incomplete = report.viewState == OCState::Unknown || report.printState == OCState::Unknown; + if (report.incomplete) + { + report.reason = QStringLiteral("membership references a group with no known state"); + } + return report; +} + class HiddenContentProcessor final : public PDFPageContentProcessor { public: @@ -4652,6 +4782,7 @@ class HiddenContentProcessor final : public PDFPageContentProcessor const PDFFontCache* fontCache, const PDFCMS* cms, const PDFOptionalContentActivity* optionalContentActivity, + const PDFOptionalContentActivity* viewActivity, const PDFMeshQualitySettings& meshQualitySettings, PDFProcessingBudget* budget, qreal offPageAllowance) : @@ -4662,7 +4793,9 @@ class HiddenContentProcessor final : public PDFPageContentProcessor optionalContentActivity, QTransform(), meshQualitySettings, - budget) + budget), + m_printActivity(optionalContentActivity), + m_viewActivity(viewActivity) { if (page) { @@ -4676,10 +4809,46 @@ class HiddenContentProcessor final : public PDFPageContentProcessor } const QList& findings() const { return m_findings; } + const QList& governors() const { return m_governors; } + + /// Records an annotation's /OC entry, which the content stream never reaches. + void inspectAnnotationGovernor(const PDFObject& governor, const QString& key) + { + recordGovernor(governor, QStringLiteral("annotation"), key); + } protected: + bool isContentSuppressedByOC(PDFObjectReference ocgOrOcmd) override + { + if (ocgOrOcmd.isValid()) + { + recordGovernor(PDFObject::createReference(ocgOrOcmd), + m_currentOperator == Operator::PaintXObject ? QStringLiteral("xobject") + : QStringLiteral("marked-content"), + QStringLiteral("%1 %2").arg(ocgOrOcmd.objectNumber).arg(ocgOrOcmd.generation)); + } + return PDFPageContentProcessor::isContentSuppressedByOC(ocgOrOcmd); + } + void performMarkedContentBegin(const QByteArray& tag, const PDFObject& properties) override { + if (tag == "OC") + { + // A direct membership dictionary is never a reference, so the content + // processor cannot evaluate it; do it here instead of treating it as visible. + PDFObject governor = properties; + QString key = QStringLiteral("inline"); + if (properties.isName() && getPropertiesDictionary()) + { + governor = getPropertiesDictionary()->get(properties.getString()); + key = QStringLiteral("inline:%1").arg(QString::fromLatin1(properties.getString())); + } + if (governor.isDictionary()) + { + recordGovernor(governor, QStringLiteral("marked-content"), key); + } + } + if (tag != "OC" || !isContentSuppressed()) { return; @@ -4714,6 +4883,11 @@ class HiddenContentProcessor final : public PDFPageContentProcessor ProcessOrder processOrder, const QByteArray& operatorAsText) override { + if (processOrder == ProcessOrder::BeforeOperation) + { + m_currentOperator = currentOperator; + } + if (processOrder != ProcessOrder::BeforeOperation || getGraphicState()->getTextRenderingMode() != TextRenderingMode::Invisible) { return; @@ -4778,10 +4952,27 @@ class HiddenContentProcessor final : public PDFPageContentProcessor } private: + void recordGovernor(const PDFObject& governor, const QString& source, const QString& key) + { + if (!m_viewActivity || !m_printActivity || !m_recordedGovernors.insert(source + QLatin1Char('|') + key).second) + { + return; + } + + OptionalContentGovernorReport report = evaluateOptionalContentGovernor(getDocument(), governor, *m_viewActivity, *m_printActivity); + report.source = source; + m_governors.append(report); + } + QRectF m_toleratedBounds; QList m_paintedBounds; QStringList m_hiddenLayers; QList m_findings; + QList m_governors; + std::set m_recordedGovernors; + const PDFOptionalContentActivity* m_printActivity = nullptr; + const PDFOptionalContentActivity* m_viewActivity = nullptr; + Operator m_currentOperator = Operator::Invalid; }; void runHiddenContentCheck(PDFDocumentSession* session, @@ -4796,6 +4987,7 @@ void runHiddenContentCheck(PDFDocumentSession* session, PDFDocument* document = session->getDocument(); PDFOptionalContentActivity printActivity(document, OCUsage::Print, nullptr); + PDFOptionalContentActivity viewActivity(document, OCUsage::View, nullptr); const PDFCatalog* catalog = document->getCatalog(); const PDFInteger pageCount = catalog->getPageCount(); PDFMeshQualitySettings meshQualitySettings; @@ -4813,11 +5005,80 @@ void runHiddenContentCheck(PDFDocumentSession* session, session->getFontCache(), session->getCMS(), &printActivity, + &viewActivity, meshQualitySettings, session->getProcessingBudget(), check.amountPt); processor.processContents(); + if (check.id == QStringLiteral("hidden-layers")) + { + for (const PDFObjectReference& annotationReference : page->getAnnotations()) + { + const PDFObject annotation = document->getObjectByReference(annotationReference); + if (annotation.isDictionary() && annotation.getDictionary()->hasKey("OC")) + { + processor.inspectAnnotationGovernor(annotation.getDictionary()->get("OC"), + QStringLiteral("%1 %2").arg(annotationReference.objectNumber).arg(annotationReference.generation)); + } + } + + for (const OptionalContentGovernorReport& governor : processor.governors()) + { + const bool divergent = !governor.incomplete && governor.viewState != governor.printState; + const bool hiddenInPrint = !governor.incomplete && governor.printState == OCState::OFF; + // A plain group hidden in print from marked content is already reported by name. + if (!governor.incomplete && !divergent && !(hiddenInPrint && (governor.kind == QStringLiteral("ocmd") || governor.source != QStringLiteral("marked-content")))) + { + continue; + } + + const auto stateName = [](OCState state) + { + return state == OCState::ON ? QStringLiteral("on") + : (state == OCState::OFF ? QStringLiteral("off") : QStringLiteral("unknown")); + }; + const QString subject = governor.kind == QStringLiteral("ocg") ? QStringLiteral("group") : QStringLiteral("membership dictionary"); + + PreflightFinding finding; + finding.scope = QString::fromLatin1(PREFLIGHT_FINDING_SCOPE_OBJECT); + finding.page = int(pageIndex + 1); + finding.type = QStringLiteral("hidden-layers"); + finding.checkId = check.id; + finding.severity = governor.incomplete ? QStringLiteral("error") : check.severity; + if (governor.incomplete) + { + finding.message = PDFTranslationContext::tr("Optional-content %1 on page %2 could not be evaluated for print: %3.") + .arg(subject) + .arg(pageIndex + 1) + .arg(governor.reason); + } + else if (divergent) + { + finding.message = governor.printState == OCState::OFF + ? PDFTranslationContext::tr("Optional-content %1 on page %2 is visible on screen but hidden in print.").arg(subject).arg(pageIndex + 1) + : PDFTranslationContext::tr("Optional-content %1 on page %2 is hidden on screen but printed.").arg(subject).arg(pageIndex + 1); + } + else + { + finding.message = PDFTranslationContext::tr("Optional-content %1 on page %2 is hidden in print.").arg(subject).arg(pageIndex + 1); + } + finding.evidence.insert(QStringLiteral("confidence"), QStringLiteral("exact")); + finding.evidence.insert(QStringLiteral("governor"), governor.kind); + finding.evidence.insert(QStringLiteral("governor_source"), governor.source); + if (!governor.policy.isEmpty()) + { + finding.evidence.insert(QStringLiteral("policy"), governor.policy); + } + finding.evidence.insert(QStringLiteral("ocg_names"), QJsonArray::fromStringList(governor.groups)); + finding.evidence.insert(QStringLiteral("view_state"), stateName(governor.viewState)); + finding.evidence.insert(QStringLiteral("print_state"), stateName(governor.printState)); + finding.evidence.insert(QStringLiteral("divergence"), divergent); + finding.evidence.insert(QStringLiteral("inspection_complete"), !governor.incomplete); + pushPreflightFinding(finding, finding.severity, errors, warnings); + } + } + for (const HiddenContentFinding& source : processor.findings()) { if (source.type != check.id) diff --git a/UnitTests/tst_preflightenginetest.cpp b/UnitTests/tst_preflightenginetest.cpp index a9fcd0294..7e8ae66eb 100644 --- a/UnitTests/tst_preflightenginetest.cpp +++ b/UnitTests/tst_preflightenginetest.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include #include @@ -83,6 +84,8 @@ private slots: void fontIntegrity_checkIsRegistered(); void run_fontIntegrity_keepsValidEmbeddedFixtureClean(); void hiddenContent_checksAreRegistered(); + void run_hiddenLayers_reportsOcmdScreenPrintDivergence(); + void run_hiddenLayers_reportsUnevaluableMembershipAsIncomplete(); void run_offPageContent_detectsMarksOutsideToleratedBox(); void run_includesProfileFixups(); void run_synthesizesAddBleedWhenGapAndNoProfileFixup(); @@ -910,6 +913,117 @@ void PreflightEngineTest::hiddenContent_checksAreRegistered() QVERIFY(engine.hasCheck(QStringLiteral("obscured-content"))); } +void PreflightEngineTest::run_hiddenLayers_reportsOcmdScreenPrintDivergence() +{ + const QString fixturePath = QStringLiteral(LOOP_PREFLIGHT_SOURCE_DIR "/testdata/fixtures/ocmd-print-divergence.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("Hidden layers") }, + { QStringLiteral("checks"), QJsonArray{ + QJsonObject{ { QStringLiteral("id"), QStringLiteral("hidden-layers") }, + { QStringLiteral("severity"), QStringLiteral("warning") } } } } + }; + + const pdf::PreflightResult result = engine.run(profile); + QVERIFY(result.errors.isEmpty()); + QCOMPARE(result.warnings.size(), 5); + + QMap directions; // policy -> "view/print" + for (const pdf::PreflightFinding& finding : result.warnings) + { + QCOMPARE(finding.checkId, QStringLiteral("hidden-layers")); + QCOMPARE(finding.page, 1); + QCOMPARE(finding.evidence.value(QStringLiteral("governor")).toString(), QStringLiteral("ocmd")); + QCOMPARE(finding.evidence.value(QStringLiteral("divergence")).toBool(), true); + QVERIFY(!finding.evidence.value(QStringLiteral("ocg_names")).toArray().isEmpty()); + directions.insert(finding.evidence.value(QStringLiteral("policy")).toString(), + finding.evidence.value(QStringLiteral("view_state")).toString() + QLatin1Char('/') + finding.evidence.value(QStringLiteral("print_state")).toString()); + } + + QCOMPARE(directions.value(QStringLiteral("AllOn")), QStringLiteral("on/off")); + QCOMPARE(directions.value(QStringLiteral("AnyOn")), QStringLiteral("off/on")); + QCOMPARE(directions.value(QStringLiteral("AllOff")), QStringLiteral("off/on")); + QCOMPARE(directions.value(QStringLiteral("AnyOff")), QStringLiteral("off/on")); + QCOMPARE(directions.value(QStringLiteral("VE")), QStringLiteral("off/on")); +} + +void PreflightEngineTest::run_hiddenLayers_reportsUnevaluableMembershipAsIncomplete() +{ + // The visibility expression names an operator the specification does not define. + const QList objects{ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [ 5 0 R ] /D << /BaseState /ON >> >> >>", + "<< /Type /Pages /Kids [ 3 0 R ] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 100 100 ] /Contents 4 0 R /Resources << /Properties << /M 6 0 R >> >> >>", + "<< /Length 41 >> + stream / + OC / M BDC 0 0 0 rg 10 10 20 20 re f EMC + + endstream ", + "<< /Type /OCG /Name (Layer) >>", + "<< /Type /OCMD /VE [ /Xor 5 0 R 5 0 R ] >>" + }; + QByteArray bytes("%PDF-1.7 +"); + QList offsets; + for (int index = 0; index < objects.size(); ++index) + { + offsets.append(bytes.size()); + bytes += QByteArray::number(index + 1) + " 0 obj + " + objects.at(index) + " endobj + "; + } + const int xref = bytes.size(); + bytes += "xref +0 " + QByteArray::number(objects.size() + 1) + " +0000000000 65535 f +"; + for (int offset : offsets) + { + bytes += QByteArray::number(offset).rightJustified(10, '0') + " 00000 n +"; + } + bytes += "trailer +<< /Size " + QByteArray::number(objects.size() + 1) + " /Root 1 0 R >> +startxref +" + + QByteArray::number(xref) + " +%%EOF +"; + + QTemporaryFile file; + QVERIFY(file.open()); + file.write(bytes); + file.flush(); + + pdf::PDFDocumentReader reader(nullptr, [](bool*) + { + return QString(); }, true, false); + pdf::PDFDocument document = reader.readFromFile(file.fileName()); + QCOMPARE(reader.getReadingResult(), pdf::PDFDocumentReader::Result::OK); + + pdf::PDFDocumentSession session(&document); + pdf::PreflightEngine engine(&session); + const QJsonObject profile{ + { QStringLiteral("name"), QStringLiteral("Hidden layers") }, + { QStringLiteral("checks"), QJsonArray{ + QJsonObject{ { QStringLiteral("id"), QStringLiteral("hidden-layers") }, + { QStringLiteral("severity"), QStringLiteral("warning") } } } } + }; + + const pdf::PreflightResult result = engine.run(profile); + QVERIFY(!result.pass); + QCOMPARE(result.errors.size(), 1); + QCOMPARE(result.errors.first().evidence.value(QStringLiteral("inspection_complete")).toBool(), false); +} + void PreflightEngineTest::run_offPageContent_detectsMarksOutsideToleratedBox() { pdf::PDFDocumentBuilder builder; From 5348b6eee8ae7a38b250e34276857a8c4536443a Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:27:31 -0700 Subject: [PATCH 3/6] test(preflight): repair string literals in unevaluable-membership test (#115) Co-Authored-By: Claude Sonnet 5.5 --- UnitTests/tst_preflightenginetest.cpp | 40 +++++++++------------------ 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/UnitTests/tst_preflightenginetest.cpp b/UnitTests/tst_preflightenginetest.cpp index 7e8ae66eb..eb916556e 100644 --- a/UnitTests/tst_preflightenginetest.cpp +++ b/UnitTests/tst_preflightenginetest.cpp @@ -958,45 +958,32 @@ void PreflightEngineTest::run_hiddenLayers_reportsOcmdScreenPrintDivergence() void PreflightEngineTest::run_hiddenLayers_reportsUnevaluableMembershipAsIncomplete() { // The visibility expression names an operator the specification does not define. + const QByteArray content("/OC /M BDC 0 0 0 rg 10 10 20 20 re f EMC\n"); const QList objects{ "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [ 5 0 R ] /D << /BaseState /ON >> >> >>", "<< /Type /Pages /Kids [ 3 0 R ] /Count 1 >>", - "<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 100 100 ] /Contents 4 0 R /Resources << /Properties << /M 6 0 R >> >> >>", - "<< /Length 41 >> - stream / - OC / M BDC 0 0 0 rg 10 10 20 20 re f EMC - - endstream ", - "<< /Type /OCG /Name (Layer) >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 100 100 ] /Contents 4 0 R " + "/Resources << /Properties << /M 6 0 R >> >> >>", + "<< /Length " + QByteArray::number(content.size()) + " >>\nstream\n" + content + "endstream", + "<< /Type /OCG /Name (Layer) >>", "<< /Type /OCMD /VE [ /Xor 5 0 R 5 0 R ] >>" }; - QByteArray bytes("%PDF-1.7 -"); + + QByteArray bytes("%PDF-1.7\n"); QList offsets; for (int index = 0; index < objects.size(); ++index) { offsets.append(bytes.size()); - bytes += QByteArray::number(index + 1) + " 0 obj - " + objects.at(index) + " endobj - "; + bytes += QByteArray::number(index + 1) + " 0 obj\n" + objects.at(index) + "\nendobj\n"; } const int xref = bytes.size(); - bytes += "xref -0 " + QByteArray::number(objects.size() + 1) + " -0000000000 65535 f -"; + bytes += "xref\n0 " + QByteArray::number(objects.size() + 1) + "\n0000000000 65535 f \n"; for (int offset : offsets) { - bytes += QByteArray::number(offset).rightJustified(10, '0') + " 00000 n -"; + bytes += QByteArray::number(offset).rightJustified(10, '0') + " 00000 n \n"; } - bytes += "trailer -<< /Size " + QByteArray::number(objects.size() + 1) + " /Root 1 0 R >> -startxref -" - + QByteArray::number(xref) + " -%%EOF -"; + bytes += "trailer\n<< /Size " + QByteArray::number(objects.size() + 1) + " /Root 1 0 R >>\nstartxref\n" + + QByteArray::number(xref) + "\n%%EOF\n"; QTemporaryFile file; QVERIFY(file.open()); @@ -1004,8 +991,7 @@ startxref file.flush(); pdf::PDFDocumentReader reader(nullptr, [](bool*) - { - return QString(); }, true, false); + { return QString(); }, true, false); pdf::PDFDocument document = reader.readFromFile(file.fileName()); QCOMPARE(reader.getReadingResult(), pdf::PDFDocumentReader::Result::OK); From 8b6b5ba269e9177511fa1aae4a8c0bf37d51b60a Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:30:04 -0700 Subject: [PATCH 4/6] docs(preflight): land hidden-layers-ocmd with snapshot, catalogs and evidence (#115) Co-Authored-By: Claude Sonnet 5.5 --- ...cc-issue-115-ocmd-membership.evidence.yaml | 0 changes/cc-issue-115-ocmd-membership.md | 6 + docs/generated/preflight-check-catalog.json | 16 ++- docs/generated/preflight-corpus-coverage.json | 5 +- .../generated/preflight-coverage-backlog.json | 6 +- docs/preflight-check-catalog-overlay.json | 22 ++- loop-preflight/README.md | 9 ++ .../snapshots/ocmd-print-divergence.json | 133 ++++++++++++++++++ 8 files changed, 181 insertions(+), 16 deletions(-) create mode 100644 changes/cc-issue-115-ocmd-membership.evidence.yaml create mode 100644 changes/cc-issue-115-ocmd-membership.md create mode 100644 loop-preflight/testdata/snapshots/ocmd-print-divergence.json diff --git a/changes/cc-issue-115-ocmd-membership.evidence.yaml b/changes/cc-issue-115-ocmd-membership.evidence.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/changes/cc-issue-115-ocmd-membership.md b/changes/cc-issue-115-ocmd-membership.md new file mode 100644 index 000000000..82c236c50 --- /dev/null +++ b/changes/cc-issue-115-ocmd-membership.md @@ -0,0 +1,6 @@ +# Evaluate optional-content membership for hidden content (#115) + +Category: fixed +Audience: prepress operators and preflight maintainers relying on hidden-layers +Breaking-Change: no +Summary: hidden-layers now evaluates optional-content membership dictionaries (every /P policy and /VE expression) on marked content, Form XObject /OC entries and annotation /OC entries under both the View and Print usages with the default configuration's /AS events, and reports content that is hidden in print or differs between screen and print; a governor it cannot evaluate is reported incomplete instead of clean. Adds the ocmd-print-divergence regression fixture and lands the hidden-layers-ocmd backlog row. diff --git a/docs/generated/preflight-check-catalog.json b/docs/generated/preflight-check-catalog.json index 700197bf2..51927e47a 100644 --- a/docs/generated/preflight-check-catalog.json +++ b/docs/generated/preflight-check-catalog.json @@ -405,14 +405,22 @@ "message", "check_id", "evidence.confidence", - "evidence.ocg_name" + "evidence.ocg_name", + "evidence.governor", + "evidence.governor_source", + "evidence.policy", + "evidence.ocg_names", + "evidence.view_state", + "evidence.print_state", + "evidence.divergence", + "evidence.inspection_complete" ], "families": [ "packaging" ], "fixups": [], - "limitations": "Does not enumerate every OCMD combination.", - "measures": "Optional content groups that hide print content.", + "limitations": "Evaluates the default configuration under the View and Print usages with its /AS events; alternate configurations and content an /OC entry governs on Form XObjects or annotations that are never painted are not evaluated. A governor that cannot be evaluated is reported incomplete.", + "measures": "Optional content groups and membership dictionaries (/P AnyOn, AllOn, AnyOff, AllOff and /VE) that hide print content, and content whose visibility differs between screen and print.", "parameters": [ { "default": 9.0, @@ -424,7 +432,7 @@ ], "severity": [ { - "condition": "an optional-content group referenced by print content is not printable by default. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings.", + "condition": "an optional-content group referenced by print content is not printable by default, or a membership dictionary governing marked content, a Form XObject or an annotation is hidden in print or differs between screen and print; an optional-content governor that cannot be evaluated is forced to error with inspection_complete false. The profile's check severity (error|warning|info, default error) replaces the others and warning/info route the finding to warnings.", "finding_type": "hidden-layers", "severity": "error" } diff --git a/docs/generated/preflight-corpus-coverage.json b/docs/generated/preflight-corpus-coverage.json index c261ad5f0..2920571b0 100644 --- a/docs/generated/preflight-corpus-coverage.json +++ b/docs/generated/preflight-corpus-coverage.json @@ -129,7 +129,8 @@ "corpus_gap": null, "coverage": "partial", "finding_fixtures": [ - "hidden-layers" + "hidden-layers", + "ocmd-print-divergence" ], "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..1a4ce3c83 100644 --- a/docs/generated/preflight-coverage-backlog.json +++ b/docs/generated/preflight-coverage-backlog.json @@ -311,15 +311,15 @@ "state": "landed" }, { - "closed_by": "#115", + "closed_by": "hidden-layers", "deferral": null, "families": [ "packaging" ], - "gap": "the shared hidden-content scan does not enumerate every optional-content membership-dictionary combination, so an unprintable configuration passes clean", + "gap": "membership dictionaries (every /P policy and /VE expressions) on marked content, Form XObjects and annotations were not evaluated for print, so an unprintable configuration passed clean; hidden-layers now evaluates them under the View and Print usages and reports screen-versus-print divergence (filed as #115)", "id": "hidden-layers-ocmd", "priority": "P2", - "state": "open" + "state": "landed" }, { "closed_by": "#116", diff --git a/docs/preflight-check-catalog-overlay.json b/docs/preflight-check-catalog-overlay.json index 916f07a9d..147cbfd86 100644 --- a/docs/preflight-check-catalog-overlay.json +++ b/docs/preflight-check-catalog-overlay.json @@ -549,8 +549,8 @@ "fixups": [] }, "hidden-layers": { - "measures": "Optional content groups that hide print content.", - "limitations": "Does not enumerate every OCMD combination.", + "measures": "Optional content groups and membership dictionaries (/P AnyOn, AllOn, AnyOff, AllOff and /VE) that hide print content, and content whose visibility differs between screen and print.", + "limitations": "Evaluates the default configuration under the View and Print usages with its /AS events; alternate configurations and content an /OC entry governs on Form XObjects or annotations that are never painted are not evaluated. A governor that cannot be evaluated is reported incomplete.", "coverage": "partial", "families": [ "packaging" @@ -568,7 +568,7 @@ { "finding_type": "hidden-layers", "severity": "error", - "condition": "an optional-content group referenced by print content is not printable by default. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings." + "condition": "an optional-content group referenced by print content is not printable by default, or a membership dictionary governing marked content, a Form XObject or an annotation is hidden in print or differs between screen and print; an optional-content governor that cannot be evaluated is forced to error with inspection_complete false. The profile's check severity (error|warning|info, default error) replaces the others and warning/info route the finding to warnings." } ], "evidence": [ @@ -578,7 +578,15 @@ "message", "check_id", "evidence.confidence", - "evidence.ocg_name" + "evidence.ocg_name", + "evidence.governor", + "evidence.governor_source", + "evidence.policy", + "evidence.ocg_names", + "evidence.view_state", + "evidence.print_state", + "evidence.divergence", + "evidence.inspection_complete" ], "fixups": [] }, @@ -1494,12 +1502,12 @@ { "id": "hidden-layers-ocmd", "priority": "P2", - "gap": "the shared hidden-content scan does not enumerate every optional-content membership-dictionary combination, so an unprintable configuration passes clean", + "gap": "membership dictionaries (every /P policy and /VE expressions) on marked content, Form XObjects and annotations were not evaluated for print, so an unprintable configuration passed clean; hidden-layers now evaluates them under the View and Print usages and reports screen-versus-print divergence (filed as #115)", "families": [ "packaging" ], - "state": "open", - "closed_by": "#115", + "state": "landed", + "closed_by": "hidden-layers", "deferral": null }, { diff --git a/loop-preflight/README.md b/loop-preflight/README.md index 961836f20..3a1712603 100644 --- a/loop-preflight/README.md +++ b/loop-preflight/README.md @@ -94,6 +94,15 @@ is observed; it does not claim full compositing equivalence. Off-page content is tolerated inside the page BleedBox, or inside the configured allowance when no BleedBox exists. These checks do not register corrective fixups. +`hidden-layers` evaluates every optional-content governor it meets under both +the View and the Print usage, with the default configuration's `/AS` events +applied: groups and membership dictionaries (`/P` AnyOn, AllOn, AnyOff, AllOff +and `/VE` expressions) on marked content, on Form XObject `/OC` entries and on +annotation `/OC` entries. It reports membership hidden in print and content +that differs between screen and print, in either direction, with +`evidence.view_state`, `evidence.print_state` and `evidence.policy`. A governor +that cannot be evaluated is reported with `inspection_complete: false`. + The `transparency-risk` check has no additional parameters; it observes transparency groups, blend modes, and blend-space crossings. diff --git a/loop-preflight/testdata/snapshots/ocmd-print-divergence.json b/loop-preflight/testdata/snapshots/ocmd-print-divergence.json new file mode 100644 index 000000000..267f9e538 --- /dev/null +++ b/loop-preflight/testdata/snapshots/ocmd-print-divergence.json @@ -0,0 +1,133 @@ +{ + "checks": [ + { + "id": "hidden-layers", + "status": "warning" + } + ], + "errors": [ + ], + "fixups_available": [ + ], + "inspection_complete": true, + "pass": true, + "profile": "Loop Test - Hidden Layers", + "schema_kind": "preflight-report", + "schema_version": 4, + "verdict": { + "blocking_finding_ids": [ + ], + "reason": "Inspection completed with no blocking findings.", + "reason_code": "no-blocking-findings", + "state": "pass", + "waived_finding_ids": [ + ] + }, + "warnings": [ + { + "check_id": "hidden-layers", + "evidence": { + "confidence": "exact", + "divergence": true, + "governor": "ocmd", + "governor_source": "xobject", + "inspection_complete": true, + "ocg_names": [ + "ScreenOnly" + ], + "policy": "AllOn", + "print_state": "off", + "view_state": "on" + }, + "message": "Optional-content membership dictionary on page 1 is visible on screen but hidden in print.", + "page": 1, + "scope": "object", + "severity": "warning", + "type": "hidden-layers" + }, + { + "check_id": "hidden-layers", + "evidence": { + "confidence": "exact", + "divergence": true, + "governor": "ocmd", + "governor_source": "xobject", + "inspection_complete": true, + "ocg_names": [ + "ScreenOnly" + ], + "policy": "AllOff", + "print_state": "on", + "view_state": "off" + }, + "message": "Optional-content membership dictionary on page 1 is hidden on screen but printed.", + "page": 1, + "scope": "object", + "severity": "warning", + "type": "hidden-layers" + }, + { + "check_id": "hidden-layers", + "evidence": { + "confidence": "exact", + "divergence": true, + "governor": "ocmd", + "governor_source": "marked-content", + "inspection_complete": true, + "ocg_names": [ + "PrintOnly" + ], + "policy": "AnyOn", + "print_state": "on", + "view_state": "off" + }, + "message": "Optional-content membership dictionary on page 1 is hidden on screen but printed.", + "page": 1, + "scope": "object", + "severity": "warning", + "type": "hidden-layers" + }, + { + "check_id": "hidden-layers", + "evidence": { + "confidence": "exact", + "divergence": true, + "governor": "ocmd", + "governor_source": "marked-content", + "inspection_complete": true, + "ocg_names": [ + "ScreenOnly" + ], + "policy": "VE", + "print_state": "on", + "view_state": "off" + }, + "message": "Optional-content membership dictionary on page 1 is hidden on screen but printed.", + "page": 1, + "scope": "object", + "severity": "warning", + "type": "hidden-layers" + }, + { + "check_id": "hidden-layers", + "evidence": { + "confidence": "exact", + "divergence": true, + "governor": "ocmd", + "governor_source": "marked-content", + "inspection_complete": true, + "ocg_names": [ + "ScreenOnly" + ], + "policy": "AnyOff", + "print_state": "on", + "view_state": "off" + }, + "message": "Optional-content membership dictionary on page 1 is hidden on screen but printed.", + "page": 1, + "scope": "object", + "severity": "warning", + "type": "hidden-layers" + } + ] +} From 2478cd80c09e63dc8ea693089e4a79dab138aba9 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:30:17 -0700 Subject: [PATCH 5/6] docs(preflight): land hidden-layers-ocmd with snapshot, catalogs and evidence (#115) Co-Authored-By: Claude Sonnet 5.5 --- ...cc-issue-115-ocmd-membership.evidence.yaml | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/changes/cc-issue-115-ocmd-membership.evidence.yaml b/changes/cc-issue-115-ocmd-membership.evidence.yaml index e69de29bb..210a7ae31 100644 --- a/changes/cc-issue-115-ocmd-membership.evidence.yaml +++ b/changes/cc-issue-115-ocmd-membership.evidence.yaml @@ -0,0 +1,29 @@ +format_version: 1 +kind: evidence +claims: + - id: ocmd-print-divergence-regression-fixture + evidence: + - 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: ocmd-membership-print-evaluation + evidence: + - unit:agent-policy:core + - unit:agent-policy:preflight + - unit:UnitTestsPreflightChecks + - unit:UnitTestsPreflightEngine + - 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 other listed unit, integration and differential lanes are proven by the linux-build and windows-build CI lanes, not by this worktree. + - Alternate optional-content configurations are not evaluated, and an /OC entry on a Form XObject or annotation that is never painted is not inspected. From 90b32c903af7ebe8fd297a6aba45429033e95ae1 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:22:39 -0700 Subject: [PATCH 6/6] fix(preflight): report each optional-content governor once A marked-content OCMD hidden in print was reported both as an unnamed group and by the governor report; a plain group diverging between view and print was likewise reported twice. Co-Authored-By: Claude Opus 5.5 --- LoopLibCore/sources/preflightengine.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index f99feceea..c9a1db02d 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -4864,7 +4864,12 @@ class HiddenContentProcessor final : public PDFPageContentProcessor reference = property.getReference(); } } - if (reference.isValid() && getDocument()->getCatalog()->getOptionalContentProperties()->hasOptionalContentGroup(reference)) + if (reference.isValid() && !getDocument()->getCatalog()->getOptionalContentProperties()->hasOptionalContentGroup(reference)) + { + // A membership dictionary is reported with its evaluation by the governor report. + return; + } + if (reference.isValid()) { name = getDocument()->getCatalog()->getOptionalContentProperties()->getOptionalContentGroup(reference).getName(); if (name.isEmpty()) @@ -5028,7 +5033,8 @@ void runHiddenContentCheck(PDFDocumentSession* session, const bool divergent = !governor.incomplete && governor.viewState != governor.printState; const bool hiddenInPrint = !governor.incomplete && governor.printState == OCState::OFF; // A plain group hidden in print from marked content is already reported by name. - if (!governor.incomplete && !divergent && !(hiddenInPrint && (governor.kind == QStringLiteral("ocmd") || governor.source != QStringLiteral("marked-content")))) + const bool reportedByName = hiddenInPrint && governor.kind == QStringLiteral("ocg") && governor.source == QStringLiteral("marked-content"); + if (!governor.incomplete && (reportedByName || (!divergent && !hiddenInPrint))) { continue; }