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/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..65e18dc1 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, @@ -4662,6 +4934,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 +5078,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 +5088,7 @@ class HiddenContentProcessor final : public PDFPageContentProcessor QTransform(), meshQualitySettings, budget), + m_trackOcclusion(trackOcclusion), m_printActivity(optionalContentActivity), m_viewActivity(viewActivity) { @@ -4932,51 +5207,194 @@ class HiddenContentProcessor final : public PDFPageContentProcessor } } + void performClipping(const QPainterPath& path, Qt::FillRule fillRule) override + { + QPainterPath world = getCurrentWorldMatrix().map(path); + world.setFillRule(fillRule); + if (m_clip.active) + { + m_clip.path = m_clip.path.intersected(world); + } + else + { + m_clip.active = true; + m_clip.path = world; + } + } + + void performSaveGraphicState(ProcessOrder order) override + { + if (order == ProcessOrder::BeforeOperation) + { + m_clipStack.append(m_clip); + } + } + + void performRestoreGraphicState(ProcessOrder order) override + { + if (order == ProcessOrder::BeforeOperation && !m_clipStack.isEmpty()) + { + m_clip = m_clipStack.takeLast(); + } + } + void performBeforePathPainting(const QPainterPath& path, bool stroke, bool fill, bool text, Qt::FillRule fillRule) override { - Q_UNUSED(fillRule); if (path.isEmpty() || (!stroke && !fill && !text)) { return; } - const QRectF bounds = getCurrentWorldMatrix().map(path).boundingRect().normalized(); + QPainterPath world = getCurrentWorldMatrix().map(path); + world.setFillRule(fillRule); const PDFPageContentProcessorState* state = getGraphicState(); + const QRectF rawBounds = world.boundingRect().normalized(); if (state->getAlphaFilling() <= 0.0 || state->getAlphaStroking() <= 0.0) { - m_findings.append({ QStringLiteral("invisible-content"), bounds, + m_findings.append({ QStringLiteral("invisible-content"), rawBounds, QStringLiteral("graphics-state alpha is zero"), false }); } - if (!m_toleratedBounds.isEmpty() && !m_toleratedBounds.intersects(bounds)) + // Everything below judges the geometry that is actually painted: the path + // intersected with the effective clip, not its raw bounding box. + const PaintedGeometry painted = clipPainted(world, fill); + if (painted.clippedAway) + { + return; + } + + if (!m_toleratedBounds.isEmpty() && !m_toleratedBounds.intersects(painted.bounds)) { - m_findings.append({ QStringLiteral("off-page-content"), bounds, + m_findings.append({ QStringLiteral("off-page-content"), painted.bounds, QStringLiteral("mark lies outside the effective page/bleed box"), false }); } - if (fill && state->getAlphaFilling() >= 1.0 && !m_paintedBounds.isEmpty()) + // Occlusion subtracts paths pairwise, so it only runs for the check that reports it. + if (!m_trackOcclusion) { - for (const QRectF& previous : m_paintedBounds) + return; + } + + if (fill) + { + const bool opaque = state->getAlphaFilling() >= 1.0; + const bool plainBlend = state->getBlendMode() == BlendMode::Normal && !state->getSoftMask(); + if (opaque) { - if (bounds.contains(previous)) + for (PaintedItem& item : m_items) { - m_findings.append({ QStringLiteral("obscured-content"), previous, - QStringLiteral("fully covered by later opaque paint"), true }); - break; + if (item.reported || !painted.bounds.intersects(item.bounds)) + { + continue; + } + if (!plainBlend) + { + // A blend mode or soft mask decides whether this paint hides the + // earlier mark, and that is not decidable without a RIP model. + if (painted.bounds.contains(item.bounds) && !m_undecided) + { + m_undecided = true; + m_findings.append({ QStringLiteral("obscured-content"), item.bounds, + QStringLiteral("a blend mode or soft mask over earlier paint prevents deciding whether it is hidden"), + true, true }); + } + continue; + } + item.remaining = item.remaining.subtracted(painted.region); + const QRectF left = item.remaining.boundingRect(); + if (item.remaining.isEmpty() || left.width() < NegligibleExtentPt || left.height() < NegligibleExtentPt) + { + item.reported = true; + m_findings.append({ QStringLiteral("obscured-content"), item.bounds, + QStringLiteral("fully covered by later opaque paint"), true }); + } } } } + if (fill || stroke || text) { - m_paintedBounds.append(bounds); + if (m_items.size() < MaxTrackedItems) + { + m_items.append({ painted.region, painted.bounds, false }); + } + else if (!m_truncated) + { + m_truncated = true; + m_findings.append({ QStringLiteral("obscured-content"), QRectF(), + QStringLiteral("the page paints more objects than occlusion tracking covers"), true, true }); + } } } private: + static constexpr qreal NegligibleExtentPt = 0.01; + static constexpr int MaxTrackedItems = 1000; + + struct ClipState + { + bool active = false; + QPainterPath path; + }; + + struct PaintedGeometry + { + QPainterPath region; + QRectF bounds; + bool clippedAway = false; + }; + + struct PaintedItem + { + QPainterPath remaining; + QRectF bounds; + bool reported = false; + }; + + /// Intersects painted geometry with the effective clip. Area paints use the exact + /// path intersection; strokes and degenerate paths fall back to rectangle bounds, + /// because a zero-area path has an empty exact intersection. + PaintedGeometry clipPainted(const QPainterPath& world, bool areaPaint) const + { + PaintedGeometry result; + result.region = world; + result.bounds = world.boundingRect().normalized(); + if (!m_clip.active) + { + return result; + } + + const QRectF clipBounds = m_clip.path.boundingRect().normalized(); + if (!clipBounds.intersects(result.bounds)) + { + result.clippedAway = true; + return result; + } + + if (areaPaint && result.bounds.width() > 0.0 && result.bounds.height() > 0.0) + { + const QPainterPath inside = m_clip.path.intersected(world); + if (inside.isEmpty()) + { + result.clippedAway = true; + return result; + } + result.region = inside; + result.bounds = inside.boundingRect().normalized(); + return result; + } + + result.bounds = result.bounds.intersected(clipBounds); + QPainterPath boundsPath; + boundsPath.addRect(result.bounds); + result.region = boundsPath; + return result; + } + void recordGovernor(const PDFObject& governor, const QString& source, const QString& key) { if (!m_viewActivity || !m_printActivity || !m_recordedGovernors.insert(source + QLatin1Char('|') + key).second) @@ -4990,7 +5408,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 +5456,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 +5536,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 +7111,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 +8497,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 +8580,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..2d7032e4 100644 --- a/UnitTests/tst_preflightenginetest.cpp +++ b/UnitTests/tst_preflightenginetest.cpp @@ -91,6 +91,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(); @@ -1108,6 +1114,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); 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/docs/generated/preflight-check-catalog.json b/docs/generated/preflight-check-catalog.json index fb6b9661..df475748 100644 --- a/docs/generated/preflight-check-catalog.json +++ b/docs/generated/preflight-check-catalog.json @@ -640,7 +640,7 @@ "sheetfed-offset" ], "fixups": [], - "limitations": "Heuristic; not a RIP occlusion model.", + "limitations": "Painted vector geometry is intersected with the effective clip and covered in paint order; images, shadings and transparency are not modelled, a blend mode or soft mask over earlier paint reports the page incomplete, and findings stay heuristic, not a RIP occlusion model.", "measures": "Content covered by later painting.", "parameters": [ { @@ -676,7 +676,7 @@ "digital" ], "fixups": [], - "limitations": "Clipping can hide false positives and false negatives.", + "limitations": "Painted bounds are intersected with the effective clip before the page test; stroke and degenerate geometry use rectangle bounds, and images and shadings are not inspected.", "measures": "Content whose bounds fall outside the page.", "parameters": [ { @@ -1113,7 +1113,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 +1134,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 +1216,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 +1229,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..d039eb45 100644 --- a/docs/generated/preflight-corpus-coverage.json +++ b/docs/generated/preflight-corpus-coverage.json @@ -170,7 +170,8 @@ "corpus_gap": null, "coverage": "partial", "finding_fixtures": [ - "obscured-content" + "obscured-content", + "obscured-content-clipped" ], "uninspected_fixtures": [] }, @@ -179,7 +180,8 @@ "corpus_gap": null, "coverage": "partial", "finding_fixtures": [ - "off-page-content" + "off-page-content", + "off-page-content-clipped" ], "uninspected_fixtures": [] }, @@ -242,6 +244,7 @@ "transparency-cmyk-group-rgb", "transparency-cmyk-group-spot", "transparency-hue", + "transparency-overprint-knockout", "transparency-rgb-group-cmyk" ], "uninspected_fixtures": [ @@ -267,6 +270,7 @@ "white-overprint-devicen", "white-overprint-form", "white-overprint-iccbased", + "white-overprint-image", "white-overprint-separation" ], "uninspected_fixtures": [ @@ -275,6 +279,6 @@ } }, "snapshot_dir": "loop-preflight/testdata/snapshots", - "source_fixtures": 77, + "source_fixtures": 83, "unattributed_findings": 1 } diff --git a/docs/generated/preflight-coverage-backlog.json b/docs/generated/preflight-coverage-backlog.json index 6fe9c77e..cd183180 100644 --- a/docs/generated/preflight-coverage-backlog.json +++ b/docs/generated/preflight-coverage-backlog.json @@ -362,27 +362,27 @@ "state": "landed" }, { - "closed_by": "#118", + "closed_by": "obscured-content", "deferral": null, "families": [ "sheetfed-offset" ], - "gap": "obscured-content is a bounding-box heuristic rather than a RIP occlusion model, so covered content can pass clean", + "gap": "obscured-content compared raw bounding boxes, so content covered by clipped or combined opaque paint passed clean; it now covers painted, clipped vector geometry in paint order and reports a blend mode or soft mask over earlier paint as incomplete (filed as #118)", "id": "obscured-content-occlusion", "priority": "P2", - "state": "open" + "state": "landed" }, { - "closed_by": "#118", + "closed_by": "off-page-content", "deferral": null, "families": [ "sheetfed-offset", "digital" ], - "gap": "off-page-content compares painted bounds before clipping, so a clipped mark can be a false negative or a false positive", + "gap": "off-page-content compared painted bounds before clipping, so a clipped mark could be a false negative or a false positive; painted bounds are now intersected with the effective clip first (filed as #118)", "id": "off-page-content-clipping", "priority": "P2", - "state": "open" + "state": "landed" }, { "closed_by": "output-intent", @@ -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..c69a4868 100644 --- a/docs/preflight-check-catalog-overlay.json +++ b/docs/preflight-check-catalog-overlay.json @@ -778,7 +778,7 @@ }, "obscured-content": { "measures": "Content covered by later painting.", - "limitations": "Heuristic; not a RIP occlusion model.", + "limitations": "Painted vector geometry is intersected with the effective clip and covered in paint order; images, shadings and transparency are not modelled, a blend mode or soft mask over earlier paint reports the page incomplete, and findings stay heuristic, not a RIP occlusion model.", "coverage": "partial", "families": [ "sheetfed-offset" @@ -813,7 +813,7 @@ }, "off-page-content": { "measures": "Content whose bounds fall outside the page.", - "limitations": "Clipping can hide false positives and false negatives.", + "limitations": "Painted bounds are intersected with the effective clip before the page test; stroke and degenerate geometry use rectangle bounds, and images and shadings are not inspected.", "coverage": "partial", "families": [ "sheetfed-offset", @@ -1250,7 +1250,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 +1276,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 +1354,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 +1373,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": [ @@ -1547,49 +1562,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..b3a42a21 100644 --- a/loop-preflight/README.md +++ b/loop-preflight/README.md @@ -275,6 +275,24 @@ PdfTool capabilities --console-format json | jq '.data.fixups' The command reports build capability; the preflight report still applies the profile and finding/document gates before placing an item in `fixups_available`. +## Compositor-judged overprint + +`white-overprint` and `transparency-risk` also consult the overprint-accurate +compositor that Output Preview uses (`PDFTransparencyRenderer` with the +preflight-analysis policy), not only page-view overprint state. A page that +declares overprint (`/OP` or `/op` in an ExtGState, including inside Form +XObjects) is rasterized at `probe_dpi` once per run and the compositor reports +the overprint it applied. `white-overprint` then adds a finding for paper-white +pixels overprinted through paint the page-view scan does not inspect (images, +shadings, results of transparency); `transparency-risk` adds a +`transparency-overprint-interaction` finding where overprint is composited under +a non-Normal blend mode, constant alpha below one or a knockout group. A page +that cannot be rasterized, exceeds `max_raster_pixels`, or uses an overprint +combination the compositor only approximates reports an informational +`check-incomplete` finding instead of passing clean. Pages that do not declare +overprint are not rasterized. White paint smaller than a probe pixel and +annotation appearances are not seen by the compositor probe. + ## Transparency risk checking The `transparency-risk` check observes actual page-content processing and emits diff --git a/loop-preflight/testdata/fixtures/manifest.json b/loop-preflight/testdata/fixtures/manifest.json index 2bd39e9b..0f017bfc 100644 --- a/loop-preflight/testdata/fixtures/manifest.json +++ b/loop-preflight/testdata/fixtures/manifest.json @@ -375,6 +375,28 @@ "source": "hand-built", "notes": "White overprint using an ICCBased color space." }, + { + "id": "white-overprint-image", + "pdf": "white-overprint-image.pdf", + "profile": "testdata/profiles/test-white-overprint.json", + "expect": { + "pass": true, + "check_ids": ["white-overprint"] + }, + "source": "generated", + "notes": "#119 regression: a pure-white DeviceCMYK image painted with overprint on and OPM 1 over cyan. The page-view scan only inspects path paints, so it passed clean; under OPM 1 the compositor makes the white vanish." + }, + { + "id": "transparency-overprint-knockout", + "pdf": "transparency-overprint-knockout.pdf", + "profile": "testdata/profiles/test-transparency-risk.json", + "expect": { + "pass": true, + "check_ids": ["transparency-risk"] + }, + "source": "generated", + "notes": "#119 regression: black painted with overprint, OPM 1 and alpha 0.5 inside a knockout CMYK transparency group over cyan. Only the Normal blend mode is used, so the blend scan passed it clean; how overprint, alpha and knockout combine is decided by the compositor." + }, { "id": "ink-coverage-over", "pdf": "ink-coverage-over.pdf", @@ -863,6 +885,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/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_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))