diff --git a/UnitTests/tst_overprintrendertest.cpp b/UnitTests/tst_overprintrendertest.cpp index fcb30af1..c63b8aa0 100644 --- a/UnitTests/tst_overprintrendertest.cpp +++ b/UnitTests/tst_overprintrendertest.cpp @@ -24,12 +24,20 @@ // intentionally tolerant of small Qt/platform rasterization differences while // still requiring a bounded number of differing pixels. Mismatch images are // written beside the committed baselines for CI artifact inspection. +// +// Flatten-then-render goldens (`flatten-*.png`) run the real +// PDFTransparencyFlattener::apply() path before the same 128x128 measurement +// renderer. Structural flatten tests can report a full-page region and +// fullyOpaque while the replacement raster is blank; these goldens fail that +// case. Linux CI is the source of truth; Windows uses the same PNGs with the +// shared channel-delta / differing-pixel budgets. #include "pdfcms.h" #include "pdfdocument.h" #include "pdfdocumentreader.h" #include "pdfoptionalcontent.h" #include "pdfrenderer.h" +#include "pdftransparencyflattener.h" #include "pdftransparencyrenderer.h" #include @@ -84,12 +92,21 @@ bool updateSnapshotsRequested() return qEnvironmentVariableIntValue("LOOP_UPDATE_SNAPSHOTS") == 1; } -QImage renderFixture(const QString& fixturePath, bool separationSimulation) +pdf::PDFDocument loadFixtureDocument(const QString& fixturePath) { 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; +} + +QImage renderDocumentPage(pdf::PDFDocument& document, bool separationSimulation) +{ + if (!document.getCatalog()) { return QImage(); } @@ -127,6 +144,31 @@ QImage renderFixture(const QString& fixturePath, bool separationSimulation) return renderer.toImage(false, true, pdf::PDFRGB{ 1.0f, 1.0f, 1.0f }); } +QImage renderFixture(const QString& fixturePath, bool separationSimulation) +{ + pdf::PDFDocument document = loadFixtureDocument(fixturePath); + return renderDocumentPage(document, separationSimulation); +} + +int nonWhitePixelCount(const QImage& image) +{ + const QImage rgba = image.convertToFormat(QImage::Format_RGBA8888); + int count = 0; + for (int y = 0; y < rgba.height(); ++y) + { + const uchar* line = rgba.constScanLine(y); + for (int x = 0; x < rgba.width(); ++x) + { + const int offset = x * 4; + if (line[offset] < 250 || line[offset + 1] < 250 || line[offset + 2] < 250) + { + ++count; + } + } + } + return count; +} + void compareRender(const QString& name, const QImage& actual, const QImage& expected) { QVERIFY2(!actual.isNull(), qPrintable(QStringLiteral("Renderer returned no image for %1").arg(name))); @@ -220,6 +262,8 @@ private slots: void renderSubstituteFont_data(); void renderSubstituteFont(); void rendererDifferentialDoesNotDriftBeyondTolerance(); + void flattenThenRender_data(); + void flattenThenRender(); }; void OverprintRenderTest::render_data() @@ -280,5 +324,50 @@ void OverprintRenderTest::rendererDifferentialDoesNotDriftBeyondTolerance() compareRender(name + QStringLiteral(".png"), actual, expected); } +void OverprintRenderTest::flattenThenRender_data() +{ + QTest::addColumn("fixture"); + QTest::addColumn("baseline"); + + QTest::newRow("transparency-normal-cmyk") << QStringLiteral("transparency-normal-cmyk.pdf") + << QStringLiteral("flatten-transparency-normal-cmyk.png"); +} + +void OverprintRenderTest::flattenThenRender() +{ + QFETCH(QString, fixture); + QFETCH(QString, baseline); + + pdf::PDFDocument document = loadFixtureDocument(fixturesDirectory() + QLatin1Char('/') + fixture); + QVERIFY2(document.getCatalog(), qPrintable(QStringLiteral("Could not load flatten fixture %1").arg(fixture))); + QVERIFY2(document.getCatalog()->getPage(0), + qPrintable(QStringLiteral("Flatten fixture %1 has no first page").arg(fixture))); + + // Flatten rasterizes every selected page even when the structural live-transparency + // walk misses a nested Normal group. The pixel golden is the paint proof. + + pdf::PDFTransparencyFlattenSettings settings; + settings.rasterizationDpi = 72; + settings.maxRasterPixels = 100000; + pdf::PDFTransparencyFlattenReport report; + const pdf::PDFOperationResult result = pdf::PDFTransparencyFlattener::apply(&document, settings, &report); + QVERIFY2(result, qPrintable(result.getErrorMessage())); + QVERIFY(report.changed); + QVERIFY(report.fullyOpaque); + QVERIFY(!pdf::PDFTransparencyFlattener::hasLiveTransparency(&document)); + + const QImage actual = renderDocumentPage(document, false); + QVERIFY2(!actual.isNull(), qPrintable(QStringLiteral("Flattened renderer returned no image for %1").arg(fixture))); + constexpr int minNonWhitePixels = 256; + const int paintedPixels = nonWhitePixelCount(actual); + QVERIFY2(paintedPixels >= minNonWhitePixels, + qPrintable(QStringLiteral("%1 flattened to a blank raster (%2 non-white pixels); structural flatten success is not paint proof") + .arg(fixture) + .arg(paintedPixels))); + + const QImage expected = QImage(rendersDirectory() + QLatin1Char('/') + baseline); + compareRender(baseline, actual, expected); +} + QTEST_APPLESS_MAIN(OverprintRenderTest) #include "tst_overprintrendertest.moc" diff --git a/agent-policy.json b/agent-policy.json index 4e184c17..25cc7ed1 100644 --- a/agent-policy.json +++ b/agent-policy.json @@ -96,6 +96,7 @@ "UnitTests/tst_jbig2decodertest.cpp", "UnitTests/tst_lifecycletest.cpp", "UnitTests/tst_overprinttest.cpp", + "UnitTests/tst_overprintrendertest.cpp", "UnitTests/tst_pageboxcorpustest.cpp", "UnitTests/tst_repairdifftest.cpp", "UnitTests/tst_repairoperationtest.cpp", diff --git a/changes/cursor-transparency-flatten-pixel-golden-9148.md b/changes/cursor-transparency-flatten-pixel-golden-9148.md new file mode 100644 index 00000000..96f7f1bc --- /dev/null +++ b/changes/cursor-transparency-flatten-pixel-golden-9148.md @@ -0,0 +1,4 @@ +Category: added +Audience: developers +Breaking-Change: no +Summary: Add a flatten-then-render pixel golden for transparency-normal-cmyk on UnitTestsOverprintRender so a silent blank flatten fails CI instead of only structural region and dry-run checks, map tst_overprintrendertest.cpp into the core agent-policy path set so PR CI executes the target, and assert catalog presence before dereferencing a missing flatten fixture. diff --git a/docs/RENDERER_DIFFERENTIALS.md b/docs/RENDERER_DIFFERENTIALS.md index 858ac003..390aeea2 100644 --- a/docs/RENDERER_DIFFERENTIALS.md +++ b/docs/RENDERER_DIFFERENTIALS.md @@ -16,7 +16,20 @@ cannot catch silent substitute-font rendering regressions. - declared budgets (max channel delta 2, 64 differing pixels) A drift beyond those budgets fails the named test. Refresh goldens only with -`LOOP_UPDATE_SNAPSHOTS=1`. +`LOOP_UPDATE_SNAPSHOTS=1` (Linux is the source of truth; Windows uses the same +PNGs with those budgets): + +```bash +LOOP_UPDATE_SNAPSHOTS=1 ctest --test-dir build -R UnitTestsOverprintRender +``` + +The same target also flattens `transparency-normal-cmyk.pdf` through +`PDFTransparencyFlattener::apply()` at 72 DPI, re-renders the opaque page at +128×128, and compares it to `flatten-transparency-normal-cmyk.png`. The slot +fails closed if flatten reports success but the raster is blank (fewer than +256 non-white pixels) or drifts beyond the shared budgets. Structural flatten +tests in `UnitTestsTransparencyFlattener` only check region reports and dry-run +identity; they cannot catch a silent blank paint. **Disclosed limitation:** page-view overprint (the ordinary viewer paint path) is not this measurement renderer and must not be cited as proof of separation diff --git a/docs/TRANSPARENCY_FLATTENING_PLAN.md b/docs/TRANSPARENCY_FLATTENING_PLAN.md index 2e968f46..79ad712e 100644 --- a/docs/TRANSPARENCY_FLATTENING_PLAN.md +++ b/docs/TRANSPARENCY_FLATTENING_PLAN.md @@ -31,3 +31,10 @@ source document. The current implementation intentionally reports the entire page as the rasterized region. A later vector-preserving balance mode can use the existing settings and report contract without introducing a second pipeline. + +Paint proof is `UnitTestsOverprintRender::flattenThenRender`, which flattens +`transparency-normal-cmyk.pdf` and compares the re-rendered page to +`loop-preflight/testdata/renders/flatten-transparency-normal-cmyk.png`. Refresh +that golden with `LOOP_UPDATE_SNAPSHOTS=1` as documented in +`docs/RENDERER_DIFFERENTIALS.md`. A flatten that still reports a full-page +region and `fullyOpaque` but writes a blank raster fails that slot. diff --git a/loop-preflight/README.md b/loop-preflight/README.md index 50db7263..c19e78a8 100644 --- a/loop-preflight/README.md +++ b/loop-preflight/README.md @@ -516,6 +516,11 @@ passes, and only the target check is exercised. | `transparency-cmyk-group-spot.pdf` | test-transparency-risk | warning | `transparency-blend-space` | | `transparency-annotation-appearance.pdf` | test-transparency-risk | warning | annotation `/AP` `transparency-blend-space` | +Flatten-then-render paint proof for `transparency-normal-cmyk.pdf` lives beside the +overprint PNG goldens in `testdata/renders/flatten-transparency-normal-cmyk.png` +and is executed by `UnitTestsOverprintRender`. Refresh it with +`LOOP_UPDATE_SNAPSHOTS=1` as documented in `docs/RENDERER_DIFFERENTIALS.md`. + The `bleed-*` pair above covers the `bleed` check, so every Loop Default custom check has at least one known-pass and one known-fail (or warning) case. diff --git a/loop-preflight/testdata/renders/flatten-transparency-normal-cmyk.png b/loop-preflight/testdata/renders/flatten-transparency-normal-cmyk.png new file mode 100644 index 00000000..e1264746 Binary files /dev/null and b/loop-preflight/testdata/renders/flatten-transparency-normal-cmyk.png differ diff --git a/loop-preflight/testdata/renders/flatten-transparency-normal-cmyk.png.measurements.json b/loop-preflight/testdata/renders/flatten-transparency-normal-cmyk.png.measurements.json new file mode 100644 index 00000000..93e56cc7 --- /dev/null +++ b/loop-preflight/testdata/renders/flatten-transparency-normal-cmyk.png.measurements.json @@ -0,0 +1,6 @@ +{ + "width": 128, + "height": 128, + "max_channel_delta_budget": 2, + "differing_pixel_budget": 64 +}