From 50601d0341750e46f72dbd0f8d10d384b9fa8886 Mon Sep 17 00:00:00 2001 From: calixteman Date: Sun, 19 Jul 2026 19:12:56 +0200 Subject: [PATCH] Copy the backdrop for non-isolated groups with a soft mask PR #21455 drew non-isolated blend-mode groups against their backdrop via a direct path on the parent canvas, but a soft mask forces the group onto an intermediate canvas instead. There the inner blend modes composited against transparency, so e.g. a /Multiply highlight was painted opaquely over the text behind it, hiding it. Copy the backdrop into that intermediate canvas so the blends see the real background. The copy is limited to groups that would otherwise have taken the direct path (source-over, alpha 1, not knockout/gray) but were displaced onto the intermediate canvas by the soft mask. It fixes #21593. --- src/display/canvas.js | 68 ++++++++++--- test/pdfs/.gitignore | 1 + test/pdfs/nonisolated_blend_smask.pdf | 137 ++++++++++++++++++++++++++ test/test_manifest.json | 7 ++ 4 files changed, 201 insertions(+), 12 deletions(-) create mode 100644 test/pdfs/nonisolated_blend_smask.pdf diff --git a/src/display/canvas.js b/src/display/canvas.js index 14cb734d5..d9ab2025e 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -3333,18 +3333,53 @@ class CanvasGraphics { groupCtx.translate(-offsetX, -offsetY); groupCtx.transform(...currentTransform); - if ( - !group.isolated && - !group.smask && - inSMaskMode && - group.needsIsolation - ) { - // For non-isolated groups that need isolation and are entered from SMask - // mode, copy the current canvas background so that inner blend modes - // (e.g. "screen") interact correctly with the background rather than - // compositing onto a transparent canvas. - // Groups without needsIsolation have no inner blend modes; their content - // is composited correctly via the SMask in endGroup without a copy. + const needsBackdropCopy = + !group.isolated && !group.smask && group.needsIsolation; + const replaceBackdrop = + needsBackdropCopy && + !inSMaskMode && + savedKnockoutLevel === 0 && + !group.knockout && + !group.isGray && + group.hasSoftMask && + currentCtx.globalAlpha === 1 && + currentCtx.globalCompositeOperation === "source-over" && + this.current.transferMaps === "none"; + if (needsBackdropCopy && (inSMaskMode || replaceBackdrop)) { + // A non-isolated group that needs isolation (because of an inner blend + // mode and/or a soft mask) can't use the direct path above, so it + // renders on a transparent intermediate canvas. Copy the current + // backdrop into it so the inner blend modes (e.g. "multiply", "screen") + // interact with the real background instead of transparency; otherwise a + // /Multiply highlight, say, would be painted opaquely over the text + // behind it, hiding it (bug 1873345 -- same as the direct path, but here + // the soft mask forces an intermediate canvas). + // This is needed both when entered from SMask mode and at the top level; + // a non-isolated subgroup nested inside a knockout group instead gets its + // backdrop through the hasInnerBackdrop path in endGroup, so it's + // excluded here via savedKnockoutLevel. + // + // Outside SMask mode the copy is limited to a group that *would* have + // qualified for the direct path (source-over, group alpha 1, not + // knockout/gray) but was forced onto the intermediate canvas by its soft + // mask (`hasSoftMask`). Restricting to that domain keeps the change + // surgical: + // - Only the soft-mask case is the bug 1873345 gap; a non-isolated + // blend group pushed onto the intermediate canvas merely by a + // non-default group alpha (no soft mask) is a separate pre-existing + // case, left untouched to avoid changing its rendering (issue 13520). + // - source-over/alpha 1 let endGroup replace the backdrop region with + // the copied-and-updated result; compositing it normally would blend + // partially transparent backdrop pixels twice. Under an outer blend + // mode the outer blend would combine the backdrop with itself (e.g. + // a /Multiply group with an inner soft mask darkens, issue 12798); a + // non-1 group alpha would likewise mix the copied backdrop back in. + // Those groups keep the transparent canvas and blend against the + // real backdrop instead. + // - isGray groups are grayscaled wholesale in endGroup, and an + // inherited transfer filter (`transferMaps`) would be applied on + // write-back, both of which would corrupt the copied backdrop, so + // they're excluded too. groupCtx.save(); groupCtx.setTransform(1, 0, 0, 1, 0, 0); groupCtx.drawImage(currentCtx.canvas, -offsetX, -offsetY); @@ -3413,6 +3448,7 @@ class CanvasGraphics { offsetX, offsetY, hasInnerBackdrop, + replaceBackdrop, knockoutMaskEntry, // Per-group scratch pools, lazily filled and freed in endGroup. knockoutTempEntry: null, @@ -3566,6 +3602,14 @@ class CanvasGraphics { }); } } else { + if (groupMeta.replaceBackdrop) { + // "copy" clears the destination outside the source within the + // current clip, so limit it to the intermediate canvas bounds. + const clip = new Path2D(); + clip.rect(0, 0, groupCtx.canvas.width, groupCtx.canvas.height); + this.ctx.clip(clip); + this.ctx.globalCompositeOperation = "copy"; + } this.ctx.drawImage(groupCtx.canvas, 0, 0); } this.ctx.restore(); diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index e25516a81..a2873d619 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -946,3 +946,4 @@ !issue21579.pdf !saslprep-r6.pdf !jpx_smaskindata.pdf +!nonisolated_blend_smask.pdf diff --git a/test/pdfs/nonisolated_blend_smask.pdf b/test/pdfs/nonisolated_blend_smask.pdf new file mode 100644 index 000000000..560f9e561 --- /dev/null +++ b/test/pdfs/nonisolated_blend_smask.pdf @@ -0,0 +1,137 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 240] + /Group << /Type /Group /S /Transparency /CS /DeviceRGB >> + /Resources << + /Font << /F1 5 0 R >> + /ExtGState << /Half 7 0 R >> + /XObject << /FxText 6 0 R /FxAlpha 12 0 R >> + >> + /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 419 >> +stream +q +/Half gs +0.10 0.35 1 rg +20 160 120 35 re f +180 160 120 35 re f +Q +BT /F1 17 Tf 0 0 0 rg 48 213 Td (The two blue boxes should match) Tj ET +BT /F1 12 Tf 0 0 0 rg 61 140 Td (control) Tj ET +BT /F1 12 Tf 0 0 0 rg 203 140 Td (inside group) Tj ET +BT /F1 12 Tf 0 0 0 rg 58 108 Td (Original issue: text should stay visible) Tj ET +1 1 1 rg +10 15 300 80 re f +BT /F1 30 Tf 0 0 0 rg 105 39 Td (Hidden) Tj ET +/FxText Do +/FxAlpha Do +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /XObject /Subtype /Form /FormType 1 /BBox [0 0 320 100] + /Group << /Type /Group /S /Transparency /CS /DeviceRGB /I false >> + /Resources << + /ExtGState << /Blend 8 0 R /Masked 9 0 R >> + /XObject << /Paint 10 0 R >> + >> /Length 35 >> +stream +/Blend gs +q /Masked gs /Paint Do Q +endstream +endobj +7 0 obj +<< /Type /ExtGState /ca 0.5 /CA 0.5 >> +endobj +8 0 obj +<< /Type /ExtGState /BM /Multiply >> +endobj +9 0 obj +<< /Type /ExtGState /ca 1 /CA 1 + /SMask << /Type /Mask /S /Alpha /G 11 0 R >> >> +endobj +10 0 obj +<< /Type /XObject /Subtype /Form /FormType 1 /BBox [0 0 320 100] + /Group << /Type /Group /S /Transparency /CS /DeviceRGB /I true >> + /Resources << >> /Length 36 >> +stream +0.55 0.35 0.95 rg +30 25 260 55 re f +endstream +endobj +11 0 obj +<< /Type /XObject /Subtype /Form /FormType 1 /BBox [0 0 320 100] + /Group << /Type /Group /S /Transparency /CS /DeviceGray /I true >> + /Resources << >> /Length 22 >> +stream +1 g +30 25 260 55 re f +endstream +endobj +12 0 obj +<< /Type /XObject /Subtype /Form /FormType 1 /BBox [170 150 310 200] + /Group << /Type /Group /S /Transparency /CS /DeviceRGB /I false >> + /Resources << + /ExtGState << /Blend 8 0 R /Masked 13 0 R >> + /XObject << /Paint 14 0 R >> + >> /Length 35 >> +stream +/Blend gs +q /Masked gs /Paint Do Q +endstream +endobj +13 0 obj +<< /Type /ExtGState /ca 1 /CA 1 + /SMask << /Type /Mask /S /Alpha /G 15 0 R >> >> +endobj +14 0 obj +<< /Type /XObject /Subtype /Form /FormType 1 /BBox [170 150 310 200] + /Group << /Type /Group /S /Transparency /CS /DeviceRGB /I true >> + /Resources << >> /Length 29 >> +stream +1 0 0 rg +170 150 140 50 re f +endstream +endobj +15 0 obj +<< /Type /XObject /Subtype /Form /FormType 1 /BBox [170 150 310 200] + /Group << /Type /Group /S /Transparency /CS /DeviceGray /I true >> + /Resources << >> /Length 1 >> +stream + +endstream +endobj +xref +0 16 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000434 00000 n +0000000904 00000 n +0000000974 00000 n +0000001306 00000 n +0000001360 00000 n +0000001412 00000 n +0000001514 00000 n +0000001755 00000 n +0000001983 00000 n +0000002321 00000 n +0000002424 00000 n +0000002662 00000 n +trailer +<< /Size 16 /Root 1 0 R >> +startxref +2872 +%%EOF diff --git a/test/test_manifest.json b/test/test_manifest.json index c60a97fdf..72fa8944e 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -14486,5 +14486,12 @@ "md5": "f7a8628b14ca176700247f106c16812a", "rounds": 1, "type": "eq" + }, + { + "id": "nonisolated_blend_smask", + "file": "pdfs/nonisolated_blend_smask.pdf", + "md5": "4b87611416be34894eeb19c158fafbad", + "rounds": 1, + "type": "eq" } ]