diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index 9e5acf66..f729f22c 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -4662,6 +4662,7 @@ struct HiddenContentFinding QRectF bbox; QString detail; bool heuristic = false; + bool incomplete = false; }; /// What a single optional-content governor (an OCG or a membership dictionary) @@ -4805,7 +4806,8 @@ class HiddenContentProcessor final : public PDFPageContentProcessor const PDFOptionalContentActivity* viewActivity, const PDFMeshQualitySettings& meshQualitySettings, PDFProcessingBudget* budget, - qreal offPageAllowance) : + qreal offPageAllowance, + bool trackOcclusion) : PDFPageContentProcessor(page, document, fontCache, @@ -4814,6 +4816,7 @@ class HiddenContentProcessor final : public PDFPageContentProcessor QTransform(), meshQualitySettings, budget), + m_trackOcclusion(trackOcclusion), m_printActivity(optionalContentActivity), m_viewActivity(viewActivity) { @@ -4932,51 +4935,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) + { + return; + } + + if (!m_toleratedBounds.isEmpty() && !m_toleratedBounds.intersects(painted.bounds)) { - m_findings.append({ QStringLiteral("off-page-content"), 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()) + // Occlusion subtracts paths pairwise, so it only runs for the check that reports it. + if (!m_trackOcclusion) { - for (const QRectF& previous : m_paintedBounds) + return; + } + + if (fill) + { + 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; + } + void recordGovernor(const PDFObject& governor, const QString& source, const QString& key) { if (!m_viewActivity || !m_printActivity || !m_recordedGovernors.insert(source + QLatin1Char('|') + key).second) @@ -4990,7 +5136,12 @@ class HiddenContentProcessor final : public PDFPageContentProcessor } QRectF m_toleratedBounds; - QList m_paintedBounds; + bool m_trackOcclusion = false; + ClipState m_clip; + QList m_clipStack; + QList m_items; + bool m_undecided = false; + bool m_truncated = false; QStringList m_hiddenLayers; QList m_findings; QList m_governors; @@ -5033,7 +5184,8 @@ void runHiddenContentCheck(PDFDocumentSession* session, &viewActivity, meshQualitySettings, session->getProcessingBudget(), - check.amountPt); + check.amountPt, + check.id == QStringLiteral("obscured-content")); processor.processContents(); if (check.id == QStringLiteral("hidden-layers")) @@ -5112,6 +5264,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 9c8468b5..7e042caf 100644 --- a/UnitTests/tst_preflightenginetest.cpp +++ b/UnitTests/tst_preflightenginetest.cpp @@ -91,6 +91,9 @@ private slots: void run_hiddenLayers_reportsOcmdScreenPrintDivergence(); void run_hiddenLayers_reportsUnevaluableMembershipAsIncomplete(); 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(); @@ -1108,6 +1111,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 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 400 400 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); 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 00000000..96d2a701 --- /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 00000000..10871998 --- /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 fb6b9661..872a5b1a 100644 --- a/docs/generated/preflight-check-catalog.json +++ b/docs/generated/preflight-check-catalog.json @@ -640,7 +640,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": [ { @@ -676,7 +676,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 3b3ff09a..a3567b11 100644 --- a/docs/generated/preflight-corpus-coverage.json +++ b/docs/generated/preflight-corpus-coverage.json @@ -170,7 +170,8 @@ "corpus_gap": null, "coverage": "partial", "finding_fixtures": [ - "obscured-content" + "obscured-content", + "obscured-content-clipped" ], "uninspected_fixtures": [] }, @@ -179,7 +180,8 @@ "corpus_gap": null, "coverage": "partial", "finding_fixtures": [ - "off-page-content" + "off-page-content", + "off-page-content-clipped" ], "uninspected_fixtures": [] }, @@ -275,6 +277,6 @@ } }, "snapshot_dir": "loop-preflight/testdata/snapshots", - "source_fixtures": 77, + "source_fixtures": 81, "unattributed_findings": 1 } diff --git a/docs/generated/preflight-coverage-backlog.json b/docs/generated/preflight-coverage-backlog.json index 6fe9c77e..d266a62d 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 93032652..09c4a32b 100644 --- a/docs/preflight-check-catalog-overlay.json +++ b/docs/preflight-check-catalog-overlay.json @@ -778,7 +778,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" @@ -813,7 +813,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", @@ -1547,24 +1547,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/fixtures/manifest.json b/loop-preflight/testdata/fixtures/manifest.json index 2bd39e9b..23b9f8d6 100644 --- a/loop-preflight/testdata/fixtures/manifest.json +++ b/loop-preflight/testdata/fixtures/manifest.json @@ -863,6 +863,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 00000000..4fad1a8b Binary files /dev/null and b/loop-preflight/testdata/fixtures/obscured-content-clipped.pdf differ 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 00000000..a7d5a26c Binary files /dev/null and b/loop-preflight/testdata/fixtures/off-page-content-clipped.pdf differ 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 00000000..f74285db --- /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 00000000..8b1b2a41 --- /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" + } + ] +} diff --git a/loop-preflight/tools/generate_clip_aware_geometry_fixtures.py b/loop-preflight/tools/generate_clip_aware_geometry_fixtures.py new file mode 100644 index 00000000..ac4b964a --- /dev/null +++ b/loop-preflight/tools/generate_clip_aware_geometry_fixtures.py @@ -0,0 +1,70 @@ +"""Generate the clip-aware geometry regression fixtures (issue #118). + +Standard library only. Both pages are 200 x 200 pt. + +off-page-content-clipped.pdf + A 400 x 400 black square is painted under a clip on 300..400, wholly outside + the page. The square raw bounds cover the page, so a bounds-only test + sees a mark on the page, yet every painted pixel is off the page. + +obscured-content-clipped.pdf + Two marks that later opaque paint covers although no covering object's + bounds contain the mark's raw bounds: + A a 200 x 200 square clipped to 40..60, so only a 20 x 20 patch paints; + one opaque 50 x 50 rectangle covers that patch. + C a 20 x 20 square covered only by the union of two adjacent opaque + rectangles; neither rectangle covers it alone. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUT = ROOT / "testdata" / "fixtures" + + +def build(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 /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))