diff --git a/LoopLibCore/sources/pdfbleedmarginprobe.cpp b/LoopLibCore/sources/pdfbleedmarginprobe.cpp index 41f43b17..92e3b3ce 100644 --- a/LoopLibCore/sources/pdfbleedmarginprobe.cpp +++ b/LoopLibCore/sources/pdfbleedmarginprobe.cpp @@ -35,8 +35,11 @@ #include #include +#include #include #include +#include +#include namespace pdf { @@ -72,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) @@ -101,15 +105,160 @@ 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; } -} // namespace +/// 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) +{ + return info.isImage() || ((info.isVectorGraphics() || info.isText()) && info.isFilled); +} + +/// 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; +} + +/// Area enclosed by a path produced by a path boolean operation, whose holes wind opposite to +/// their outer contour. +qreal pathArea(const QPainterPath& path) +{ + qreal twiceArea = 0.0; + for (const QPolygonF& polygon : path.toSubpathPolygons()) + { + 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 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 painted by filled geometry, cut by its clip. +}; + +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 +/// 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 QRectF& strip, + PDFBleedFixupSide side) +{ + constexpr int Bands = 8; + + 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; + for (const PDFPrecompiledPage::GraphicPieceInfo& piece : pieces) + { + const QRectF rect = paintableBounds(piece); + 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); + } + + coverage.upper += mergedLength(touching) / length; + } + coverage.upper /= 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; +} + +} // namespace PDFBleedMarginProbe::PDFBleedMarginProbe(PDFDocumentSession* session) : m_session(session) @@ -117,12 +266,12 @@ 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); - if (settings.fastOnly || result.allEdgesCovered() || !m_session) + if (settings.fastOnly || (result.allEdgesCovered() && result.allEdgesConfirmed()) || !m_session) { return result; } @@ -141,58 +290,54 @@ 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; } PDFBleedMarginProbeResult PDFBleedMarginProbe::probeFast(const PDFPage* page, - size_t pageIndex, - const PDFBleedMarginProbeSettings& settings) + size_t pageIndex, + const PDFBleedMarginProbeSettings& settings) { PDFBleedMarginProbeResult result; @@ -221,7 +366,7 @@ PDFBleedMarginProbeResult PDFBleedMarginProbe::probeFast(const PDFPage* page, { if (info.boundingRect.isValid()) { - contentBounds = contentBounds.united(info.boundingRect); + contentBounds = contentBounds.united(paintableBounds(info)); } } @@ -245,10 +390,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; } @@ -257,15 +410,27 @@ 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 >= settings.minEdgeCoverage; 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; } } @@ -273,10 +438,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; @@ -306,8 +471,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; } @@ -316,9 +480,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; } @@ -349,10 +511,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; } @@ -405,10 +575,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; } } @@ -430,9 +608,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; } @@ -440,4 +616,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 1b7c042a..8b8fba46 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,12 @@ 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 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; }; struct LOOPLIBCORESHARED_EXPORT PDFBleedMarginProbeResult @@ -62,22 +72,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. +/// 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 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); @@ -98,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/pdfinkcoverageprobe.cpp b/LoopLibCore/sources/pdfinkcoverageprobe.cpp index 0d7f5215..4ca83cad 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(); @@ -269,7 +291,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(); @@ -310,4 +332,57 @@ 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 CompositorRaster raster = planCompositorRaster(page, analysisBox, dpi, maxRasterPixels); + result.budgetExceeded = raster.budgetExceeded; + if (raster.size.isEmpty()) + { + return result; + } + + PDFInkMapper inkMapper(nullptr, document); + inkMapper.createSpotColors(true); + + PDFTransparencyRenderer renderer(page, + document, + m_session->getFontCache(), + m_session->getCMS(), + m_session->getOptionalContentActivity(), + &inkMapper, + compositorRendererSettings(), + raster.pagePointToDevice); + renderer.beginPaint(raster.size); + 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 f35a005a..c74702de 100644 --- a/LoopLibCore/sources/pdfinkcoverageprobe.h +++ b/LoopLibCore/sources/pdfinkcoverageprobe.h @@ -109,6 +109,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/pdfpainter.cpp b/LoopLibCore/sources/pdfpainter.cpp index fb4f6a4e..0f5dc0cd 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(); @@ -926,6 +928,9 @@ 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; + info.isClipped = stateStack.top().clipped; + info.clipPath = stateStack.top().clipPath; const int elementCount = pagePath.elementCount(); for (int i = 0; i < elementCount; ++i) @@ -976,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) @@ -1059,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 f6783f6e..884a7a65 100644 --- a/LoopLibCore/sources/pdfpainter.h +++ b/LoopLibCore/sources/pdfpainter.h @@ -297,6 +297,9 @@ 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) + 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/LoopLibCore/sources/pdftransparencyrenderer.cpp b/LoopLibCore/sources/pdftransparencyrenderer.cpp index 0800c193..40ed296b 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,14 +2670,19 @@ 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); + } PDFFloatBitmap::blend(sourceData.immediateBackdrop, targetData.immediateBackdrop, *getBackdrop(), *getInitialBackdrop(), *sourceData.softMask.getSoftMask(), sourceData.alphaIsShape, sourceData.alphaFill, sourceData.blendMode, sourceData.group.knockout, selectedOverprintMode, getPaintRect(), @@ -2806,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)) @@ -2833,8 +2827,7 @@ bool PDFTransparencyRenderer::performOriginalImagePainting(const PDFImage& image } m_drawBuffer.modify(tileRect, true, false); - flushDrawBuffer(); - }); + flushDrawBuffer(); }); } return true; @@ -2888,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); @@ -3189,6 +3182,12 @@ 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(), qMin(containsFilling ? getGraphicState()->getAlphaFilling() : 1.0, containsStroking ? getGraphicState()->getAlphaStroking() : 1.0), isTransparencyGroupKnockout()); + } PDFFloatBitmap::blend(m_drawBuffer, *getImmediateBackdrop(), *getBackdrop(), *getInitialBackdrop(), *getPainterState()->softMask.getSoftMask(), getGraphicState()->getAlphaIsShape(), 1.0f, getGraphicState()->getBlendMode(), isTransparencyGroupKnockout(), @@ -3213,6 +3212,68 @@ 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(); + + 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; + } + + if (!plainBlend || alpha < 1.0 - Tolerance || knockout) + { + ++m_overprintObservation.transparentOverprintPixels; + } + } + } +} + void PDFTransparencyRenderer::recordOverprintDiagnostics(bool containsFilling, bool containsStroking) { const PDFOverprintMode overprintMode = getGraphicState()->getOverprintMode(); @@ -3447,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 @@ -3550,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; @@ -3561,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; @@ -3572,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; @@ -3957,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; @@ -3968,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; diff --git a/LoopLibCore/sources/pdftransparencyrenderer.h b/LoopLibCore/sources/pdftransparencyrenderer.h index 50cfd7e0..12112401 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,20 @@ 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, constant alpha below one + /// or a knockout group, where overprint and transparency interact. Antialiased edge + /// coverage is not counted. + quint64 transparentOverprintPixels = 0; +}; + struct PDFRenderDiagnostics { PDFRenderFidelity fidelity = PDFRenderFidelity::ExactSupported; @@ -785,6 +800,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 +971,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 +1084,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 9e5acf66..e158f121 100644 --- a/LoopLibCore/sources/preflightengine.cpp +++ b/LoopLibCore/sources/preflightengine.cpp @@ -1948,6 +1948,278 @@ void evaluateEmbeddedFontsFromGraph(const PreflightCheckConfig& check, } } +/// 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 true; + } + 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; + } + if (setsOverprint(state)) + { + 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; + } + } + } + + const PDFDictionary* xobjects = document->getDictionaryFromObject(resources.getDictionary()->get("XObject")); + if (xobjects) + { + for (size_t i = 0; i < xobjects->getCount(); ++i) + { + if (scanStreamResources(xobjects->getValue(i), depth)) + { + 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, PDFOverprintProbeResult>& cache, + PDFInteger pageIndex, + const PreflightCheckConfig& check) +{ + const std::tuple key(int(pageIndex), check.probeDpi, check.maxRasterPixels); + const auto cached = cache.find(key); + 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[key] = 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, PDFOverprintProbeResult>& 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, PDFOverprintProbeResult>& 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, @@ -2068,36 +2340,20 @@ void evaluateThinStrokesFromGraph(const PreflightCheckConfig& check, } } -bool edgeHasContent(const PDFBleedMarginProbeResult& result, PDFBleedFixupSide side) -{ - switch (side) - { - case PDFBleedFixupSide::Left: - return result.left.hasContent; - case PDFBleedFixupSide::Right: - return result.right.hasContent; - case PDFBleedFixupSide::Top: - return result.top.hasContent; - case PDFBleedFixupSide::Bottom: - return result.bottom.hasContent; - } - return false; -} - -QRectF edgeStripRect(const PDFBleedMarginProbeResult& result, PDFBleedFixupSide side) +const PDFBleedMarginProbeEdgeResult& edgeResult(const PDFBleedMarginProbeResult& result, PDFBleedFixupSide side) { switch (side) { case PDFBleedFixupSide::Left: - return result.left.stripRect; + return result.left; case PDFBleedFixupSide::Right: - return result.right.stripRect; + return result.right; case PDFBleedFixupSide::Top: - return result.top.stripRect; + return result.top; case PDFBleedFixupSide::Bottom: - return result.bottom.stripRect; + break; } - return QRectF(); + return result.bottom; } void emitNeedsAutoBleedFinding(int pageNumber, @@ -2241,28 +2497,62 @@ 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 (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 (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); + 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) { 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); @@ -2282,13 +2572,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); @@ -4662,6 +4952,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 +5096,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 +5106,7 @@ class HiddenContentProcessor final : public PDFPageContentProcessor QTransform(), meshQualitySettings, budget), + m_trackOcclusion(trackOcclusion), m_printActivity(optionalContentActivity), m_viewActivity(viewActivity) { @@ -4932,51 +5225,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()) + // 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 +5426,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 +5474,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 +5554,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) @@ -6672,6 +7129,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(); @@ -8057,8 +8515,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, @@ -8140,8 +8598,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 1bb567f4..5cc83114 100644 --- a/LoopLibCore/sources/preflightengine.h +++ b/LoopLibCore/sources/preflightengine.h @@ -37,6 +37,7 @@ #include #include #include +#include "pdfinkcoverageprobe.h" #include #include #include @@ -44,6 +45,7 @@ #include #include #include +#include namespace pdf { @@ -457,6 +459,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, probe + /// dpi and pixel budget, so checks sharing a raster setup render each page once between them. + std::map, PDFOverprintProbeResult> m_overprintProbes; }; } // namespace pdf diff --git a/UnitTests/tst_preflightenginetest.cpp b/UnitTests/tst_preflightenginetest.cpp index 9c8468b5..f78d0f06 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" @@ -91,6 +92,12 @@ 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_whiteOverprint_reportsWhiteImageOverprintFromCompositor(); + void run_transparencyRisk_reportsOverprintInteractionFromCompositor(); + void run_overprintCompositorProbe_skipsPagesWithoutOverprintAndReportsBudgetAsIncomplete(); void run_includesProfileFixups(); void run_synthesizesAddBleedWhenGapAndNoProfileFixup(); void run_removesAddBleedWhenNoGap(); @@ -106,6 +113,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(); @@ -1108,6 +1119,200 @@ 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); +} + +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_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::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); @@ -1689,6 +1894,132 @@ 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 (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 }) + { + QVERIFY(!edge.hasContent); + QVERIFY(edge.confirmed); + QVERIFY(edge.totalPixels > 0); + const qreal ink = static_cast(edge.inkPixels) / static_cast(edge.totalPixels); + QVERIFY2(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/architecture/proof-lanes.yaml b/architecture/proof-lanes.yaml index 099ed617..9c8fa406 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-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/changes/cc-issue-119-white-overprint-renderer.evidence.yaml b/changes/cc-issue-119-white-overprint-renderer.evidence.yaml new file mode 100644 index 00000000..b8fad8b1 --- /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 00000000..1f8e5fca --- /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/changes/cc-issue-120-sparse-bleed-raster.evidence.yaml b/changes/cc-issue-120-sparse-bleed-raster.evidence.yaml new file mode 100644 index 00000000..e47e0b4c --- /dev/null +++ b/changes/cc-issue-120-sparse-bleed-raster.evidence.yaml @@ -0,0 +1,33 @@ +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 + - 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. + - 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. 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 00000000..7888f9b2 --- /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 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 fb6b9661..9a5a82cd 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 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": [ { @@ -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" } ] }, @@ -640,7 +645,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 +681,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": [ { @@ -1113,7 +1118,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": [ { @@ -1134,6 +1139,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" } ] }, @@ -1206,7 +1221,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": [ { @@ -1219,9 +1234,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 3b3ff09a..af797380 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": [] @@ -170,7 +172,8 @@ "corpus_gap": null, "coverage": "partial", "finding_fixtures": [ - "obscured-content" + "obscured-content", + "obscured-content-clipped" ], "uninspected_fixtures": [] }, @@ -179,7 +182,8 @@ "corpus_gap": null, "coverage": "partial", "finding_fixtures": [ - "off-page-content" + "off-page-content", + "off-page-content-clipped" ], "uninspected_fixtures": [] }, @@ -242,6 +246,7 @@ "transparency-cmyk-group-rgb", "transparency-cmyk-group-spot", "transparency-hue", + "transparency-overprint-knockout", "transparency-rgb-group-cmyk" ], "uninspected_fixtures": [ @@ -267,6 +272,7 @@ "white-overprint-devicen", "white-overprint-form", "white-overprint-iccbased", + "white-overprint-image", "white-overprint-separation" ], "uninspected_fixtures": [ @@ -275,6 +281,6 @@ } }, "snapshot_dir": "loop-preflight/testdata/snapshots", - "source_fixtures": 77, + "source_fixtures": 85, "unattributed_findings": 1 } diff --git a/docs/generated/preflight-coverage-backlog.json b/docs/generated/preflight-coverage-backlog.json index 6fe9c77e..87d711cb 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", @@ -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", @@ -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 93032652..1eeb88dc 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 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", @@ -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": [ @@ -778,7 +783,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 +818,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", @@ -1250,7 +1255,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", @@ -1276,6 +1281,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": [ @@ -1344,7 +1359,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", @@ -1363,7 +1378,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": [ @@ -1460,14 +1480,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 }, { @@ -1547,49 +1567,49 @@ { "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 }, { "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 d78569ac..0b0f7685 100644 --- a/loop-preflight/README.md +++ b/loop-preflight/README.md @@ -187,6 +187,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 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 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`). @@ -275,6 +285,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 @@ -551,6 +579,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 | 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 00000000..dae72bd0 Binary files /dev/null and b/loop-preflight/testdata/fixtures/content-bleed-hairline-margin.pdf differ diff --git a/loop-preflight/testdata/fixtures/content-bleed-sparse-marks.pdf b/loop-preflight/testdata/fixtures/content-bleed-sparse-marks.pdf new file mode 100644 index 00000000..2ccb3243 Binary files /dev/null and b/loop-preflight/testdata/fixtures/content-bleed-sparse-marks.pdf differ diff --git a/loop-preflight/testdata/fixtures/manifest.json b/loop-preflight/testdata/fixtures/manifest.json index 2bd39e9b..848f476e 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": ["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. The strips now need 10% coverage, so each edge reports empty." + }, + { + "id": "content-bleed-hairline-margin", + "pdf": "content-bleed-hairline-margin.pdf", + "profile": "examples/profile-tiered-bleed-raster.json", + "expect": { + "pass": true, + "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. Raster now demotes each edge to bleed-margin-empty because the hairline inks under 10% of the strip." + }, { "id": "ai-art-missing-bleed", "pdf": "ai-art-missing-bleed.pdf", @@ -375,6 +397,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", @@ -863,6 +907,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/fixtures/transparency-overprint-knockout.pdf b/loop-preflight/testdata/fixtures/transparency-overprint-knockout.pdf new file mode 100644 index 00000000..b7a0131a Binary files /dev/null and b/loop-preflight/testdata/fixtures/transparency-overprint-knockout.pdf differ 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 00000000..8d4aa45e Binary files /dev/null and b/loop-preflight/testdata/fixtures/white-overprint-image.pdf differ 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 00000000..0e9182ef --- /dev/null +++ b/loop-preflight/testdata/snapshots/content-bleed-hairline-margin.json @@ -0,0 +1,111 @@ +{ + "checks": [ + { + "id": "bleed", + "status": "ok" + }, + { + "id": "content-bleed", + "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, + "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": [ + { + "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 new file mode 100644 index 00000000..a95c78f8 --- /dev/null +++ b/loop-preflight/testdata/snapshots/content-bleed-sparse-marks.json @@ -0,0 +1,69 @@ +{ + "checks": [ + { + "id": "bleed", + "status": "ok" + }, + { + "id": "content-bleed", + "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, + "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": [ + { + "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" + } + ] +} 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/testdata/snapshots/transparency-overprint-knockout.json b/loop-preflight/testdata/snapshots/transparency-overprint-knockout.json new file mode 100644 index 00000000..eaa1ca64 --- /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 00000000..753aa08a --- /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" + } + ] +} 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)) 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 00000000..33f75b2e --- /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)) diff --git a/loop-preflight/tools/generate_white_overprint_compositor_fixture.py b/loop-preflight/tools/generate_white_overprint_compositor_fixture.py new file mode 100644 index 00000000..69b6059e --- /dev/null +++ b/loop-preflight/tools/generate_white_overprint_compositor_fixture.py @@ -0,0 +1,103 @@ +"""Generate the compositor-authority regression fixtures (issue #119). + +Standard library only. + +white-overprint-image.pdf + A cyan backdrop is painted, then a 4 x 4 DeviceCMYK image of pure paper + white (0 0 0 0) is painted with fill overprint on and overprint mode 1. + The page-view scan only inspects path paints, so it never sees the image; + the overprint-accurate compositor does: under OPM 1 a zero colorant selects + the backdrop, so the white image vanishes instead of knocking out the cyan. + +transparency-overprint-knockout.pdf + A knockout transparency group (CMYK blend space) paints black with overprint + on, OPM 1 and fill alpha 0.5 over a cyan backdrop. It uses only the Normal + blend mode in a CMYK group, so the blend-mode and blend-space scan has + nothing to report, yet how overprint, alpha and knockout combine is decided + by the compositor. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUT = ROOT / "testdata" / "fixtures" + + +def assemble(objects: dict[int, bytes], ident: bytes) -> 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))