From 126daa5fa2c55a5d7699fd7ef38f5b9b9ac73ab2 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:57:33 -0700 Subject: [PATCH 1/7] test(preflight): add isolated over-limit ink region regression fixture (#116) Co-Authored-By: Claude Sonnet 5.5 --- .../fixtures/ink-coverage-isolated-region.pdf | Bin 0 -> 551 bytes .../testdata/fixtures/manifest.json | 13 ++++ .../generate_ink_isolated_region_fixture.py | 61 ++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 loop-preflight/testdata/fixtures/ink-coverage-isolated-region.pdf create mode 100644 loop-preflight/tools/generate_ink_isolated_region_fixture.py diff --git a/loop-preflight/testdata/fixtures/ink-coverage-isolated-region.pdf b/loop-preflight/testdata/fixtures/ink-coverage-isolated-region.pdf new file mode 100644 index 0000000000000000000000000000000000000000..55de9a11051e20c16046134b5d32400836f3f534 GIT binary patch literal 551 zcmZ`$K~BRk5WM>pd&!a5u4AW>svMwHLVzfR8|uMroe(H7IIdLqBnN%~AL%+NZ2@6R zw!AyDvpbH)!_g6sQxbiCy?+o64D9TI3g4HIC@?!8n^?|;-^^0u4)hbfErSNsX@pYgmy~w(kv={Y}Z(e;qE6q?q|Sx#$qX< zEnIuVH;}YabYdF2a&znzhsg5-!2tLcyfAg??;(*P-*{*A6M4b*y8Q@(v6wNC-Nn=J z=uT&#fa@^-mbP_b?My+CLD+7Al2S;7?_yj+n}vEGQ`lEn2Ql75bAH8oLhWPT>1t_Q z`%gF3n*j-QVr?)u{0}`FqF)1*DWhg8ONA=bT=6tfQYHESiD~DCcD|iP2u7ml^lC&l D2y~0y literal 0 HcmV?d00001 diff --git a/loop-preflight/testdata/fixtures/manifest.json b/loop-preflight/testdata/fixtures/manifest.json index 503e700f2..ad5137c23 100644 --- a/loop-preflight/testdata/fixtures/manifest.json +++ b/loop-preflight/testdata/fixtures/manifest.json @@ -388,6 +388,19 @@ "source": "generated", "notes": "CMYK patch at approximately 370% TAC on an otherwise light page; the test profile isolates the ink-coverage warning." }, + { + "id": "ink-coverage-isolated-region", + "pdf": "ink-coverage-isolated-region.pdf", + "profile": "testdata/profiles/test-ink-coverage.json", + "expect": { + "pass": true, + "check_ids": [ + "ink-coverage" + ] + }, + "source": "generated", + "notes": "#116 regression: one 6 mm square of 400% rich black on an otherwise blank A3 page. It covers about 0.03% of the page, under the old page-percentage floor, so the check passed it clean." + }, { "id": "ink-coverage-ok", "pdf": "ink-coverage-ok.pdf", diff --git a/loop-preflight/tools/generate_ink_isolated_region_fixture.py b/loop-preflight/tools/generate_ink_isolated_region_fixture.py new file mode 100644 index 000000000..d00aa233e --- /dev/null +++ b/loop-preflight/tools/generate_ink_isolated_region_fixture.py @@ -0,0 +1,61 @@ +"""Generate the ink-coverage-isolated-region regression fixture (issue #116). + +Standard library only. A single A3 page carries one 6 mm x 6 mm rich-black +square (C=M=Y=K=1, 400% total area coverage) on otherwise blank paper. The +square covers about 0.03% of the page, under the 0.05% page-percentage floor +the ink-coverage probe used to apply, so the check passed the page clean +although the element sits far above any total-area-coverage limit. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUT = ROOT / "testdata" / "fixtures" + +A3_WIDTH_PT = 841.89 +A3_HEIGHT_PT = 1190.55 +SQUARE_PT = 17.0 # about 6 mm + + +def build() -> bytes: + content = ( + b"q 1 1 1 1 k %.2f %.2f %.2f %.2f re f Q\n" + % (300.0, 500.0, SQUARE_PT, SQUARE_PT) + ) + 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 %.2f %.2f ] /Contents 4 0 R /Resources << >> >>" + % (A3_WIDTH_PT, A3_HEIGHT_PT), + 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 [ <696e6b69736f6c61746564> <696e6b69736f6c61746564> ] >>\n" % size + buffer += b"startxref\n%d\n%%%%EOF\n" % xref + return bytes(buffer) + + +def main(argv: list[str]) -> int: + out = Path(argv[1] if len(argv) > 1 else DEFAULT_OUT) + out.mkdir(parents=True, exist_ok=True) + target = out / "ink-coverage-isolated-region.pdf" + target.write_bytes(build()) + print(f"wrote {target.name}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 8931baa43c924b2ece9d21ea9e02765f354da929 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:01:42 -0700 Subject: [PATCH 2/7] feat(preflight): ink-coverage physical area floor in mm^2 (#116) Co-Authored-By: Claude Sonnet 5.5 --- LoopLibCore/sources/pdfinkcoverageprobe.cpp | 6 +- LoopLibCore/sources/pdfinkcoverageprobe.h | 16 +++- LoopLibCore/sources/preflightengine.cpp | 32 +++++++- LoopLibCore/sources/preflightengine.h | 2 + UnitTests/tst_preflightenginetest.cpp | 82 +++++++++++++++++++ loop-preflight/README.md | 6 +- .../examples/profile-ink-coverage.json | 2 +- loop-preflight/schemas/profile.schema.json | 9 +- .../testdata/profiles/test-ink-coverage.json | 2 +- 9 files changed, 146 insertions(+), 11 deletions(-) diff --git a/LoopLibCore/sources/pdfinkcoverageprobe.cpp b/LoopLibCore/sources/pdfinkcoverageprobe.cpp index b192e4c20..0d7f5215d 100644 --- a/LoopLibCore/sources/pdfinkcoverageprobe.cpp +++ b/LoopLibCore/sources/pdfinkcoverageprobe.cpp @@ -193,6 +193,8 @@ PDFInkCoverageProbeResult PDFInkCoverageProbe::probe(const PDFPage* page, const QSizeF pageSizeMM = page->getRectMM(analysisBox).size(); const qreal pixelAreaMM2 = (pageSizeMM.width() * pageSizeMM.height()) / static_cast(totalPixels); result.overLimitAreaMM2 = static_cast(overLimitPixels) * pixelAreaMM2; + result.pixelAreaMM2 = pixelAreaMM2; + result.minResolvableAreaMM2 = pixelAreaMM2 * static_cast(PDFInkCoverageProbeSettings::MinResolvablePixels); struct DeviceRegion { @@ -256,8 +258,8 @@ PDFInkCoverageProbeResult PDFInkCoverageProbe::probe(const PDFPage* page, } } - const qreal areaRatio = static_cast(region.pixelCount) / static_cast(totalPixels); - if (areaRatio < settings.minRegionAreaRatio) + const qreal regionAreaMM2 = static_cast(region.pixelCount) * pixelAreaMM2; + if (regionAreaMM2 < settings.minRegionAreaMM2 || region.pixelCount < PDFInkCoverageProbeSettings::MinResolvablePixels) { continue; } diff --git a/LoopLibCore/sources/pdfinkcoverageprobe.h b/LoopLibCore/sources/pdfinkcoverageprobe.h index cd90b8b90..f35a005a1 100644 --- a/LoopLibCore/sources/pdfinkcoverageprobe.h +++ b/LoopLibCore/sources/pdfinkcoverageprobe.h @@ -50,8 +50,15 @@ struct LOOPLIBCORESHARED_EXPORT PDFInkCoverageProbeSettings qreal maxInkCoverage = 3.0; /// Rasterization resolution for the coverage probe. int dpi = 150; - /// Regions smaller than this fraction of the page are ignored (antialiasing noise). - qreal minRegionAreaRatio = 0.0005; + /// Smallest over-limit region, in mm^2, that is reported. The floor is physical rather + /// than a fraction of the page: a 0.5 mm x 0.5 mm solid is about the smallest element a + /// press reproduces as a distinct area rather than as dot gain, so it is the same on an + /// A6 card and on an A0 poster. + qreal minRegionAreaMM2 = 0.25; + /// An over-limit region needs at least this many raster pixels to be told apart from + /// antialiasing, so the raster can only vouch for regions of MinResolvablePixels pixels + /// or more. When the floor is smaller than that, the probe result reports it. + static constexpr size_t MinResolvablePixels = 4; /// Maximum number of regions reported per page; the largest are kept. int maxRegionsPerPage = 20; /// Maximum raster pixel count before the probe returns budgetExceeded. @@ -81,6 +88,11 @@ struct LOOPLIBCORESHARED_EXPORT PDFInkCoverageProbeResult PDFRenderDiagnostics diagnostics; qreal peakInkCoverage = 0.0; // page-wide max TAC qreal overLimitAreaMM2 = 0.0; + /// Area of one raster pixel in mm^2 at the probe resolution. + qreal pixelAreaMM2 = 0.0; + /// Smallest region area the raster can resolve (MinResolvablePixels pixels). A configured + /// floor below this cannot be honored, so a floor-sized element may go unseen. + qreal minResolvableAreaMM2 = 0.0; std::vector regions; // sorted by areaMM2, descending }; diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index d0d7dcc24..a97f5a56a 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -2327,7 +2327,7 @@ void runInkCoverageCheck(PDFDocumentSession* session, QList& warnings) { const QString analysisBox = check.restrictions.pageBox.value_or(check.inkCoverageAnalysisBox); - auto emitIncomplete = [&](int pageNumber, const QString& reason, bool budgetExceeded = false) + auto emitIncomplete = [&](int pageNumber, const QString& reason, bool budgetExceeded = false, const QJsonObject& extraEvidence = QJsonObject()) { PreflightFinding finding; finding.scope = pageNumber > 0 @@ -2343,6 +2343,10 @@ void runInkCoverageCheck(PDFDocumentSession* session, { QStringLiteral("analysis_box"), analysisBox }, { QStringLiteral("max_raster_pixels"), check.maxRasterPixels } }; + for (auto it = extraEvidence.begin(); it != extraEvidence.end(); ++it) + { + finding.evidence.insert(it.key(), it.value()); + } finding.message = pageNumber > 0 ? PDFTranslationContext::tr("Page %1 skipped: %2").arg(pageNumber).arg(reason) : PDFTranslationContext::tr("Ink coverage skipped: %1").arg(reason); @@ -2365,7 +2369,7 @@ void runInkCoverageCheck(PDFDocumentSession* session, PDFInkCoverageProbeSettings probeSettings; probeSettings.maxInkCoverage = check.maxInkPct / 100.0; probeSettings.dpi = check.probeDpi; - probeSettings.minRegionAreaRatio = check.minRegionAreaPct / 100.0; + probeSettings.minRegionAreaMM2 = check.minRegionAreaMm2; probeSettings.maxRegionsPerPage = check.maxRegionsPerPage; probeSettings.maxRasterPixels = check.maxRasterPixels; if (analysisBox == QStringLiteral("trim")) @@ -2423,6 +2427,22 @@ void runInkCoverageCheck(PDFDocumentSession* session, continue; } + if (check.minRegionAreaMm2 < result.minResolvableAreaMM2) + { + emitIncomplete(int(pageIndex + 1), + PDFTranslationContext::tr("probe_dpi %1 cannot resolve min_region_area_mm2 %2 (the raster resolves %3 mm^2); raise probe_dpi") + .arg(check.probeDpi) + .arg(check.minRegionAreaMm2) + .arg(result.minResolvableAreaMM2), + false, + QJsonObject{ + { QStringLiteral("min_region_area_mm2"), check.minRegionAreaMm2 }, + { QStringLiteral("min_resolvable_area_mm2"), result.minResolvableAreaMM2 }, + { QStringLiteral("pixel_area_mm2"), result.pixelAreaMM2 }, + { QStringLiteral("probe_dpi"), check.probeDpi } + }); + } + int regionRank = 0; for (const PDFInkCoverageRegion& region : result.regions) { @@ -2438,6 +2458,7 @@ void runInkCoverageCheck(PDFDocumentSession* session, { QStringLiteral("peak_ink_pct"), region.peakInkCoverage * 100.0 }, { QStringLiteral("max_ink_pct"), check.maxInkPct }, { QStringLiteral("area_mm2"), region.areaMM2 }, + { QStringLiteral("min_region_area_mm2"), check.minRegionAreaMm2 }, { QStringLiteral("analysis_box"), analysisBox }, { QStringLiteral("region_rank"), ++regionRank } }; @@ -7124,6 +7145,8 @@ bool PreflightEngine::parseProfile(const QJsonObject& profileObject, PreflightPr const QJsonValue maxRasterPixelsValue = checkObject.value(QStringLiteral("max_raster_pixels")); check.maxInkPct = maxInkValue.toDouble(0.0); check.minRegionAreaPct = minRegionAreaValue.toDouble(0.05); + const QJsonValue minRegionAreaMm2Value = checkObject.value(QStringLiteral("min_region_area_mm2")); + check.minRegionAreaMm2 = minRegionAreaMm2Value.toDouble(0.25); check.maxRegionsPerPage = maxRegionsValue.toInt(20); check.maxRasterPixels = 250LL * 1000 * 1000; if (check.id == QStringLiteral("ink-coverage") && (!maxInkValue.isDouble() || !std::isfinite(check.maxInkPct) || check.maxInkPct <= 0.0)) @@ -7144,6 +7167,11 @@ bool PreflightEngine::parseProfile(const QJsonObject& profileObject, PreflightPr errorMessage = PDFTranslationContext::tr("Check '%1' requires min_region_area_pct between 0 and 100.").arg(check.id); return false; } + if (checkObject.contains(QStringLiteral("min_region_area_mm2")) && (!minRegionAreaMm2Value.isDouble() || !std::isfinite(check.minRegionAreaMm2) || check.minRegionAreaMm2 < 0.0)) + { + errorMessage = PDFTranslationContext::tr("Check '%1' requires non-negative min_region_area_mm2.").arg(check.id); + return false; + } if (checkObject.contains(QStringLiteral("max_regions_per_page")) && (!maxRegionsValue.isDouble() || std::floor(maxRegionsValue.toDouble()) != maxRegionsValue.toDouble() || check.maxRegionsPerPage < 0)) { errorMessage = PDFTranslationContext::tr("Check '%1' requires non-negative integral max_regions_per_page.").arg(check.id); diff --git a/LoopLibCore/sources/preflightengine.h b/LoopLibCore/sources/preflightengine.h index c1bb35322..1bb567f41 100644 --- a/LoopLibCore/sources/preflightengine.h +++ b/LoopLibCore/sources/preflightengine.h @@ -189,7 +189,9 @@ struct LOOPLIBCORESHARED_EXPORT PreflightCheckConfig // ink-coverage parameters. qreal maxInkPct = 0.0; + /// Deprecated and no longer a suppression floor; accepted so existing profiles still load. qreal minRegionAreaPct = 0.05; + qreal minRegionAreaMm2 = 0.25; int maxRegionsPerPage = 20; qint64 maxRasterPixels = 250LL * 1000 * 1000; QString inkCoverageAnalysisBox = QStringLiteral("bleed"); diff --git a/UnitTests/tst_preflightenginetest.cpp b/UnitTests/tst_preflightenginetest.cpp index a9fcd0294..a8837aeb3 100644 --- a/UnitTests/tst_preflightenginetest.cpp +++ b/UnitTests/tst_preflightenginetest.cpp @@ -105,6 +105,8 @@ private slots: void run_inkCoverage_emitsRegionalWarningForOverLimitFixture(); void run_inkCoverage_passesBelowLimitFixture(); void run_inkCoverage_budgetAbortIsIncomplete(); + void run_inkCoverage_reportsIsolatedOverLimitRegionOnLargePage(); + void run_inkCoverage_reportsFloorFinerThanProbeResolutionAsIncomplete(); void inkCoverageProbe_usesAnalysisBoxAndReportsBudget(); void run_downsampleFixupAdvertisedForHighDpiImage(); void run_imageResolutionBBoxMatchesCtmPlacement(); @@ -495,6 +497,8 @@ void PreflightEngineTest::parseProfile_rejectsInvalidInkCoverageParameters() { QStringLiteral("min_region_area_pct"), QJsonValue(-1) }, { QStringLiteral("min_region_area_pct"), QJsonValue(101) }, { QStringLiteral("min_region_area_pct"), QJsonValue(QStringLiteral("0.05")) }, + { QStringLiteral("min_region_area_mm2"), QJsonValue(-1) }, + { QStringLiteral("min_region_area_mm2"), QJsonValue(QStringLiteral("0.25")) }, { QStringLiteral("max_regions_per_page"), QJsonValue(1.5) }, { QStringLiteral("max_regions_per_page"), QJsonValue(QStringLiteral("20")) }, { QStringLiteral("max_raster_pixels"), QJsonValue(0) }, @@ -1712,6 +1716,84 @@ void PreflightEngineTest::run_inkCoverage_budgetAbortIsIncomplete() QVERIFY(!pdf::preflightAllowsCertification(result)); } +void PreflightEngineTest::run_inkCoverage_reportsIsolatedOverLimitRegionOnLargePage() +{ + const QString fixturePath = QStringLiteral(LOOP_PREFLIGHT_SOURCE_DIR "/testdata/fixtures/ink-coverage-isolated-region.pdf"); + QVERIFY(QFile::exists(fixturePath)); + + pdf::PDFDocumentReader reader(nullptr, [](bool*) + { return QString(); }, true, false); + pdf::PDFDocument document = reader.readFromFile(fixturePath); + QCOMPARE(reader.getReadingResult(), pdf::PDFDocumentReader::Result::OK); + + pdf::PDFDocumentSession session(&document); + pdf::PreflightEngine engine(&session); + + const QJsonObject profile{ + { QStringLiteral("name"), QStringLiteral("Ink coverage isolated region") }, + { QStringLiteral("checks"), QJsonArray{ + QJsonObject{ + { QStringLiteral("id"), QStringLiteral("ink-coverage") }, + { QStringLiteral("max_ink_pct"), 300 }, + { QStringLiteral("probe_dpi"), 150 }, + { QStringLiteral("severity"), QStringLiteral("warning") } } } } + }; + + const pdf::PreflightResult result = engine.run(profile); + QVERIFY(result.inspectionComplete); + QCOMPARE(result.warnings.size(), 1); + QCOMPARE(result.warnings.first().type, QStringLiteral("ink-coverage")); + const double areaMm2 = result.warnings.first().evidence.value(QStringLiteral("area_mm2")).toDouble(); + QVERIFY2(areaMm2 > 25.0 && areaMm2 < 50.0, qPrintable(QString::number(areaMm2))); + QCOMPARE(result.warnings.first().evidence.value(QStringLiteral("min_region_area_mm2")).toDouble(), 0.25); + QVERIFY(result.warnings.first().evidence.value(QStringLiteral("peak_ink_pct")).toDouble() > 390.0); + + // The deprecated page-percentage floor no longer suppresses the region. + QJsonObject legacyCheck = profile.value(QStringLiteral("checks")).toArray().first().toObject(); + legacyCheck.insert(QStringLiteral("min_region_area_pct"), 5.0); + const QJsonObject legacyProfile{ { QStringLiteral("name"), QStringLiteral("Ink coverage legacy floor") }, + { QStringLiteral("checks"), QJsonArray{ legacyCheck } } }; + QCOMPARE(engine.run(legacyProfile).warnings.size(), 1); +} + +void PreflightEngineTest::run_inkCoverage_reportsFloorFinerThanProbeResolutionAsIncomplete() +{ + const QString fixturePath = QStringLiteral(LOOP_PREFLIGHT_SOURCE_DIR "/testdata/fixtures/ink-coverage-isolated-region.pdf"); + QVERIFY(QFile::exists(fixturePath)); + + pdf::PDFDocumentReader reader(nullptr, [](bool*) + { return QString(); }, true, false); + pdf::PDFDocument document = reader.readFromFile(fixturePath); + QCOMPARE(reader.getReadingResult(), pdf::PDFDocumentReader::Result::OK); + + pdf::PDFDocumentSession session(&document); + pdf::PreflightEngine engine(&session); + + // At 20 dpi one pixel is about 1.6 mm^2, so a 0.25 mm^2 floor cannot be honored. + const QJsonObject profile{ + { QStringLiteral("name"), QStringLiteral("Ink coverage coarse probe") }, + { QStringLiteral("checks"), QJsonArray{ + QJsonObject{ + { QStringLiteral("id"), QStringLiteral("ink-coverage") }, + { QStringLiteral("max_ink_pct"), 300 }, + { QStringLiteral("probe_dpi"), 20 }, + { QStringLiteral("severity"), QStringLiteral("warning") } } } } + }; + + const pdf::PreflightResult result = engine.run(profile); + QVERIFY(!result.pass); + bool foundIncomplete = false; + for (const pdf::PreflightFinding& finding : result.warnings) + { + if (finding.type == QStringLiteral("check-incomplete")) + { + foundIncomplete = true; + QVERIFY(finding.evidence.value(QStringLiteral("min_resolvable_area_mm2")).toDouble() > 0.25); + } + } + QVERIFY(foundIncomplete); +} + void PreflightEngineTest::inkCoverageProbe_usesAnalysisBoxAndReportsBudget() { pdf::PDFDocumentBuilder builder; diff --git a/loop-preflight/README.md b/loop-preflight/README.md index 961836f20..4720786b7 100644 --- a/loop-preflight/README.md +++ b/loop-preflight/README.md @@ -188,8 +188,10 @@ connected region whose total ink coverage exceeds `max_ink_pct`. `max_ink_pct` is expressed as a percentage of summed colorant values, so `300` means 300% TAC. The finding is emitted only when the measured value is strictly greater than the threshold; an exact boundary is clean. Optional parameters are `probe_dpi` -(default 150), `min_region_area_pct` (default 0.05% of the analyzed box), -`max_regions_per_page` (default 20; `0` deliberately suppresses region +(default 150), `min_region_area_mm2` (default 0.25 mm^2, a physical floor of about a +0.5 mm square that a press reproduces as a distinct solid; the deprecated +`min_region_area_pct` is accepted but ignored, because a page-relative floor let +an isolated over-limit element pass clean on a large page), `max_regions_per_page` (default 20; `0` deliberately suppresses region findings), and `max_raster_pixels` (default 250,000,000). The analysis is skipped and marked incomplete when that raster budget is exceeded. `analysis_box` defaults to `bleed` and falls diff --git a/loop-preflight/examples/profile-ink-coverage.json b/loop-preflight/examples/profile-ink-coverage.json index f5b25415a..7fce5c8a7 100644 --- a/loop-preflight/examples/profile-ink-coverage.json +++ b/loop-preflight/examples/profile-ink-coverage.json @@ -3,6 +3,6 @@ "name": "Loop Ink Coverage", "description": "Opt-in total ink coverage preflight using the Output Preview transparency renderer.", "checks": [ - { "id": "ink-coverage", "max_ink_pct": 300, "probe_dpi": 150, "min_region_area_pct": 0.05, "max_regions_per_page": 20, "max_raster_pixels": 250000000, "analysis_box": "bleed", "severity": "error" } + { "id": "ink-coverage", "max_ink_pct": 300, "probe_dpi": 150, "min_region_area_mm2": 0.25, "max_regions_per_page": 20, "max_raster_pixels": 250000000, "analysis_box": "bleed", "severity": "error" } ] } diff --git a/loop-preflight/schemas/profile.schema.json b/loop-preflight/schemas/profile.schema.json index 7e11023f4..541c59035 100644 --- a/loop-preflight/schemas/profile.schema.json +++ b/loop-preflight/schemas/profile.schema.json @@ -283,7 +283,14 @@ "type": "number", "minimum": 0, "default": 0.05, - "description": "Minimum over-limit connected-region area as a percentage of the page (ink-coverage)." + "deprecated": true, + "description": "Deprecated and ignored (ink-coverage). A page-relative floor let an isolated over-limit element pass clean; use min_region_area_mm2." + }, + "min_region_area_mm2": { + "type": "number", + "minimum": 0, + "default": 0.25, + "description": "Smallest over-limit connected region, in mm^2, that ink-coverage reports. Over-limit regions at or above it that the raster cannot resolve make the check incomplete." }, "max_regions_per_page": { "type": "integer", diff --git a/loop-preflight/testdata/profiles/test-ink-coverage.json b/loop-preflight/testdata/profiles/test-ink-coverage.json index 7e645822a..ae316cbf6 100644 --- a/loop-preflight/testdata/profiles/test-ink-coverage.json +++ b/loop-preflight/testdata/profiles/test-ink-coverage.json @@ -3,6 +3,6 @@ "name": "Loop Test - Ink Coverage", "description": "Test-only profile for TAC threshold fixtures.", "checks": [ - { "id": "ink-coverage", "max_ink_pct": 300, "probe_dpi": 150, "min_region_area_pct": 0.05, "max_regions_per_page": 20, "analysis_box": "bleed", "severity": "warning" } + { "id": "ink-coverage", "max_ink_pct": 300, "probe_dpi": 150, "min_region_area_mm2": 0.25, "max_regions_per_page": 20, "analysis_box": "bleed", "severity": "warning" } ] } From 110e2a3b6e4aa9388a6a55801f3005a8908fd681 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:14:20 -0700 Subject: [PATCH 3/7] docs(preflight): land ink-coverage-raster-tac catalogs and snapshot (#116) Co-Authored-By: Claude Sonnet 5.5 --- LoopLibCore/sources/preflightengine.cpp | 1 - UnitTests/tst_preflightenginetest.cpp | 1 - docs/generated/preflight-check-catalog.json | 13 +++- docs/generated/preflight-corpus-coverage.json | 3 +- .../generated/preflight-coverage-backlog.json | 6 +- docs/preflight-check-catalog-overlay.json | 19 ++++-- loop-preflight/README.md | 7 ++- .../ink-coverage-isolated-region.json | 59 +++++++++++++++++++ 8 files changed, 93 insertions(+), 16 deletions(-) create mode 100644 loop-preflight/testdata/snapshots/ink-coverage-isolated-region.json diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index a97f5a56a..f3e0aa648 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -2458,7 +2458,6 @@ void runInkCoverageCheck(PDFDocumentSession* session, { QStringLiteral("peak_ink_pct"), region.peakInkCoverage * 100.0 }, { QStringLiteral("max_ink_pct"), check.maxInkPct }, { QStringLiteral("area_mm2"), region.areaMM2 }, - { QStringLiteral("min_region_area_mm2"), check.minRegionAreaMm2 }, { QStringLiteral("analysis_box"), analysisBox }, { QStringLiteral("region_rank"), ++regionRank } }; diff --git a/UnitTests/tst_preflightenginetest.cpp b/UnitTests/tst_preflightenginetest.cpp index a8837aeb3..3c2e318a1 100644 --- a/UnitTests/tst_preflightenginetest.cpp +++ b/UnitTests/tst_preflightenginetest.cpp @@ -1745,7 +1745,6 @@ void PreflightEngineTest::run_inkCoverage_reportsIsolatedOverLimitRegionOnLargeP QCOMPARE(result.warnings.first().type, QStringLiteral("ink-coverage")); const double areaMm2 = result.warnings.first().evidence.value(QStringLiteral("area_mm2")).toDouble(); QVERIFY2(areaMm2 > 25.0 && areaMm2 < 50.0, qPrintable(QString::number(areaMm2))); - QCOMPARE(result.warnings.first().evidence.value(QStringLiteral("min_region_area_mm2")).toDouble(), 0.25); QVERIFY(result.warnings.first().evidence.value(QStringLiteral("peak_ink_pct")).toDouble() > 390.0); // The deprecated page-percentage floor no longer suppresses the region. diff --git a/docs/generated/preflight-check-catalog.json b/docs/generated/preflight-check-catalog.json index 700197bf2..572c8347f 100644 --- a/docs/generated/preflight-check-catalog.json +++ b/docs/generated/preflight-check-catalog.json @@ -522,10 +522,17 @@ "range": "> 0 (integer)", "type": "integer" }, + { + "default": 0.25, + "id": "min_region_area_mm2", + "meaning": "Smallest over-limit connected region, in mm^2 of physical area, that is reported. The default is about a 0.5 mm square, the smallest solid a press reproduces as a distinct area. A floor finer than four raster pixels at probe_dpi cannot be honored and reports the page incomplete.", + "range": ">= 0", + "type": "number" + }, { "default": 0.05, "id": "min_region_area_pct", - "meaning": "Smallest over-limit connected region, as a percentage of the page, that is reported.", + "meaning": "Deprecated and ignored; a page-relative floor let an isolated over-limit element pass clean on a large page. Use min_region_area_mm2.", "range": "0..100", "type": "number" }, @@ -560,12 +567,12 @@ ], "severity": [ { - "condition": "a connected region exceeds max_ink_pct over more than min_region_area_pct of the page. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings.", + "condition": "a connected region of at least min_region_area_mm2 exceeds max_ink_pct. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings.", "finding_type": "ink-coverage", "severity": "error" }, { - "condition": "the page raster exceeded max_raster_pixels, was unavailable, or reported non-exact fidelity, so the page is reported incomplete rather than clean.", + "condition": "the page raster exceeded max_raster_pixels, was unavailable, reported non-exact fidelity, or probe_dpi is too coarse to resolve min_region_area_mm2, 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..e16cce1b5 100644 --- a/docs/generated/preflight-corpus-coverage.json +++ b/docs/generated/preflight-corpus-coverage.json @@ -149,6 +149,7 @@ "corpus_gap": null, "coverage": "partial", "finding_fixtures": [ + "ink-coverage-isolated-region", "ink-coverage-over" ], "uninspected_fixtures": [] @@ -272,6 +273,6 @@ } }, "snapshot_dir": "loop-preflight/testdata/snapshots", - "source_fixtures": 76, + "source_fixtures": 77, "unattributed_findings": 1 } diff --git a/docs/generated/preflight-coverage-backlog.json b/docs/generated/preflight-coverage-backlog.json index 8c428da56..46bafa16a 100644 --- a/docs/generated/preflight-coverage-backlog.json +++ b/docs/generated/preflight-coverage-backlog.json @@ -322,17 +322,17 @@ "state": "open" }, { - "closed_by": "#116", + "closed_by": "ink-coverage", "deferral": null, "families": [ "sheetfed-offset", "web-offset", "newspaper" ], - "gap": "ink-coverage is a raster probe that suppresses regions below min_region_area_pct, so an isolated over-limit element passes clean", + "gap": "ink-coverage suppressed regions below min_region_area_pct, a fraction of the page, so an isolated over-limit element passed clean on a large page; the floor is now physical (min_region_area_mm2) and a probe_dpi too coarse to resolve it reports the page incomplete (filed as #116)", "id": "ink-coverage-raster-tac", "priority": "P2", - "state": "open" + "state": "landed" }, { "closed_by": "#117", diff --git a/docs/preflight-check-catalog-overlay.json b/docs/preflight-check-catalog-overlay.json index 916f07a9d..4b69a816c 100644 --- a/docs/preflight-check-catalog-overlay.json +++ b/docs/preflight-check-catalog-overlay.json @@ -655,12 +655,19 @@ "range": "> 0 (integer)", "meaning": "Resolution of the area-coverage raster." }, + { + "id": "min_region_area_mm2", + "type": "number", + "default": 0.25, + "range": ">= 0", + "meaning": "Smallest over-limit connected region, in mm^2 of physical area, that is reported. The default is about a 0.5 mm square, the smallest solid a press reproduces as a distinct area. A floor finer than four raster pixels at probe_dpi cannot be honored and reports the page incomplete." + }, { "id": "min_region_area_pct", "type": "number", "default": 0.05, "range": "0..100", - "meaning": "Smallest over-limit connected region, as a percentage of the page, that is reported." + "meaning": "Deprecated and ignored; a page-relative floor let an isolated over-limit element pass clean on a large page. Use min_region_area_mm2." }, { "id": "max_regions_per_page", @@ -695,12 +702,12 @@ { "finding_type": "ink-coverage", "severity": "error", - "condition": "a connected region exceeds max_ink_pct over more than min_region_area_pct of the page. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings." + "condition": "a connected region of at least min_region_area_mm2 exceeds max_ink_pct. 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 page raster exceeded max_raster_pixels, was unavailable, or reported non-exact fidelity, so the page is reported incomplete rather than clean." + "condition": "the page raster exceeded max_raster_pixels, was unavailable, reported non-exact fidelity, or probe_dpi is too coarse to resolve min_region_area_mm2, so the page is reported incomplete rather than clean." } ], "evidence": [ @@ -1505,14 +1512,14 @@ { "id": "ink-coverage-raster-tac", "priority": "P2", - "gap": "ink-coverage is a raster probe that suppresses regions below min_region_area_pct, so an isolated over-limit element passes clean", + "gap": "ink-coverage suppressed regions below min_region_area_pct, a fraction of the page, so an isolated over-limit element passed clean on a large page; the floor is now physical (min_region_area_mm2) and a probe_dpi too coarse to resolve it reports the page incomplete (filed as #116)", "families": [ "sheetfed-offset", "web-offset", "newspaper" ], - "state": "open", - "closed_by": "#116", + "state": "landed", + "closed_by": "ink-coverage", "deferral": null }, { diff --git a/loop-preflight/README.md b/loop-preflight/README.md index 4720786b7..5f472321e 100644 --- a/loop-preflight/README.md +++ b/loop-preflight/README.md @@ -206,7 +206,12 @@ This check is deliberately not enabled by `loop-default.json`: it requires a full-page rasterization and is intended for profiles that explicitly opt in. Pages exceeding the raster pixel budget emit an informational page-scope finding, set the check status to `skipped`, and set `inspection_complete` to `false`; -budget exhaustion never silently passes as a clean inspection. +budget exhaustion never silently passes as a clean inspection. The +`min_region_area_mm2` floor is physical, so a small rich-black element is +reported on an A3 page exactly as on an A6 card. A region needs four raster pixels +to be told apart from antialiasing; when `probe_dpi` is too coarse for that +(the pixel area times four exceeds `min_region_area_mm2`) each page reports an +informational `check-incomplete` finding instead of passing clean. ## Image downsampling fixup diff --git a/loop-preflight/testdata/snapshots/ink-coverage-isolated-region.json b/loop-preflight/testdata/snapshots/ink-coverage-isolated-region.json new file mode 100644 index 000000000..28a237a2c --- /dev/null +++ b/loop-preflight/testdata/snapshots/ink-coverage-isolated-region.json @@ -0,0 +1,59 @@ +{ + "checks": [ + { + "diagnostics": [ + "deprecated:analysis_box; use restrictions.page_box" + ], + "id": "ink-coverage", + "scope_restrictions": { + "page_box": "bleed" + }, + "status": "warning" + } + ], + "errors": [ + ], + "fixups_available": [ + ], + "inspection_complete": true, + "pass": true, + "profile": "Loop Test - Ink Coverage", + "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": [ + 299.9893101482326, + 499.5415356711003, + 317.2686944127708, + 517.2966142684401 + ], + "check_id": "ink-coverage", + "evidence": { + "analysis_box": "bleed", + "area_mm2": 38.18152779481039, + "max_ink_pct": 300, + "min_region_area_mm2": 0.25, + "peak_ink_pct": 400, + "region_rank": 1 + }, + "message": "Total ink coverage 400% exceeds maximum 300% over 38 mm^2 on page 1", + "page": 1, + "scope": "object", + "scope_restrictions": { + "page_box": "bleed" + }, + "severity": "warning", + "type": "ink-coverage" + } + ] +} From 4320bd7518a0d6d21111668e5a76c0e92b9b9782 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:21:11 -0700 Subject: [PATCH 4/7] docs(preflight): refresh isolated-region snapshot, add change record and evidence (#116) Co-Authored-By: Claude Sonnet 5.5 --- ...issue-116-ink-coverage-floor.evidence.yaml | 29 +++++++++++++++++++ changes/cc-issue-116-ink-coverage-floor.md | 6 ++++ .../ink-coverage-isolated-region.json | 1 - 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 changes/cc-issue-116-ink-coverage-floor.evidence.yaml create mode 100644 changes/cc-issue-116-ink-coverage-floor.md diff --git a/changes/cc-issue-116-ink-coverage-floor.evidence.yaml b/changes/cc-issue-116-ink-coverage-floor.evidence.yaml new file mode 100644 index 000000000..f87a84597 --- /dev/null +++ b/changes/cc-issue-116-ink-coverage-floor.evidence.yaml @@ -0,0 +1,29 @@ +format_version: 1 +kind: evidence +claims: + - id: ink-isolated-region-regression-fixture + evidence: + - integration:UnitTestsPreflightCorpus + - architecture:loop-preflight/testdata/fixtures + - architecture:docs/generated/preflight-check-catalog.json + - architecture:docs/generated/architecture-catalog.json + - architecture:docs/generated/preflight-corpus-coverage.json + - id: ink-coverage-physical-area-floor + 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. + - Probe resolution limits: a floor finer than four raster pixels at probe_dpi reports incomplete; regions below the floor stay suppressed by design, and the probe is not a plate-ready separation TAC. diff --git a/changes/cc-issue-116-ink-coverage-floor.md b/changes/cc-issue-116-ink-coverage-floor.md new file mode 100644 index 000000000..063a68822 --- /dev/null +++ b/changes/cc-issue-116-ink-coverage-floor.md @@ -0,0 +1,6 @@ +# Report isolated over-limit ink regions (#116) + +Category: fixed +Audience: prepress operators and preflight maintainers relying on ink-coverage +Breaking-Change: no +Summary: ink-coverage now reports every over-limit region above a physical floor, min_region_area_mm2 (default 0.25 mm^2), instead of suppressing regions below a percentage of the page, so a small rich-black element on a large page no longer passes clean; a probe_dpi too coarse to resolve the floor reports the page incomplete. min_region_area_pct is deprecated and ignored. Adds the ink-coverage-isolated-region regression fixture and lands the ink-coverage-raster-tac backlog row. diff --git a/loop-preflight/testdata/snapshots/ink-coverage-isolated-region.json b/loop-preflight/testdata/snapshots/ink-coverage-isolated-region.json index 28a237a2c..81049bbe2 100644 --- a/loop-preflight/testdata/snapshots/ink-coverage-isolated-region.json +++ b/loop-preflight/testdata/snapshots/ink-coverage-isolated-region.json @@ -42,7 +42,6 @@ "analysis_box": "bleed", "area_mm2": 38.18152779481039, "max_ink_pct": 300, - "min_region_area_mm2": 0.25, "peak_ink_pct": 400, "region_rank": 1 }, From 834270951dc2b47e9b18c628be27f34a9612329e Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:22:00 -0700 Subject: [PATCH 5/7] docs(evidence): add schemas_contracts proof lane for #116 Co-Authored-By: Claude Sonnet 5.5 --- changes/cc-issue-116-ink-coverage-floor.evidence.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changes/cc-issue-116-ink-coverage-floor.evidence.yaml b/changes/cc-issue-116-ink-coverage-floor.evidence.yaml index f87a84597..4042b48dd 100644 --- a/changes/cc-issue-116-ink-coverage-floor.evidence.yaml +++ b/changes/cc-issue-116-ink-coverage-floor.evidence.yaml @@ -12,6 +12,7 @@ claims: evidence: - unit:agent-policy:core - unit:agent-policy:preflight + - unit:agent-policy:schemas_contracts - unit:UnitTestsPreflightChecks - unit:UnitTestsPreflightEngine - unit:UnitTestsPreflightInteraction @@ -26,4 +27,4 @@ claims: 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. - - Probe resolution limits: a floor finer than four raster pixels at probe_dpi reports incomplete; regions below the floor stay suppressed by design, and the probe is not a plate-ready separation TAC. + - Residual limits - a floor finer than four raster pixels at probe_dpi reports incomplete; regions below the floor stay suppressed by design, and the probe is not a plate-ready separation TAC. From 8d32cd2cb17e6962bad59acd65135e0530591fb3 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:23:42 -0700 Subject: [PATCH 6/7] style(preflight): clang-format the ink-coverage incomplete evidence (#116) Co-Authored-By: Claude Sonnet 5.5 --- LoopLibCore/sources/preflightengine.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index f3e0aa648..b2dfeb42f 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -2439,8 +2439,7 @@ void runInkCoverageCheck(PDFDocumentSession* session, { QStringLiteral("min_region_area_mm2"), check.minRegionAreaMm2 }, { QStringLiteral("min_resolvable_area_mm2"), result.minResolvableAreaMM2 }, { QStringLiteral("pixel_area_mm2"), result.pixelAreaMM2 }, - { QStringLiteral("probe_dpi"), check.probeDpi } - }); + { QStringLiteral("probe_dpi"), check.probeDpi } }); } int regionRank = 0; From 84d503f059533f80410033b26a169ef2476491d0 Mon Sep 17 00:00:00 2001 From: mbx30 <212453881+mberrys@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:22:41 -0700 Subject: [PATCH 7/7] fix(preflight): only report an unresolvable ink floor with over-limit ink The coarse-probe incomplete finding fired on every page whenever the floor was below raster resolution, even on pages with no over-limit pixels. Co-Authored-By: Claude Opus 5.5 --- LoopLibCore/sources/preflightengine.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopLibCore/sources/preflightengine.cpp b/LoopLibCore/sources/preflightengine.cpp index b2dfeb42f..0c8791194 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -2427,7 +2427,7 @@ void runInkCoverageCheck(PDFDocumentSession* session, continue; } - if (check.minRegionAreaMm2 < result.minResolvableAreaMM2) + if (result.overLimitAreaMM2 > 0.0 && check.minRegionAreaMm2 < result.minResolvableAreaMM2) { emitIncomplete(int(pageIndex + 1), PDFTranslationContext::tr("probe_dpi %1 cannot resolve min_region_area_mm2 %2 (the raster resolves %3 mm^2); raise probe_dpi")