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 01/23] 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 02/23] 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 03/23] 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 04/23] 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 994e89dccb5149e7a59468c4e1a3dd311b844d1a Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:59:36 -0700 Subject: [PATCH 05/23] test(preflight): add compositor-authority white-overprint and transparency regression fixtures (#119) Co-Authored-By: Claude Sonnet 5.5 --- .../testdata/fixtures/manifest.json | 22 ++++ .../transparency-overprint-knockout.pdf | Bin 0 -> 844 bytes .../fixtures/white-overprint-image.pdf | Bin 0 -> 950 bytes ...rate_white_overprint_compositor_fixture.py | 103 ++++++++++++++++++ 4 files changed, 125 insertions(+) create mode 100644 loop-preflight/testdata/fixtures/transparency-overprint-knockout.pdf create mode 100644 loop-preflight/testdata/fixtures/white-overprint-image.pdf create mode 100644 loop-preflight/tools/generate_white_overprint_compositor_fixture.py diff --git a/loop-preflight/testdata/fixtures/manifest.json b/loop-preflight/testdata/fixtures/manifest.json index 503e700f2..60f8e26cc 100644 --- a/loop-preflight/testdata/fixtures/manifest.json +++ b/loop-preflight/testdata/fixtures/manifest.json @@ -375,6 +375,28 @@ "source": "hand-built", "notes": "White overprint using an ICCBased color space." }, + { + "id": "white-overprint-image", + "pdf": "white-overprint-image.pdf", + "profile": "testdata/profiles/test-white-overprint.json", + "expect": { + "pass": true, + "check_ids": ["white-overprint"] + }, + "source": "generated", + "notes": "#119 regression: a pure-white DeviceCMYK image painted with overprint on and OPM 1 over cyan. The page-view scan only inspects path paints, so it passed clean; under OPM 1 the compositor makes the white vanish." + }, + { + "id": "transparency-overprint-knockout", + "pdf": "transparency-overprint-knockout.pdf", + "profile": "testdata/profiles/test-transparency-risk.json", + "expect": { + "pass": true, + "check_ids": ["transparency-risk"] + }, + "source": "generated", + "notes": "#119 regression: black painted with overprint, OPM 1 and alpha 0.5 inside a knockout CMYK transparency group over cyan. Only the Normal blend mode is used, so the blend scan passed it clean; how overprint, alpha and knockout combine is decided by the compositor." + }, { "id": "ink-coverage-over", "pdf": "ink-coverage-over.pdf", diff --git a/loop-preflight/testdata/fixtures/transparency-overprint-knockout.pdf b/loop-preflight/testdata/fixtures/transparency-overprint-knockout.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b7a0131ac951baa12c34919be6638e34aab985f2 GIT binary patch literal 844 zcmZuv!EW0y487|sc*${<9mmQB1P0co35G04Q17r|=s|Jl%w1yzvOJ{w$sP6w+mDQ- zo3u+}%aBYxK9Y~@Eyt5L@zqfE{`~#*Tg4E8TRo`J2=u3?9fH2G!9KbT=%w9Y1APMA zL6#}3*9_$U{~bDl7xzCt4Sd*X3z9Uy+?%2(&E9LOU&jlcJ#j_!#!>eP zV;udyIqIZi{fVxT};$J>MY&f literal 0 HcmV?d00001 diff --git a/loop-preflight/testdata/fixtures/white-overprint-image.pdf b/loop-preflight/testdata/fixtures/white-overprint-image.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8d4aa45e6bc24d09a86df23925e102662db7a95d GIT binary patch literal 950 zcmc&z&2HN;48H3rc*${-oY-;-1O`^SX^k#O77y5lp$EriTz9dR$;y!JC3n~xtS?eg zPTI8HeKrlr)JO8;j|Pim`o?=3vccc`-+!0~0@Uh`O(wuU{@fz)I5+u5RlpZ{g&hnn zU bytes: + 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) + + +def build_image() -> bytes: + pixels = b"00" * (4 * 4 * 4) # 4 x 4 x CMYK, all zero, as ASCIIHex + content = ( + b"1 0 0 0 k 10 10 80 80 re f\n" + b"/GS0 gs\n" + b"q 40 0 0 40 30 30 cm /Im0 Do Q\n" + ) + image = ( + b"<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceCMYK " + b"/BitsPerComponent 8 /Filter /ASCIIHexDecode /Length %d >>\nstream\n" % (len(pixels) + 1) + + pixels + b">\nendstream" + ) + return assemble( + { + 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 100 100 ] /Contents 4 0 R " + b"/Resources << /XObject << /Im0 5 0 R >> " + b"/ExtGState << /GS0 << /op true /OP true /OPM 1 >> >> >> >>", + 4: b"<< /Length %d >>\nstream\n" % len(content) + content + b"endstream", + 5: image, + }, + b"77686974656f7076", + ) + + +def build_knockout() -> bytes: + form_content = b"/GS1 gs 0 0 0 1 k 30 30 40 40 re f\n" + page_content = b"1 0 0 0 k 10 10 80 80 re f\n/Fm0 Do\n" + form = ( + b"<< /Type /XObject /Subtype /Form /BBox [ 0 0 100 100 ] " + b"/Group << /S /Transparency /CS /DeviceCMYK /K true >> " + b"/Resources << /ExtGState << /GS1 << /op true /OP true /OPM 1 /ca 0.5 /CA 0.5 >> >> >> " + b"/Length %d >>\nstream\n" % len(form_content) + form_content + b"endstream" + ) + return assemble( + { + 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 100 100 ] /Contents 4 0 R " + b"/Resources << /XObject << /Fm0 5 0 R >> >> >>", + 4: b"<< /Length %d >>\nstream\n" % len(page_content) + page_content + b"endstream", + 5: form, + }, + b"6b6e6f636b6f7574", + ) + + +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 / "white-overprint-image.pdf").write_bytes(build_image()) + (out / "transparency-overprint-knockout.pdf").write_bytes(build_knockout()) + print("wrote white-overprint-image.pdf, transparency-overprint-knockout.pdf") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 249444ae3595e451bb015cf7b91986c4dd464ad3 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:01:31 -0700 Subject: [PATCH 06/23] feat(preflight): judge white overprint and transparency interaction on the compositor (#119) Co-Authored-By: Claude Sonnet 5.5 --- LoopLibCore/sources/pdfinkcoverageprobe.cpp | 77 ++++++ LoopLibCore/sources/pdfinkcoverageprobe.h | 21 ++ .../sources/pdftransparencyrenderer.cpp | 81 ++++++ LoopLibCore/sources/pdftransparencyrenderer.h | 34 +++ LoopLibCore/sources/preflightengine.cpp | 242 +++++++++++++++++- LoopLibCore/sources/preflightengine.h | 4 + 6 files changed, 456 insertions(+), 3 deletions(-) diff --git a/LoopLibCore/sources/pdfinkcoverageprobe.cpp b/LoopLibCore/sources/pdfinkcoverageprobe.cpp index b192e4c20..4ad7d386d 100644 --- a/LoopLibCore/sources/pdfinkcoverageprobe.cpp +++ b/LoopLibCore/sources/pdfinkcoverageprobe.cpp @@ -308,4 +308,81 @@ PDFInkCoverageProbeResult PDFInkCoverageProbe::probe(const PDFPage* page, return result; } +PDFOverprintProbe::PDFOverprintProbe(PDFDocumentSession* session) : + m_session(session) +{ +} + +PDFOverprintProbeResult PDFOverprintProbe::probe(const PDFPage* page, int dpi, qint64 maxRasterPixels) +{ + PDFOverprintProbeResult result; + if (!page || !m_session || dpi <= 0) + { + return result; + } + + PDFDocument* document = m_session->getDocument(); + if (!document) + { + return result; + } + + const QRectF analysisBox = resolveAnalysisBox(page, PDFInkCoverageAnalysisBox::Bleed); + if (!isUsableBox(analysisBox)) + { + return result; + } + + const PageRotation pageRotation = page->getPageRotation(); + const QRectF rotatedAnalysisBox = PDFPage::getRotatedBox(analysisBox, pageRotation).normalized(); + const qreal pointToPixel = static_cast(dpi) / 72.0; + const double widthReal = std::ceil(rotatedAnalysisBox.width() * pointToPixel); + const double heightReal = std::ceil(rotatedAnalysisBox.height() * pointToPixel); + if (!std::isfinite(widthReal) || !std::isfinite(heightReal) || widthReal <= 0.0 || heightReal <= 0.0 + || widthReal > static_cast(std::numeric_limits::max()) || heightReal > static_cast(std::numeric_limits::max())) + { + return result; + } + + const int width = qMax(1, static_cast(widthReal)); + const int height = qMax(1, static_cast(heightReal)); + if (maxRasterPixels > 0 && static_cast(width) * static_cast(height) > maxRasterPixels) + { + result.budgetExceeded = true; + return result; + } + + PDFTransparencyRendererSettings rendererSettings; + rendererSettings.flags.setFlag(PDFTransparencyRendererSettings::SaveOriginalProcessImage, true); + rendererSettings.flags.setFlag(PDFTransparencyRendererSettings::ActiveColorMask, false); + rendererSettings.flags.setFlag(PDFTransparencyRendererSettings::SeparationSimulation, true); + rendererSettings.activeColorMask = PDFPixelFormat::getAllColorsMask(); + rendererSettings.renderPolicy = PDFRenderPolicy::forPreflightAnalysis(); + + const QSize imageSize(width, height); + const QTransform pagePointToDevice = PDFRenderer::createMediaBoxToDevicePointMatrix( + rotatedAnalysisBox, + QRect(QPoint(0, 0), imageSize), + pageRotation); + PDFInkMapper inkMapper(nullptr, document); + inkMapper.createSpotColors(true); + + PDFTransparencyRenderer renderer(page, + document, + m_session->getFontCache(), + m_session->getCMS(), + m_session->getOptionalContentActivity(), + &inkMapper, + rendererSettings, + pagePointToDevice); + renderer.beginPaint(imageSize); + renderer.processContents(); + renderer.endPaint(); + + result.rendered = true; + result.diagnostics = renderer.getRenderDiagnostics(); + result.observation = renderer.getOverprintObservation(); + return result; +} + } // namespace pdf diff --git a/LoopLibCore/sources/pdfinkcoverageprobe.h b/LoopLibCore/sources/pdfinkcoverageprobe.h index cd90b8b90..79f3f936b 100644 --- a/LoopLibCore/sources/pdfinkcoverageprobe.h +++ b/LoopLibCore/sources/pdfinkcoverageprobe.h @@ -97,6 +97,27 @@ class LOOPLIBCORESHARED_EXPORT PDFInkCoverageProbe PDFDocumentSession* m_session; }; +struct LOOPLIBCORESHARED_EXPORT PDFOverprintProbeResult +{ + bool rendered = false; // false when rasterization was unavailable or over budget + bool budgetExceeded = false; + PDFRenderDiagnostics diagnostics; + PDFOverprintObservation observation; +}; + +/// Renders a page on the overprint-accurate compositor (the Output Preview path) and +/// reports the overprint that compositor applied, instead of reading page-view state. +class LOOPLIBCORESHARED_EXPORT PDFOverprintProbe +{ +public: + explicit PDFOverprintProbe(PDFDocumentSession* session); + + PDFOverprintProbeResult probe(const PDFPage* page, int dpi, qint64 maxRasterPixels); + +private: + PDFDocumentSession* m_session; +}; + } // namespace pdf #endif // PDFINKCOVERAGEPROBE_H diff --git a/LoopLibCore/sources/pdftransparencyrenderer.cpp b/LoopLibCore/sources/pdftransparencyrenderer.cpp index 0800c1931..395259220 100644 --- a/LoopLibCore/sources/pdftransparencyrenderer.cpp +++ b/LoopLibCore/sources/pdftransparencyrenderer.cpp @@ -2689,6 +2689,15 @@ void PDFTransparencyRenderer::performEndTransparencyGroup(ProcessOrder order, co const PDFFloatBitmap::OverprintMode selectedOverprintMode = containsOverprintContent ? selectBlendOverprintMode(overprintMode, sourceData.containsFilling, sourceData.containsStroking) : PDFFloatBitmap::OverprintMode::NoOveprint; + if (selectedOverprintMode != PDFFloatBitmap::OverprintMode::NoOveprint) + { + const std::vector& groupContentMask = sourceData.contentMask; + const size_t groupWidth = sourceData.immediateBackdrop.getWidth(); + observeOverprint(sourceData.immediateBackdrop, getPaintRect(), + [&groupContentMask, groupWidth](size_t x, size_t y) + { return y * groupWidth + x < groupContentMask.size() ? groupContentMask[y * groupWidth + x] : uint8_t(0); }, + enabledContentMask, sourceData.blendMode, static_cast(sourceData.alphaFill), sourceData.group.knockout); + } PDFFloatBitmap::blend(sourceData.immediateBackdrop, targetData.immediateBackdrop, *getBackdrop(), *getInitialBackdrop(), *sourceData.softMask.getSoftMask(), sourceData.alphaIsShape, sourceData.alphaFill, sourceData.blendMode, sourceData.group.knockout, selectedOverprintMode, getPaintRect(), @@ -3189,6 +3198,14 @@ void PDFTransparencyRenderer::flushDrawBuffer() const PDFFloatBitmap::OverprintMode selectedOverprintMode = selectBlendOverprintMode(overprintMode, containsFilling, containsStroking); + if (selectedOverprintMode != PDFFloatBitmap::OverprintMode::NoOveprint) + { + const uint8_t enabledMask = (overprintMode.overprintFilling ? 0x01 : 0x00) | (overprintMode.overprintStroking ? 0x02 : 0x00); + observeOverprint(m_drawBuffer, m_drawBuffer.getModifiedRect(), + [this](size_t x, size_t y) + { return m_drawBuffer.getPixelContentMask(x, y); }, + enabledMask, getGraphicState()->getBlendMode(), 1.0, isTransparencyGroupKnockout()); + } PDFFloatBitmap::blend(m_drawBuffer, *getImmediateBackdrop(), *getBackdrop(), *getInitialBackdrop(), *getPainterState()->softMask.getSoftMask(), getGraphicState()->getAlphaIsShape(), 1.0f, getGraphicState()->getBlendMode(), isTransparencyGroupKnockout(), @@ -3213,6 +3230,70 @@ void PDFTransparencyRenderer::flushDrawBuffer() } } +void PDFTransparencyRenderer::observeOverprint(const PDFFloatBitmap& source, + const QRect& rect, + const std::function& contentMask, + uint8_t enabledContentMask, + BlendMode blendMode, + PDFReal alpha, + bool knockout) +{ + const PDFPixelFormat format = source.getPixelFormat(); + const QRect bounded = rect.intersected(QRect(0, 0, int(source.getWidth()), int(source.getHeight()))); + if (bounded.isEmpty() || !format.hasProcessColors()) + { + return; + } + + constexpr PDFColorComponent Tolerance = 0.005f; + const bool subtractive = format.hasProcessColorsSubtractive(); + const bool plainBlend = blendMode == BlendMode::Normal || blendMode == BlendMode::Compatible; + const uint8_t shapeChannel = format.getShapeChannelIndex(); + const uint8_t opacityChannel = format.getOpacityChannelIndex(); + + for (int y = bounded.top(); y <= bounded.bottom(); ++y) + { + for (int x = bounded.left(); x <= bounded.right(); ++x) + { + if ((contentMask(size_t(x), size_t(y)) & enabledContentMask) == 0) + { + continue; + } + + PDFConstColorBuffer pixel = source.getPixel(size_t(x), size_t(y)); + if (shapeChannel != PDFPixelFormat::INVALID_CHANNEL_INDEX && pixel[shapeChannel] <= 0.0f) + { + continue; + } + + ++m_overprintObservation.overprintPixels; + + bool white = true; + for (uint8_t channel = format.getProcessColorChannelIndexStart(); channel < format.getProcessColorChannelIndexEnd() && white; ++channel) + { + white = subtractive ? pixel[channel] <= Tolerance : pixel[channel] >= 1.0f - Tolerance; + } + if (format.hasSpotColors()) + { + for (uint8_t channel = format.getSpotColorChannelIndexStart(); channel < format.getSpotColorChannelIndexEnd() && white; ++channel) + { + white = pixel[channel] <= Tolerance; + } + } + if (white) + { + ++m_overprintObservation.whiteOverprintPixels; + } + + const bool partialOpacity = opacityChannel != PDFPixelFormat::INVALID_CHANNEL_INDEX && pixel[opacityChannel] < 1.0f - Tolerance; + if (!plainBlend || alpha < 1.0 - Tolerance || partialOpacity || knockout) + { + ++m_overprintObservation.transparentOverprintPixels; + } + } + } +} + void PDFTransparencyRenderer::recordOverprintDiagnostics(bool containsFilling, bool containsStroking) { const PDFOverprintMode overprintMode = getGraphicState()->getOverprintMode(); diff --git a/LoopLibCore/sources/pdftransparencyrenderer.h b/LoopLibCore/sources/pdftransparencyrenderer.h index 50cfd7e01..e16765987 100644 --- a/LoopLibCore/sources/pdftransparencyrenderer.h +++ b/LoopLibCore/sources/pdftransparencyrenderer.h @@ -30,6 +30,7 @@ #include "pdfutils.h" #include "pdfprogress.h" +#include #include #include @@ -89,6 +90,19 @@ enum class PDFRenderFidelity Unsupported }; +/// Overprint that the compositor actually applied while painting, counted in raster +/// pixels. It is what the overprint-accurate renderer did, not page-view state. +struct PDFOverprintObservation +{ + /// Pixels painted with an overprint mode in effect. + quint64 overprintPixels = 0; + /// Overprinted pixels whose painted colorants are all paper white (no ink). + quint64 whiteOverprintPixels = 0; + /// Overprinted pixels composited under a non-Normal blend mode, fill alpha below one, + /// partial opacity or a knockout group, where overprint and transparency interact. + quint64 transparentOverprintPixels = 0; +}; + struct PDFRenderDiagnostics { PDFRenderFidelity fidelity = PDFRenderFidelity::ExactSupported; @@ -785,6 +799,9 @@ class LOOPLIBCORESHARED_EXPORT PDFTransparencyRenderer : public PDFPageContentPr const PDFRenderDiagnostics& getRenderDiagnostics() const { return m_renderDiagnostics; } + /// Overprint the compositor applied while painting; see PDFOverprintObservation. + const PDFOverprintObservation& getOverprintObservation() const { return m_overprintObservation; } + static PDFRenderFidelity classifyOverprintFidelity(const PDFOverprintMode& overprintMode, BlendMode blendMode, bool hasSpotColors); @@ -953,6 +970,22 @@ class LOOPLIBCORESHARED_EXPORT PDFTransparencyRenderer : public PDFPageContentPr void recordOverprintDiagnostics(bool containsFilling, bool containsStroking); + /// Counts the overprinted pixels of a source about to be blended. + /// \param source Source bitmap + /// \param rect Painted rectangle + /// \param contentMask Returns the per-pixel content mask (fill 0x01, stroke 0x02) + /// \param enabledContentMask Content kinds for which overprint is enabled + /// \param blendMode Blend mode of the source + /// \param alpha Constant fill alpha of the source + /// \param knockout True, if the source is composited into a knockout group + void observeOverprint(const PDFFloatBitmap& source, + const QRect& rect, + const std::function& contentMask, + uint8_t enabledContentMask, + BlendMode blendMode, + PDFReal alpha, + bool knockout); + /// Returns true, if multithreaded painter path sampling should be used /// for a given fill rectangle. /// \param fillRect Fill rectangle @@ -1050,6 +1083,7 @@ class LOOPLIBCORESHARED_EXPORT PDFTransparencyRenderer : public PDFPageContentPr PDFDrawBuffer m_drawBuffer; PDFFloatBitmapWithColorSpace m_originalProcessBitmap; PDFRenderDiagnostics m_renderDiagnostics; + PDFOverprintObservation m_overprintObservation; }; /// Ink coverage calculator. Calculates ink coverage for a given diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index d0d7dcc24..f96293fd2 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -1947,6 +1947,235 @@ void evaluateEmbeddedFontsFromGraph(const PreflightCheckConfig& check, } } +/// True when the page, or a Form XObject it paints, declares overprint (/OP or /op true) in an +/// ExtGState. Only such pages are rasterized on the compositor, which keeps the cost off +/// the common case. +bool pageDeclaresOverprint(const PDFDocument* document, const PDFPage* page) +{ + std::set visited; + std::function scan; + scan = [&](const PDFObject& resourcesObject, int depth) -> bool + { + if (depth > 8) + { + return false; + } + const PDFObject resources = document->getObject(resourcesObject); + if (!resources.isDictionary()) + { + return false; + } + if (resourcesObject.isReference() && !visited.insert(resourcesObject.getReference()).second) + { + return false; + } + + const PDFDictionary* states = document->getDictionaryFromObject(resources.getDictionary()->get("ExtGState")); + if (states) + { + for (size_t i = 0; i < states->getCount(); ++i) + { + const PDFDictionary* state = document->getDictionaryFromObject(states->getValue(i)); + if (!state) + { + continue; + } + for (const char* key : { "OP", "op" }) + { + const PDFObject value = document->getObject(state->get(key)); + if (value.isBool() && value.getBool()) + { + return true; + } + } + } + } + + const PDFDictionary* xobjects = document->getDictionaryFromObject(resources.getDictionary()->get("XObject")); + if (xobjects) + { + for (size_t i = 0; i < xobjects->getCount(); ++i) + { + const PDFObject entry = xobjects->getValue(i); + if (entry.isReference() && !visited.insert(entry.getReference()).second) + { + continue; + } + const PDFObject xobject = document->getObject(entry); + if (xobject.isStream() && scan(xobject.getStream()->getDictionary()->get("Resources"), depth + 1)) + { + return true; + } + } + } + return false; + }; + return scan(page->getResources(), 0); +} + +/// Rasterizes every page that declares overprint on the compositor, once per run, and +/// returns the probe for a page index. Returns nullptr for pages that do not declare overprint. +const PDFOverprintProbeResult* compositorOverprintProbe(PDFDocumentSession* session, + std::map& cache, + PDFInteger pageIndex, + const PreflightCheckConfig& check) +{ + const auto cached = cache.find(int(pageIndex)); + if (cached != cache.end()) + { + return &cached->second; + } + + PDFDocument* document = session ? session->getDocument() : nullptr; + const PDFPage* page = document ? document->getCatalog()->getPage(pageIndex) : nullptr; + if (!page || !pageDeclaresOverprint(document, page)) + { + return nullptr; + } + + PDFOverprintProbe probe(session); + return &(cache[int(pageIndex)] = probe.probe(page, check.probeDpi, check.maxRasterPixels)); +} + +PreflightFinding compositorIncompleteFinding(const PreflightCheckConfig& check, + PDFInteger pageIndex, + const PDFOverprintProbeResult& probe) +{ + PreflightFinding finding; + finding.scope = QString::fromLatin1(PREFLIGHT_FINDING_SCOPE_PAGE); + finding.page = int(pageIndex + 1); + finding.type = QStringLiteral("check-incomplete"); + finding.severity = QStringLiteral("info"); + finding.checkId = check.id; + QString reason; + if (probe.budgetExceeded) + { + reason = PDFTranslationContext::tr("the compositor raster exceeds the pixel budget"); + } + else if (!probe.rendered) + { + reason = PDFTranslationContext::tr("the compositor could not render the page"); + } + else + { + reason = probe.diagnostics.reasons.join(QStringLiteral("; ")); + if (reason.isEmpty()) + { + reason = PDFTranslationContext::tr("the compositor reported non-exact overprint fidelity"); + } + } + finding.message = PDFTranslationContext::tr("Page %1 overprint was not judged on the compositor: %2.").arg(pageIndex + 1).arg(reason); + finding.evidence.insert(QStringLiteral("reason"), reason); + finding.evidence.insert(QStringLiteral("budget_exceeded"), probe.budgetExceeded); + finding.evidence.insert(QStringLiteral("inspection_complete"), false); + return finding; +} + +/// Adds findings for white overprint that only the overprint-accurate compositor sees (images, +/// shadings, patterns and results of transparency), for pages the page-view scan did not flag. +void evaluateWhiteOverprintOnCompositor(PDFDocumentSession* session, + std::map& cache, + const PreflightCheckConfig& check, + const std::set& flaggedPages, + QList& errors, + QList& warnings) +{ + PDFDocument* document = session ? session->getDocument() : nullptr; + if (!document) + { + return; + } + + const PDFInteger pageCount = document->getCatalog()->getPageCount(); + for (PDFInteger pageIndex = 0; pageIndex < pageCount; ++pageIndex) + { + if (!check.restrictions.allowsPage(int(pageIndex)) || flaggedPages.count(int(pageIndex + 1))) + { + continue; + } + const PDFOverprintProbeResult* probe = compositorOverprintProbe(session, cache, pageIndex, check); + if (!probe) + { + continue; + } + + if (probe->rendered && probe->observation.whiteOverprintPixels > 0) + { + PreflightFinding finding; + finding.scope = QString::fromLatin1(PREFLIGHT_FINDING_SCOPE_PAGE); + finding.page = int(pageIndex + 1); + finding.type = QStringLiteral("white-overprint"); + finding.severity = check.severity; + finding.checkId = check.id; + finding.message = PDFTranslationContext::tr( + "The compositor applies overprint to paper-white paint on page %1 (paint the page-view scan does not inspect, such as an image or shading).") + .arg(pageIndex + 1); + finding.evidence.insert(QStringLiteral("source"), QStringLiteral("compositor")); + finding.evidence.insert(QStringLiteral("white_overprint_pixels"), static_cast(probe->observation.whiteOverprintPixels)); + finding.evidence.insert(QStringLiteral("overprint_pixels"), static_cast(probe->observation.overprintPixels)); + finding.evidence.insert(QStringLiteral("probe_dpi"), check.probeDpi); + pushPreflightFinding(finding, check.severity, errors, warnings); + } + else if (!probe->rendered || !probe->diagnostics.isExact()) + { + const PreflightFinding finding = compositorIncompleteFinding(check, pageIndex, *probe); + pushPreflightFinding(finding, finding.severity, errors, warnings); + } + } +} + +/// Adds findings where the compositor composites overprint under a non-Normal blend mode, partial +/// opacity or a knockout group, the combinations a RIP resolves differently from page view. +void evaluateTransparencyOverprintOnCompositor(PDFDocumentSession* session, + std::map& cache, + const PreflightCheckConfig& check, + QList& errors, + QList& warnings) +{ + PDFDocument* document = session ? session->getDocument() : nullptr; + if (!document) + { + return; + } + + const PDFInteger pageCount = document->getCatalog()->getPageCount(); + for (PDFInteger pageIndex = 0; pageIndex < pageCount; ++pageIndex) + { + if (!check.restrictions.allowsPage(int(pageIndex))) + { + continue; + } + const PDFOverprintProbeResult* probe = compositorOverprintProbe(session, cache, pageIndex, check); + if (!probe) + { + continue; + } + + if (probe->rendered && probe->observation.transparentOverprintPixels > 0) + { + PreflightFinding finding; + finding.scope = QString::fromLatin1(PREFLIGHT_FINDING_SCOPE_PAGE); + finding.page = int(pageIndex + 1); + finding.type = QStringLiteral("transparency-overprint-interaction"); + finding.severity = check.severity; + finding.checkId = check.id; + finding.message = PDFTranslationContext::tr( + "Overprint is composited together with transparency (blend mode, opacity or a knockout group) on page %1; a RIP may resolve it differently.") + .arg(pageIndex + 1); + finding.evidence.insert(QStringLiteral("source"), QStringLiteral("compositor")); + finding.evidence.insert(QStringLiteral("transparent_overprint_pixels"), static_cast(probe->observation.transparentOverprintPixels)); + finding.evidence.insert(QStringLiteral("overprint_pixels"), static_cast(probe->observation.overprintPixels)); + finding.evidence.insert(QStringLiteral("probe_dpi"), check.probeDpi); + pushPreflightFinding(finding, check.severity, errors, warnings); + } + else if (!probe->rendered || !probe->diagnostics.isExact()) + { + const PreflightFinding finding = compositorIncompleteFinding(check, pageIndex, *probe); + pushPreflightFinding(finding, finding.severity, errors, warnings); + } + } +} + void evaluateWhiteOverprintFromGraph(const PreflightCheckConfig& check, QList& errors, QList& warnings, @@ -6171,6 +6400,7 @@ PreflightResult PreflightEngine::run(const PreflightProfileData& profile, const result.effectiveProfileDigest = profile.effectiveDigest; result.revalidation = revalidationReport(effectivePlan, false); m_activeGraph = PDFEvidenceGraph(); + m_overprintProbes.clear(); if (m_session) { m_session->resetProcessingBudget(); @@ -7549,8 +7779,8 @@ void PreflightEngine::registerBuiltInChecks() QList& errors, QList& warnings) { - Q_UNUSED(session); evaluateTransparencyRiskFromGraph(check, errors, warnings, evidenceGraphForCheck(m_activeGraph, check.restrictions, check.id, session)); + evaluateTransparencyOverprintOnCompositor(session, m_overprintProbes, check, errors, warnings); }; m_checks[QStringLiteral("thin-strokes")] = [this](PDFDocumentSession* session, @@ -7632,8 +7862,14 @@ void PreflightEngine::registerBuiltInChecks() QList& errors, QList& warnings) { - Q_UNUSED(session); - evaluateWhiteOverprintFromGraph(check, errors, warnings, evidenceGraphForCheck(m_activeGraph, check.restrictions, check.id, session)); + const PDFEvidenceGraph scopedGraph = evidenceGraphForCheck(m_activeGraph, check.restrictions, check.id, session); + evaluateWhiteOverprintFromGraph(check, errors, warnings, scopedGraph); + std::set flaggedPages; + for (const PDFEvidenceRecord& record : scopedGraph.recordsForTarget(PDFEvidenceDomain::OverprintTransparency, QStringLiteral("white-overprint"))) + { + flaggedPages.insert(record.page); + } + evaluateWhiteOverprintOnCompositor(session, m_overprintProbes, check, flaggedPages, errors, warnings); }; m_checks[QStringLiteral("conformance-claims")] = [](PDFDocumentSession* session, diff --git a/LoopLibCore/sources/preflightengine.h b/LoopLibCore/sources/preflightengine.h index c1bb35322..74e49f3d0 100644 --- a/LoopLibCore/sources/preflightengine.h +++ b/LoopLibCore/sources/preflightengine.h @@ -37,6 +37,7 @@ #include #include #include +#include "pdfinkcoverageprobe.h" #include #include #include @@ -455,6 +456,9 @@ class LOOPLIBCORESHARED_EXPORT PreflightEngine const PDFOperationControl* m_operationControl = nullptr; std::map m_checks; PDFEvidenceGraph m_activeGraph; + /// Compositor overprint probes of the current run, keyed by zero-based page index, so + /// white-overprint and transparency-risk render each page once between them. + std::map m_overprintProbes; }; } // namespace pdf From c63055edfdf69e27b3fcc29bdf2496460ec11a73 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:06:48 -0700 Subject: [PATCH 07/23] fix(preflight): count only real transparency as overprint interaction (#119) Co-Authored-By: Claude Sonnet 5.5 --- LoopLibCore/sources/pdftransparencyrenderer.cpp | 9 +++++---- LoopLibCore/sources/pdftransparencyrenderer.h | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/LoopLibCore/sources/pdftransparencyrenderer.cpp b/LoopLibCore/sources/pdftransparencyrenderer.cpp index 395259220..e486f705a 100644 --- a/LoopLibCore/sources/pdftransparencyrenderer.cpp +++ b/LoopLibCore/sources/pdftransparencyrenderer.cpp @@ -3204,7 +3204,10 @@ void PDFTransparencyRenderer::flushDrawBuffer() observeOverprint(m_drawBuffer, m_drawBuffer.getModifiedRect(), [this](size_t x, size_t y) { return m_drawBuffer.getPixelContentMask(x, y); }, - enabledMask, getGraphicState()->getBlendMode(), 1.0, isTransparencyGroupKnockout()); + enabledMask, getGraphicState()->getBlendMode(), + qMin(containsFilling ? getGraphicState()->getAlphaFilling() : 1.0, + containsStroking ? getGraphicState()->getAlphaStroking() : 1.0), + isTransparencyGroupKnockout()); } PDFFloatBitmap::blend(m_drawBuffer, *getImmediateBackdrop(), *getBackdrop(), *getInitialBackdrop(), *getPainterState()->softMask.getSoftMask(), @@ -3249,7 +3252,6 @@ void PDFTransparencyRenderer::observeOverprint(const PDFFloatBitmap& source, const bool subtractive = format.hasProcessColorsSubtractive(); const bool plainBlend = blendMode == BlendMode::Normal || blendMode == BlendMode::Compatible; const uint8_t shapeChannel = format.getShapeChannelIndex(); - const uint8_t opacityChannel = format.getOpacityChannelIndex(); for (int y = bounded.top(); y <= bounded.bottom(); ++y) { @@ -3285,8 +3287,7 @@ void PDFTransparencyRenderer::observeOverprint(const PDFFloatBitmap& source, ++m_overprintObservation.whiteOverprintPixels; } - const bool partialOpacity = opacityChannel != PDFPixelFormat::INVALID_CHANNEL_INDEX && pixel[opacityChannel] < 1.0f - Tolerance; - if (!plainBlend || alpha < 1.0 - Tolerance || partialOpacity || knockout) + if (!plainBlend || alpha < 1.0 - Tolerance || knockout) { ++m_overprintObservation.transparentOverprintPixels; } diff --git a/LoopLibCore/sources/pdftransparencyrenderer.h b/LoopLibCore/sources/pdftransparencyrenderer.h index e16765987..12112401f 100644 --- a/LoopLibCore/sources/pdftransparencyrenderer.h +++ b/LoopLibCore/sources/pdftransparencyrenderer.h @@ -98,8 +98,9 @@ struct PDFOverprintObservation quint64 overprintPixels = 0; /// Overprinted pixels whose painted colorants are all paper white (no ink). quint64 whiteOverprintPixels = 0; - /// Overprinted pixels composited under a non-Normal blend mode, fill alpha below one, - /// partial opacity or a knockout group, where overprint and transparency interact. + /// Overprinted pixels composited under a non-Normal blend mode, constant alpha below one + /// or a knockout group, where overprint and transparency interact. Antialiased edge + /// coverage is not counted. quint64 transparentOverprintPixels = 0; }; From 705a04ea90e4e2c98e9504d02c7db9bad018ccb3 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:11:49 -0700 Subject: [PATCH 08/23] docs(preflight): land white-overprint-renderer and transparency-rip-interaction, add tests and snapshots (#119) Co-Authored-By: Claude Sonnet 5.5 --- UnitTests/tst_preflightenginetest.cpp | 76 +++++++++++++++++++ ...119-white-overprint-renderer.evidence.yaml | 29 +++++++ .../cc-issue-119-white-overprint-renderer.md | 6 ++ docs/generated/preflight-check-catalog.json | 21 ++++- docs/generated/preflight-corpus-coverage.json | 4 +- .../generated/preflight-coverage-backlog.json | 12 +-- docs/preflight-check-catalog-overlay.json | 33 +++++--- loop-preflight/README.md | 18 +++++ .../transparency-overprint-knockout.json | 42 ++++++++++ .../snapshots/white-overprint-image.json | 42 ++++++++++ 10 files changed, 264 insertions(+), 19 deletions(-) create mode 100644 changes/cc-issue-119-white-overprint-renderer.evidence.yaml create mode 100644 changes/cc-issue-119-white-overprint-renderer.md create mode 100644 loop-preflight/testdata/snapshots/transparency-overprint-knockout.json create mode 100644 loop-preflight/testdata/snapshots/white-overprint-image.json diff --git a/UnitTests/tst_preflightenginetest.cpp b/UnitTests/tst_preflightenginetest.cpp index a9fcd0294..8dbc88d7f 100644 --- a/UnitTests/tst_preflightenginetest.cpp +++ b/UnitTests/tst_preflightenginetest.cpp @@ -84,6 +84,9 @@ private slots: void run_fontIntegrity_keepsValidEmbeddedFixtureClean(); void hiddenContent_checksAreRegistered(); void run_offPageContent_detectsMarksOutsideToleratedBox(); + void run_whiteOverprint_reportsWhiteImageOverprintFromCompositor(); + void run_transparencyRisk_reportsOverprintInteractionFromCompositor(); + void run_overprintCompositorProbe_skipsPagesWithoutOverprintAndReportsBudgetAsIncomplete(); void run_includesProfileFixups(); void run_synthesizesAddBleedWhenGapAndNoProfileFixup(); void run_removesAddBleedWhenNoGap(); @@ -943,6 +946,79 @@ void PreflightEngineTest::run_offPageContent_detectsMarksOutsideToleratedBox() QVERIFY(result.warnings.first().bbox.isValid()); } +namespace +{ +pdf::PreflightResult runCompositorFixture(const QString& fixtureName, const QString& checkId, const QJsonObject& extra = QJsonObject()) +{ + const QString fixturePath = QStringLiteral(LOOP_PREFLIGHT_SOURCE_DIR "/testdata/fixtures/") + fixtureName; + pdf::PDFDocumentReader reader(nullptr, [](bool*) + { return QString(); }, true, false); + pdf::PDFDocument document = reader.readFromFile(fixturePath); + if (reader.getReadingResult() != pdf::PDFDocumentReader::Result::OK) + { + return pdf::PreflightResult(); + } + + pdf::PDFDocumentSession session(&document); + pdf::PreflightEngine engine(&session); + QJsonObject check{ { QStringLiteral("id"), checkId }, { QStringLiteral("severity"), QStringLiteral("warning") } }; + for (auto it = extra.begin(); it != extra.end(); ++it) + { + check.insert(it.key(), it.value()); + } + return engine.run(QJsonObject{ { QStringLiteral("name"), QStringLiteral("Compositor overprint") }, + { QStringLiteral("checks"), QJsonArray{ check } } }); +} +} // namespace + +void PreflightEngineTest::run_whiteOverprint_reportsWhiteImageOverprintFromCompositor() +{ + const pdf::PreflightResult result = runCompositorFixture(QStringLiteral("white-overprint-image.pdf"), QStringLiteral("white-overprint")); + QCOMPARE(result.warnings.size(), 1); + QCOMPARE(result.warnings.first().type, QStringLiteral("white-overprint")); + QCOMPARE(result.warnings.first().evidence.value(QStringLiteral("source")).toString(), QStringLiteral("compositor")); + // A 40 pt image at 150 dpi covers about 7000 pixels. + const double whitePixels = result.warnings.first().evidence.value(QStringLiteral("white_overprint_pixels")).toDouble(); + QVERIFY2(whitePixels > 5000.0 && whitePixels < 9000.0, qPrintable(QString::number(whitePixels))); +} + +void PreflightEngineTest::run_transparencyRisk_reportsOverprintInteractionFromCompositor() +{ + const pdf::PreflightResult result = runCompositorFixture(QStringLiteral("transparency-overprint-knockout.pdf"), QStringLiteral("transparency-risk")); + QCOMPARE(result.warnings.size(), 1); + QCOMPARE(result.warnings.first().type, QStringLiteral("transparency-overprint-interaction")); + QVERIFY(result.warnings.first().evidence.value(QStringLiteral("transparent_overprint_pixels")).toDouble() > 0.0); + + // A plain overprint page with no transparency stays clean for transparency-risk. + const pdf::PreflightResult plain = runCompositorFixture(QStringLiteral("overprint-cmyk-mode1-on.pdf"), QStringLiteral("transparency-risk")); + QCOMPARE(plain.warnings.size(), 0); +} + +void PreflightEngineTest::run_overprintCompositorProbe_skipsPagesWithoutOverprintAndReportsBudgetAsIncomplete() +{ + // No overprint declared: the compositor is not consulted and the page stays clean. + const pdf::PreflightResult none = runCompositorFixture(QStringLiteral("white-overprint-ok.pdf"), QStringLiteral("white-overprint")); + QCOMPARE(none.warnings.size(), 0); + QVERIFY(none.pass); + + // A budget too small for the raster is reported by the probe as unrendered, never as clean. + const QString fixturePath = QStringLiteral(LOOP_PREFLIGHT_SOURCE_DIR "/testdata/fixtures/white-overprint-image.pdf"); + 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::PDFOverprintProbe probe(&session); + const pdf::PDFPage* page = document.getCatalog()->getPage(0); + QVERIFY(page != nullptr); + const pdf::PDFOverprintProbeResult limited = probe.probe(page, 150, 100); + QVERIFY(!limited.rendered); + QVERIFY(limited.budgetExceeded); + const pdf::PDFOverprintProbeResult full = probe.probe(page, 150, 250LL * 1000 * 1000); + QVERIFY(full.rendered); + QVERIFY(full.observation.whiteOverprintPixels > 0); +} + void PreflightEngineTest::parseProfile_rejectsOutputIntentInvalidAllowedColorSpace() { pdf::PreflightEngine engine(nullptr); diff --git a/changes/cc-issue-119-white-overprint-renderer.evidence.yaml b/changes/cc-issue-119-white-overprint-renderer.evidence.yaml new file mode 100644 index 000000000..b8fad8b1d --- /dev/null +++ b/changes/cc-issue-119-white-overprint-renderer.evidence.yaml @@ -0,0 +1,29 @@ +format_version: 1 +kind: evidence +claims: + - id: compositor-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: compositor-judged-overprint + 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 - the compositor probe sees paint of at least one probe pixel at probe_dpi and not annotation appearances, only pages that declare overprint are rasterized, and the RIP result itself is not simulated. diff --git a/changes/cc-issue-119-white-overprint-renderer.md b/changes/cc-issue-119-white-overprint-renderer.md new file mode 100644 index 000000000..1f8e5fca8 --- /dev/null +++ b/changes/cc-issue-119-white-overprint-renderer.md @@ -0,0 +1,6 @@ +# Judge white overprint and transparency interaction on the compositor (#119) + +Category: fixed +Audience: prepress operators and preflight maintainers relying on white-overprint and transparency-risk +Breaking-Change: no +Summary: white-overprint and transparency-risk now also consult the overprint-accurate compositor that Output Preview uses. Pages that declare overprint are rasterized once per run; white-overprint reports paper-white pixels overprinted through images, shadings or transparency results that the page-view scan does not inspect, transparency-risk reports overprint composited with a non-Normal blend mode, constant alpha or a knockout group, and a page the compositor cannot judge reports check-incomplete. Adds the white-overprint-image and transparency-overprint-knockout regression fixtures and lands the white-overprint-renderer and transparency-rip-interaction backlog rows. diff --git a/docs/generated/preflight-check-catalog.json b/docs/generated/preflight-check-catalog.json index 700197bf2..2b13e5818 100644 --- a/docs/generated/preflight-check-catalog.json +++ b/docs/generated/preflight-check-catalog.json @@ -1096,7 +1096,7 @@ "digital" ], "fixups": [], - "limitations": "Does not flatten; does not simulate overprint+transparency interaction as a RIP.", + "limitations": "Does not flatten. Overprint composited with a non-Normal blend mode, constant alpha below one or a knockout group is reported from the compositor; the RIP result itself is not simulated.", "measures": "Live transparency blend modes and blend-space mismatches.", "parameters": [ { @@ -1117,6 +1117,16 @@ "condition": "a page records one or more transparency blend-space mismatches. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings.", "finding_type": "transparency-blend-space", "severity": "error" + }, + { + "condition": "the compositor composites overprint under a non-Normal blend mode, constant alpha below one or a knockout group. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings.", + "finding_type": "transparency-overprint-interaction", + "severity": "error" + }, + { + "condition": "the compositor raster exceeded max_raster_pixels, was unavailable, or reported non-exact overprint fidelity, so the page is reported incomplete rather than clean.", + "finding_type": "check-incomplete", + "severity": "info" } ] }, @@ -1189,7 +1199,7 @@ "packaging" ], "fixups": [], - "limitations": "Page-view overprint is not the measurement renderer; Output Preview / transparency renderer is.", + "limitations": "Pages that declare overprint (/OP or /op in an ExtGState, including Form XObjects) are also rasterized on the overprint-accurate compositor at probe_dpi; sub-pixel white paint and annotation appearances are not seen there.", "measures": "White/knockout overprint flags on fills and strokes.", "parameters": [ { @@ -1202,9 +1212,14 @@ ], "severity": [ { - "condition": "a page paints white or near-white content with overprint set. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings.", + "condition": "a page paints white or near-white content with overprint set, or the compositor applies overprint to paper-white pixels (images, shadings, results of transparency) the page-view scan does not inspect. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings.", "finding_type": "white-overprint", "severity": "error" + }, + { + "condition": "the compositor raster exceeded max_raster_pixels, was unavailable, or reported non-exact overprint fidelity, so the page is reported incomplete rather than clean.", + "finding_type": "check-incomplete", + "severity": "info" } ] } diff --git a/docs/generated/preflight-corpus-coverage.json b/docs/generated/preflight-corpus-coverage.json index c261ad5f0..b72d6ce73 100644 --- a/docs/generated/preflight-corpus-coverage.json +++ b/docs/generated/preflight-corpus-coverage.json @@ -239,6 +239,7 @@ "transparency-cmyk-group-rgb", "transparency-cmyk-group-spot", "transparency-hue", + "transparency-overprint-knockout", "transparency-rgb-group-cmyk" ], "uninspected_fixtures": [ @@ -264,6 +265,7 @@ "white-overprint-devicen", "white-overprint-form", "white-overprint-iccbased", + "white-overprint-image", "white-overprint-separation" ], "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..feb29e554 100644 --- a/docs/generated/preflight-coverage-backlog.json +++ b/docs/generated/preflight-coverage-backlog.json @@ -410,29 +410,29 @@ "state": "landed" }, { - "closed_by": "#119", + "closed_by": "transparency-risk", "deferral": null, "families": [ "sheetfed-offset", "packaging", "digital" ], - "gap": "transparency-risk does not flatten and does not simulate overprint-plus-transparency interaction as a RIP does, so a press-only interaction passes clean", + "gap": "transparency-risk did not look at overprint-plus-transparency interaction, so a press-only interaction passed clean; it now reports overprint composited with a non-Normal blend mode, constant alpha or a knockout group from the compositor (filed as #119)", "id": "transparency-rip-interaction", "priority": "P2", - "state": "open" + "state": "landed" }, { - "closed_by": "#119", + "closed_by": "white-overprint", "deferral": null, "families": [ "sheetfed-offset", "packaging" ], - "gap": "white-overprint reads page-view overprint state rather than the Output Preview or transparency renderer, so a renderer-dependent white overprint passes clean", + "gap": "white-overprint read page-view overprint state only, so white overprint that appears through the compositor (images, shadings, transparency) passed clean; pages that declare overprint are now also judged on the overprint-accurate compositor (filed as #119)", "id": "white-overprint-renderer", "priority": "P2", - "state": "open" + "state": "landed" }, { "closed_by": "content-bleed", diff --git a/docs/preflight-check-catalog-overlay.json b/docs/preflight-check-catalog-overlay.json index 916f07a9d..086dc42ac 100644 --- a/docs/preflight-check-catalog-overlay.json +++ b/docs/preflight-check-catalog-overlay.json @@ -1233,7 +1233,7 @@ }, "transparency-risk": { "measures": "Live transparency blend modes and blend-space mismatches.", - "limitations": "Does not flatten; does not simulate overprint+transparency interaction as a RIP.", + "limitations": "Does not flatten. Overprint composited with a non-Normal blend mode, constant alpha below one or a knockout group is reported from the compositor; the RIP result itself is not simulated.", "coverage": "partial", "families": [ "sheetfed-offset", @@ -1259,6 +1259,16 @@ "finding_type": "transparency-blend-space", "severity": "error", "condition": "a page records one or more transparency blend-space mismatches. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings." + }, + { + "finding_type": "transparency-overprint-interaction", + "severity": "error", + "condition": "the compositor composites overprint under a non-Normal blend mode, constant alpha below one or a knockout group. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings." + }, + { + "finding_type": "check-incomplete", + "severity": "info", + "condition": "the compositor raster exceeded max_raster_pixels, was unavailable, or reported non-exact overprint fidelity, so the page is reported incomplete rather than clean." } ], "evidence": [ @@ -1327,7 +1337,7 @@ }, "white-overprint": { "measures": "White/knockout overprint flags on fills and strokes.", - "limitations": "Page-view overprint is not the measurement renderer; Output Preview / transparency renderer is.", + "limitations": "Pages that declare overprint (/OP or /op in an ExtGState, including Form XObjects) are also rasterized on the overprint-accurate compositor at probe_dpi; sub-pixel white paint and annotation appearances are not seen there.", "coverage": "partial", "families": [ "sheetfed-offset", @@ -1346,7 +1356,12 @@ { "finding_type": "white-overprint", "severity": "error", - "condition": "a page paints white or near-white content with overprint set. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings." + "condition": "a page paints white or near-white content with overprint set, or the compositor applies overprint to paper-white pixels (images, shadings, results of transparency) the page-view scan does not inspect. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings." + }, + { + "finding_type": "check-incomplete", + "severity": "info", + "condition": "the compositor raster exceeded max_raster_pixels, was unavailable, or reported non-exact overprint fidelity, so the page is reported incomplete rather than clean." } ], "evidence": [ @@ -1553,26 +1568,26 @@ { "id": "transparency-rip-interaction", "priority": "P2", - "gap": "transparency-risk does not flatten and does not simulate overprint-plus-transparency interaction as a RIP does, so a press-only interaction passes clean", + "gap": "transparency-risk did not look at overprint-plus-transparency interaction, so a press-only interaction passed clean; it now reports overprint composited with a non-Normal blend mode, constant alpha or a knockout group from the compositor (filed as #119)", "families": [ "sheetfed-offset", "packaging", "digital" ], - "state": "open", - "closed_by": "#119", + "state": "landed", + "closed_by": "transparency-risk", "deferral": null }, { "id": "white-overprint-renderer", "priority": "P2", - "gap": "white-overprint reads page-view overprint state rather than the Output Preview or transparency renderer, so a renderer-dependent white overprint passes clean", + "gap": "white-overprint read page-view overprint state only, so white overprint that appears through the compositor (images, shadings, transparency) passed clean; pages that declare overprint are now also judged on the overprint-accurate compositor (filed as #119)", "families": [ "sheetfed-offset", "packaging" ], - "state": "open", - "closed_by": "#119", + "state": "landed", + "closed_by": "white-overprint", "deferral": null }, { diff --git a/loop-preflight/README.md b/loop-preflight/README.md index 961836f20..e6f17fe9c 100644 --- a/loop-preflight/README.md +++ b/loop-preflight/README.md @@ -251,6 +251,24 @@ PdfTool capabilities --console-format json | jq '.data.fixups' The command reports build capability; the preflight report still applies the profile and finding/document gates before placing an item in `fixups_available`. +## Compositor-judged overprint + +`white-overprint` and `transparency-risk` also consult the overprint-accurate +compositor that Output Preview uses (`PDFTransparencyRenderer` with the +preflight-analysis policy), not only page-view overprint state. A page that +declares overprint (`/OP` or `/op` in an ExtGState, including inside Form +XObjects) is rasterized at `probe_dpi` once per run and the compositor reports +the overprint it applied. `white-overprint` then adds a finding for paper-white +pixels overprinted through paint the page-view scan does not inspect (images, +shadings, results of transparency); `transparency-risk` adds a +`transparency-overprint-interaction` finding where overprint is composited under +a non-Normal blend mode, constant alpha below one or a knockout group. A page +that cannot be rasterized, exceeds `max_raster_pixels`, or uses an overprint +combination the compositor only approximates reports an informational +`check-incomplete` finding instead of passing clean. Pages that do not declare +overprint are not rasterized. White paint smaller than a probe pixel and +annotation appearances are not seen by the compositor probe. + ## Transparency risk checking The `transparency-risk` check observes actual page-content processing and emits diff --git a/loop-preflight/testdata/snapshots/transparency-overprint-knockout.json b/loop-preflight/testdata/snapshots/transparency-overprint-knockout.json new file mode 100644 index 000000000..eaa1ca64f --- /dev/null +++ b/loop-preflight/testdata/snapshots/transparency-overprint-knockout.json @@ -0,0 +1,42 @@ +{ + "checks": [ + { + "id": "transparency-risk", + "status": "warning" + } + ], + "errors": [ + ], + "fixups_available": [ + ], + "inspection_complete": true, + "pass": true, + "profile": "Loop Test - Transparency Risk", + "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": "transparency-risk", + "evidence": { + "overprint_pixels": 14450, + "probe_dpi": 150, + "source": "compositor", + "transparent_overprint_pixels": 14450 + }, + "message": "Overprint is composited together with transparency (blend mode, opacity or a knockout group) on page 1; a RIP may resolve it differently.", + "page": 1, + "scope": "page", + "severity": "warning", + "type": "transparency-overprint-interaction" + } + ] +} diff --git a/loop-preflight/testdata/snapshots/white-overprint-image.json b/loop-preflight/testdata/snapshots/white-overprint-image.json new file mode 100644 index 000000000..753aa08ad --- /dev/null +++ b/loop-preflight/testdata/snapshots/white-overprint-image.json @@ -0,0 +1,42 @@ +{ + "checks": [ + { + "id": "white-overprint", + "status": "warning" + } + ], + "errors": [ + ], + "fixups_available": [ + ], + "inspection_complete": true, + "pass": true, + "profile": "Loop Test - White Overprint", + "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": "white-overprint", + "evidence": { + "overprint_pixels": 35617, + "probe_dpi": 150, + "source": "compositor", + "white_overprint_pixels": 7056 + }, + "message": "The compositor applies overprint to paper-white paint on page 1 (paint the page-view scan does not inspect, such as an image or shading).", + "page": 1, + "scope": "page", + "severity": "warning", + "type": "white-overprint" + } + ] +} From 4fa8558f10d263653d2ff42cd94a8730f171ce12 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:24:17 -0700 Subject: [PATCH 09/23] style(preflight): clang-format the files touched for the compositor probe (#119) pdftransparencyrenderer.cpp already violated the format gate on dev; the gate checks whole touched files, so it is formatted here without other changes. Co-Authored-By: Claude Sonnet 5.5 --- LoopLibCore/sources/pdfinkcoverageprobe.cpp | 3 +- .../sources/pdftransparencyrenderer.cpp | 104 ++++++++---------- 2 files changed, 45 insertions(+), 62 deletions(-) diff --git a/LoopLibCore/sources/pdfinkcoverageprobe.cpp b/LoopLibCore/sources/pdfinkcoverageprobe.cpp index 4ad7d386d..e73558833 100644 --- a/LoopLibCore/sources/pdfinkcoverageprobe.cpp +++ b/LoopLibCore/sources/pdfinkcoverageprobe.cpp @@ -338,8 +338,7 @@ PDFOverprintProbeResult PDFOverprintProbe::probe(const PDFPage* page, int dpi, q const qreal pointToPixel = static_cast(dpi) / 72.0; const double widthReal = std::ceil(rotatedAnalysisBox.width() * pointToPixel); const double heightReal = std::ceil(rotatedAnalysisBox.height() * pointToPixel); - if (!std::isfinite(widthReal) || !std::isfinite(heightReal) || widthReal <= 0.0 || heightReal <= 0.0 - || widthReal > static_cast(std::numeric_limits::max()) || heightReal > static_cast(std::numeric_limits::max())) + if (!std::isfinite(widthReal) || !std::isfinite(heightReal) || widthReal <= 0.0 || heightReal <= 0.0 || widthReal > static_cast(std::numeric_limits::max()) || heightReal > static_cast(std::numeric_limits::max())) { return result; } diff --git a/LoopLibCore/sources/pdftransparencyrenderer.cpp b/LoopLibCore/sources/pdftransparencyrenderer.cpp index e486f705a..40ed296b6 100644 --- a/LoopLibCore/sources/pdftransparencyrenderer.cpp +++ b/LoopLibCore/sources/pdftransparencyrenderer.cpp @@ -38,7 +38,7 @@ namespace pdf namespace { -template +template void forEachTile(const QRect& rect, const QSize& tileSize, Callback&& callback) { Q_ASSERT(tileSize.width() > 0); @@ -55,14 +55,13 @@ void forEachTile(const QRect& rect, const QSize& tileSize, Callback&& callback) } } -} // namespace +} // namespace PDFFloatBitmap::PDFFloatBitmap() : m_width(0), m_height(0), m_pixelSize(0) { - } PDFFloatBitmap::PDFFloatBitmap(size_t width, size_t height, PDFPixelFormat format) : @@ -569,8 +568,8 @@ void PDFFloatBitmap::blend(const PDFFloatBitmap& source, Q_ASSERT(blendRegion.left() >= 0); Q_ASSERT(blendRegion.top() >= 0); - Q_ASSERT(static_cast( blendRegion.right() ) < source.getWidth()); - Q_ASSERT(static_cast< std::size_t >( blendRegion.bottom() ) < source.getHeight()); + Q_ASSERT(static_cast(blendRegion.right()) < source.getWidth()); + Q_ASSERT(static_cast(blendRegion.bottom()) < source.getHeight()); const PDFPixelFormat pixelFormat = source.getPixelFormat(); const uint8_t shapeChannel = pixelFormat.getShapeChannelIndex(); @@ -599,8 +598,7 @@ void PDFFloatBitmap::blend(const PDFFloatBitmap& source, auto getBlendModeForPixel = [&source, &channelBlendModes, pixelFormat, overprintMode, mode, overprintContentMask, enabledContentMask](size_t x, size_t y, uint8_t channel) { - if (overprintContentMask && enabledContentMask != 0 - && ((*overprintContentMask)[y * source.getWidth() + x] & enabledContentMask) == 0) + if (overprintContentMask && enabledContentMask != 0 && ((*overprintContentMask)[y * source.getWidth() + x] & enabledContentMask) == 0) { return channelBlendModes[channel]; } @@ -617,8 +615,7 @@ void PDFFloatBitmap::blend(const PDFFloatBitmap& source, const uint32_t activeColorChannels = source.hasActiveColorMask() ? source.getPixelActiveColorMask(x, y) : PDFPixelFormat::getAllColorsMask(); uint32_t flag = (static_cast(1)) << channel; - if ((channelBlendModes[channel] == BlendMode::Normal || channelBlendModes[channel] == BlendMode::Compatible) - && !(activeColorChannels & flag)) + if ((channelBlendModes[channel] == BlendMode::Normal || channelBlendModes[channel] == BlendMode::Compatible) && !(activeColorChannels & flag)) { // Color channel is inactive return BlendMode::Overprint_SelectBackdrop; @@ -658,8 +655,7 @@ void PDFFloatBitmap::blend(const PDFFloatBitmap& source, // otherwise select source color. uint32_t flag = (static_cast(1)) << channel; - if ((channelBlendModes[channel] == BlendMode::Normal || channelBlendModes[channel] == BlendMode::Compatible) - && !(activeColorChannels & flag)) + if ((channelBlendModes[channel] == BlendMode::Normal || channelBlendModes[channel] == BlendMode::Compatible) && !(activeColorChannels & flag)) { // Color channel is inactive return BlendMode::Overprint_SelectBackdrop; @@ -893,7 +889,7 @@ void PDFFloatBitmap::blendConvertedSpots(const PDFFloatBitmap& convertedSpotColo const uint8_t processColorChannelEnd = m_format.getProcessColorChannelIndexEnd(); const PDFColorComponent* sourcePixel = convertedSpotColors.begin(); - for (PDFColorComponent* targetPixel = begin(); targetPixel != end(); targetPixel += m_pixelSize, sourcePixel+= convertedSpotColors.getPixelSize()) + for (PDFColorComponent* targetPixel = begin(); targetPixel != end(); targetPixel += m_pixelSize, sourcePixel += convertedSpotColors.getPixelSize()) { for (uint8_t i = processColorChannelStart; i < processColorChannelEnd; ++i) { @@ -951,20 +947,17 @@ PDFFloatBitmap PDFFloatBitmap::createOpaqueSoftMask(size_t width, size_t height) PDFFloatBitmapWithColorSpace::PDFFloatBitmapWithColorSpace() { - } PDFFloatBitmapWithColorSpace::PDFFloatBitmapWithColorSpace(size_t width, size_t height, PDFPixelFormat format) : PDFFloatBitmap(width, height, format) { - } PDFFloatBitmapWithColorSpace::PDFFloatBitmapWithColorSpace(size_t width, size_t height, PDFPixelFormat format, PDFColorSpacePointer blendColorSpace) : PDFFloatBitmap(width, height, format), m_colorSpace(blendColorSpace) { - } PDFColorSpacePointer PDFFloatBitmapWithColorSpace::getColorSpace() const @@ -1049,8 +1042,8 @@ PDFTransparencyRenderer::PDFTransparencyRenderer(const PDFPage* page, } PDFRenderFidelity PDFTransparencyRenderer::classifyOverprintFidelity(const PDFOverprintMode& overprintMode, - BlendMode blendMode, - bool hasSpotColors) + BlendMode blendMode, + bool hasSpotColors) { if (!overprintMode.appliesToContent(true, true)) { @@ -1155,7 +1148,7 @@ void PDFTransparencyRenderer::beginPaint(QSize pixelSize) const PDFFloatBitmap& PDFTransparencyRenderer::endPaint() { Q_ASSERT(m_active); - m_textTransparencyGroupGuard.reset(); // Just safeguard - ET operator may not be present + m_textTransparencyGroupGuard.reset(); // Just safeguard - ET operator may not be present m_pageTransparencyGroupGuard.reset(); m_active = false; m_painterStateStack.pop(); @@ -1235,8 +1228,8 @@ QImage PDFTransparencyRenderer::toImage(bool use16Bit, bool usePaper, const PDFR { QImage image; - if (m_transparencyGroupDataStack.size() == 1 && // We have finished the painting - getImmediateBackdrop()->getPixelFormat().getProcessColorChannelCount() == 3) // We have exactly three process colors (RGB) + if (m_transparencyGroupDataStack.size() == 1 && // We have finished the painting + getImmediateBackdrop()->getPixelFormat().getProcessColorChannelCount() == 3) // We have exactly three process colors (RGB) { const PDFFloatBitmapWithColorSpace& floatImage = *getImmediateBackdrop(); Q_ASSERT(floatImage.getPixelFormat().hasOpacityChannel()); @@ -1408,7 +1401,7 @@ void PDFTransparencyRenderer::performPixelSampling(const PDFReal shape, PDFColorBuffer pixel = m_drawBuffer.getPixel(x, y); pixel[shapeChannel] = PDFBlendFunction::blend_Union(shapeValue, pixel[shapeChannel]); - pixel[opacityChannel] = pixel[shapeChannel] * opacity; + pixel[opacityChannel] = pixel[shapeChannel] * opacity; // Copy color for (uint8_t colorChannelIndex = colorChannelStart; colorChannelIndex < colorChannelEnd; ++colorChannelIndex) @@ -1530,7 +1523,6 @@ void PDFTransparencyRenderer::collapseSpotColorsToDeviceColors(PDFFloatBitmapWit reportRenderError(RenderErrorType::Error, PDFTranslationContext::tr("Transformation of spot color to blend color space failed.")); break; } - } } @@ -1643,7 +1635,7 @@ PDFFloatBitmapWithColorSpace PDFTransparencyRenderer::convertImageToBlendSpace(c for (size_t x = 0; x < sourceImage->getWidth(); ++x) { PDFConstColorBuffer sourceBuffer = sourceImage->getPixel(x, y); - PDFColorBuffer targetBuffer = convertedImage.getPixel(x, y); + PDFColorBuffer targetBuffer = convertedImage.getPixel(x, y); for (const PDFInkMapping::Mapping& ink : inkMapping.mapping) { @@ -2022,7 +2014,7 @@ PDFFloatBitmapWithColorSpace PDFTransparencyRenderer::getColoredImage(const PDFI Q_ASSERT(2 * k + 1 < colorKeyMask.size()); if (static_cast::type::value_type>(value) >= colorKeyMask[2 * k] && - static_cast::type::value_type>(value) <= colorKeyMask[2 * k + 1]) + static_cast::type::value_type>(value) <= colorKeyMask[2 * k + 1]) { ++maskedColors; } @@ -2266,7 +2258,7 @@ void PDFTransparencyRenderer::performPathPainting(const QPainterPath& path, bool const PDFMappedColor& fillColor = getMappedFillColor(); forEachTile(fillRect, tileSize, [&](const QRect& tileRect) - { + { const PDFRasterMask clipMask = rasterizePathToMask(m_painterStateStack.top().clipPath, tileRect, 1.0f); const PDFRasterMask pathMask = rasterizePathToMask(worldPath, tileRect, 0.0f); @@ -2294,8 +2286,7 @@ void PDFTransparencyRenderer::performPathPainting(const QPainterPath& path, bool } m_drawBuffer.modify(tileRect, true, false); - flushDrawBuffer(); - }); + flushDrawBuffer(); }); } } @@ -2326,7 +2317,7 @@ void PDFTransparencyRenderer::performPathPainting(const QPainterPath& path, bool const PDFMappedColor& strokeColor = getMappedStrokeColor(); forEachTile(strokeRect, tileSize, [&](const QRect& tileRect) - { + { const PDFRasterMask clipMask = rasterizePathToMask(m_painterStateStack.top().clipPath, tileRect, 1.0f); const PDFRasterMask pathMask = rasterizePathToMask(worldPath, tileRect, 0.0f); @@ -2354,8 +2345,7 @@ void PDFTransparencyRenderer::performPathPainting(const QPainterPath& path, bool } m_drawBuffer.modify(tileRect, false, true); - flushDrawBuffer(); - }); + flushDrawBuffer(); }); } } @@ -2403,7 +2393,7 @@ bool PDFTransparencyRenderer::performPathPaintingUsingShading(const QPainterPath const uint32_t colorChannelEnd = drawBufferPixelFormat.getColorChannelIndexEnd(); forEachTile(fillRect, tileSize, [&](const QRect& tileRect) - { + { PDFFloatBitmapWithColorSpace texture(tileRect.width(), tileRect.height(), PDFPixelFormat::createFormat(uint8_t(shadingColorComponentCount), 0, true, shadingColorComponentCount == 4, false), @@ -2488,8 +2478,7 @@ bool PDFTransparencyRenderer::performPathPaintingUsingShading(const QPainterPath } m_drawBuffer.modify(tileRect, fill, stroke); - flushDrawBuffer(); - }); + flushDrawBuffer(); }); return true; } @@ -2681,22 +2670,18 @@ void PDFTransparencyRenderer::performEndTransparencyGroup(ProcessOrder order, co sourceData.immediateBackdrop.convertToColorSpace(getCMS(), targetData.renderingIntent, targetData.blendColorSpace, this); const PDFOverprintMode overprintMode = getGraphicState()->getOverprintMode(); - const uint8_t enabledContentMask = (overprintMode.overprintFilling ? 0x01 : 0x00) - | (overprintMode.overprintStroking ? 0x02 : 0x00); - const bool containsOverprintContent = (sourceData.containsFilling && (enabledContentMask & 0x01)) - || (sourceData.containsStroking && (enabledContentMask & 0x02)); + const uint8_t enabledContentMask = (overprintMode.overprintFilling ? 0x01 : 0x00) | (overprintMode.overprintStroking ? 0x02 : 0x00); + const bool containsOverprintContent = (sourceData.containsFilling && (enabledContentMask & 0x01)) || (sourceData.containsStroking && (enabledContentMask & 0x02)); recordOverprintDiagnostics(sourceData.containsFilling, sourceData.containsStroking); const PDFFloatBitmap::OverprintMode selectedOverprintMode = containsOverprintContent - ? selectBlendOverprintMode(overprintMode, sourceData.containsFilling, sourceData.containsStroking) - : PDFFloatBitmap::OverprintMode::NoOveprint; + ? selectBlendOverprintMode(overprintMode, sourceData.containsFilling, sourceData.containsStroking) + : PDFFloatBitmap::OverprintMode::NoOveprint; if (selectedOverprintMode != PDFFloatBitmap::OverprintMode::NoOveprint) { const std::vector& groupContentMask = sourceData.contentMask; const size_t groupWidth = sourceData.immediateBackdrop.getWidth(); - observeOverprint(sourceData.immediateBackdrop, getPaintRect(), - [&groupContentMask, groupWidth](size_t x, size_t y) - { return y * groupWidth + x < groupContentMask.size() ? groupContentMask[y * groupWidth + x] : uint8_t(0); }, - enabledContentMask, sourceData.blendMode, static_cast(sourceData.alphaFill), sourceData.group.knockout); + observeOverprint(sourceData.immediateBackdrop, getPaintRect(), [&groupContentMask, groupWidth](size_t x, size_t y) + { return y * groupWidth + x < groupContentMask.size() ? groupContentMask[y * groupWidth + x] : uint8_t(0); }, enabledContentMask, sourceData.blendMode, static_cast(sourceData.alphaFill), sourceData.group.knockout); } PDFFloatBitmap::blend(sourceData.immediateBackdrop, targetData.immediateBackdrop, *getBackdrop(), *getInitialBackdrop(), *sourceData.softMask.getSoftMask(), @@ -2815,7 +2800,7 @@ bool PDFTransparencyRenderer::performOriginalImagePainting(const PDFImage& image { const QSize tileSize = getEffectiveTileSize(); forEachTile(fillRect, tileSize, [&](const QRect& tileRect) - { + { const PDFRasterMask clipMask = rasterizePathToMask(m_painterStateStack.top().clipPath, tileRect, 1.0f); if (isMultithreadedPathSamplingUsed(tileRect)) @@ -2842,8 +2827,7 @@ bool PDFTransparencyRenderer::performOriginalImagePainting(const PDFImage& image } m_drawBuffer.modify(tileRect, true, false); - flushDrawBuffer(); - }); + flushDrawBuffer(); }); } return true; @@ -2897,7 +2881,7 @@ void PDFTransparencyRenderer::removeInitialBackdrop() const uint8_t alphaChannelIndex = pixelFormat.getOpacityChannelIndex(); const uint8_t colorChannelIndexStart = pixelFormat.getColorChannelIndexStart(); - const uint8_t colorChannelIndexEnd= pixelFormat.getColorChannelIndexEnd(); + const uint8_t colorChannelIndexEnd = pixelFormat.getColorChannelIndexEnd(); Q_ASSERT(alphaChannelIndex != PDFPixelFormat::INVALID_CHANNEL_INDEX); Q_ASSERT(colorChannelIndexStart != PDFPixelFormat::INVALID_CHANNEL_INDEX); @@ -3201,13 +3185,8 @@ void PDFTransparencyRenderer::flushDrawBuffer() if (selectedOverprintMode != PDFFloatBitmap::OverprintMode::NoOveprint) { const uint8_t enabledMask = (overprintMode.overprintFilling ? 0x01 : 0x00) | (overprintMode.overprintStroking ? 0x02 : 0x00); - observeOverprint(m_drawBuffer, m_drawBuffer.getModifiedRect(), - [this](size_t x, size_t y) - { return m_drawBuffer.getPixelContentMask(x, y); }, - enabledMask, getGraphicState()->getBlendMode(), - qMin(containsFilling ? getGraphicState()->getAlphaFilling() : 1.0, - containsStroking ? getGraphicState()->getAlphaStroking() : 1.0), - isTransparencyGroupKnockout()); + observeOverprint(m_drawBuffer, m_drawBuffer.getModifiedRect(), [this](size_t x, size_t y) + { return m_drawBuffer.getPixelContentMask(x, y); }, enabledMask, getGraphicState()->getBlendMode(), qMin(containsFilling ? getGraphicState()->getAlphaFilling() : 1.0, containsStroking ? getGraphicState()->getAlphaStroking() : 1.0), isTransparencyGroupKnockout()); } PDFFloatBitmap::blend(m_drawBuffer, *getImmediateBackdrop(), *getBackdrop(), *getInitialBackdrop(), *getPainterState()->softMask.getSoftMask(), @@ -3529,7 +3508,7 @@ void PDFInkMapper::createSpotColors(bool activate) if (colorSpaceDictionary) { std::size_t colorSpaces = colorSpaceDictionary->getCount(); - for (size_t csIndex = 0; csIndex < colorSpaces; ++ csIndex) + for (size_t csIndex = 0; csIndex < colorSpaces; ++csIndex) { PDFColorSpacePointer colorSpacePointer; try @@ -3632,7 +3611,8 @@ bool PDFInkMapper::containsProcessColor(const QByteArray& colorName) const const PDFInkMapper::ColorInfo* PDFInkMapper::getSpotColor(const QByteArray& colorName) const { - auto it = std::find_if(m_spotColors.cbegin(), m_spotColors.cend(), [&colorName](const auto& info) { return info.name == colorName; }); + auto it = std::find_if(m_spotColors.cbegin(), m_spotColors.cend(), [&colorName](const auto& info) + { return info.name == colorName; }); if (it != m_spotColors.cend()) { return &*it; @@ -3643,7 +3623,8 @@ const PDFInkMapper::ColorInfo* PDFInkMapper::getSpotColor(const QByteArray& colo const PDFInkMapper::ColorInfo* PDFInkMapper::getProcessColor(const QByteArray& colorName) const { - auto it = std::find_if(m_deviceColors.cbegin(), m_deviceColors.cend(), [&colorName](const auto& info) { return info.name == colorName; }); + auto it = std::find_if(m_deviceColors.cbegin(), m_deviceColors.cend(), [&colorName](const auto& info) + { return info.name == colorName; }); if (it != m_deviceColors.cend()) { return &*it; @@ -3654,7 +3635,8 @@ const PDFInkMapper::ColorInfo* PDFInkMapper::getProcessColor(const QByteArray& c const PDFInkMapper::ColorInfo* PDFInkMapper::getActiveProcessColor(const QByteArray& colorName, PDFAbstractColorSpace::ColorSpace colorSpace) const { - auto it = std::find_if(m_deviceColors.cbegin(), m_deviceColors.cend(), [&colorName, colorSpace](const auto& info) { return info.name == colorName && info.active && info.colorSpaceType == colorSpace; }); + auto it = std::find_if(m_deviceColors.cbegin(), m_deviceColors.cend(), [&colorName, colorSpace](const auto& info) + { return info.name == colorName && info.active && info.colorSpaceType == colorSpace; }); if (it != m_deviceColors.cend()) { return &*it; @@ -4039,7 +4021,8 @@ const std::vector* PDFInkCover const PDFInkCoverageCalculator::InkCoverageChannelInfo* PDFInkCoverageCalculator::findCoverageInfoByName(const std::vector& infos, const QByteArray& name) { - auto it = std::find_if(infos.cbegin(), infos.cend(), [&name](const auto& info) { return info.name == name; }); + auto it = std::find_if(infos.cbegin(), infos.cend(), [&name](const auto& info) + { return info.name == name; }); if (it != infos.cend()) { return &*it; @@ -4050,7 +4033,8 @@ const PDFInkCoverageCalculator::InkCoverageChannelInfo* PDFInkCoverageCalculator PDFInkCoverageCalculator::InkCoverageChannelInfo* PDFInkCoverageCalculator::findCoverageInfoByName(std::vector& infos, const QByteArray& name) { - auto it = std::find_if(infos.begin(), infos.end(), [&name](const auto& info) { return info.name == name; }); + auto it = std::find_if(infos.begin(), infos.end(), [&name](const auto& info) + { return info.name == name; }); if (it != infos.cend()) { return &*it; From 6ea3891b45d436a8b9442a8d55ff7ce4194eec10 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:38:21 -0700 Subject: [PATCH 10/23] test(preflight): add sparse-mark bleed regression fixtures (#120) Co-Authored-By: Claude Sonnet 5.5 --- .../content-bleed-hairline-margin.pdf | Bin 0 -> 664 bytes .../fixtures/content-bleed-sparse-marks.pdf | Bin 0 -> 646 bytes .../testdata/fixtures/manifest.json | 22 +++++ .../content-bleed-hairline-margin.json | 32 +++++++ .../snapshots/content-bleed-sparse-marks.json | 32 +++++++ .../tools/generate_sparse_bleed_fixtures.py | 90 ++++++++++++++++++ 6 files changed, 176 insertions(+) create mode 100644 loop-preflight/testdata/fixtures/content-bleed-hairline-margin.pdf create mode 100644 loop-preflight/testdata/fixtures/content-bleed-sparse-marks.pdf create mode 100644 loop-preflight/testdata/snapshots/content-bleed-hairline-margin.json create mode 100644 loop-preflight/testdata/snapshots/content-bleed-sparse-marks.json create mode 100644 loop-preflight/tools/generate_sparse_bleed_fixtures.py diff --git a/loop-preflight/testdata/fixtures/content-bleed-hairline-margin.pdf b/loop-preflight/testdata/fixtures/content-bleed-hairline-margin.pdf new file mode 100644 index 0000000000000000000000000000000000000000..dae72bd0323c01154f34831de5f5685a182b9761 GIT binary patch literal 664 zcmZuv%~HZZ48HGELiH9e+V}s%_bTI!Wcw-&Ag7k*IAad5u|~4yqm1^63kdaYhyAHs7=X z6$1ZJYZMw3h*=wpAcDZ}J99G1hN{=lbHd~l>uPJ4G#QPYBq#EiaFBO}<+`~C6?B4yDOIT=$f+x-B2wIAMxIlBB)93 zBIlg@OgiJ?=uDo+tn>Nx{=p;&sOHZsNq|pZUo7y8%xCLr34EL_Z4C+q6G&5L%bbLC zKXqty{JO|%cz`JAXBem2mLBAhD^Y)4DUq`%Z8Gs&n-|%j+F>k#TS*fPBTikhX$aW^ zI{n6w8mz6&8?Fz1P$CW%$|yZXub~?X;}cs~Tla{aVasWHR2~W1@@{Oo^iLp#CPD3; z%{F0HRMILWrxHXkXJNsJz$>hnROp8+?!YJ!odSVHQ7fQ(5VZh~dY^JX^qsSmK{Ih! zV6=`jEZ~4i4S|L72Q%0dts_h-e1~6{>f!KP%)6{uTNl2>eeq^N1D{kCy(N7Gel^6W iN#ZCLM#d_Rj5fKMr~g5-W_p?Pp@VLO->!3cH)7x4mY`Sw literal 0 HcmV?d00001 diff --git a/loop-preflight/testdata/fixtures/manifest.json b/loop-preflight/testdata/fixtures/manifest.json index 503e700f2..b2d3d7d4d 100644 --- a/loop-preflight/testdata/fixtures/manifest.json +++ b/loop-preflight/testdata/fixtures/manifest.json @@ -247,6 +247,28 @@ "source": "generated", "notes": "Artwork into left/right/top bleed strips only; Tier-1 emits exactly one content-bleed on the bottom edge." }, + { + "id": "content-bleed-sparse-marks", + "pdf": "content-bleed-sparse-marks.pdf", + "profile": "examples/profile-tiered-bleed.json", + "expect": { + "pass": true, + "check_ids": [] + }, + "source": "generated", + "notes": "#120 regression: the bleed margin holds only a 1 pt dot per edge. The artwork bounds touch every strip, so the bounds-only probe passed it clean." + }, + { + "id": "content-bleed-hairline-margin", + "pdf": "content-bleed-hairline-margin.pdf", + "profile": "examples/profile-tiered-bleed-raster.json", + "expect": { + "pass": true, + "check_ids": [] + }, + "source": "generated", + "notes": "#120 regression: the bleed margin holds only a 0.1 pt diagonal hairline across each edge. Raster confirmation never re-checked an edge the bounds pass had already accepted, so it passed clean." + }, { "id": "ai-art-missing-bleed", "pdf": "ai-art-missing-bleed.pdf", diff --git a/loop-preflight/testdata/snapshots/content-bleed-hairline-margin.json b/loop-preflight/testdata/snapshots/content-bleed-hairline-margin.json new file mode 100644 index 000000000..c06abc382 --- /dev/null +++ b/loop-preflight/testdata/snapshots/content-bleed-hairline-margin.json @@ -0,0 +1,32 @@ +{ + "checks": [ + { + "id": "bleed", + "status": "ok" + }, + { + "id": "content-bleed", + "status": "ok" + } + ], + "errors": [ + ], + "fixups_available": [ + ], + "inspection_complete": true, + "pass": true, + "profile": "Sinalite Tiered Bleed (Raster Confirm)", + "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": [ + ] +} diff --git a/loop-preflight/testdata/snapshots/content-bleed-sparse-marks.json b/loop-preflight/testdata/snapshots/content-bleed-sparse-marks.json new file mode 100644 index 000000000..d0db7a3d5 --- /dev/null +++ b/loop-preflight/testdata/snapshots/content-bleed-sparse-marks.json @@ -0,0 +1,32 @@ +{ + "checks": [ + { + "id": "bleed", + "status": "ok" + }, + { + "id": "content-bleed", + "status": "ok" + } + ], + "errors": [ + ], + "fixups_available": [ + ], + "inspection_complete": true, + "pass": true, + "profile": "Sinalite Tiered Bleed", + "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": [ + ] +} diff --git a/loop-preflight/tools/generate_sparse_bleed_fixtures.py b/loop-preflight/tools/generate_sparse_bleed_fixtures.py new file mode 100644 index 000000000..33f75b2e9 --- /dev/null +++ b/loop-preflight/tools/generate_sparse_bleed_fixtures.py @@ -0,0 +1,90 @@ +"""Generate the sparse-mark bleed regression fixtures (issue #120). + +Standard library only. + +Both pages are 216 x 216 pt with a 180 x 180 pt TrimBox at 18 pt and a BleedBox +9 pt outside it, painted with cyan artwork over the trim area only. The 9 pt +bleed margin holds nothing but sparse marks. + +content-bleed-sparse-marks.pdf + One black 1 x 1 pt dot in the middle of each bleed strip. The union of the + artwork bounds touches every strip, so a bounds-only probe reports all four + edges as covered although the margin is effectively empty. + +content-bleed-hairline-margin.pdf + A 0.1 pt black diagonal hairline crosses each bleed strip corner to corner. + Its bounding box fills the strip, so even a coverage estimate taken from + the bounds sees a populated margin, but it inks well under a tenth of the + strip's pixels at 150 dpi. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUT = ROOT / "testdata" / "fixtures" + +MEDIA = 216 +TRIM_LO, TRIM_HI = 18, 198 +BLEED_LO, BLEED_HI = 9, 207 +BOXES = ( + b"/MediaBox [ 0 0 %d %d ] /TrimBox [ %d %d %d %d ] /BleedBox [ %d %d %d %d ]" + % (MEDIA, MEDIA, TRIM_LO, TRIM_LO, TRIM_HI, TRIM_HI, BLEED_LO, BLEED_LO, BLEED_HI, BLEED_HI) +) + + +def assemble(content: bytes, ident: bytes) -> 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 " + BOXES + b" /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) + + +ARTWORK = b"1 0 0 0 k %d %d %d %d re f\n" % (TRIM_LO, TRIM_LO, TRIM_HI - TRIM_LO, TRIM_HI - TRIM_LO) + + +def build_sparse_marks() -> bytes: + mid = MEDIA // 2 + dots = b"0 0 0 1 k\n" + for x, y in ((12, mid), (203, mid), (mid, 12), (mid, 203)): + dots += b"%d %d 1 1 re f\n" % (x, y) + return assemble(ARTWORK + dots, b"7370617273656d6b") + + +def build_hairline_margin() -> bytes: + lines = b"0 0 0 1 K 0.1 w\n" + lines += b"%d %d m %d %d l S\n" % (BLEED_LO, TRIM_LO, TRIM_LO, TRIM_HI) + lines += b"%d %d m %d %d l S\n" % (TRIM_HI, TRIM_LO, BLEED_HI, TRIM_HI) + lines += b"%d %d m %d %d l S\n" % (TRIM_LO, BLEED_LO, TRIM_HI, TRIM_LO) + lines += b"%d %d m %d %d l S\n" % (TRIM_LO, TRIM_HI, TRIM_HI, BLEED_HI) + return assemble(ARTWORK + lines, b"68616972206c696e") + + +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 / "content-bleed-sparse-marks.pdf").write_bytes(build_sparse_marks()) + (out / "content-bleed-hairline-margin.pdf").write_bytes(build_hairline_margin()) + print("wrote content-bleed-sparse-marks.pdf, content-bleed-hairline-margin.pdf") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From fcecd63b65843d3715c59f34c46aa1916cd59d7d Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:45:46 -0700 Subject: [PATCH 11/23] wip(preflight): bleed strip coverage floor and unconfirmed edges (#120) --- LoopLibCore/sources/pdfbleedmarginprobe.cpp | 212 ++++++++++++++++---- LoopLibCore/sources/pdfbleedmarginprobe.h | 30 ++- LoopLibCore/sources/preflightengine.cpp | 60 +++++- 3 files changed, 255 insertions(+), 47 deletions(-) diff --git a/LoopLibCore/sources/pdfbleedmarginprobe.cpp b/LoopLibCore/sources/pdfbleedmarginprobe.cpp index 41f43b17c..8f36635e8 100644 --- a/LoopLibCore/sources/pdfbleedmarginprobe.cpp +++ b/LoopLibCore/sources/pdfbleedmarginprobe.cpp @@ -35,8 +35,11 @@ #include #include +#include #include #include +#include +#include namespace pdf { @@ -109,6 +112,143 @@ PDFReal sideBleedPt(const QMarginsF& bleedMM, PDFBleedFixupSide side) return 0.0; } +bool isAxisAlignedRectangle(const QPainterPath& path, const QRectF& bounds) +{ + constexpr qreal Tolerance = 0.01; + const QList subpaths = path.toSubpathPolygons(); + if (subpaths.size() != 1) + { + return false; + } + + const QPolygonF& polygon = subpaths.front(); + if (polygon.size() < 4 || polygon.size() > 5) + { + return false; + } + + for (const QPointF& point : polygon) + { + const bool onVerticalEdge = std::abs(point.x() - bounds.left()) <= Tolerance || std::abs(point.x() - bounds.right()) <= Tolerance; + const bool onHorizontalEdge = std::abs(point.y() - bounds.top()) <= Tolerance || std::abs(point.y() - bounds.bottom()) <= Tolerance; + if (!onVerticalEdge || !onHorizontalEdge) + { + return false; + } + } + return true; +} + +/// True when the piece's bounding rect is fully painted: an image, a shading or a filled rectangle. +/// A stroked outline shares a rectangle's geometry, so a stroked rectangle is not told apart here. +bool pieceFillsItsBounds(const PDFPrecompiledPage::GraphicPieceInfo& info) +{ + if (info.isImage() || info.isShading()) + { + return true; + } + return info.isVectorGraphics() && isAxisAlignedRectangle(info.pagePath, info.boundingRect); +} + +struct StripCoverage +{ + qreal upper = 0.0; ///< Share of the strip touched by any piece's bounds. + qreal solid = 0.0; ///< Share of the strip covered by pieces that fill their bounds. +}; + +qreal mergedLength(std::vector>& intervals) +{ + constexpr qreal Gap = 0.01; + std::sort(intervals.begin(), intervals.end()); + + qreal total = 0.0; + qreal currentLo = 0.0; + qreal currentHi = 0.0; + bool open = false; + for (const std::pair& interval : intervals) + { + if (open && interval.first <= currentHi + Gap) + { + currentHi = qMax(currentHi, interval.second); + continue; + } + if (open) + { + total += currentHi - currentLo; + } + currentLo = interval.first; + currentHi = interval.second; + open = true; + } + if (open) + { + total += currentHi - currentLo; + } + return total; +} + +/// Splits the strip into bands across its depth and measures, per band, the length along the strip +/// that is touched by piece bounds (upper) and fully crossed by a solid piece (solid). +StripCoverage measureStripCoverage(const PDFPrecompiledPage::GraphicPieceInfos& pieces, + const QRectF& strip, + PDFBleedFixupSide side) +{ + constexpr int Bands = 8; + constexpr qreal Tolerance = 0.01; + + const bool vertical = side == PDFBleedFixupSide::Left || side == PDFBleedFixupSide::Right; + const qreal length = vertical ? strip.height() : strip.width(); + const qreal depth = vertical ? strip.width() : strip.height(); + if (!(length > 0.0) || !(depth > 0.0)) + { + return StripCoverage(); + } + + const qreal alongLo = vertical ? strip.top() : strip.left(); + const qreal alongHi = alongLo + length; + const qreal acrossLo = vertical ? strip.left() : strip.top(); + + StripCoverage coverage; + for (int band = 0; band < Bands; ++band) + { + const qreal bandLo = acrossLo + depth * band / Bands; + const qreal bandHi = acrossLo + depth * (band + 1) / Bands; + + std::vector> touching; + std::vector> solid; + for (const PDFPrecompiledPage::GraphicPieceInfo& piece : pieces) + { + const QRectF& rect = piece.boundingRect; + if (!rect.isValid()) + { + continue; + } + + const qreal rectAcrossLo = vertical ? rect.left() : rect.top(); + const qreal rectAcrossHi = vertical ? rect.right() : rect.bottom(); + const qreal rectAlongLo = qMax(alongLo, vertical ? rect.top() : rect.left()); + const qreal rectAlongHi = qMin(alongHi, vertical ? rect.bottom() : rect.right()); + if (!(rectAlongHi > rectAlongLo) || !(rectAcrossHi > bandLo) || !(rectAcrossLo < bandHi)) + { + continue; + } + + touching.emplace_back(rectAlongLo, rectAlongHi); + if (rectAcrossLo <= bandLo + Tolerance && rectAcrossHi >= bandHi - Tolerance && pieceFillsItsBounds(piece)) + { + solid.emplace_back(rectAlongLo, rectAlongHi); + } + } + + coverage.upper += mergedLength(touching) / length; + coverage.solid += mergedLength(solid) / length; + } + + coverage.upper /= Bands; + coverage.solid /= Bands; + return coverage; +} + } // namespace PDFBleedMarginProbe::PDFBleedMarginProbe(PDFDocumentSession* session) : @@ -122,7 +262,7 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probe(const PDFPage* page, { PDFBleedMarginProbeResult result = probeFast(page, pageIndex, settings); - if (settings.fastOnly || result.allEdgesCovered() || !m_session) + if (settings.fastOnly || (result.allEdgesCovered() && result.allEdgesConfirmed()) || !m_session) { return result; } @@ -141,51 +281,47 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probe(const PDFPage* page, PDFBleedMarginProbeResult rasterResult = probeRaster(page, pageIndex, settings, reference, target); - auto rasterUpgradesEmptyEdge = [](const PDFBleedMarginProbeEdgeResult& rasterEdge) -> bool + // Raster confirmation decides every edge the bounds pass could not prove solid: + // - an empty edge is upgraded when the raster sees substantial margin ink (trim-edge + // antialiasing on an otherwise empty strip stays below the floor); + // - an edge that bounds called populated is demoted when the raster finds it too sparse. + // An edge the raster could not measure (strip over budget) keeps its bounds verdict and + // stays unconfirmed, so the caller can report the gap instead of passing it. + auto applyRaster = [&settings](PDFBleedMarginProbeEdgeResult& target, const PDFBleedMarginProbeEdgeResult& rasterEdge) { if (rasterEdge.totalPixels <= 0) { - return false; - } - - const qreal inkCoverage = static_cast(rasterEdge.inkPixels) - / static_cast(rasterEdge.totalPixels); - // Tier-2 only downgrades Tier-1 false positives when raster sees substantial - // margin ink. Trim-edge antialiasing on otherwise empty strips stays below this. - constexpr qreal kMinInkCoverageToUpgrade = 0.10; - return inkCoverage >= kMinInkCoverageToUpgrade; - }; - - const bool upgradeLeft = !result.left.hasContent && rasterUpgradesEmptyEdge(rasterResult.left); - const bool upgradeRight = !result.right.hasContent && rasterUpgradesEmptyEdge(rasterResult.right); - const bool upgradeTop = !result.top.hasContent && rasterUpgradesEmptyEdge(rasterResult.top); - const bool upgradeBottom = !result.bottom.hasContent && rasterUpgradesEmptyEdge(rasterResult.bottom); - - // Raster confirmation may upgrade fast-path empty edges when strips have real content. - // Even when an edge isn't upgraded (fast path already found content there), keep its - // raster calibration numbers (totalPixels/inkPixels/stripRect) so callers can inspect - // what the raster pass measured instead of the fast path's unset defaults. - auto mergeCalibration = [](PDFBleedMarginProbeEdgeResult& target, - const PDFBleedMarginProbeEdgeResult& rasterEdge, - bool upgrade) - { - if (upgrade) - { - target = rasterEdge; return; } - if (rasterEdge.totalPixels > 0) + + const qreal inkCoverage = static_cast(rasterEdge.inkPixels) / static_cast(rasterEdge.totalPixels); + const bool populated = inkCoverage >= settings.minEdgeCoverage; + const qreal boundsCoverage = target.boundsCoverage; + if (target.confirmed) { + // Solid by bounds, or no applicable bleed: keep the verdict, record the calibration. target.inkPixels = rasterEdge.inkPixels; target.totalPixels = rasterEdge.totalPixels; target.stripRect = rasterEdge.stripRect; + if (!target.hasContent && populated) + { + target = rasterEdge; + target.hasContent = true; + target.boundsCoverage = boundsCoverage; + } + return; } + + target = rasterEdge; + target.hasContent = populated; + target.boundsCoverage = boundsCoverage; + target.confirmed = true; }; - mergeCalibration(result.left, rasterResult.left, upgradeLeft); - mergeCalibration(result.right, rasterResult.right, upgradeRight); - mergeCalibration(result.top, rasterResult.top, upgradeTop); - mergeCalibration(result.bottom, rasterResult.bottom, upgradeBottom); + applyRaster(result.left, rasterResult.left); + applyRaster(result.right, rasterResult.right); + applyRaster(result.top, rasterResult.top); + applyRaster(result.bottom, rasterResult.bottom); return result; } @@ -257,8 +393,12 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probeFast(const PDFPage* page, PDFBleedMarginProbeEdgeResult edgeResult; edgeResult.stripRect = strip; - // Check if content bounds overlap the strip rect. - edgeResult.hasContent = contentBounds.intersects(strip); + // A strip is populated only when the artwork bounds cover enough of it; a few stray + // marks that merely touch it are not bleed. + const StripCoverage coverage = measureStripCoverage(infos, strip, side); + edgeResult.boundsCoverage = coverage.upper; + edgeResult.hasContent = contentBounds.intersects(strip) && coverage.upper >= settings.minEdgeCoverage; + edgeResult.confirmed = !edgeResult.hasContent || coverage.solid >= 1.0 - 1e-3; switch (side) { diff --git a/LoopLibCore/sources/pdfbleedmarginprobe.h b/LoopLibCore/sources/pdfbleedmarginprobe.h index 1b7c042aa..17851b28c 100644 --- a/LoopLibCore/sources/pdfbleedmarginprobe.h +++ b/LoopLibCore/sources/pdfbleedmarginprobe.h @@ -41,6 +41,10 @@ struct LOOPLIBCORESHARED_EXPORT PDFBleedMarginProbeSettings QMarginsF bleedMM = QMarginsF(3.0, 3.0, 3.0, 3.0); bool fastOnly = false; qint64 maxRasterPixels = 250LL * 1000 * 1000; + /// Share of a bleed strip that artwork must cover (by bounds) or ink (by raster) + /// before the edge counts as populated. A margin holding only a few stray marks is + /// below it and is reported as empty. + qreal minEdgeCoverage = 0.10; }; struct LOOPLIBCORESHARED_EXPORT PDFBleedMarginProbeEdgeResult @@ -49,6 +53,11 @@ struct LOOPLIBCORESHARED_EXPORT PDFBleedMarginProbeEdgeResult int inkPixels = 0; int totalPixels = 0; QRectF stripRect; + /// Upper bound of the share of the strip covered by artwork bounds (0..1), set by the fast pass. + qreal boundsCoverage = 0.0; + /// False when hasContent rests only on bounds that cannot prove the strip is solid + /// (a stroke or an odd shape) and no raster measurement confirmed it. + bool confirmed = true; }; struct LOOPLIBCORESHARED_EXPORT PDFBleedMarginProbeResult @@ -62,22 +71,31 @@ struct LOOPLIBCORESHARED_EXPORT PDFBleedMarginProbeResult { return left.hasContent && right.hasContent && top.hasContent && bottom.hasContent; } + + bool allEdgesConfirmed() const + { + return left.confirmed && right.confirmed && top.confirmed && bottom.confirmed; + } }; /// Probes whether rendered artwork on a page extends into the bleed margin. /// -/// Fast path: unions bounding rects from `PDFPrecompiledPage::calculateGraphicPieceInfos` -/// and compares against the reference box expanded by the bleed amount. No rasterization. +/// Fast path: measures how much of each bleed strip the bounding rects from +/// `PDFPrecompiledPage::calculateGraphicPieceInfos` cover. A strip is populated when that +/// coverage reaches `minEdgeCoverage`, so a few stray marks do not count. No rasterization. +/// Bounds only prove a strip is solid for images, shadings and filled rectangles; for any other +/// piece the edge is left unconfirmed. /// -/// Raster path (raster_confirm): renders only the four edge strips at probe_dpi -/// and counts non-background pixels against a threshold. +/// Raster path (raster_confirm): renders the four edge strips at probe_dpi and counts +/// non-background pixels against `minEdgeCoverage`. It confirms or demotes every edge the bounds +/// pass could not prove solid, and upgrades empty edges that the raster finds inked. class LOOPLIBCORESHARED_EXPORT PDFBleedMarginProbe { public: explicit PDFBleedMarginProbe(PDFDocumentSession* session); - /// Full probe: fast bounds pass first, then raster confirmation if the settings - /// request it and the fast pass flagged missing content on any side. + /// Full probe: fast bounds pass first, then raster confirmation if the settings request it + /// and the fast pass left any side empty or unconfirmed. PDFBleedMarginProbeResult probe(const PDFPage* page, size_t pageIndex, const PDFBleedMarginProbeSettings& settings); diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index d0d7dcc24..51857f19b 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -2201,6 +2201,22 @@ void runProcessingStepsCheck(PDFDocumentSession* session, } } +bool edgeIsConfirmed(const PDFBleedMarginProbeResult& result, PDFBleedFixupSide side) +{ + switch (side) + { + case PDFBleedFixupSide::Left: + return result.left.confirmed; + case PDFBleedFixupSide::Right: + return result.right.confirmed; + case PDFBleedFixupSide::Top: + return result.top.confirmed; + case PDFBleedFixupSide::Bottom: + return result.bottom.confirmed; + } + return true; +} + void runContentBleedCheck(PDFDocumentSession* session, const PreflightCheckConfig& check, QList& errors, @@ -2240,16 +2256,50 @@ void runContentBleedCheck(PDFDocumentSession* session, const PDFBleedMarginProbeResult result = probe.probe(page, static_cast(pageIndex), probeSettings); - if (result.allEdgesCovered()) - { - continue; - } - const PDFBleedFixupSide sides[4] = { PDFBleedFixupSide::Left, PDFBleedFixupSide::Right, PDFBleedFixupSide::Top, PDFBleedFixupSide::Bottom }; + // An edge that artwork bounds call populated but that is not provably solid, and that no + // raster measurement confirmed, must not pass as clean bleed. + if (!result.allEdgesConfirmed()) + { + QStringList unconfirmedSides; + for (PDFBleedFixupSide side : sides) + { + if (edgeHasContent(result, side) && !edgeIsConfirmed(result, side)) + { + unconfirmedSides.append(sideNameForFinding(side)); + } + } + + const QString reason = check.rasterConfirm + ? PDFTranslationContext::tr("the strip raster could not be measured within the pixel budget") + : PDFTranslationContext::tr("raster confirmation is off, so artwork bounds alone cannot show the strip is inked"); + PreflightFinding incomplete; + incomplete.scope = QString::fromLatin1(PREFLIGHT_FINDING_SCOPE_PAGE); + incomplete.page = int(pageIndex + 1); + incomplete.type = QStringLiteral("check-incomplete"); + incomplete.severity = QStringLiteral("info"); + incomplete.checkId = check.id; + incomplete.message = PDFTranslationContext::tr("Bleed margin on %1 was not confirmed on page %2: %3.") + .arg(unconfirmedSides.join(QStringLiteral(", "))) + .arg(pageIndex + 1) + .arg(reason); + incomplete.evidence.insert(QStringLiteral("reason"), reason); + incomplete.evidence.insert(QStringLiteral("sides"), unconfirmedSides.join(QStringLiteral(", "))); + incomplete.evidence.insert(QStringLiteral("raster_confirm"), check.rasterConfirm); + incomplete.evidence.insert(QStringLiteral("min_edge_coverage"), probeSettings.minEdgeCoverage); + incomplete.evidence.insert(QStringLiteral("inspection_complete"), false); + pushPreflightFinding(incomplete, incomplete.severity, errors, warnings); + } + + if (result.allEdgesCovered()) + { + continue; + } + bool pageHasBleedGap = false; if (check.rasterConfirm) From 7eebe64384362e63831a845d2dd6eb1d16103780 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:53:51 -0700 Subject: [PATCH 12/23] wip(preflight): confirm bleed edges by solid coverage floor (#120) --- LoopLibCore/sources/pdfbleedmarginprobe.cpp | 2 +- LoopLibCore/sources/pdfbleedmarginprobe.h | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/LoopLibCore/sources/pdfbleedmarginprobe.cpp b/LoopLibCore/sources/pdfbleedmarginprobe.cpp index 8f36635e8..76d88ee27 100644 --- a/LoopLibCore/sources/pdfbleedmarginprobe.cpp +++ b/LoopLibCore/sources/pdfbleedmarginprobe.cpp @@ -398,7 +398,7 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probeFast(const PDFPage* page, const StripCoverage coverage = measureStripCoverage(infos, strip, side); edgeResult.boundsCoverage = coverage.upper; edgeResult.hasContent = contentBounds.intersects(strip) && coverage.upper >= settings.minEdgeCoverage; - edgeResult.confirmed = !edgeResult.hasContent || coverage.solid >= 1.0 - 1e-3; + edgeResult.confirmed = !edgeResult.hasContent || coverage.solid >= settings.minEdgeCoverage; switch (side) { diff --git a/LoopLibCore/sources/pdfbleedmarginprobe.h b/LoopLibCore/sources/pdfbleedmarginprobe.h index 17851b28c..c6059bb65 100644 --- a/LoopLibCore/sources/pdfbleedmarginprobe.h +++ b/LoopLibCore/sources/pdfbleedmarginprobe.h @@ -55,8 +55,9 @@ struct LOOPLIBCORESHARED_EXPORT PDFBleedMarginProbeEdgeResult QRectF stripRect; /// Upper bound of the share of the strip covered by artwork bounds (0..1), set by the fast pass. qreal boundsCoverage = 0.0; - /// False when hasContent rests only on bounds that cannot prove the strip is solid - /// (a stroke or an odd shape) and no raster measurement confirmed it. + /// False when hasContent rests on bounds that touch enough of the strip, but the pieces proven + /// to fill their bounds (images, shadings, filled rectangles) cover less than minEdgeCoverage + /// of it, and no raster measurement confirmed it. bool confirmed = true; }; @@ -83,7 +84,7 @@ struct LOOPLIBCORESHARED_EXPORT PDFBleedMarginProbeResult /// Fast path: measures how much of each bleed strip the bounding rects from /// `PDFPrecompiledPage::calculateGraphicPieceInfos` cover. A strip is populated when that /// coverage reaches `minEdgeCoverage`, so a few stray marks do not count. No rasterization. -/// Bounds only prove a strip is solid for images, shadings and filled rectangles; for any other +/// Bounds prove a strip is populated only through images, shadings and filled rectangles; for any other /// piece the edge is left unconfirmed. /// /// Raster path (raster_confirm): renders the four edge strips at probe_dpi and counts From 2573b17b6f9c8b42d8bd3b597a3fd5145025674e Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:55:59 -0700 Subject: [PATCH 13/23] wip(preflight): manifest expectations (#120) --- loop-preflight/testdata/fixtures/manifest.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/loop-preflight/testdata/fixtures/manifest.json b/loop-preflight/testdata/fixtures/manifest.json index b2d3d7d4d..c63883fa6 100644 --- a/loop-preflight/testdata/fixtures/manifest.json +++ b/loop-preflight/testdata/fixtures/manifest.json @@ -253,10 +253,10 @@ "profile": "examples/profile-tiered-bleed.json", "expect": { "pass": true, - "check_ids": [] + "check_ids": ["content-bleed"] }, "source": "generated", - "notes": "#120 regression: the bleed margin holds only a 1 pt dot per edge. The artwork bounds touch every strip, so the bounds-only probe passed it clean." + "notes": "#120 regression: the bleed margin holds only a 1 pt dot per edge. The artwork bounds touch every strip, so the bounds-only probe passed it clean. The strips now need 10% coverage, so each edge reports empty." }, { "id": "content-bleed-hairline-margin", @@ -264,10 +264,10 @@ "profile": "examples/profile-tiered-bleed-raster.json", "expect": { "pass": true, - "check_ids": [] + "check_ids": ["content-bleed"] }, "source": "generated", - "notes": "#120 regression: the bleed margin holds only a 0.1 pt diagonal hairline across each edge. Raster confirmation never re-checked an edge the bounds pass had already accepted, so it passed clean." + "notes": "#120 regression: the bleed margin holds only a 0.1 pt diagonal hairline across each edge. Raster confirmation never re-checked an edge the bounds pass had already accepted, so it passed clean. Raster now demotes each edge to bleed-margin-empty because the hairline inks under 10% of the strip." }, { "id": "ai-art-missing-bleed", From 830d8eb56cf7c21b721b5c921e53b837df16e876 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:58:09 -0700 Subject: [PATCH 14/23] wip(preflight): bleed coverage tests (#120) --- UnitTests/tst_preflightenginetest.cpp | 130 ++++++++++++++++++ .../content-bleed-hairline-margin.json | 81 ++++++++++- .../snapshots/content-bleed-sparse-marks.json | 39 +++++- 3 files changed, 248 insertions(+), 2 deletions(-) diff --git a/UnitTests/tst_preflightenginetest.cpp b/UnitTests/tst_preflightenginetest.cpp index a9fcd0294..072461482 100644 --- a/UnitTests/tst_preflightenginetest.cpp +++ b/UnitTests/tst_preflightenginetest.cpp @@ -28,6 +28,7 @@ #include "pdfcolorinventory.h" #include "pdfdocumentbuilder.h" #include "pdfdocumentreader.h" +#include "pdfbleedmarginprobe.h" #include "pdfdocumentsession.h" #include "pdfimage.h" #include "pdfinkcoverageprobe.h" @@ -99,6 +100,10 @@ private slots: void legacyDecisionsWithoutSchemaKindStillLoad(); void run_contentBleedWithoutRaster_emitsContentBleedAndNeedsAutoBleed(); void run_contentBleedRasterConfirm_emitsBleedMarginEmptyAndNeedsAutoBleed(); + void run_contentBleedSparseMarks_reportsEmptyMarginInsteadOfPassing(); + void run_contentBleedHairlineWithoutRaster_reportsIncompleteNotClean(); + void run_contentBleedHairlineWithRaster_reportsEveryEdgeEmpty(); + void probe_sparseMarginCalibration_pinsTheCoverageFloor(); void run_whiteOverprint_emitsWarningForWhitePaintWithOverprint(); void run_whiteOverprint_passesWhenOverprintOff(); void run_whiteOverprint_emitsWarningInsideFormXObject(); @@ -1524,6 +1529,131 @@ void PreflightEngineTest::run_contentBleedRasterConfirm_emitsBleedMarginEmptyAnd QCOMPARE(result.fixupsAvailable.first().id, QStringLiteral("add-bleed")); } +namespace +{ +pdf::PDFDocument readBleedFixture(const QString& name) +{ + const QString fixturePath = QStringLiteral(LOOP_PREFLIGHT_SOURCE_DIR "/testdata/fixtures/") + name; + pdf::PDFDocumentReader reader(nullptr, [](bool*) + { return QString(); }, true, false); + pdf::PDFDocument document = reader.readFromFile(fixturePath); + if (reader.getReadingResult() != pdf::PDFDocumentReader::Result::OK) + { + return pdf::PDFDocument(); + } + return document; +} +} // namespace + +void PreflightEngineTest::run_contentBleedSparseMarks_reportsEmptyMarginInsteadOfPassing() +{ + pdf::PDFDocument document = readBleedFixture(QStringLiteral("content-bleed-sparse-marks.pdf")); + QVERIFY(document.getCatalog() && document.getCatalog()->getPageCount() == 1); + pdf::PDFDocumentSession session(&document); + pdf::PreflightEngine engine(&session); + + // A 1 pt dot per edge touches every strip but covers far less than the coverage floor. + const pdf::PreflightResult result = engine.run(tieredBleedProfile(false)); + QVERIFY(result.pass); + QCOMPARE(result.warnings.size(), 2); + QCOMPARE(result.warnings.at(0).type, QStringLiteral("content-bleed")); + QVERIFY(result.warnings.at(0).message.contains(QStringLiteral("left, right, top, bottom"))); + QCOMPARE(result.warnings.at(1).type, QStringLiteral("needs-auto-bleed")); +} + +void PreflightEngineTest::run_contentBleedHairlineWithoutRaster_reportsIncompleteNotClean() +{ + pdf::PDFDocument document = readBleedFixture(QStringLiteral("content-bleed-hairline-margin.pdf")); + QVERIFY(document.getCatalog() && document.getCatalog()->getPageCount() == 1); + pdf::PDFDocumentSession session(&document); + pdf::PreflightEngine engine(&session); + + // The hairline's bounds fill each strip, but bounds cannot show a stroke inks it: without + // raster confirmation the check must say so instead of passing. + const pdf::PreflightResult result = engine.run(tieredBleedProfile(false)); + QCOMPARE(result.errors.size(), 0); + QCOMPARE(result.warnings.size(), 1); + QCOMPARE(result.warnings.at(0).type, QStringLiteral("check-incomplete")); + QCOMPARE(result.warnings.at(0).checkId, QStringLiteral("content-bleed")); + QCOMPARE(result.warnings.at(0).evidence.value(QStringLiteral("raster_confirm")).toBool(), false); + QCOMPARE(result.warnings.at(0).evidence.value(QStringLiteral("inspection_complete")).toBool(), false); + QVERIFY(!result.pass); +} + +void PreflightEngineTest::run_contentBleedHairlineWithRaster_reportsEveryEdgeEmpty() +{ + pdf::PDFDocument document = readBleedFixture(QStringLiteral("content-bleed-hairline-margin.pdf")); + QVERIFY(document.getCatalog() && document.getCatalog()->getPageCount() == 1); + pdf::PDFDocumentSession session(&document); + pdf::PreflightEngine engine(&session); + + const pdf::PreflightResult result = engine.run(tieredBleedProfile(true)); + QVERIFY(result.pass); + int emptyEdges = 0; + for (const pdf::PreflightFinding& finding : result.warnings) + { + QVERIFY(finding.type != QStringLiteral("check-incomplete")); + if (finding.type == QStringLiteral("bleed-margin-empty")) + { + ++emptyEdges; + } + } + QCOMPARE(emptyEdges, 4); +} + +void PreflightEngineTest::probe_sparseMarginCalibration_pinsTheCoverageFloor() +{ + pdf::PDFBleedMarginProbeSettings settings; + const pdf::PDFReal bleedMm = pdf::convertPDFPointToMM(9.0); + settings.bleedMM = QMarginsF(bleedMm, bleedMm, bleedMm, bleedMm); + settings.dpi = 150; + QCOMPARE(settings.minEdgeCoverage, 0.10); + + auto probeFixture = [&settings](const QString& name, bool fastOnly) + { + pdf::PDFDocument document = readBleedFixture(name); + pdf::PDFDocumentSession session(&document); + pdf::PDFBleedMarginProbe probe(&session); + pdf::PDFBleedMarginProbeSettings local = settings; + local.fastOnly = fastOnly; + return probe.probe(document.getCatalog()->getPage(0), 0, local); + }; + + // Solid artwork across every strip: populated and proven by bounds alone. + const pdf::PDFBleedMarginProbeResult adequate = probeFixture(QStringLiteral("content-bleed-adequate.pdf"), true); + QVERIFY(adequate.allEdgesCovered()); + QVERIFY(adequate.allEdgesConfirmed()); + QVERIFY(adequate.left.boundsCoverage > 0.99); + + // Stray dots: bounds coverage is a fraction of a percent, far under the floor. + const pdf::PDFBleedMarginProbeResult sparse = probeFixture(QStringLiteral("content-bleed-sparse-marks.pdf"), true); + for (const pdf::PDFBleedMarginProbeEdgeResult& edge : { sparse.left, sparse.right, sparse.top, sparse.bottom }) + { + QVERIFY(!edge.hasContent); + QVERIFY(edge.boundsCoverage > 0.0 && edge.boundsCoverage < 0.02); + } + + // Diagonal hairline: bounds see a full strip but cannot prove it inked. + const pdf::PDFBleedMarginProbeResult hairlineBounds = probeFixture(QStringLiteral("content-bleed-hairline-margin.pdf"), true); + for (const pdf::PDFBleedMarginProbeEdgeResult& edge : { hairlineBounds.left, hairlineBounds.right, hairlineBounds.top, hairlineBounds.bottom }) + { + QVERIFY(edge.hasContent); + QVERIFY(!edge.confirmed); + QVERIFY(edge.boundsCoverage > 0.9); + } + + // The raster measures the same hairline well under the floor and demotes every edge. + const pdf::PDFBleedMarginProbeResult hairlineRaster = probeFixture(QStringLiteral("content-bleed-hairline-margin.pdf"), false); + for (const pdf::PDFBleedMarginProbeEdgeResult& edge : { hairlineRaster.left, hairlineRaster.right, hairlineRaster.top, hairlineRaster.bottom }) + { + QVERIFY(!edge.hasContent); + QVERIFY(edge.confirmed); + QVERIFY(edge.totalPixels > 0); + const qreal ink = static_cast(edge.inkPixels) / static_cast(edge.totalPixels); + QVERIFY2(ink > 0.0 && ink < settings.minEdgeCoverage, qPrintable(QString::number(ink))); + } +} + void PreflightEngineTest::run_whiteOverprint_emitsWarningForWhitePaintWithOverprint() { const QString fixturePath = QStringLiteral(LOOP_PREFLIGHT_SOURCE_DIR "/testdata/fixtures/white-overprint.pdf"); diff --git a/loop-preflight/testdata/snapshots/content-bleed-hairline-margin.json b/loop-preflight/testdata/snapshots/content-bleed-hairline-margin.json index c06abc382..0e9182efc 100644 --- a/loop-preflight/testdata/snapshots/content-bleed-hairline-margin.json +++ b/loop-preflight/testdata/snapshots/content-bleed-hairline-margin.json @@ -6,12 +6,21 @@ }, { "id": "content-bleed", - "status": "ok" + "status": "warning" } ], "errors": [ ], "fixups_available": [ + { + "description": "Extend page boxes / artwork to provide bleed", + "id": "add-bleed", + "params": { + "amount_pt": 9, + "mode": "mirror" + }, + "safe": false + } ], "inspection_complete": true, "pass": true, @@ -28,5 +37,75 @@ ] }, "warnings": [ + { + "bbox": [ + 9, + 18, + 18, + 198 + ], + "check_id": "content-bleed", + "message": "Bleed margin empty on left edge", + "page": 1, + "scope": "object", + "severity": "warning", + "type": "bleed-margin-empty" + }, + { + "bbox": [ + 198, + 18, + 207, + 198 + ], + "check_id": "content-bleed", + "message": "Bleed margin empty on right edge", + "page": 1, + "scope": "object", + "severity": "warning", + "type": "bleed-margin-empty" + }, + { + "bbox": [ + 18, + 198, + 198, + 207 + ], + "check_id": "content-bleed", + "message": "Bleed margin empty on top edge", + "page": 1, + "scope": "object", + "severity": "warning", + "type": "bleed-margin-empty" + }, + { + "bbox": [ + 18, + 9, + 198, + 18 + ], + "check_id": "content-bleed", + "message": "Bleed margin empty on bottom edge", + "page": 1, + "scope": "object", + "severity": "warning", + "type": "bleed-margin-empty" + }, + { + "bbox": [ + 18, + 18, + 198, + 198 + ], + "check_id": "content-bleed", + "message": "Page is a candidate for the add-bleed fixup", + "page": 1, + "scope": "page", + "severity": "info", + "type": "needs-auto-bleed" + } ] } diff --git a/loop-preflight/testdata/snapshots/content-bleed-sparse-marks.json b/loop-preflight/testdata/snapshots/content-bleed-sparse-marks.json index d0db7a3d5..a95c78f85 100644 --- a/loop-preflight/testdata/snapshots/content-bleed-sparse-marks.json +++ b/loop-preflight/testdata/snapshots/content-bleed-sparse-marks.json @@ -6,12 +6,21 @@ }, { "id": "content-bleed", - "status": "ok" + "status": "warning" } ], "errors": [ ], "fixups_available": [ + { + "description": "Extend page boxes / artwork to provide bleed", + "id": "add-bleed", + "params": { + "amount_pt": 9, + "mode": "mirror" + }, + "safe": false + } ], "inspection_complete": true, "pass": true, @@ -28,5 +37,33 @@ ] }, "warnings": [ + { + "bbox": [ + 9, + 9, + 207, + 207 + ], + "check_id": "content-bleed", + "message": "Artwork does not extend into bleed margin on left, right, top, bottom", + "page": 1, + "scope": "object", + "severity": "warning", + "type": "content-bleed" + }, + { + "bbox": [ + 18, + 18, + 198, + 198 + ], + "check_id": "content-bleed", + "message": "Page is a candidate for the add-bleed fixup", + "page": 1, + "scope": "page", + "severity": "info", + "type": "needs-auto-bleed" + } ] } From e88ec5a1efcc42eca77daa176db8b6935cd820f6 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:00:36 -0700 Subject: [PATCH 15/23] docs(preflight): land bleed-raster-strip-depth, document coverage floor, add change files (#120) --- UnitTests/tst_preflightenginetest.cpp | 5 ++-- ...ssue-120-sparse-bleed-raster.evidence.yaml | 30 +++++++++++++++++++ changes/cc-issue-120-sparse-bleed-raster.md | 6 ++++ docs/generated/preflight-check-catalog.json | 7 ++++- docs/generated/preflight-corpus-coverage.json | 6 ++-- .../generated/preflight-coverage-backlog.json | 6 ++-- docs/preflight-check-catalog-overlay.json | 13 +++++--- loop-preflight/README.md | 12 ++++++++ 8 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 changes/cc-issue-120-sparse-bleed-raster.evidence.yaml create mode 100644 changes/cc-issue-120-sparse-bleed-raster.md diff --git a/UnitTests/tst_preflightenginetest.cpp b/UnitTests/tst_preflightenginetest.cpp index 072461482..8f756dd10 100644 --- a/UnitTests/tst_preflightenginetest.cpp +++ b/UnitTests/tst_preflightenginetest.cpp @@ -1642,7 +1642,8 @@ void PreflightEngineTest::probe_sparseMarginCalibration_pinsTheCoverageFloor() QVERIFY(edge.boundsCoverage > 0.9); } - // The raster measures the same hairline well under the floor and demotes every edge. + // The raster measures the same hairline (marks at or below the probe threshold) well under the + // floor and demotes every edge. const pdf::PDFBleedMarginProbeResult hairlineRaster = probeFixture(QStringLiteral("content-bleed-hairline-margin.pdf"), false); for (const pdf::PDFBleedMarginProbeEdgeResult& edge : { hairlineRaster.left, hairlineRaster.right, hairlineRaster.top, hairlineRaster.bottom }) { @@ -1650,7 +1651,7 @@ void PreflightEngineTest::probe_sparseMarginCalibration_pinsTheCoverageFloor() QVERIFY(edge.confirmed); QVERIFY(edge.totalPixels > 0); const qreal ink = static_cast(edge.inkPixels) / static_cast(edge.totalPixels); - QVERIFY2(ink > 0.0 && ink < settings.minEdgeCoverage, qPrintable(QString::number(ink))); + QVERIFY2(ink < settings.minEdgeCoverage, qPrintable(QString::number(ink))); } } diff --git a/changes/cc-issue-120-sparse-bleed-raster.evidence.yaml b/changes/cc-issue-120-sparse-bleed-raster.evidence.yaml new file mode 100644 index 000000000..ff66cf1d5 --- /dev/null +++ b/changes/cc-issue-120-sparse-bleed-raster.evidence.yaml @@ -0,0 +1,30 @@ +format_version: 1 +kind: evidence +claims: + - id: sparse-bleed-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: bleed-strip-coverage-floor + evidence: + - unit:agent-policy:core + - unit:agent-policy:preflight + - unit:UnitTestsBleedMarginProbe + - 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, UnitTestsPreflightCorpus and UnitTestsBleedMarginProbe 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 - the 10% coverage floor is a policy choice that can flag a legitimately sparse bleed texture, a stroked rectangle outline cannot be told from a filled rectangle by bounds, and a white fill still counts as artwork. diff --git a/changes/cc-issue-120-sparse-bleed-raster.md b/changes/cc-issue-120-sparse-bleed-raster.md new file mode 100644 index 000000000..b65688d34 --- /dev/null +++ b/changes/cc-issue-120-sparse-bleed-raster.md @@ -0,0 +1,6 @@ +# Stop sparse bleed marks passing content-bleed (#120) + +Category: fixed +Audience: prepress operators and preflight maintainers relying on content-bleed +Breaking-Change: no +Summary: content-bleed no longer accepts a bleed strip because artwork bounds merely touch it. A strip counts as populated only above a 10% coverage floor, and bounds prove a strip populated only through images, shadings and filled rectangles. With raster_confirm the strip raster decides every other edge, including demoting a sparse one to bleed-margin-empty; without it, or when the strip exceeds max_raster_pixels, the page reports check-incomplete instead of passing. Adds the content-bleed-sparse-marks and content-bleed-hairline-margin regression fixtures and lands the bleed-raster-strip-depth backlog row. diff --git a/docs/generated/preflight-check-catalog.json b/docs/generated/preflight-check-catalog.json index 700197bf2..a755cc79c 100644 --- a/docs/generated/preflight-check-catalog.json +++ b/docs/generated/preflight-check-catalog.json @@ -212,7 +212,7 @@ "fixups": [ "add-bleed" ], - "limitations": "Raster confirm is opt-in; vector-only bounds can miss sparse marks.", + "limitations": "A strip counts as populated only above a 10% coverage floor (artwork bounds, or raster ink with raster_confirm). Bounds prove a strip populated only through images, shadings and filled rectangles, so stroked or oddly shaped artwork is judged by raster confirmation or reported check-incomplete. A stroked rectangle outline cannot be told from a filled rectangle by bounds. White or near-white fills count as artwork.", "measures": "Whether painted content reaches the requested bleed margin.", "parameters": [ { @@ -266,6 +266,11 @@ "condition": "always info: the page has a bleed gap and is a candidate for the add-bleed fixup.", "finding_type": "needs-auto-bleed", "severity": "info" + }, + { + "condition": "an edge's bounds touch enough of the strip but do not prove it solid, and raster_confirm is off or the strip raster exceeded max_raster_pixels, so the edge is reported incomplete rather than clean.", + "finding_type": "check-incomplete", + "severity": "info" } ] }, diff --git a/docs/generated/preflight-corpus-coverage.json b/docs/generated/preflight-corpus-coverage.json index c261ad5f0..69268d51e 100644 --- a/docs/generated/preflight-corpus-coverage.json +++ b/docs/generated/preflight-corpus-coverage.json @@ -8,7 +8,7 @@ "matrix_id": "loop-gwg-pdfx-v1", "rows": { "bleed": { - "clean_fixture_count": 24, + "clean_fixture_count": 26, "corpus_gap": null, "coverage": "partial", "finding_fixtures": [ @@ -89,8 +89,10 @@ "ai-art-missing-bleed", "ai-art-partial-bleed", "ai-art-raster-trim-edge", + "content-bleed-hairline-margin", "content-bleed-missing", "content-bleed-raster-confirm", + "content-bleed-sparse-marks", "content-bleed-three-of-four" ], "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..e37101036 100644 --- a/docs/generated/preflight-coverage-backlog.json +++ b/docs/generated/preflight-coverage-backlog.json @@ -225,17 +225,17 @@ "state": "open" }, { - "closed_by": "#120", + "closed_by": "content-bleed", "deferral": null, "families": [ "sheetfed-offset", "packaging", "digital" ], - "gap": "content-bleed raster confirmation is opt-in and the strip probe reads at probe_dpi, so sparse marks below probe_threshold can pass while the bleed margin is empty", + "gap": "content-bleed accepted any artwork bounds touching a bleed strip, and raster confirmation never re-checked an accepted edge, so sparse marks passed while the bleed margin was empty; strips now need 10% coverage by bounds or raster ink, and an edge bounds cannot prove solid is raster-checked or reported check-incomplete (filed as #120)", "id": "bleed-raster-strip-depth", "priority": "P2", - "state": "open" + "state": "landed" }, { "closed_by": "#113", diff --git a/docs/preflight-check-catalog-overlay.json b/docs/preflight-check-catalog-overlay.json index 916f07a9d..307632941 100644 --- a/docs/preflight-check-catalog-overlay.json +++ b/docs/preflight-check-catalog-overlay.json @@ -346,7 +346,7 @@ }, "content-bleed": { "measures": "Whether painted content reaches the requested bleed margin.", - "limitations": "Raster confirm is opt-in; vector-only bounds can miss sparse marks.", + "limitations": "A strip counts as populated only above a 10% coverage floor (artwork bounds, or raster ink with raster_confirm). Bounds prove a strip populated only through images, shadings and filled rectangles, so stroked or oddly shaped artwork is judged by raster confirmation or reported check-incomplete. A stroked rectangle outline cannot be told from a filled rectangle by bounds. White or near-white fills count as artwork.", "coverage": "partial", "families": [ "sheetfed-offset", @@ -405,6 +405,11 @@ "finding_type": "needs-auto-bleed", "severity": "info", "condition": "always info: the page has a bleed gap and is a candidate for the add-bleed fixup." + }, + { + "finding_type": "check-incomplete", + "severity": "info", + "condition": "an edge's bounds touch enough of the strip but do not prove it solid, and raster_confirm is off or the strip raster exceeded max_raster_pixels, so the edge is reported incomplete rather than clean." } ], "evidence": [ @@ -1443,14 +1448,14 @@ { "id": "bleed-raster-strip-depth", "priority": "P2", - "gap": "content-bleed raster confirmation is opt-in and the strip probe reads at probe_dpi, so sparse marks below probe_threshold can pass while the bleed margin is empty", + "gap": "content-bleed accepted any artwork bounds touching a bleed strip, and raster confirmation never re-checked an accepted edge, so sparse marks passed while the bleed margin was empty; strips now need 10% coverage by bounds or raster ink, and an edge bounds cannot prove solid is raster-checked or reported check-incomplete (filed as #120)", "families": [ "sheetfed-offset", "packaging", "digital" ], - "state": "open", - "closed_by": "#120", + "state": "landed", + "closed_by": "content-bleed", "deferral": null }, { diff --git a/loop-preflight/README.md b/loop-preflight/README.md index 961836f20..0868658bf 100644 --- a/loop-preflight/README.md +++ b/loop-preflight/README.md @@ -170,6 +170,16 @@ Tier-2 runs only when Tier-1 passes (boxes are adequate) and the profile include `raster_confirm: true`, pages flagged by the fast pass undergo a focused strip-raster confirmation — no full-page render. +A bleed strip counts as populated only when artwork covers at least 10% of it, so +a few stray marks that merely touch the strip do not pass. The bounds pass proves a +strip populated only through images, shadings and filled rectangles; any other +artwork (hairlines, strokes, text) that touches the strip is not proven. With +`raster_confirm: true` the strip raster decides such an edge (ink share of at least +10%, at `probe_dpi`); without it, or when the strip exceeds `max_raster_pixels`, the +page reports an informational `check-incomplete` finding instead of passing clean. +A stroked rectangle outline cannot be told from a filled rectangle by bounds, and a +white fill counts as artwork. + The default `loop-default` profile does **not** enable Tier-2. Use a separate profile to opt in (see `examples/profile-tiered-bleed.json`). @@ -527,6 +537,8 @@ passes, and only the target check is exercised. | `content-bleed-missing.pdf` | tiered-bleed | fail (warnings) | `content-bleed`, `needs-auto-bleed` (artwork stops at trim) | | `content-bleed-raster-confirm.pdf` | tiered-bleed-raster | fail (warnings) | `bleed-margin-empty`, `needs-auto-bleed` (raster-confirmed empty margins) | | `content-bleed-three-of-four.pdf` | tiered-bleed | fail (warnings) | `content-bleed` (one empty edge only) | +| `content-bleed-sparse-marks.pdf` | tiered-bleed | fail (warnings) | `content-bleed` (a 1 pt dot per edge is below the coverage floor) | +| `content-bleed-hairline-margin.pdf` | tiered-bleed-raster | fail (warnings) | `bleed-margin-empty` (a diagonal hairline per edge inks under the floor; `check-incomplete` without raster) | | `ink-coverage-over.pdf` | test-ink-coverage | warning | `ink-coverage` (over-limit TAC region) | | `ink-coverage-ok.pdf` | test-ink-coverage | pass | clean TAC below the threshold | | `transparency-normal-cmyk.pdf` | test-transparency-risk | pass | matching CMYK group/content is clean | From 0559ee6252317c58ad8d18e0fbbbc2186f65de3b Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:00:42 -0700 Subject: [PATCH 16/23] style(preflight): clang-format bleed probe and engine (#120) --- LoopLibCore/sources/pdfbleedmarginprobe.cpp | 110 +++++++++++++------- LoopLibCore/sources/pdfbleedmarginprobe.h | 4 +- LoopLibCore/sources/preflightengine.cpp | 4 +- 3 files changed, 75 insertions(+), 43 deletions(-) diff --git a/LoopLibCore/sources/pdfbleedmarginprobe.cpp b/LoopLibCore/sources/pdfbleedmarginprobe.cpp index 76d88ee27..0367f44f8 100644 --- a/LoopLibCore/sources/pdfbleedmarginprobe.cpp +++ b/LoopLibCore/sources/pdfbleedmarginprobe.cpp @@ -75,7 +75,8 @@ QRectF targetBleedRect(const QRectF& reference, const QMarginsF& bleedMM) return QRectF(reference.left() - left, reference.top() - bottom, reference.width() + left + right, - reference.height() + top + bottom).normalized(); + reference.height() + top + bottom) + .normalized(); } QRect mapPageRectToImage(const QRectF& pageRect, const QTransform& pageToDevice, const QSize& imageSize) @@ -104,10 +105,14 @@ PDFReal sideBleedPt(const QMarginsF& bleedMM, PDFBleedFixupSide side) { switch (side) { - case PDFBleedFixupSide::Left: return bleedMM.left() * PDF_MM_TO_POINT; - case PDFBleedFixupSide::Right: return bleedMM.right() * PDF_MM_TO_POINT; - case PDFBleedFixupSide::Top: return bleedMM.top() * PDF_MM_TO_POINT; - case PDFBleedFixupSide::Bottom: return bleedMM.bottom() * PDF_MM_TO_POINT; + case PDFBleedFixupSide::Left: + return bleedMM.left() * PDF_MM_TO_POINT; + case PDFBleedFixupSide::Right: + return bleedMM.right() * PDF_MM_TO_POINT; + case PDFBleedFixupSide::Top: + return bleedMM.top() * PDF_MM_TO_POINT; + case PDFBleedFixupSide::Bottom: + return bleedMM.bottom() * PDF_MM_TO_POINT; } return 0.0; } @@ -249,7 +254,7 @@ StripCoverage measureStripCoverage(const PDFPrecompiledPage::GraphicPieceInfos& return coverage; } -} // namespace +} // namespace PDFBleedMarginProbe::PDFBleedMarginProbe(PDFDocumentSession* session) : m_session(session) @@ -257,8 +262,8 @@ PDFBleedMarginProbe::PDFBleedMarginProbe(PDFDocumentSession* session) : } PDFBleedMarginProbeResult PDFBleedMarginProbe::probe(const PDFPage* page, - size_t pageIndex, - const PDFBleedMarginProbeSettings& settings) + size_t pageIndex, + const PDFBleedMarginProbeSettings& settings) { PDFBleedMarginProbeResult result = probeFast(page, pageIndex, settings); @@ -327,8 +332,8 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probe(const PDFPage* page, } PDFBleedMarginProbeResult PDFBleedMarginProbe::probeFast(const PDFPage* page, - size_t pageIndex, - const PDFBleedMarginProbeSettings& settings) + size_t pageIndex, + const PDFBleedMarginProbeSettings& settings) { PDFBleedMarginProbeResult result; @@ -381,10 +386,18 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probeFast(const PDFPage* page, edgeResult.hasContent = true; switch (side) { - case PDFBleedFixupSide::Left: result.left = edgeResult; break; - case PDFBleedFixupSide::Right: result.right = edgeResult; break; - case PDFBleedFixupSide::Top: result.top = edgeResult; break; - case PDFBleedFixupSide::Bottom: result.bottom = edgeResult; break; + case PDFBleedFixupSide::Left: + result.left = edgeResult; + break; + case PDFBleedFixupSide::Right: + result.right = edgeResult; + break; + case PDFBleedFixupSide::Top: + result.top = edgeResult; + break; + case PDFBleedFixupSide::Bottom: + result.bottom = edgeResult; + break; } continue; } @@ -402,10 +415,18 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probeFast(const PDFPage* page, switch (side) { - case PDFBleedFixupSide::Left: result.left = edgeResult; break; - case PDFBleedFixupSide::Right: result.right = edgeResult; break; - case PDFBleedFixupSide::Top: result.top = edgeResult; break; - case PDFBleedFixupSide::Bottom: result.bottom = edgeResult; break; + case PDFBleedFixupSide::Left: + result.left = edgeResult; + break; + case PDFBleedFixupSide::Right: + result.right = edgeResult; + break; + case PDFBleedFixupSide::Top: + result.top = edgeResult; + break; + case PDFBleedFixupSide::Bottom: + result.bottom = edgeResult; + break; } } @@ -413,10 +434,10 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probeFast(const PDFPage* page, } PDFBleedMarginProbeResult PDFBleedMarginProbe::probeRaster(const PDFPage* page, - size_t pageIndex, - const PDFBleedMarginProbeSettings& settings, - const QRectF& reference, - const QRectF&) + size_t pageIndex, + const PDFBleedMarginProbeSettings& settings, + const QRectF& reference, + const QRectF&) { PDFBleedMarginProbeResult result; @@ -446,8 +467,7 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probeRaster(const PDFPage* page, features.setFlag(PDFRenderer::DisplayAnnotations, false); const QSizeF mediaSize = page->getRotatedMediaBox().size(); - if (settings.dpi <= 0 || !std::isfinite(mediaSize.width()) || !std::isfinite(mediaSize.height()) - || mediaSize.width() <= 0.0 || mediaSize.height() <= 0.0) + if (settings.dpi <= 0 || !std::isfinite(mediaSize.width()) || !std::isfinite(mediaSize.height()) || mediaSize.width() <= 0.0 || mediaSize.height() <= 0.0) { return result; } @@ -456,9 +476,7 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probeRaster(const PDFPage* page, const double fullWidthPxReal = std::ceil(mediaSize.width() * pointToPixel); const double fullHeightPxReal = std::ceil(mediaSize.height() * pointToPixel); - if (!std::isfinite(fullWidthPxReal) || !std::isfinite(fullHeightPxReal) - || fullWidthPxReal > static_cast(std::numeric_limits::max()) - || fullHeightPxReal > static_cast(std::numeric_limits::max())) + if (!std::isfinite(fullWidthPxReal) || !std::isfinite(fullHeightPxReal) || fullWidthPxReal > static_cast(std::numeric_limits::max()) || fullHeightPxReal > static_cast(std::numeric_limits::max())) { return result; } @@ -489,10 +507,18 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probeRaster(const PDFPage* page, edgeResult.hasContent = true; switch (side) { - case PDFBleedFixupSide::Left: result.left = edgeResult; break; - case PDFBleedFixupSide::Right: result.right = edgeResult; break; - case PDFBleedFixupSide::Top: result.top = edgeResult; break; - case PDFBleedFixupSide::Bottom: result.bottom = edgeResult; break; + case PDFBleedFixupSide::Left: + result.left = edgeResult; + break; + case PDFBleedFixupSide::Right: + result.right = edgeResult; + break; + case PDFBleedFixupSide::Top: + result.top = edgeResult; + break; + case PDFBleedFixupSide::Bottom: + result.bottom = edgeResult; + break; } continue; } @@ -545,10 +571,18 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probeRaster(const PDFPage* page, switch (side) { - case PDFBleedFixupSide::Left: result.left = edgeResult; break; - case PDFBleedFixupSide::Right: result.right = edgeResult; break; - case PDFBleedFixupSide::Top: result.top = edgeResult; break; - case PDFBleedFixupSide::Bottom: result.bottom = edgeResult; break; + case PDFBleedFixupSide::Left: + result.left = edgeResult; + break; + case PDFBleedFixupSide::Right: + result.right = edgeResult; + break; + case PDFBleedFixupSide::Top: + result.top = edgeResult; + break; + case PDFBleedFixupSide::Bottom: + result.bottom = edgeResult; + break; } } @@ -570,9 +604,7 @@ bool PDFBleedMarginProbe::pixelIsInk(const QImage& image, int x, int y, int thre } // Premultiplied white/near-white background is empty margin, not ink. - if (qRed(pixel) >= 255 - threshold - && qGreen(pixel) >= 255 - threshold - && qBlue(pixel) >= 255 - threshold) + if (qRed(pixel) >= 255 - threshold && qGreen(pixel) >= 255 - threshold && qBlue(pixel) >= 255 - threshold) { return false; } @@ -580,4 +612,4 @@ bool PDFBleedMarginProbe::pixelIsInk(const QImage& image, int x, int y, int thre return true; } -} // namespace pdf +} // namespace pdf diff --git a/LoopLibCore/sources/pdfbleedmarginprobe.h b/LoopLibCore/sources/pdfbleedmarginprobe.h index c6059bb65..b3a070a17 100644 --- a/LoopLibCore/sources/pdfbleedmarginprobe.h +++ b/LoopLibCore/sources/pdfbleedmarginprobe.h @@ -117,6 +117,6 @@ class LOOPLIBCORESHARED_EXPORT PDFBleedMarginProbe PDFDocumentSession* m_session; }; -} // namespace pdf +} // namespace pdf -#endif // PDFBLEEDMARGINPROBE_H +#endif // PDFBLEEDMARGINPROBE_H diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index 51857f19b..572338841 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -2275,8 +2275,8 @@ void runContentBleedCheck(PDFDocumentSession* session, } const QString reason = check.rasterConfirm - ? PDFTranslationContext::tr("the strip raster could not be measured within the pixel budget") - : PDFTranslationContext::tr("raster confirmation is off, so artwork bounds alone cannot show the strip is inked"); + ? PDFTranslationContext::tr("the strip raster could not be measured within the pixel budget") + : PDFTranslationContext::tr("raster confirmation is off, so artwork bounds alone cannot show the strip is inked"); PreflightFinding incomplete; incomplete.scope = QString::fromLatin1(PREFLIGHT_FINDING_SCOPE_PAGE); incomplete.page = int(pageIndex + 1); 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 17/23] 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()) From 998bbbfb6275671be9975521338826303b6bd6f9 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:22:46 -0700 Subject: [PATCH 18/23] fix(preflight): key compositor probes by dpi and scan nested overprint The probe cache was keyed by page only, so the second check reused the first check's raster. The overprint gate now also scans patterns, soft-mask groups and Type 3 fonts, and treats overly deep nesting as declaring overprint. Both compositor probes share raster setup, and the overprint probe no longer keeps an unused process bitmap. Co-Authored-By: Claude Opus 5.5 --- LoopLibCore/sources/pdfinkcoverageprobe.cpp | 119 ++++++++++---------- LoopLibCore/sources/preflightengine.cpp | 87 ++++++++++---- LoopLibCore/sources/preflightengine.h | 7 +- 3 files changed, 128 insertions(+), 85 deletions(-) diff --git a/LoopLibCore/sources/pdfinkcoverageprobe.cpp b/LoopLibCore/sources/pdfinkcoverageprobe.cpp index e73558833..ff7148139 100644 --- a/LoopLibCore/sources/pdfinkcoverageprobe.cpp +++ b/LoopLibCore/sources/pdfinkcoverageprobe.cpp @@ -83,6 +83,52 @@ QRectF resolveAnalysisBox(const PDFPage* page, PDFInkCoverageAnalysisBox request return media; } +/// Device raster of an analysis box at a resolution. An empty size means the box +/// cannot be rasterized. +struct CompositorRaster +{ + QSize size; + QTransform pagePointToDevice; + bool budgetExceeded = false; +}; + +CompositorRaster planCompositorRaster(const PDFPage* page, const QRectF& analysisBox, int dpi, qint64 maxRasterPixels) +{ + CompositorRaster raster; + const PageRotation pageRotation = page->getPageRotation(); + const QRectF rotatedAnalysisBox = PDFPage::getRotatedBox(analysisBox, pageRotation).normalized(); + const qreal pointToPixel = static_cast(dpi) / 72.0; + const double widthReal = std::ceil(rotatedAnalysisBox.width() * pointToPixel); + const double heightReal = std::ceil(rotatedAnalysisBox.height() * pointToPixel); + + if (!std::isfinite(widthReal) || !std::isfinite(heightReal) || widthReal <= 0.0 || heightReal <= 0.0 || widthReal > static_cast(std::numeric_limits::max()) || heightReal > static_cast(std::numeric_limits::max())) + { + return raster; + } + + const int width = qMax(1, static_cast(widthReal)); + const int height = qMax(1, static_cast(heightReal)); + if (maxRasterPixels > 0 && static_cast(width) * static_cast(height) > maxRasterPixels) + { + raster.budgetExceeded = true; + return raster; + } + + raster.size = QSize(width, height); + raster.pagePointToDevice = PDFRenderer::createMediaBoxToDevicePointMatrix(rotatedAnalysisBox, QRect(QPoint(0, 0), raster.size), pageRotation); + return raster; +} + +PDFTransparencyRendererSettings compositorRendererSettings() +{ + PDFTransparencyRendererSettings rendererSettings; + rendererSettings.flags.setFlag(PDFTransparencyRendererSettings::ActiveColorMask, false); + rendererSettings.flags.setFlag(PDFTransparencyRendererSettings::SeparationSimulation, true); + rendererSettings.activeColorMask = PDFPixelFormat::getAllColorsMask(); + rendererSettings.renderPolicy = PDFRenderPolicy::forPreflightAnalysis(); + return rendererSettings; +} + } // namespace PDFInkCoverageProbe::PDFInkCoverageProbe(PDFDocumentSession* session) : @@ -114,39 +160,15 @@ PDFInkCoverageProbeResult PDFInkCoverageProbe::probe(const PDFPage* page, return result; } - const PageRotation pageRotation = page->getPageRotation(); - const QRectF rotatedAnalysisBox = PDFPage::getRotatedBox(analysisBox, pageRotation).normalized(); - const QSizeF mediaSize = rotatedAnalysisBox.size(); - const qreal pointToPixel = static_cast(settings.dpi) / 72.0; - const double widthReal = std::ceil(mediaSize.width() * pointToPixel); - const double heightReal = std::ceil(mediaSize.height() * pointToPixel); - - if (!std::isfinite(widthReal) || !std::isfinite(heightReal) || widthReal <= 0.0 || heightReal <= 0.0 || widthReal > static_cast(std::numeric_limits::max()) || heightReal > static_cast(std::numeric_limits::max())) - { - return result; - } - - const int width = qMax(1, static_cast(widthReal)); - const int height = qMax(1, static_cast(heightReal)); - const qint64 rasterPixels = static_cast(width) * static_cast(height); - if (settings.maxRasterPixels > 0 && rasterPixels > settings.maxRasterPixels) + const CompositorRaster raster = planCompositorRaster(page, analysisBox, settings.dpi, settings.maxRasterPixels); + result.budgetExceeded = raster.budgetExceeded; + if (raster.size.isEmpty()) { - result.budgetExceeded = true; return result; } - PDFTransparencyRendererSettings rendererSettings; + PDFTransparencyRendererSettings rendererSettings = compositorRendererSettings(); rendererSettings.flags.setFlag(PDFTransparencyRendererSettings::SaveOriginalProcessImage, true); - rendererSettings.flags.setFlag(PDFTransparencyRendererSettings::ActiveColorMask, false); - rendererSettings.flags.setFlag(PDFTransparencyRendererSettings::SeparationSimulation, true); - rendererSettings.activeColorMask = PDFPixelFormat::getAllColorsMask(); - rendererSettings.renderPolicy = PDFRenderPolicy::forPreflightAnalysis(); - - const QSize imageSize(width, height); - const QTransform pagePointToDevice = PDFRenderer::createMediaBoxToDevicePointMatrix( - rotatedAnalysisBox, - QRect(QPoint(0, 0), imageSize), - pageRotation); PDFInkMapper inkMapper(nullptr, document); inkMapper.createSpotColors(true); @@ -157,8 +179,8 @@ PDFInkCoverageProbeResult PDFInkCoverageProbe::probe(const PDFPage* page, m_session->getOptionalContentActivity(), &inkMapper, rendererSettings, - pagePointToDevice); - renderer.beginPaint(imageSize); + raster.pagePointToDevice); + renderer.beginPaint(raster.size); renderer.processContents(); renderer.endPaint(); result.diagnostics = renderer.getRenderDiagnostics(); @@ -267,7 +289,7 @@ PDFInkCoverageProbeResult PDFInkCoverageProbe::probe(const PDFPage* page, } bool invertible = false; - const QTransform deviceToPage = pagePointToDevice.inverted(&invertible); + const QTransform deviceToPage = raster.pagePointToDevice.inverted(&invertible); if (!invertible) { result.regions.clear(); @@ -333,36 +355,13 @@ PDFOverprintProbeResult PDFOverprintProbe::probe(const PDFPage* page, int dpi, q return result; } - const PageRotation pageRotation = page->getPageRotation(); - const QRectF rotatedAnalysisBox = PDFPage::getRotatedBox(analysisBox, pageRotation).normalized(); - const qreal pointToPixel = static_cast(dpi) / 72.0; - const double widthReal = std::ceil(rotatedAnalysisBox.width() * pointToPixel); - const double heightReal = std::ceil(rotatedAnalysisBox.height() * pointToPixel); - if (!std::isfinite(widthReal) || !std::isfinite(heightReal) || widthReal <= 0.0 || heightReal <= 0.0 || widthReal > static_cast(std::numeric_limits::max()) || heightReal > static_cast(std::numeric_limits::max())) - { - return result; - } - - const int width = qMax(1, static_cast(widthReal)); - const int height = qMax(1, static_cast(heightReal)); - if (maxRasterPixels > 0 && static_cast(width) * static_cast(height) > maxRasterPixels) + const CompositorRaster raster = planCompositorRaster(page, analysisBox, dpi, maxRasterPixels); + result.budgetExceeded = raster.budgetExceeded; + if (raster.size.isEmpty()) { - result.budgetExceeded = true; return result; } - PDFTransparencyRendererSettings rendererSettings; - rendererSettings.flags.setFlag(PDFTransparencyRendererSettings::SaveOriginalProcessImage, true); - rendererSettings.flags.setFlag(PDFTransparencyRendererSettings::ActiveColorMask, false); - rendererSettings.flags.setFlag(PDFTransparencyRendererSettings::SeparationSimulation, true); - rendererSettings.activeColorMask = PDFPixelFormat::getAllColorsMask(); - rendererSettings.renderPolicy = PDFRenderPolicy::forPreflightAnalysis(); - - const QSize imageSize(width, height); - const QTransform pagePointToDevice = PDFRenderer::createMediaBoxToDevicePointMatrix( - rotatedAnalysisBox, - QRect(QPoint(0, 0), imageSize), - pageRotation); PDFInkMapper inkMapper(nullptr, document); inkMapper.createSpotColors(true); @@ -372,9 +371,9 @@ PDFOverprintProbeResult PDFOverprintProbe::probe(const PDFPage* page, int dpi, q m_session->getCMS(), m_session->getOptionalContentActivity(), &inkMapper, - rendererSettings, - pagePointToDevice); - renderer.beginPaint(imageSize); + compositorRendererSettings(), + raster.pagePointToDevice); + renderer.beginPaint(raster.size); renderer.processContents(); renderer.endPaint(); diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index f96293fd2..eec1b7020 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -1947,18 +1947,40 @@ void evaluateEmbeddedFontsFromGraph(const PreflightCheckConfig& check, } } -/// True when the page, or a Form XObject it paints, declares overprint (/OP or /op true) in an -/// ExtGState. Only such pages are rasterized on the compositor, which keeps the cost off -/// the common case. +/// True when the page, or a Form XObject, pattern, soft-mask group or Type 3 glyph it paints, +/// declares overprint (/OP or /op true) in an ExtGState. Only such pages are rasterized on the +/// compositor, which keeps the cost off the common case. Resources nested too deep to scan +/// count as declaring overprint, so such a page is judged rather than passed unseen. bool pageDeclaresOverprint(const PDFDocument* document, const PDFPage* page) { std::set visited; std::function scan; + const auto setsOverprint = [document](const PDFDictionary* state) + { + for (const char* key : { "OP", "op" }) + { + const PDFObject value = state ? document->getObject(state->get(key)) : PDFObject(); + if (value.isBool() && value.getBool()) + { + return true; + } + } + return false; + }; + const auto scanStreamResources = [&](const PDFObject& entry, int depth) -> bool + { + if (entry.isReference() && !visited.insert(entry.getReference()).second) + { + return false; + } + const PDFDictionary* dictionary = document->getDictionaryFromObject(entry); + return dictionary && scan(dictionary->get("Resources"), depth + 1); + }; scan = [&](const PDFObject& resourcesObject, int depth) -> bool { if (depth > 8) { - return false; + return true; } const PDFObject resources = document->getObject(resourcesObject); if (!resources.isDictionary()) @@ -1980,13 +2002,39 @@ bool pageDeclaresOverprint(const PDFDocument* document, const PDFPage* page) { continue; } - for (const char* key : { "OP", "op" }) + if (setsOverprint(state)) { - const PDFObject value = document->getObject(state->get(key)); - if (value.isBool() && value.getBool()) - { - return true; - } + return true; + } + const PDFDictionary* softMask = document->getDictionaryFromObject(state->get("SMask")); + if (softMask && scanStreamResources(softMask->get("G"), depth)) + { + return true; + } + } + } + + const PDFDictionary* patterns = document->getDictionaryFromObject(resources.getDictionary()->get("Pattern")); + if (patterns) + { + for (size_t i = 0; i < patterns->getCount(); ++i) + { + const PDFDictionary* pattern = document->getDictionaryFromObject(patterns->getValue(i)); + if ((pattern && setsOverprint(document->getDictionaryFromObject(pattern->get("ExtGState")))) || scanStreamResources(patterns->getValue(i), depth)) + { + return true; + } + } + } + + const PDFDictionary* fonts = document->getDictionaryFromObject(resources.getDictionary()->get("Font")); + if (fonts) + { + for (size_t i = 0; i < fonts->getCount(); ++i) + { + if (scanStreamResources(fonts->getValue(i), depth)) + { + return true; } } } @@ -1996,13 +2044,7 @@ bool pageDeclaresOverprint(const PDFDocument* document, const PDFPage* page) { for (size_t i = 0; i < xobjects->getCount(); ++i) { - const PDFObject entry = xobjects->getValue(i); - if (entry.isReference() && !visited.insert(entry.getReference()).second) - { - continue; - } - const PDFObject xobject = document->getObject(entry); - if (xobject.isStream() && scan(xobject.getStream()->getDictionary()->get("Resources"), depth + 1)) + if (scanStreamResources(xobjects->getValue(i), depth)) { return true; } @@ -2016,11 +2058,12 @@ bool pageDeclaresOverprint(const PDFDocument* document, const PDFPage* page) /// Rasterizes every page that declares overprint on the compositor, once per run, and /// returns the probe for a page index. Returns nullptr for pages that do not declare overprint. const PDFOverprintProbeResult* compositorOverprintProbe(PDFDocumentSession* session, - std::map& cache, + std::map, PDFOverprintProbeResult>& cache, PDFInteger pageIndex, const PreflightCheckConfig& check) { - const auto cached = cache.find(int(pageIndex)); + const std::tuple key(int(pageIndex), check.probeDpi, check.maxRasterPixels); + const auto cached = cache.find(key); if (cached != cache.end()) { return &cached->second; @@ -2034,7 +2077,7 @@ const PDFOverprintProbeResult* compositorOverprintProbe(PDFDocumentSession* sess } PDFOverprintProbe probe(session); - return &(cache[int(pageIndex)] = probe.probe(page, check.probeDpi, check.maxRasterPixels)); + return &(cache[key] = probe.probe(page, check.probeDpi, check.maxRasterPixels)); } PreflightFinding compositorIncompleteFinding(const PreflightCheckConfig& check, @@ -2074,7 +2117,7 @@ PreflightFinding compositorIncompleteFinding(const PreflightCheckConfig& check, /// Adds findings for white overprint that only the overprint-accurate compositor sees (images, /// shadings, patterns and results of transparency), for pages the page-view scan did not flag. void evaluateWhiteOverprintOnCompositor(PDFDocumentSession* session, - std::map& cache, + std::map, PDFOverprintProbeResult>& cache, const PreflightCheckConfig& check, const std::set& flaggedPages, QList& errors, @@ -2127,7 +2170,7 @@ void evaluateWhiteOverprintOnCompositor(PDFDocumentSession* session, /// Adds findings where the compositor composites overprint under a non-Normal blend mode, partial /// opacity or a knockout group, the combinations a RIP resolves differently from page view. void evaluateTransparencyOverprintOnCompositor(PDFDocumentSession* session, - std::map& cache, + std::map, PDFOverprintProbeResult>& cache, const PreflightCheckConfig& check, QList& errors, QList& warnings) diff --git a/LoopLibCore/sources/preflightengine.h b/LoopLibCore/sources/preflightengine.h index 74e49f3d0..701bc7d6a 100644 --- a/LoopLibCore/sources/preflightengine.h +++ b/LoopLibCore/sources/preflightengine.h @@ -45,6 +45,7 @@ #include #include #include +#include namespace pdf { @@ -456,9 +457,9 @@ class LOOPLIBCORESHARED_EXPORT PreflightEngine const PDFOperationControl* m_operationControl = nullptr; std::map m_checks; PDFEvidenceGraph m_activeGraph; - /// Compositor overprint probes of the current run, keyed by zero-based page index, so - /// white-overprint and transparency-risk render each page once between them. - std::map m_overprintProbes; + /// Compositor overprint probes of the current run, keyed by zero-based page index, probe + /// dpi and pixel budget, so checks sharing a raster setup render each page once between them. + std::map, PDFOverprintProbeResult> m_overprintProbes; }; } // namespace pdf From 79d44ad2591731cd6ad087f415db74d9db5a65a3 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:31:07 -0700 Subject: [PATCH 19/23] fix(preflight): do not count keylines or bowties as solid bleed A stroke-only rectangle on the bleed box passed the rectangle test because piece paths are unstroked, confirming every edge with an empty margin. GraphicPieceInfo now records whether a path is filled, and the rectangle test rejects diagonal edges and doubled-back corners. The fill test runs once per piece instead of per band, the per-side probe accessors collapse into one, and the incomplete reason no longer blames only the pixel budget. Co-Authored-By: Claude Opus 5.5 --- LoopLibCore/sources/pdfbleedmarginprobe.cpp | 44 ++++++++++++---- LoopLibCore/sources/pdfpainter.cpp | 1 + LoopLibCore/sources/pdfpainter.h | 1 + LoopLibCore/sources/preflightengine.cpp | 56 +++++---------------- 4 files changed, 47 insertions(+), 55 deletions(-) diff --git a/LoopLibCore/sources/pdfbleedmarginprobe.cpp b/LoopLibCore/sources/pdfbleedmarginprobe.cpp index 0367f44f8..088fac2dc 100644 --- a/LoopLibCore/sources/pdfbleedmarginprobe.cpp +++ b/LoopLibCore/sources/pdfbleedmarginprobe.cpp @@ -132,27 +132,40 @@ bool isAxisAlignedRectangle(const QPainterPath& path, const QRectF& bounds) return false; } - for (const QPointF& point : polygon) + // Every vertex is a bounds corner, consecutive vertices share an axis (no diagonal, so no + // bowtie) and the four corners are distinct (no doubled-back triangle). + int corners = 0; + for (int index = 0; index < polygon.size(); ++index) { - const bool onVerticalEdge = std::abs(point.x() - bounds.left()) <= Tolerance || std::abs(point.x() - bounds.right()) <= Tolerance; - const bool onHorizontalEdge = std::abs(point.y() - bounds.top()) <= Tolerance || std::abs(point.y() - bounds.bottom()) <= Tolerance; - if (!onVerticalEdge || !onHorizontalEdge) + const QPointF& point = polygon[index]; + const bool onLeft = std::abs(point.x() - bounds.left()) <= Tolerance; + const bool onRight = std::abs(point.x() - bounds.right()) <= Tolerance; + const bool onTop = std::abs(point.y() - bounds.top()) <= Tolerance; + const bool onBottom = std::abs(point.y() - bounds.bottom()) <= Tolerance; + if (!(onLeft || onRight) || !(onTop || onBottom)) + { + return false; + } + corners |= (onLeft ? 1 : 2) << (onTop ? 0 : 2); + + const QPointF& next = polygon[(index + 1) % polygon.size()]; + if (std::abs(next.x() - point.x()) > Tolerance && std::abs(next.y() - point.y()) > Tolerance) { return false; } } - return true; + return corners == 0xF; } /// True when the piece's bounding rect is fully painted: an image, a shading or a filled rectangle. -/// A stroked outline shares a rectangle's geometry, so a stroked rectangle is not told apart here. +/// A stroke-only rectangle (a keyline) inks only its outline, so it does not count. bool pieceFillsItsBounds(const PDFPrecompiledPage::GraphicPieceInfo& info) { if (info.isImage() || info.isShading()) { return true; } - return info.isVectorGraphics() && isAxisAlignedRectangle(info.pagePath, info.boundingRect); + return info.isVectorGraphics() && info.isFilled && isAxisAlignedRectangle(info.pagePath, info.boundingRect); } struct StripCoverage @@ -194,7 +207,9 @@ qreal mergedLength(std::vector>& intervals) /// Splits the strip into bands across its depth and measures, per band, the length along the strip /// that is touched by piece bounds (upper) and fully crossed by a solid piece (solid). +/// \param fillsBounds pieceFillsItsBounds() of each piece, computed once per page StripCoverage measureStripCoverage(const PDFPrecompiledPage::GraphicPieceInfos& pieces, + const std::vector& fillsBounds, const QRectF& strip, PDFBleedFixupSide side) { @@ -221,9 +236,9 @@ StripCoverage measureStripCoverage(const PDFPrecompiledPage::GraphicPieceInfos& std::vector> touching; std::vector> solid; - for (const PDFPrecompiledPage::GraphicPieceInfo& piece : pieces) + for (size_t pieceIndex = 0; pieceIndex < pieces.size(); ++pieceIndex) { - const QRectF& rect = piece.boundingRect; + const QRectF& rect = pieces[pieceIndex].boundingRect; if (!rect.isValid()) { continue; @@ -239,7 +254,7 @@ StripCoverage measureStripCoverage(const PDFPrecompiledPage::GraphicPieceInfos& } touching.emplace_back(rectAlongLo, rectAlongHi); - if (rectAcrossLo <= bandLo + Tolerance && rectAcrossHi >= bandHi - Tolerance && pieceFillsItsBounds(piece)) + if (rectAcrossLo <= bandLo + Tolerance && rectAcrossHi >= bandHi - Tolerance && fillsBounds[pieceIndex]) { solid.emplace_back(rectAlongLo, rectAlongHi); } @@ -372,6 +387,13 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probeFast(const PDFPage* page, return result; } + std::vector fillsBounds; + fillsBounds.reserve(infos.size()); + for (const PDFPrecompiledPage::GraphicPieceInfo& info : infos) + { + fillsBounds.push_back(pieceFillsItsBounds(info)); + } + const PDFBleedFixupSide sides[4] = { PDFBleedFixupSide::Left, PDFBleedFixupSide::Right, PDFBleedFixupSide::Top, PDFBleedFixupSide::Bottom @@ -408,7 +430,7 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probeFast(const PDFPage* page, // A strip is populated only when the artwork bounds cover enough of it; a few stray // marks that merely touch it are not bleed. - const StripCoverage coverage = measureStripCoverage(infos, strip, side); + const StripCoverage coverage = measureStripCoverage(infos, fillsBounds, strip, side); edgeResult.boundsCoverage = coverage.upper; edgeResult.hasContent = contentBounds.intersects(strip) && coverage.upper >= settings.minEdgeCoverage; edgeResult.confirmed = !edgeResult.hasContent || coverage.solid >= settings.minEdgeCoverage; diff --git a/LoopLibCore/sources/pdfpainter.cpp b/LoopLibCore/sources/pdfpainter.cpp index fb4f6a4ed..4cf93dde0 100644 --- a/LoopLibCore/sources/pdfpainter.cpp +++ b/LoopLibCore/sources/pdfpainter.cpp @@ -926,6 +926,7 @@ PDFPrecompiledPage::GraphicPieceInfos PDFPrecompiledPage::calculateGraphicPieceI info.type = data.isText ? GraphicPieceInfo::Type::Text : GraphicPieceInfo::Type::VectorGraphics; info.boundingRect = pagePath.controlPointRect(); info.pagePath = pagePath; + info.isFilled = data.brush.style() != Qt::NoBrush; const int elementCount = pagePath.elementCount(); for (int i = 0; i < elementCount; ++i) diff --git a/LoopLibCore/sources/pdfpainter.h b/LoopLibCore/sources/pdfpainter.h index f6783f6e0..a3c27c244 100644 --- a/LoopLibCore/sources/pdfpainter.h +++ b/LoopLibCore/sources/pdfpainter.h @@ -297,6 +297,7 @@ class LOOPLIBCORESHARED_EXPORT PDFPrecompiledPage std::array hash = {}; ///< Hash of all data std::array imageHash = {}; ///< Hash of the image only QPainterPath pagePath; + bool isFilled = false; ///< Path piece painted with a fill brush (pagePath is the unstroked path) }; using GraphicPieceInfos = std::vector; diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index 572338841..ed1603cdd 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -2067,36 +2067,20 @@ void evaluateThinStrokesFromGraph(const PreflightCheckConfig& check, } } -bool edgeHasContent(const PDFBleedMarginProbeResult& result, PDFBleedFixupSide side) +const PDFBleedMarginProbeEdgeResult& edgeResult(const PDFBleedMarginProbeResult& result, PDFBleedFixupSide side) { switch (side) { case PDFBleedFixupSide::Left: - return result.left.hasContent; + return result.left; case PDFBleedFixupSide::Right: - return result.right.hasContent; + return result.right; case PDFBleedFixupSide::Top: - return result.top.hasContent; + return result.top; case PDFBleedFixupSide::Bottom: - return result.bottom.hasContent; - } - return false; -} - -QRectF edgeStripRect(const PDFBleedMarginProbeResult& result, PDFBleedFixupSide side) -{ - switch (side) - { - case PDFBleedFixupSide::Left: - return result.left.stripRect; - case PDFBleedFixupSide::Right: - return result.right.stripRect; - case PDFBleedFixupSide::Top: - return result.top.stripRect; - case PDFBleedFixupSide::Bottom: - return result.bottom.stripRect; + break; } - return QRectF(); + return result.bottom; } void emitNeedsAutoBleedFinding(int pageNumber, @@ -2201,22 +2185,6 @@ void runProcessingStepsCheck(PDFDocumentSession* session, } } -bool edgeIsConfirmed(const PDFBleedMarginProbeResult& result, PDFBleedFixupSide side) -{ - switch (side) - { - case PDFBleedFixupSide::Left: - return result.left.confirmed; - case PDFBleedFixupSide::Right: - return result.right.confirmed; - case PDFBleedFixupSide::Top: - return result.top.confirmed; - case PDFBleedFixupSide::Bottom: - return result.bottom.confirmed; - } - return true; -} - void runContentBleedCheck(PDFDocumentSession* session, const PreflightCheckConfig& check, QList& errors, @@ -2268,14 +2236,14 @@ void runContentBleedCheck(PDFDocumentSession* session, QStringList unconfirmedSides; for (PDFBleedFixupSide side : sides) { - if (edgeHasContent(result, side) && !edgeIsConfirmed(result, side)) + if (edgeResult(result, side).hasContent && !edgeResult(result, side).confirmed) { unconfirmedSides.append(sideNameForFinding(side)); } } const QString reason = check.rasterConfirm - ? PDFTranslationContext::tr("the strip raster could not be measured within the pixel budget") + ? PDFTranslationContext::tr("the strip raster could not be measured (strip over the pixel budget, or unusable page boxes)") : PDFTranslationContext::tr("raster confirmation is off, so artwork bounds alone cannot show the strip is inked"); PreflightFinding incomplete; incomplete.scope = QString::fromLatin1(PREFLIGHT_FINDING_SCOPE_PAGE); @@ -2306,12 +2274,12 @@ void runContentBleedCheck(PDFDocumentSession* session, { for (PDFBleedFixupSide side : sides) { - if (edgeHasContent(result, side)) + if (edgeResult(result, side).hasContent) { continue; } - const QRectF stripRect = edgeStripRect(result, side); + const QRectF stripRect = edgeResult(result, side).stripRect; PreflightFinding finding; finding.scope = QString::fromLatin1(PREFLIGHT_FINDING_SCOPE_OBJECT); finding.page = int(pageIndex + 1); @@ -2331,13 +2299,13 @@ void runContentBleedCheck(PDFDocumentSession* session, QRectF unionMissingBbox; for (PDFBleedFixupSide side : sides) { - if (edgeHasContent(result, side)) + if (edgeResult(result, side).hasContent) { continue; } missingSides.append(sideNameForFinding(side)); - const QRectF stripRect = edgeStripRect(result, side); + const QRectF stripRect = edgeResult(result, side).stripRect; if (stripRect.isValid()) { unionMissingBbox = unionMissingBbox.united(stripRect); From 1b1bf0bea362be8ce48c468313c343da598fbe15 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:35:41 -0700 Subject: [PATCH 20/23] fix(preflight): confirm bleed from painted area under the clip Edges were confirmed only by images, shadings and filled rectangles, judged on unclipped bounds. A full-bleed image clipped at trim was proven solid, while any filled non-rectangular background (curves, pale tints) went to raster, where near-white tints were demoted, or reported check-incomplete when raster_confirm is off. GraphicPieceInfo now carries the clip in effect, and an edge is confirmed by the area that images and filled paths or glyphs actually paint inside the strip, cut by their clip. Co-Authored-By: Claude Opus 5.5 --- LoopLibCore/sources/pdfbleedmarginprobe.cpp | 112 ++++++++------------ LoopLibCore/sources/pdfbleedmarginprobe.h | 10 +- LoopLibCore/sources/pdfpainter.cpp | 11 +- LoopLibCore/sources/pdfpainter.h | 2 + changes/cc-issue-120-sparse-bleed-raster.md | 2 +- docs/generated/preflight-check-catalog.json | 2 +- docs/preflight-check-catalog-overlay.json | 2 +- loop-preflight/README.md | 8 +- 8 files changed, 71 insertions(+), 78 deletions(-) diff --git a/LoopLibCore/sources/pdfbleedmarginprobe.cpp b/LoopLibCore/sources/pdfbleedmarginprobe.cpp index 088fac2dc..fe50a47fa 100644 --- a/LoopLibCore/sources/pdfbleedmarginprobe.cpp +++ b/LoopLibCore/sources/pdfbleedmarginprobe.cpp @@ -117,61 +117,40 @@ PDFReal sideBleedPt(const QMarginsF& bleedMM, PDFBleedFixupSide side) return 0.0; } -bool isAxisAlignedRectangle(const QPainterPath& path, const QRectF& bounds) +/// True when the piece is proven to paint the area its path encloses: an image, or a filled +/// path or glyph. A stroke-only path (a keyline, a hairline) inks only along its outline. +bool pieceFillsItsPath(const PDFPrecompiledPage::GraphicPieceInfo& info) { - constexpr qreal Tolerance = 0.01; - const QList subpaths = path.toSubpathPolygons(); - if (subpaths.size() != 1) - { - return false; - } - - const QPolygonF& polygon = subpaths.front(); - if (polygon.size() < 4 || polygon.size() > 5) - { - return false; - } - - // Every vertex is a bounds corner, consecutive vertices share an axis (no diagonal, so no - // bowtie) and the four corners are distinct (no doubled-back triangle). - int corners = 0; - for (int index = 0; index < polygon.size(); ++index) - { - const QPointF& point = polygon[index]; - const bool onLeft = std::abs(point.x() - bounds.left()) <= Tolerance; - const bool onRight = std::abs(point.x() - bounds.right()) <= Tolerance; - const bool onTop = std::abs(point.y() - bounds.top()) <= Tolerance; - const bool onBottom = std::abs(point.y() - bounds.bottom()) <= Tolerance; - if (!(onLeft || onRight) || !(onTop || onBottom)) - { - return false; - } - corners |= (onLeft ? 1 : 2) << (onTop ? 0 : 2); + return info.isImage() || ((info.isVectorGraphics() || info.isText()) && info.isFilled); +} - const QPointF& next = polygon[(index + 1) % polygon.size()]; - if (std::abs(next.x() - point.x()) > Tolerance && std::abs(next.y() - point.y()) > Tolerance) - { - return false; - } - } - return corners == 0xF; +/// Bounds a piece can paint: its bounding rect cut by the bounds of the clip in effect. +QRectF paintableBounds(const PDFPrecompiledPage::GraphicPieceInfo& info) +{ + return info.isClipped ? info.boundingRect.intersected(info.clipPath.boundingRect()) : info.boundingRect; } -/// True when the piece's bounding rect is fully painted: an image, a shading or a filled rectangle. -/// A stroke-only rectangle (a keyline) inks only its outline, so it does not count. -bool pieceFillsItsBounds(const PDFPrecompiledPage::GraphicPieceInfo& info) +/// Area enclosed by a path produced by a path boolean operation, whose holes wind opposite to +/// their outer contour. +qreal pathArea(const QPainterPath& path) { - if (info.isImage() || info.isShading()) + qreal twiceArea = 0.0; + for (const QPolygonF& polygon : path.toSubpathPolygons()) { - return true; + for (int index = 0; index < polygon.size(); ++index) + { + const QPointF& point = polygon[index]; + const QPointF& next = polygon[(index + 1) % polygon.size()]; + twiceArea += point.x() * next.y() - next.x() * point.y(); + } } - return info.isVectorGraphics() && info.isFilled && isAxisAlignedRectangle(info.pagePath, info.boundingRect); + return std::abs(twiceArea) * 0.5; } struct StripCoverage { qreal upper = 0.0; ///< Share of the strip touched by any piece's bounds. - qreal solid = 0.0; ///< Share of the strip covered by pieces that fill their bounds. + qreal solid = 0.0; ///< Share of the strip painted by filled geometry, cut by its clip. }; qreal mergedLength(std::vector>& intervals) @@ -206,15 +185,13 @@ qreal mergedLength(std::vector>& intervals) } /// Splits the strip into bands across its depth and measures, per band, the length along the strip -/// that is touched by piece bounds (upper) and fully crossed by a solid piece (solid). -/// \param fillsBounds pieceFillsItsBounds() of each piece, computed once per page +/// touched by piece bounds (upper), then the share of the strip's area that filled geometry +/// actually paints once clipped (solid). StripCoverage measureStripCoverage(const PDFPrecompiledPage::GraphicPieceInfos& pieces, - const std::vector& fillsBounds, const QRectF& strip, PDFBleedFixupSide side) { constexpr int Bands = 8; - constexpr qreal Tolerance = 0.01; const bool vertical = side == PDFBleedFixupSide::Left || side == PDFBleedFixupSide::Right; const qreal length = vertical ? strip.height() : strip.width(); @@ -235,10 +212,9 @@ StripCoverage measureStripCoverage(const PDFPrecompiledPage::GraphicPieceInfos& const qreal bandHi = acrossLo + depth * (band + 1) / Bands; std::vector> touching; - std::vector> solid; - for (size_t pieceIndex = 0; pieceIndex < pieces.size(); ++pieceIndex) + for (const PDFPrecompiledPage::GraphicPieceInfo& piece : pieces) { - const QRectF& rect = pieces[pieceIndex].boundingRect; + const QRectF rect = paintableBounds(piece); if (!rect.isValid()) { continue; @@ -254,18 +230,31 @@ StripCoverage measureStripCoverage(const PDFPrecompiledPage::GraphicPieceInfos& } touching.emplace_back(rectAlongLo, rectAlongHi); - if (rectAcrossLo <= bandLo + Tolerance && rectAcrossHi >= bandHi - Tolerance && fillsBounds[pieceIndex]) - { - solid.emplace_back(rectAlongLo, rectAlongHi); - } } coverage.upper += mergedLength(touching) / length; - coverage.solid += mergedLength(solid) / length; } - coverage.upper /= Bands; - coverage.solid /= Bands; + + // Overlapping pieces are summed, so the share is capped; it only has to reach the floor. + QPainterPath stripPath; + stripPath.addRect(strip); + qreal paintedArea = 0.0; + for (const PDFPrecompiledPage::GraphicPieceInfo& piece : pieces) + { + if (!pieceFillsItsPath(piece) || !paintableBounds(piece).intersects(strip)) + { + continue; + } + + QPainterPath painted = piece.pagePath.intersected(stripPath); + if (piece.isClipped && !painted.isEmpty()) + { + painted = painted.intersected(piece.clipPath); + } + paintedArea += pathArea(painted); + } + coverage.solid = qMin(1.0, paintedArea / (length * depth)); return coverage; } @@ -387,13 +376,6 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probeFast(const PDFPage* page, return result; } - std::vector fillsBounds; - fillsBounds.reserve(infos.size()); - for (const PDFPrecompiledPage::GraphicPieceInfo& info : infos) - { - fillsBounds.push_back(pieceFillsItsBounds(info)); - } - const PDFBleedFixupSide sides[4] = { PDFBleedFixupSide::Left, PDFBleedFixupSide::Right, PDFBleedFixupSide::Top, PDFBleedFixupSide::Bottom @@ -430,7 +412,7 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probeFast(const PDFPage* page, // A strip is populated only when the artwork bounds cover enough of it; a few stray // marks that merely touch it are not bleed. - const StripCoverage coverage = measureStripCoverage(infos, fillsBounds, strip, side); + const StripCoverage coverage = measureStripCoverage(infos, strip, side); edgeResult.boundsCoverage = coverage.upper; edgeResult.hasContent = contentBounds.intersects(strip) && coverage.upper >= settings.minEdgeCoverage; edgeResult.confirmed = !edgeResult.hasContent || coverage.solid >= settings.minEdgeCoverage; diff --git a/LoopLibCore/sources/pdfbleedmarginprobe.h b/LoopLibCore/sources/pdfbleedmarginprobe.h index b3a070a17..8b8fba466 100644 --- a/LoopLibCore/sources/pdfbleedmarginprobe.h +++ b/LoopLibCore/sources/pdfbleedmarginprobe.h @@ -55,9 +55,9 @@ struct LOOPLIBCORESHARED_EXPORT PDFBleedMarginProbeEdgeResult QRectF stripRect; /// Upper bound of the share of the strip covered by artwork bounds (0..1), set by the fast pass. qreal boundsCoverage = 0.0; - /// False when hasContent rests on bounds that touch enough of the strip, but the pieces proven - /// to fill their bounds (images, shadings, filled rectangles) cover less than minEdgeCoverage - /// of it, and no raster measurement confirmed it. + /// False when hasContent rests on bounds that touch enough of the strip, but the filled + /// geometry (images, filled paths and glyphs, clipped) paints less than minEdgeCoverage of + /// it, and no raster measurement confirmed it. bool confirmed = true; }; @@ -84,8 +84,8 @@ struct LOOPLIBCORESHARED_EXPORT PDFBleedMarginProbeResult /// Fast path: measures how much of each bleed strip the bounding rects from /// `PDFPrecompiledPage::calculateGraphicPieceInfos` cover. A strip is populated when that /// coverage reaches `minEdgeCoverage`, so a few stray marks do not count. No rasterization. -/// Bounds prove a strip is populated only through images, shadings and filled rectangles; for any other -/// piece the edge is left unconfirmed. +/// The edge is confirmed only when images and filled paths, cut by their clip, actually paint +/// `minEdgeCoverage` of the strip; strokes alone leave it unconfirmed. /// /// Raster path (raster_confirm): renders the four edge strips at probe_dpi and counts /// non-background pixels against `minEdgeCoverage`. It confirms or demotes every edge the bounds diff --git a/LoopLibCore/sources/pdfpainter.cpp b/LoopLibCore/sources/pdfpainter.cpp index 4cf93dde0..0f5dc0cdb 100644 --- a/LoopLibCore/sources/pdfpainter.cpp +++ b/LoopLibCore/sources/pdfpainter.cpp @@ -886,6 +886,8 @@ PDFPrecompiledPage::GraphicPieceInfos PDFPrecompiledPage::calculateGraphicPieceI struct State { QTransform matrix; + QPainterPath clipPath; + bool clipped = false; }; std::stack stateStack; stateStack.emplace(); @@ -927,6 +929,8 @@ PDFPrecompiledPage::GraphicPieceInfos PDFPrecompiledPage::calculateGraphicPieceI info.boundingRect = pagePath.controlPointRect(); info.pagePath = pagePath; info.isFilled = data.brush.style() != Qt::NoBrush; + info.isClipped = stateStack.top().clipped; + info.clipPath = stateStack.top().clipPath; const int elementCount = pagePath.elementCount(); for (int i = 0; i < elementCount; ++i) @@ -977,6 +981,8 @@ PDFPrecompiledPage::GraphicPieceInfos PDFPrecompiledPage::calculateGraphicPieceI info.type = GraphicPieceInfo::Type::Image; info.boundingRect = pagePath.controlPointRect(); info.pagePath = pagePath; + info.isClipped = stateStack.top().clipped; + info.clipPath = stateStack.top().clipPath; const int elementCount = pagePath.elementCount(); for (int i = 0; i < elementCount; ++i) @@ -1060,7 +1066,10 @@ PDFPrecompiledPage::GraphicPieceInfos PDFPrecompiledPage::calculateGraphicPieceI case InstructionType::Clip: { - // Do nothing, we are just collecting information + State& state = stateStack.top(); + const QPainterPath pageClip = state.matrix.map(m_clips[instruction.dataIndex].clipPath); + state.clipPath = state.clipped ? state.clipPath.intersected(pageClip) : pageClip; + state.clipped = true; break; } diff --git a/LoopLibCore/sources/pdfpainter.h b/LoopLibCore/sources/pdfpainter.h index a3c27c244..884a7a658 100644 --- a/LoopLibCore/sources/pdfpainter.h +++ b/LoopLibCore/sources/pdfpainter.h @@ -298,6 +298,8 @@ class LOOPLIBCORESHARED_EXPORT PDFPrecompiledPage std::array imageHash = {}; ///< Hash of the image only QPainterPath pagePath; bool isFilled = false; ///< Path piece painted with a fill brush (pagePath is the unstroked path) + bool isClipped = false; ///< A clip was in effect when the piece was painted + QPainterPath clipPath; ///< Clip in effect, in page coordinates (valid when isClipped) }; using GraphicPieceInfos = std::vector; diff --git a/changes/cc-issue-120-sparse-bleed-raster.md b/changes/cc-issue-120-sparse-bleed-raster.md index b65688d34..7888f9b2f 100644 --- a/changes/cc-issue-120-sparse-bleed-raster.md +++ b/changes/cc-issue-120-sparse-bleed-raster.md @@ -3,4 +3,4 @@ Category: fixed Audience: prepress operators and preflight maintainers relying on content-bleed Breaking-Change: no -Summary: content-bleed no longer accepts a bleed strip because artwork bounds merely touch it. A strip counts as populated only above a 10% coverage floor, and bounds prove a strip populated only through images, shadings and filled rectangles. With raster_confirm the strip raster decides every other edge, including demoting a sparse one to bleed-margin-empty; without it, or when the strip exceeds max_raster_pixels, the page reports check-incomplete instead of passing. Adds the content-bleed-sparse-marks and content-bleed-hairline-margin regression fixtures and lands the bleed-raster-strip-depth backlog row. +Summary: content-bleed no longer accepts a bleed strip because artwork bounds merely touch it. A strip counts as populated only above a 10% coverage floor, and bounds prove a strip populated only through the area that images and filled paths or glyphs actually paint inside it, cut by their clip. With raster_confirm the strip raster decides every other edge, including demoting a sparse one to bleed-margin-empty; without it, or when the strip exceeds max_raster_pixels, the page reports check-incomplete instead of passing. Adds the content-bleed-sparse-marks and content-bleed-hairline-margin regression fixtures and lands the bleed-raster-strip-depth backlog row. diff --git a/docs/generated/preflight-check-catalog.json b/docs/generated/preflight-check-catalog.json index a755cc79c..a177643f4 100644 --- a/docs/generated/preflight-check-catalog.json +++ b/docs/generated/preflight-check-catalog.json @@ -212,7 +212,7 @@ "fixups": [ "add-bleed" ], - "limitations": "A strip counts as populated only above a 10% coverage floor (artwork bounds, or raster ink with raster_confirm). Bounds prove a strip populated only through images, shadings and filled rectangles, so stroked or oddly shaped artwork is judged by raster confirmation or reported check-incomplete. A stroked rectangle outline cannot be told from a filled rectangle by bounds. White or near-white fills count as artwork.", + "limitations": "A strip counts as populated only above a 10% coverage floor (artwork bounds, or raster ink with raster_confirm). Bounds prove a strip populated only through the area that images and filled paths or glyphs actually paint inside it, cut by their clip, so stroked artwork such as hairlines and keylines is judged by raster confirmation or reported check-incomplete. White or near-white fills count as artwork.", "measures": "Whether painted content reaches the requested bleed margin.", "parameters": [ { diff --git a/docs/preflight-check-catalog-overlay.json b/docs/preflight-check-catalog-overlay.json index 307632941..efb4bcd3d 100644 --- a/docs/preflight-check-catalog-overlay.json +++ b/docs/preflight-check-catalog-overlay.json @@ -346,7 +346,7 @@ }, "content-bleed": { "measures": "Whether painted content reaches the requested bleed margin.", - "limitations": "A strip counts as populated only above a 10% coverage floor (artwork bounds, or raster ink with raster_confirm). Bounds prove a strip populated only through images, shadings and filled rectangles, so stroked or oddly shaped artwork is judged by raster confirmation or reported check-incomplete. A stroked rectangle outline cannot be told from a filled rectangle by bounds. White or near-white fills count as artwork.", + "limitations": "A strip counts as populated only above a 10% coverage floor (artwork bounds, or raster ink with raster_confirm). Bounds prove a strip populated only through the area that images and filled paths or glyphs actually paint inside it, cut by their clip, so stroked artwork such as hairlines and keylines is judged by raster confirmation or reported check-incomplete. White or near-white fills count as artwork.", "coverage": "partial", "families": [ "sheetfed-offset", diff --git a/loop-preflight/README.md b/loop-preflight/README.md index 0868658bf..289a4c321 100644 --- a/loop-preflight/README.md +++ b/loop-preflight/README.md @@ -172,13 +172,13 @@ confirmation — no full-page render. A bleed strip counts as populated only when artwork covers at least 10% of it, so a few stray marks that merely touch the strip do not pass. The bounds pass proves a -strip populated only through images, shadings and filled rectangles; any other -artwork (hairlines, strokes, text) that touches the strip is not proven. With +strip populated only through the area that images and filled paths or glyphs +actually paint inside it, cut by their clip; stroked artwork (hairlines, keylines) +that touches the strip is not proven. With `raster_confirm: true` the strip raster decides such an edge (ink share of at least 10%, at `probe_dpi`); without it, or when the strip exceeds `max_raster_pixels`, the page reports an informational `check-incomplete` finding instead of passing clean. -A stroked rectangle outline cannot be told from a filled rectangle by bounds, and a -white fill counts as artwork. +A white fill counts as artwork. The default `loop-default` profile does **not** enable Tier-2. Use a separate profile to opt in (see `examples/profile-tiered-bleed.json`). From bd58484e59a47963f1ef1f047105d45257a06593 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:20:22 -0700 Subject: [PATCH 21/23] fix(architecture): add UnitTestsBleedMarginProbe proof lane Co-Authored-By: Claude Sonnet 5.5 --- architecture/proof-lanes.yaml | 5 +++++ changes/cc-issue-120-sparse-bleed-raster.evidence.yaml | 3 +++ 2 files changed, 8 insertions(+) diff --git a/architecture/proof-lanes.yaml b/architecture/proof-lanes.yaml index 099ed617c..9c8fa4064 100644 --- a/architecture/proof-lanes.yaml +++ b/architecture/proof-lanes.yaml @@ -97,6 +97,11 @@ subsystems: id: UnitTestsBleedFixup policy_module: core executes: binding + - kind: unit + bind: ctest + id: UnitTestsBleedMarginProbe + policy_module: core + executes: binding - kind: architecture bind: policy id: agent-policy:core diff --git a/changes/cc-issue-120-sparse-bleed-raster.evidence.yaml b/changes/cc-issue-120-sparse-bleed-raster.evidence.yaml index ff66cf1d5..d7f9c4cce 100644 --- a/changes/cc-issue-120-sparse-bleed-raster.evidence.yaml +++ b/changes/cc-issue-120-sparse-bleed-raster.evidence.yaml @@ -24,6 +24,9 @@ claims: - differential:UnitTestsStandardOracle - differential:UnitTestsConversionOracle - security:scripts/ci/check_source_integrity.py + - unit:scripts/agent/test_architecture_contracts.py + - architecture:scripts/agent/check-architecture.py + - architecture:scripts/agent/generate-adapters.py unresolved: - core:scripts/ci/check_independent_validation_gate.py - Only UnitTestsPreflightEngine, UnitTestsPreflightCorpus and UnitTestsBleedMarginProbe 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. From 04b2b684adb31026fc7b890175f0002a6b2756dc Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:20:52 -0700 Subject: [PATCH 22/23] fix(preflight): bound bleed content by clip-aware paintable bounds Co-Authored-By: Claude Sonnet 5.5 --- LoopLibCore/sources/pdfbleedmarginprobe.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopLibCore/sources/pdfbleedmarginprobe.cpp b/LoopLibCore/sources/pdfbleedmarginprobe.cpp index fe50a47fa..92e3b3ce5 100644 --- a/LoopLibCore/sources/pdfbleedmarginprobe.cpp +++ b/LoopLibCore/sources/pdfbleedmarginprobe.cpp @@ -366,7 +366,7 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probeFast(const PDFPage* page, { if (info.boundingRect.isValid()) { - contentBounds = contentBounds.united(info.boundingRect); + contentBounds = contentBounds.united(paintableBounds(info)); } } From f15c9441d8a7c4cc313ca9b66059e44592f41b11 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:21:12 -0700 Subject: [PATCH 23/23] docs(changes): drop resolved stroked-rectangle residual limit Co-Authored-By: Claude Sonnet 5.5 --- changes/cc-issue-120-sparse-bleed-raster.evidence.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/cc-issue-120-sparse-bleed-raster.evidence.yaml b/changes/cc-issue-120-sparse-bleed-raster.evidence.yaml index d7f9c4cce..e47e0b4c5 100644 --- a/changes/cc-issue-120-sparse-bleed-raster.evidence.yaml +++ b/changes/cc-issue-120-sparse-bleed-raster.evidence.yaml @@ -30,4 +30,4 @@ claims: unresolved: - core:scripts/ci/check_independent_validation_gate.py - Only UnitTestsPreflightEngine, UnitTestsPreflightCorpus and UnitTestsBleedMarginProbe 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 - the 10% coverage floor is a policy choice that can flag a legitimately sparse bleed texture, a stroked rectangle outline cannot be told from a filled rectangle by bounds, and a white fill still counts as artwork. + - Residual limits - the 10% coverage floor is a policy choice that can flag a legitimately sparse bleed texture, and a white fill still counts as artwork.