From e9bec0384d03231f034de53b4f57915895c0ac78 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:38:34 -0700 Subject: [PATCH 1/5] test(preflight): add clipped off-page and obscured-content regression fixtures (#118) Co-Authored-By: Claude Sonnet 5.5 --- .../testdata/fixtures/manifest.json | 22 ++++++ .../fixtures/obscured-content-clipped.pdf | Bin 0 -> 619 bytes .../fixtures/off-page-content-clipped.pdf | Bin 0 -> 531 bytes .../generate_clip_aware_geometry_fixtures.py | 70 ++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 loop-preflight/testdata/fixtures/obscured-content-clipped.pdf create mode 100644 loop-preflight/testdata/fixtures/off-page-content-clipped.pdf create mode 100644 loop-preflight/tools/generate_clip_aware_geometry_fixtures.py diff --git a/loop-preflight/testdata/fixtures/manifest.json b/loop-preflight/testdata/fixtures/manifest.json index 503e700f2..f91fa8db2 100644 --- a/loop-preflight/testdata/fixtures/manifest.json +++ b/loop-preflight/testdata/fixtures/manifest.json @@ -850,6 +850,28 @@ "source": "hand-built", "notes": "#668 corpus gap: small opaque mark fully covered by later opaque paint." }, + { + "id": "off-page-content-clipped", + "pdf": "off-page-content-clipped.pdf", + "profile": "testdata/profiles/test-off-page-content.json", + "expect": { + "pass": true, + "check_ids": ["off-page-content"] + }, + "source": "generated", + "notes": "#118 regression: a 400 pt square spanning the page, painted under a clip wholly outside the page. Its raw bounds cover the page, so the bounds-only test passed it clean." + }, + { + "id": "obscured-content-clipped", + "pdf": "obscured-content-clipped.pdf", + "profile": "testdata/profiles/test-obscured-content.json", + "expect": { + "pass": true, + "check_ids": ["obscured-content"] + }, + "source": "generated", + "notes": "#118 regression: a mark clipped to a small patch and a mark covered only by two adjacent opaque rectangles. No covering object bounds contain the mark raw bounds, so the heuristic passed both clean." + }, { "id": "hidden-layers", "pdf": "hidden-layers.pdf", diff --git a/loop-preflight/testdata/fixtures/obscured-content-clipped.pdf b/loop-preflight/testdata/fixtures/obscured-content-clipped.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4fad1a8bd827810b16d44a885c2cfbd45c9e5bf4 GIT binary patch literal 619 zcmZuuO-}+b5WV+T%w><#kEKgUHhWMcF=|94@nASqDC-*BYPTflpY-5A&_Ci#!3ETI zdgz-sZ(gU#XfQmIogPa*zurHX1Oe{;k>xq?$?LNPeqw@Ix)S)vl(qqdgfSEav-O;a zbU!TE@c4N(Z{QBn$e*H)Th#&NUMZ#imQo{ck-F)`FYUZC$8L>U0=E*I6BX-##0I*t z2OrzUt^5p4qWhxQ>qLZ~P8YT=!vn}PT4{o}=83&PS5T-J-oh=^SX1KJwM2LU*GzOY zCQ?D6V^D$-TcSBStqj7E-PeF2HPu8JDa9c|^W2)*PIm1rscI(L1)0{VMi(uHk}->R zTP(xp(%QqM!eRfylA3H}MM*q#v1~~sbTV$%2X0&HnzbA_S VpeYT$3C4%`g02)SNlveZ>>Hy)nS}rV literal 0 HcmV?d00001 diff --git a/loop-preflight/testdata/fixtures/off-page-content-clipped.pdf b/loop-preflight/testdata/fixtures/off-page-content-clipped.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a7d5a26cc00b25bc16df6ab4caf8b5050e98adc1 GIT binary patch literal 531 zcmZWm%TB{E5WM><_L3vmdAU@Ia)43^0ivWx98eFYaY9gu!EvR+PjcW7;78a^LI`xN zIe2z={hGN z-TxMJI(}Z|4ctN4>Q~suY-FGZ3%u4tKk-EEGrQLBBTjO7jR8) z?<@N)?5r-BfS8FPK*n(xqkQ1}7}_lM9XN@jQH(f0z{ULIf?)c~IjgFTw(TD+t2YfX q@XQ!mF8vKZo8hh!SxAaduH|$h(tTppEkIe bytes: + objects = { + 1: b"<< /Type /Catalog /Pages 2 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 << >> >>", + 4: b"<< /Length %d >>\nstream\n" % len(content) + content + b"endstream", + } + 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 [ <%s> <%s> ] >>\n" % (size, ident, ident) + buffer += b"startxref\n%d\n%%%%EOF\n" % xref + return bytes(buffer) + + +OFF_PAGE = b"q 300 300 100 100 re W n 0 g 0 0 400 400 re f Q\n" + +OBSCURED = ( + b"q 40 40 20 20 re W n 0 g 0 0 200 200 re f Q\n" + b"0.5 g 30 30 50 50 re f\n" + b"0 g 100 100 20 20 re f\n" + b"0.5 g 95 95 15 30 re f 110 95 15 30 re f\n" +) + + +def main(argv: list[str]) -> int: + out = Path(argv[1] if len(argv) > 1 else DEFAULT_OUT) + out.mkdir(parents=True, exist_ok=True) + (out / "off-page-content-clipped.pdf").write_bytes(build(OFF_PAGE, b"6f6666636c6970")) + (out / "obscured-content-clipped.pdf").write_bytes(build(OBSCURED, b"6f627363636c6970")) + print("wrote off-page-content-clipped.pdf, obscured-content-clipped.pdf") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 9bfd80166b67ee49d325c60600fe8473bd6240bc Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:40:44 -0700 Subject: [PATCH 2/5] feat(preflight): judge off-page and obscured content on clipped painted geometry (#118) Co-Authored-By: Claude Sonnet 5.5 --- LoopLibCore/sources/preflightengine.cpp | 183 ++++++++++++++++++++++-- UnitTests/tst_preflightenginetest.cpp | 128 +++++++++++++++++ 2 files changed, 298 insertions(+), 13 deletions(-) diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index d0d7dcc24..3ab3642e5 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -4642,6 +4642,7 @@ struct HiddenContentFinding QRectF bbox; QString detail; bool heuristic = false; + bool incomplete = false; }; class HiddenContentProcessor final : public PDFPageContentProcessor @@ -4733,53 +4734,194 @@ class HiddenContentProcessor final : public PDFPageContentProcessor } } + void performClipping(const QPainterPath& path, Qt::FillRule fillRule) override + { + QPainterPath world = getCurrentWorldMatrix().map(path); + world.setFillRule(fillRule); + if (m_clip.active) + { + m_clip.path = m_clip.path.intersected(world); + } + else + { + m_clip.active = true; + m_clip.path = world; + } + } + + void performSaveGraphicState(ProcessOrder order) override + { + if (order == ProcessOrder::BeforeOperation) + { + m_clipStack.append(m_clip); + } + } + + void performRestoreGraphicState(ProcessOrder order) override + { + if (order == ProcessOrder::BeforeOperation && !m_clipStack.isEmpty()) + { + m_clip = m_clipStack.takeLast(); + } + } + void performBeforePathPainting(const QPainterPath& path, bool stroke, bool fill, bool text, Qt::FillRule fillRule) override { - Q_UNUSED(fillRule); if (path.isEmpty() || (!stroke && !fill && !text)) { return; } - const QRectF bounds = getCurrentWorldMatrix().map(path).boundingRect().normalized(); + QPainterPath world = getCurrentWorldMatrix().map(path); + world.setFillRule(fillRule); const PDFPageContentProcessorState* state = getGraphicState(); + const QRectF rawBounds = world.boundingRect().normalized(); if (state->getAlphaFilling() <= 0.0 || state->getAlphaStroking() <= 0.0) { - m_findings.append({ QStringLiteral("invisible-content"), bounds, + m_findings.append({ QStringLiteral("invisible-content"), rawBounds, QStringLiteral("graphics-state alpha is zero"), false }); } - if (!m_toleratedBounds.isEmpty() && !m_toleratedBounds.intersects(bounds)) + // Everything below judges the geometry that is actually painted: the path + // intersected with the effective clip, not its raw bounding box. + const PaintedGeometry painted = clipPainted(world, fill); + if (painted.clippedAway) { - m_findings.append({ QStringLiteral("off-page-content"), bounds, + return; + } + + if (!m_toleratedBounds.isEmpty() && !m_toleratedBounds.intersects(painted.bounds)) + { + m_findings.append({ QStringLiteral("off-page-content"), painted.bounds, QStringLiteral("mark lies outside the effective page/bleed box"), false }); } - if (fill && state->getAlphaFilling() >= 1.0 && !m_paintedBounds.isEmpty()) + if (fill) { - for (const QRectF& previous : m_paintedBounds) + const bool opaque = state->getAlphaFilling() >= 1.0; + const bool plainBlend = state->getBlendMode() == BlendMode::Normal && !state->getSoftMask(); + if (opaque) { - if (bounds.contains(previous)) + for (PaintedItem& item : m_items) { - m_findings.append({ QStringLiteral("obscured-content"), previous, - QStringLiteral("fully covered by later opaque paint"), true }); - break; + if (item.reported || !painted.bounds.intersects(item.bounds)) + { + continue; + } + if (!plainBlend) + { + // A blend mode or soft mask decides whether this paint hides the + // earlier mark, and that is not decidable without a RIP model. + if (painted.bounds.contains(item.bounds) && !m_undecided) + { + m_undecided = true; + m_findings.append({ QStringLiteral("obscured-content"), item.bounds, + QStringLiteral("a blend mode or soft mask over earlier paint prevents deciding whether it is hidden"), + true, true }); + } + continue; + } + item.remaining = item.remaining.subtracted(painted.region); + const QRectF left = item.remaining.boundingRect(); + if (item.remaining.isEmpty() || left.width() < NegligibleExtentPt || left.height() < NegligibleExtentPt) + { + item.reported = true; + m_findings.append({ QStringLiteral("obscured-content"), item.bounds, + QStringLiteral("fully covered by later opaque paint"), true }); + } } } } + if (fill || stroke || text) { - m_paintedBounds.append(bounds); + if (m_items.size() < MaxTrackedItems) + { + m_items.append({ painted.region, painted.bounds, false }); + } + else if (!m_truncated) + { + m_truncated = true; + m_findings.append({ QStringLiteral("obscured-content"), QRectF(), + QStringLiteral("the page paints more objects than occlusion tracking covers"), true, true }); + } } } private: + static constexpr qreal NegligibleExtentPt = 0.01; + static constexpr int MaxTrackedItems = 1000; + + struct ClipState + { + bool active = false; + QPainterPath path; + }; + + struct PaintedGeometry + { + QPainterPath region; + QRectF bounds; + bool clippedAway = false; + }; + + struct PaintedItem + { + QPainterPath remaining; + QRectF bounds; + bool reported = false; + }; + + /// Intersects painted geometry with the effective clip. Area paints use the exact + /// path intersection; strokes and degenerate paths fall back to rectangle bounds, + /// because a zero-area path has an empty exact intersection. + PaintedGeometry clipPainted(const QPainterPath& world, bool areaPaint) const + { + PaintedGeometry result; + result.region = world; + result.bounds = world.boundingRect().normalized(); + if (!m_clip.active) + { + return result; + } + + const QRectF clipBounds = m_clip.path.boundingRect().normalized(); + if (!clipBounds.intersects(result.bounds)) + { + result.clippedAway = true; + return result; + } + + if (areaPaint && result.bounds.width() > 0.0 && result.bounds.height() > 0.0) + { + const QPainterPath inside = m_clip.path.intersected(world); + if (inside.isEmpty()) + { + result.clippedAway = true; + return result; + } + result.region = inside; + result.bounds = inside.boundingRect().normalized(); + return result; + } + + result.bounds = result.bounds.intersected(clipBounds); + QPainterPath boundsPath; + boundsPath.addRect(result.bounds); + result.region = boundsPath; + return result; + } + QRectF m_toleratedBounds; - QList m_paintedBounds; + ClipState m_clip; + QList m_clipStack; + QList m_items; + bool m_undecided = false; + bool m_truncated = false; QStringList m_hiddenLayers; QList m_findings; }; @@ -4825,6 +4967,21 @@ void runHiddenContentCheck(PDFDocumentSession* session, continue; } + if (source.incomplete) + { + PreflightFinding incomplete; + incomplete.scope = QString::fromLatin1(PREFLIGHT_FINDING_SCOPE_PAGE); + incomplete.page = int(pageIndex + 1); + incomplete.type = QStringLiteral("check-incomplete"); + incomplete.checkId = check.id; + incomplete.severity = QStringLiteral("info"); + incomplete.message = PDFTranslationContext::tr("Page %1 occlusion could not be decided: %2.").arg(pageIndex + 1).arg(source.detail); + incomplete.evidence.insert(QStringLiteral("reason"), source.detail); + incomplete.evidence.insert(QStringLiteral("inspection_complete"), false); + pushPreflightFinding(incomplete, incomplete.severity, errors, warnings); + continue; + } + PreflightFinding finding; finding.scope = source.type == QStringLiteral("hidden-layers") ? QString::fromLatin1(PREFLIGHT_FINDING_SCOPE_DOCUMENT) diff --git a/UnitTests/tst_preflightenginetest.cpp b/UnitTests/tst_preflightenginetest.cpp index a9fcd0294..ce1ce936e 100644 --- a/UnitTests/tst_preflightenginetest.cpp +++ b/UnitTests/tst_preflightenginetest.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include #include @@ -84,6 +85,9 @@ private slots: void run_fontIntegrity_keepsValidEmbeddedFixtureClean(); void hiddenContent_checksAreRegistered(); void run_offPageContent_detectsMarksOutsideToleratedBox(); + void run_offPageContent_judgesClippedGeometry(); + void run_obscuredContent_judgesPaintedCoverage(); + void run_obscuredContent_reportsBlendModeCoverAsIncomplete(); void run_includesProfileFixups(); void run_synthesizesAddBleedWhenGapAndNoProfileFixup(); void run_removesAddBleedWhenNoGap(); @@ -943,6 +947,130 @@ void PreflightEngineTest::run_offPageContent_detectsMarksOutsideToleratedBox() QVERIFY(result.warnings.first().bbox.isValid()); } +namespace +{ +// Builds a one-page 200 x 200 pt PDF from raw content and a resource dictionary body. +QByteArray makeClipTestPdf(const QByteArray& content, const QByteArray& resources = QByteArray()) +{ + const QList objects{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [ 3 0 R ] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 200 200 ] /Contents 4 0 R /Resources << " + resources + " >> >>", + "<< /Length " + QByteArray::number(content.size()) + " >>\nstream\n" + content + "endstream" + }; + 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\n" + objects.at(index) + "\nendobj\n"; + } + const int xref = bytes.size(); + 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 \n"; + } + bytes += "trailer\n<< /Size " + QByteArray::number(objects.size() + 1) + " /Root 1 0 R >>\nstartxref\n" + + QByteArray::number(xref) + "\n%%EOF\n"; + return bytes; +} + +pdf::PreflightResult runClipTestCheck(const QByteArray& pdfBytes, const QString& checkId) +{ + QTemporaryFile file; + if (!file.open()) + { + return pdf::PreflightResult(); + } + file.write(pdfBytes); + file.flush(); + + pdf::PDFDocumentReader reader(nullptr, [](bool*) + { return QString(); }, true, false); + pdf::PDFDocument document = reader.readFromFile(file.fileName()); + pdf::PDFDocumentSession session(&document); + pdf::PreflightEngine engine(&session); + const QJsonObject profile{ + { QStringLiteral("name"), QStringLiteral("Clip aware geometry") }, + { QStringLiteral("checks"), QJsonArray{ + QJsonObject{ + { QStringLiteral("id"), checkId }, + { QStringLiteral("severity"), QStringLiteral("warning") }, + { QStringLiteral("amount_pt"), 0 } } } } + }; + return engine.run(profile); +} +} // namespace + +void PreflightEngineTest::run_offPageContent_judgesClippedGeometry() +{ + const QString offPage = QStringLiteral("off-page-content"); + + // A page-sized mark under a clip wholly off the page paints nothing on the page. + const pdf::PreflightResult clippedOff = runClipTestCheck( + makeClipTestPdf("q 300 300 100 100 re W n 0 g 0 0 200 200 re f Q\n"), offPage); + QCOMPARE(clippedOff.warnings.size(), 1); + QCOMPARE(clippedOff.warnings.first().type, offPage); + + // A mark whose raw bounds are off the page but that the clip removes entirely + // paints nothing, so it is not off-page content. + const pdf::PreflightResult clippedAway = runClipTestCheck( + makeClipTestPdf("q 0 0 50 50 re W n 0 g 300 300 20 20 re f Q\n"), offPage); + QCOMPARE(clippedAway.warnings.size(), 0); + + // The clip is restored by Q: the same mark after Q is off the page again. + const pdf::PreflightResult afterRestore = runClipTestCheck( + makeClipTestPdf("q 0 0 50 50 re W n Q 0 g 300 300 20 20 re f\n"), offPage); + QCOMPARE(afterRestore.warnings.size(), 1); + + // A mark still inside the page after clipping stays clean. + const pdf::PreflightResult inside = runClipTestCheck( + makeClipTestPdf("q 10 10 100 100 re W n 0 g 0 0 200 200 re f Q\n"), offPage); + QCOMPARE(inside.warnings.size(), 0); +} + +void PreflightEngineTest::run_obscuredContent_judgesPaintedCoverage() +{ + const QString obscured = QStringLiteral("obscured-content"); + + // Clipped mark covered by one opaque rectangle, and a mark covered by the union of two. + const pdf::PreflightResult covered = runClipTestCheck( + makeClipTestPdf("q 40 40 20 20 re W n 0 g 0 0 200 200 re f Q\n" + "0.5 g 30 30 50 50 re f\n" + "0 g 100 100 20 20 re f\n" + "0.5 g 95 95 15 30 re f 110 95 15 30 re f\n"), + obscured); + QCOMPARE(covered.warnings.size(), 2); + QCOMPARE(covered.warnings.first().type, obscured); + + // The covering rectangle's bounds contain the mark, but the cover is a triangle that misses it. + const pdf::PreflightResult missed = runClipTestCheck( + makeClipTestPdf("0 g 100 100 20 20 re f\n" + "0.5 g 90 90 m 140 90 l 90 140 l f\n"), + obscured); + QCOMPARE(missed.warnings.size(), 0); +} + +void PreflightEngineTest::run_obscuredContent_reportsBlendModeCoverAsIncomplete() +{ + const pdf::PreflightResult result = runClipTestCheck( + makeClipTestPdf("0 g 100 100 20 20 re f /GS0 gs 1 g 90 90 50 50 re f\n", + "/ExtGState << /GS0 << /BM /Multiply >> >>"), + QStringLiteral("obscured-content")); + QVERIFY(!result.pass); + bool incomplete = false; + for (const pdf::PreflightFinding& finding : result.warnings) + { + if (finding.type == QStringLiteral("check-incomplete")) + { + incomplete = true; + QCOMPARE(finding.evidence.value(QStringLiteral("inspection_complete")).toBool(), false); + } + } + QVERIFY(incomplete); +} + void PreflightEngineTest::parseProfile_rejectsOutputIntentInvalidAllowedColorSpace() { pdf::PreflightEngine engine(nullptr); From c69414f2fcf30c1ab5b3224aefb0eedc10df6cb5 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:42:31 -0700 Subject: [PATCH 3/5] test(preflight): span the page in the off-page clip regression case (#118) Co-Authored-By: Claude Sonnet 5.5 --- UnitTests/tst_preflightenginetest.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UnitTests/tst_preflightenginetest.cpp b/UnitTests/tst_preflightenginetest.cpp index ce1ce936e..7c9b3bbc5 100644 --- a/UnitTests/tst_preflightenginetest.cpp +++ b/UnitTests/tst_preflightenginetest.cpp @@ -1007,9 +1007,9 @@ void PreflightEngineTest::run_offPageContent_judgesClippedGeometry() { const QString offPage = QStringLiteral("off-page-content"); - // A page-sized mark under a clip wholly off the page paints nothing on the page. + // A mark spanning the page, under a clip wholly off the page, paints only off the page. const pdf::PreflightResult clippedOff = runClipTestCheck( - makeClipTestPdf("q 300 300 100 100 re W n 0 g 0 0 200 200 re f Q\n"), offPage); + makeClipTestPdf("q 300 300 100 100 re W n 0 g 0 0 400 400 re f Q\n"), offPage); QCOMPARE(clippedOff.warnings.size(), 1); QCOMPARE(clippedOff.warnings.first().type, offPage); From 5ae8baaa1b4b1bc2e017d63e88713ec85e77360c Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:45:46 -0700 Subject: [PATCH 4/5] docs(preflight): land off-page-content-clipping and obscured-content-occlusion (#118) Co-Authored-By: Claude Sonnet 5.5 --- ...ssue-118-clip-aware-geometry.evidence.yaml | 29 +++++++++ changes/cc-issue-118-clip-aware-geometry.md | 6 ++ docs/generated/preflight-check-catalog.json | 4 +- docs/generated/preflight-corpus-coverage.json | 8 ++- .../generated/preflight-coverage-backlog.json | 12 ++-- docs/preflight-check-catalog-overlay.json | 16 ++--- .../snapshots/obscured-content-clipped.json | 62 +++++++++++++++++++ .../snapshots/off-page-content-clipped.json | 45 ++++++++++++++ 8 files changed, 163 insertions(+), 19 deletions(-) create mode 100644 changes/cc-issue-118-clip-aware-geometry.evidence.yaml create mode 100644 changes/cc-issue-118-clip-aware-geometry.md create mode 100644 loop-preflight/testdata/snapshots/obscured-content-clipped.json create mode 100644 loop-preflight/testdata/snapshots/off-page-content-clipped.json diff --git a/changes/cc-issue-118-clip-aware-geometry.evidence.yaml b/changes/cc-issue-118-clip-aware-geometry.evidence.yaml new file mode 100644 index 000000000..96d2a701a --- /dev/null +++ b/changes/cc-issue-118-clip-aware-geometry.evidence.yaml @@ -0,0 +1,29 @@ +format_version: 1 +kind: evidence +claims: + - id: clipped-geometry-regression-fixtures + 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: clip-aware-off-page-and-occlusion + 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. + - Residual limits - images, shadings and transparency groups are not modelled for occlusion, strokes use rectangle bounds, and pages with more than 1000 painted objects report occlusion incomplete. diff --git a/changes/cc-issue-118-clip-aware-geometry.md b/changes/cc-issue-118-clip-aware-geometry.md new file mode 100644 index 000000000..108719982 --- /dev/null +++ b/changes/cc-issue-118-clip-aware-geometry.md @@ -0,0 +1,6 @@ +# Judge off-page and obscured content on clipped geometry (#118) + +Category: fixed +Audience: prepress operators and preflight maintainers relying on off-page-content and obscured-content +Breaking-Change: no +Summary: off-page-content now intersects painted bounds with the effective clip before the page test, and obscured-content covers painted, clipped vector geometry in paint order, including coverage by several opaque objects, so clipped marks and marks hidden by combined opaque paint are no longer missed or falsely reported; a blend mode or soft mask over earlier paint reports the page incomplete. Adds the off-page-content-clipped and obscured-content-clipped regression fixtures and lands the off-page-content-clipping and obscured-content-occlusion backlog rows. diff --git a/docs/generated/preflight-check-catalog.json b/docs/generated/preflight-check-catalog.json index 700197bf2..f21596913 100644 --- a/docs/generated/preflight-check-catalog.json +++ b/docs/generated/preflight-check-catalog.json @@ -623,7 +623,7 @@ "sheetfed-offset" ], "fixups": [], - "limitations": "Heuristic; not a RIP occlusion model.", + "limitations": "Painted vector geometry is intersected with the effective clip and covered in paint order; images, shadings and transparency are not modelled, a blend mode or soft mask over earlier paint reports the page incomplete, and findings stay heuristic, not a RIP occlusion model.", "measures": "Content covered by later painting.", "parameters": [ { @@ -659,7 +659,7 @@ "digital" ], "fixups": [], - "limitations": "Clipping can hide false positives and false negatives.", + "limitations": "Painted bounds are intersected with the effective clip before the page test; stroke and degenerate geometry use rectangle bounds, and images and shadings are not inspected.", "measures": "Content whose bounds fall outside the page.", "parameters": [ { diff --git a/docs/generated/preflight-corpus-coverage.json b/docs/generated/preflight-corpus-coverage.json index c261ad5f0..73de2b929 100644 --- a/docs/generated/preflight-corpus-coverage.json +++ b/docs/generated/preflight-corpus-coverage.json @@ -167,7 +167,8 @@ "corpus_gap": null, "coverage": "partial", "finding_fixtures": [ - "obscured-content" + "obscured-content", + "obscured-content-clipped" ], "uninspected_fixtures": [] }, @@ -176,7 +177,8 @@ "corpus_gap": null, "coverage": "partial", "finding_fixtures": [ - "off-page-content" + "off-page-content", + "off-page-content-clipped" ], "uninspected_fixtures": [] }, @@ -272,6 +274,6 @@ } }, "snapshot_dir": "loop-preflight/testdata/snapshots", - "source_fixtures": 76, + "source_fixtures": 78, "unattributed_findings": 1 } diff --git a/docs/generated/preflight-coverage-backlog.json b/docs/generated/preflight-coverage-backlog.json index 8c428da56..a6f4680e5 100644 --- a/docs/generated/preflight-coverage-backlog.json +++ b/docs/generated/preflight-coverage-backlog.json @@ -362,27 +362,27 @@ "state": "landed" }, { - "closed_by": "#118", + "closed_by": "obscured-content", "deferral": null, "families": [ "sheetfed-offset" ], - "gap": "obscured-content is a bounding-box heuristic rather than a RIP occlusion model, so covered content can pass clean", + "gap": "obscured-content compared raw bounding boxes, so content covered by clipped or combined opaque paint passed clean; it now covers painted, clipped vector geometry in paint order and reports a blend mode or soft mask over earlier paint as incomplete (filed as #118)", "id": "obscured-content-occlusion", "priority": "P2", - "state": "open" + "state": "landed" }, { - "closed_by": "#118", + "closed_by": "off-page-content", "deferral": null, "families": [ "sheetfed-offset", "digital" ], - "gap": "off-page-content compares painted bounds before clipping, so a clipped mark can be a false negative or a false positive", + "gap": "off-page-content compared painted bounds before clipping, so a clipped mark could be a false negative or a false positive; painted bounds are now intersected with the effective clip first (filed as #118)", "id": "off-page-content-clipping", "priority": "P2", - "state": "open" + "state": "landed" }, { "closed_by": "output-intent", diff --git a/docs/preflight-check-catalog-overlay.json b/docs/preflight-check-catalog-overlay.json index 916f07a9d..2b5663214 100644 --- a/docs/preflight-check-catalog-overlay.json +++ b/docs/preflight-check-catalog-overlay.json @@ -761,7 +761,7 @@ }, "obscured-content": { "measures": "Content covered by later painting.", - "limitations": "Heuristic; not a RIP occlusion model.", + "limitations": "Painted vector geometry is intersected with the effective clip and covered in paint order; images, shadings and transparency are not modelled, a blend mode or soft mask over earlier paint reports the page incomplete, and findings stay heuristic, not a RIP occlusion model.", "coverage": "partial", "families": [ "sheetfed-offset" @@ -796,7 +796,7 @@ }, "off-page-content": { "measures": "Content whose bounds fall outside the page.", - "limitations": "Clipping can hide false positives and false negatives.", + "limitations": "Painted bounds are intersected with the effective clip before the page test; stroke and degenerate geometry use rectangle bounds, and images and shadings are not inspected.", "coverage": "partial", "families": [ "sheetfed-offset", @@ -1530,24 +1530,24 @@ { "id": "obscured-content-occlusion", "priority": "P2", - "gap": "obscured-content is a bounding-box heuristic rather than a RIP occlusion model, so covered content can pass clean", + "gap": "obscured-content compared raw bounding boxes, so content covered by clipped or combined opaque paint passed clean; it now covers painted, clipped vector geometry in paint order and reports a blend mode or soft mask over earlier paint as incomplete (filed as #118)", "families": [ "sheetfed-offset" ], - "state": "open", - "closed_by": "#118", + "state": "landed", + "closed_by": "obscured-content", "deferral": null }, { "id": "off-page-content-clipping", "priority": "P2", - "gap": "off-page-content compares painted bounds before clipping, so a clipped mark can be a false negative or a false positive", + "gap": "off-page-content compared painted bounds before clipping, so a clipped mark could be a false negative or a false positive; painted bounds are now intersected with the effective clip first (filed as #118)", "families": [ "sheetfed-offset", "digital" ], - "state": "open", - "closed_by": "#118", + "state": "landed", + "closed_by": "off-page-content", "deferral": null }, { diff --git a/loop-preflight/testdata/snapshots/obscured-content-clipped.json b/loop-preflight/testdata/snapshots/obscured-content-clipped.json new file mode 100644 index 000000000..f74285db3 --- /dev/null +++ b/loop-preflight/testdata/snapshots/obscured-content-clipped.json @@ -0,0 +1,62 @@ +{ + "checks": [ + { + "id": "obscured-content", + "status": "ok" + } + ], + "errors": [ + ], + "fixups_available": [ + ], + "inspection_complete": true, + "pass": true, + "profile": "Loop Test - Obscured Content", + "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": [ + { + "bbox": [ + 40, + 40, + 60, + 60 + ], + "check_id": "obscured-content", + "evidence": { + "confidence": "heuristic" + }, + "message": "fully covered by later opaque paint on page 1.", + "page": 1, + "scope": "object", + "severity": "info", + "type": "obscured-content" + }, + { + "bbox": [ + 100, + 100, + 120, + 120 + ], + "check_id": "obscured-content", + "evidence": { + "confidence": "heuristic" + }, + "message": "fully covered by later opaque paint on page 1.", + "page": 1, + "scope": "object", + "severity": "info", + "type": "obscured-content" + } + ] +} diff --git a/loop-preflight/testdata/snapshots/off-page-content-clipped.json b/loop-preflight/testdata/snapshots/off-page-content-clipped.json new file mode 100644 index 000000000..8b1b2a410 --- /dev/null +++ b/loop-preflight/testdata/snapshots/off-page-content-clipped.json @@ -0,0 +1,45 @@ +{ + "checks": [ + { + "id": "off-page-content", + "status": "warning" + } + ], + "errors": [ + ], + "fixups_available": [ + ], + "inspection_complete": true, + "pass": true, + "profile": "Loop Test - Off-page Content", + "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": [ + { + "bbox": [ + 300, + 300, + 400, + 400 + ], + "check_id": "off-page-content", + "evidence": { + "confidence": "exact" + }, + "message": "mark lies outside the effective page/bleed box on page 1.", + "page": 1, + "scope": "object", + "severity": "warning", + "type": "off-page-content" + } + ] +} From 32f03dd2d69179d767fe6e0ac798fdf70d52173e Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:22:43 -0700 Subject: [PATCH 5/5] perf(preflight): track occlusion only for obscured-content Pairwise path subtraction ran for every hidden-content check id. Co-Authored-By: Claude Opus 5.5 --- LoopLibCore/sources/preflightengine.cpp | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index 3ab3642e5..30f8c1321 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -4655,7 +4655,8 @@ class HiddenContentProcessor final : public PDFPageContentProcessor const PDFOptionalContentActivity* optionalContentActivity, const PDFMeshQualitySettings& meshQualitySettings, PDFProcessingBudget* budget, - qreal offPageAllowance) : + qreal offPageAllowance, + bool trackOcclusion) : PDFPageContentProcessor(page, document, fontCache, @@ -4663,7 +4664,8 @@ class HiddenContentProcessor final : public PDFPageContentProcessor optionalContentActivity, QTransform(), meshQualitySettings, - budget) + budget), + m_trackOcclusion(trackOcclusion) { if (page) { @@ -4800,6 +4802,12 @@ class HiddenContentProcessor final : public PDFPageContentProcessor QStringLiteral("mark lies outside the effective page/bleed box"), false }); } + // Occlusion subtracts paths pairwise, so it only runs for the check that reports it. + if (!m_trackOcclusion) + { + return; + } + if (fill) { const bool opaque = state->getAlphaFilling() >= 1.0; @@ -4917,6 +4925,7 @@ class HiddenContentProcessor final : public PDFPageContentProcessor } QRectF m_toleratedBounds; + bool m_trackOcclusion = false; ClipState m_clip; QList m_clipStack; QList m_items; @@ -4957,7 +4966,8 @@ void runHiddenContentCheck(PDFDocumentSession* session, &printActivity, meshQualitySettings, session->getProcessingBudget(), - check.amountPt); + check.amountPt, + check.id == QStringLiteral("obscured-content")); processor.processContents(); for (const HiddenContentFinding& source : processor.findings())