From d1e9194e1f7d88ec649fd7d253966683e461ef1f Mon Sep 17 00:00:00 2001 From: calixteman Date: Tue, 14 Apr 2026 21:38:19 +0200 Subject: [PATCH] Render knockout transparency groups In a knockout (KO) group each painting operator ("element") composites against the group's initial backdrop instead of accumulating onto prior elements of the same group. The backend renders each element to a per-group pooled temp canvas (keyed off `#groupStackMeta`), builds a binary alpha mask via a new `feFuncA` filter (`addKnockoutFilter`), `destination-out`s the group canvas through that mask, restores the initial backdrop into the cleared footprint for non-isolated groups (cropped to the same mask so sparse groups don't bleed the whole rectangle), and finally paints the element on top with the parent's blend mode. Path / clip / transform ops are mirrored back to the group canvas via `mirrorContextOperations` so graphics state stays in sync between elements; only the raster pixels land on the temp canvas. The temp canvas is forced to source-over for the element raster (`multiply` on a transparent backdrop would zero the color) and the original GCO is restored before `copyCtxState` writes back, so the parent's blend mode survives for the final composite. Also handled: - Nested KO groups (the level is incremented for KO, reset to 0 for non-KO subgroups so an ancestor KO doesn't leak in). - Non-isolated non-KO subgroups inside a KO parent (`hasInnerBackdrop` path: blend the elements against the subgroup's running backdrop for color, mask with the elements-only canvas). - Soft masks installed inside a KO element (`applySMaskInPlace` in `compose`, which runs the SMask destination-in directly on the temp canvas; the existing blit-to-suspended step is gated by `if (!ctx)`). - Type-3 text, shading fills, image-mask groups, inline images and the solid-color mask path: each is wrapped in `#begin/#endKnockoutElement`. - `endDrawing` cleanup so cancelled rendering doesn't leak pooled canvases or stale knockout state. --- src/display/canvas.js | 603 +++++++++++++++++++++- src/display/filter_factory.js | 35 ++ src/shared/util.js | 15 + test/pdfs/.gitignore | 7 + test/pdfs/knockout_blend_multiply.pdf | Bin 0 -> 900 bytes test/pdfs/knockout_groups_test.pdf | Bin 0 -> 25286 bytes test/pdfs/knockout_isolated_overlap.pdf | Bin 0 -> 901 bytes test/pdfs/knockout_nested.pdf | Bin 0 -> 1128 bytes test/pdfs/knockout_nested_group_alpha.pdf | Bin 0 -> 1166 bytes test/pdfs/knockout_nonisolated_sparse.pdf | Bin 0 -> 870 bytes test/pdfs/knockout_smask.pdf | Bin 0 -> 1153 bytes test/test_manifest.json | 56 ++ 12 files changed, 690 insertions(+), 26 deletions(-) create mode 100644 test/pdfs/knockout_blend_multiply.pdf create mode 100644 test/pdfs/knockout_groups_test.pdf create mode 100644 test/pdfs/knockout_isolated_overlap.pdf create mode 100644 test/pdfs/knockout_nested.pdf create mode 100644 test/pdfs/knockout_nested_group_alpha.pdf create mode 100644 test/pdfs/knockout_nonisolated_sparse.pdf create mode 100644 test/pdfs/knockout_smask.pdf diff --git a/src/display/canvas.js b/src/display/canvas.js index 59b9ab2cc305e..442b12e9cb0c2 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -544,6 +544,47 @@ const NORMAL_CLIP = {}; const EO_CLIP = {}; class CanvasGraphics { + // Knockout group support fields. + #knockoutGroupLevel = 0; + + #knockoutElementDepth = 0; + + #knockoutTempCanvasEntry = null; + + #knockoutSavedCtx = null; + + #knockoutSavedSMaskCtx = null; + + // Parent ctx globalCompositeOperation (GCO) at element start. Restored on + // tempCtx before the post-element copyCtxState so the saved ctx keeps its + // blend mode. + #knockoutSavedGCO = null; + + #knockoutElementAlpha = 1; + + /** + * Lazy alpha-scaling filter cache, populated on the first translucent + * knockout element. One of: + * - `Map` - when `ctx.filter` is supported; one SVG filter + * per quantised alpha_s value (cache bounded by 8-bit alpha precision). + * - `"none"` - no DOM available; the JS pixel-loop fallback handles + * scaling instead. + * Stays `undefined` until the first translucent element forces a resolve. + * @type {Map | "none" | undefined} + */ + #knockoutFilterCache; + + // Snapshot of #groupStackMeta.at(-1) at element-begin so the right backdrop + // is used even if nested groups push/pop during the element's lifetime. + #knockoutElementGroupMeta = null; + + // Per-group metadata, aligned with `groupStack`. `null` for the no-canvas + // fast path. Otherwise: `backdropCtx` (parent ctx for non-isolated KO, + // read directly since it's frozen), `hasInnerBackdrop` (non-isolated + // non-KO subgroup inside a KO parent), `savedKnockoutLevel` (level to + // restore on exit), pixel offsets, and pooled scratch entries. + #groupStackMeta = []; + constructor( canvasCtx, commonObjs, @@ -791,6 +832,22 @@ class CanvasGraphics { this.tempSMask = null; this.smaskStack.length = 0; + // Drop knockout state in case rendering was cancelled mid-group. Pooled + // temp/backdrop entries are owned by the meta and freed there; the + // active-element fields just alias into the meta, so only clear them. + for (const meta of this.#groupStackMeta) { + this.#destroyKnockoutPools(meta); + } + this.#groupStackMeta.length = 0; + this.#knockoutTempCanvasEntry = null; + this.#knockoutSavedCtx = null; + this.#knockoutSavedSMaskCtx = null; + this.#knockoutSavedGCO = null; + this.#knockoutElementAlpha = 1; + this.#knockoutElementGroupMeta = null; + this.#knockoutElementDepth = 0; + this.#knockoutGroupLevel = 0; + this.cachedPatterns.clear(); for (const cache of this._cachedBitmapsMap.values()) { @@ -916,7 +973,7 @@ class CanvasGraphics { paintHeight = newHeight; } - // writeEntry is now the stale buffer — destroy it. + // writeEntry is now the stale buffer; destroy it. this.canvasFactory.destroy(writeEntry); return { img: readEntry.canvas, @@ -1460,6 +1517,310 @@ class CanvasGraphics { this._clearPreparedSMask(); } + #createKnockoutMaskCanvas(sourceCanvas, reuseEntry = null, alpha = 1) { + const { width, height } = sourceCanvas; + // reuseEntry is assumed to match sourceCanvas in size (all current call + // sites guarantee this); the mask is rebuilt in-place. + const maskEntry = reuseEntry ?? this.canvasFactory.create(width, height); + const maskCtx = maskEntry.context; + // Snap alpha_s to 8-bit precision: the painted alpha we're scaling is + // already 8-bit, so any finer-grained alpha_s is indistinguishable. Caps + // both the local Map and the filter-factory cache at <=256 entries + // regardless of how many distinct gstate alpha values the PDF uses. + alpha = Math.round(alpha * 255) / 255; + const needsAlphaScaling = alpha < 1; + if (needsAlphaScaling && this.#knockoutFilterCache === undefined) { + // On Safari `ctx.filter` is settable but inert: the filter URL would + // be stored without being applied, leaving the mask unscaled. + // Force the JS fallback there. + this.#knockoutFilterCache = FeatureTest.isCanvasFilterSupported + ? new Map() + : "none"; + } + let knockoutFilter = "none"; + if (needsAlphaScaling && this.#knockoutFilterCache instanceof Map) { + knockoutFilter = this.#knockoutFilterCache.get(alpha); + if (!knockoutFilter) { + knockoutFilter = this.filterFactory.addKnockoutFilter(alpha); + this.#knockoutFilterCache.set(alpha, knockoutFilter); + } + } + + if (!needsAlphaScaling || knockoutFilter !== "none") { + // Reused entries may carry stale pixels. Avoid the + // globalCompositeOperation = "copy" + filter combo: that pair is + // browser-divergent. + if (reuseEntry) { + maskCtx.save(); + maskCtx.setTransform(1, 0, 0, 1, 0, 0); + maskCtx.clearRect(0, 0, width, height); + maskCtx.restore(); + } + maskCtx.filter = knockoutFilter; + maskCtx.drawImage(sourceCanvas, 0, 0); + maskCtx.filter = "none"; + return maskEntry; + } + + // No-DOM fallback (Node/embedded). Scale painted alpha back to shape + // coverage; color channels are irrelevant for destination-out/in. + const sourceData = sourceCanvas + .getContext("2d", { willReadFrequently: true }) + .getImageData(0, 0, width, height); + const maskData = maskCtx.createImageData(width, height); + const sourcePixels = sourceData.data, + maskPixels = maskData.data; + const alphaScale = alpha > 0 ? 1 / alpha : 1e6; + for (let i = 3, ii = sourcePixels.length; i < ii; i += 4) { + maskPixels[i] = Math.min(Math.round(sourcePixels[i] * alphaScale), 255); + } + maskCtx.putImageData(maskData, 0, 0); + return maskEntry; + } + + #getOrCreatePooledEntry(meta, key, width, height) { + let entry = meta?.[key] ?? null; + if ( + entry && + (entry.canvas.width !== width || entry.canvas.height !== height) + ) { + this.canvasFactory.destroy(entry); + entry = null; + } + if (!entry) { + entry = this.canvasFactory.create(width, height); + if (meta) { + meta[key] = entry; + } + return entry; + } + // Reused entry: clear any stale pixels before the caller refills it. + const ctx = entry.context; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, width, height); + ctx.restore(); + return entry; + } + + #compositeKnockoutSurface(destCtx, surfaceCanvas, options = {}) { + const { + // Backdrop canvas for non-isolated groups, or null for isolated. + // Passed directly (no copy) since the parent canvas is frozen while + // the group renders. + backdropCanvas = null, + // Transform for destCtx before the final draw. Identity is correct + // when destCtx and surfaceCanvas share pixel coords (per-element + // path); pass currentMtx for the endGroup subgroup-into-parent path. + destTransform = [1, 0, 0, 1, 0, 0], + // Pixel origin within backdropCanvas of the region that maps onto + // surfaceCanvas. [0,0] when the backdrop is already pre-cropped; + // pass the (possibly compounded) group offsets in endGroup. + backdropOffset = [0, 0], + // Pool entry to refill in place for the knockout mask. Caller owns + // its lifetime when provided. + reuseMaskEntry = null, + // Group meta to pool the backdrop scratch on. Without it the scratch + // is allocated and destroyed locally. + poolMeta = null, + // Per-element surfaces already have alpha/filter baked in (defaults + // 1/"none"). Subgroup canvases don't, so endGroup passes the parent + // values to apply only at the final draw. + sourceAlpha = 1, + sourceFilter = "none", + knockoutAlpha = 1, + } = options; + const { width, height } = surfaceCanvas; + const knockoutMaskEntry = this.#createKnockoutMaskCanvas( + surfaceCanvas, + reuseMaskEntry, + knockoutAlpha + ); + const sourceCompositeOperation = destCtx.globalCompositeOperation; + + destCtx.save(); + destCtx.setTransform(...destTransform); + destCtx.globalAlpha = 1; + if (FeatureTest.isCanvasFilterSupported) { + destCtx.filter = "none"; + } + + // Erase prior group content wherever the new surface has any coverage. + destCtx.globalCompositeOperation = "destination-out"; + destCtx.drawImage(knockoutMaskEntry.canvas, 0, 0); + + if (backdropCanvas) { + // Non-isolated: refill the just-erased footprint with the backdrop, + // pre-clipped to the same shape mask so non-element pixels stay + // transparent (otherwise sparse groups bleed the backdrop rect). + const [bx, by] = backdropOffset; + const backdropEntry = this.#getOrCreatePooledEntry( + poolMeta, + "knockoutBackdropEntry", + width, + height + ); + const backdropCtx = backdropEntry.context; + backdropCtx.drawImage( + backdropCanvas, + bx, + by, + width, + height, + 0, + 0, + width, + height + ); + backdropCtx.globalCompositeOperation = "destination-in"; + backdropCtx.drawImage(knockoutMaskEntry.canvas, 0, 0); + // Reset the GCO so the pooled entry is in a known state for next use. + backdropCtx.globalCompositeOperation = "source-over"; + + destCtx.globalCompositeOperation = "destination-over"; + destCtx.drawImage(backdropEntry.canvas, 0, 0); + if (!poolMeta) { + this.canvasFactory.destroy(backdropEntry); + } + } + destCtx.globalCompositeOperation = sourceCompositeOperation; + destCtx.globalAlpha = sourceAlpha; + if (FeatureTest.isCanvasFilterSupported) { + destCtx.filter = sourceFilter ?? "none"; + } + destCtx.drawImage(surfaceCanvas, 0, 0); + + destCtx.restore(); + if (!reuseMaskEntry) { + this.canvasFactory.destroy(knockoutMaskEntry); + } + } + + /** + * Begin a knockout element. In a KO group each element composites against + * the initial group backdrop (transparent if isolated, parent canvas if + * not) rather than against the running group result. We render onto a temp + * canvas; path/clip/transform ops are mirrored back to the group canvas so + * its state stays in sync for the next element. + * + * @returns {boolean} true if a knockout element was started. + */ + #beginKnockoutElement(alpha = 1) { + if ( + this.#knockoutGroupLevel === 0 || + this.#knockoutElementDepth > 0 || + !this.contentVisible + ) { + return false; + } + this.#knockoutElementDepth++; + this.#knockoutElementAlpha = alpha; + const groupMeta = this.#groupStackMeta.at(-1); + const { canvas } = this.ctx; + const tempEntry = this.#getOrCreatePooledEntry( + groupMeta, + "knockoutTempEntry", + canvas.width, + canvas.height + ); + this.#knockoutTempCanvasEntry = tempEntry; + const tempCtx = tempEntry.context; + // Bracket-save before installing mirroring so #endKnockoutElement can + // restore() the pooled canvas to a clean clip+transform without + // propagating that save through the mirror. + tempCtx.save(); + tempCtx.setTransform(this.ctx.getTransform()); + copyCtxState(this.ctx, tempCtx); + // Force source-over for the element raster: the parent's blend mode is + // meant for the final composite back onto the group canvas (done by + // #compositeKnockoutSurface), not for drawing onto a transparent temp + // (e.g. multiply on alpha=0 zeros the element's colour). Stash the + // parent GCO and re-apply it on tempCtx before the post-element + // copyCtxState so the saved ctx keeps the parent blend mode. + this.#knockoutSavedGCO = tempCtx.globalCompositeOperation; + tempCtx.globalCompositeOperation = "source-over"; + mirrorContextOperations(tempCtx, this.ctx); + this.#knockoutElementGroupMeta = groupMeta; + this.#knockoutSavedCtx = this.ctx; + this.#knockoutSavedSMaskCtx = this.suspendedCtx; + this.ctx = tempCtx; + if (this.inSMaskMode) { + this.suspendedCtx = tempCtx; + } + return true; + } + + /** + * End a knockout element started by `#beginKnockoutElement`. Composites + * the rendered surface onto the group canvas with KO semantics: build a + * shape mask from the element (painted alpha scaled back to geometric + * coverage when alpha_s < 1), destination-out the group canvas over that + * mask, restore the initial backdrop into the cleared footprint + * (non-isolated only), then paint the element on top. + * + * @param {boolean} started - the value returned by `#beginKnockoutElement`. + */ + #endKnockoutElement(started) { + if (!started) { + return; + } + const tempEntry = this.#knockoutTempCanvasEntry; + const savedCtx = this.#knockoutSavedCtx; + const savedSMaskCtx = this.#knockoutSavedSMaskCtx; + const tempCtx = tempEntry.context; + this.#knockoutTempCanvasEntry = null; + this.#knockoutSavedCtx = null; + this.#knockoutSavedSMaskCtx = null; + if ( + this.inSMaskMode && + this.suspendedCtx === tempCtx && + this.ctx !== tempCtx + ) { + this.endSMaskMode(); + } + if (this.inSMaskMode) { + this.suspendedCtx = savedSMaskCtx; + } + this.ctx._removeMirroring(); + // Re-apply the parent GCO before copyCtxState writes it back to + // savedCtx so #compositeKnockoutSurface sees the original blend mode. + this.ctx.globalCompositeOperation = this.#knockoutSavedGCO; + this.#knockoutSavedGCO = null; + copyCtxState(this.ctx, savedCtx); + this.ctx = savedCtx; + const groupMeta = this.#knockoutElementGroupMeta; + this.#knockoutElementGroupMeta = null; + const knockoutAlpha = this.#knockoutElementAlpha; + this.#knockoutElementAlpha = 1; + try { + this.#compositeKnockoutSurface( + savedSMaskCtx ?? savedCtx, + tempEntry.canvas, + { + backdropCanvas: groupMeta?.backdropCtx?.canvas ?? null, + backdropOffset: groupMeta?.backdropCtx + ? [groupMeta.offsetX, groupMeta.offsetY] + : [0, 0], + reuseMaskEntry: groupMeta?.knockoutMaskEntry ?? null, + poolMeta: groupMeta, + knockoutAlpha, + } + ); + } finally { + // Pop the begin-element bracket save so the pooled canvas re-enters + // with a clean clip+transform stack. + tempCtx.restore(); + // Decrement only after the canvas is fully reset, so a re-entry from + // a compositing callback sees depth>0 and bails out. + this.#knockoutElementDepth--; + // Defensive: groupMeta is non-null in practice for any active KO + // element, but if it isn't we must release the unpooled entry. + if (!groupMeta) { + this.canvasFactory.destroy(tempEntry); + } + } + } + compose(dirtyBox) { if (!this.current.activeSMask) { return; @@ -1477,10 +1838,21 @@ class CanvasGraphics { : [0, 0, this.ctx.canvas.width, this.ctx.canvas.height]; const smask = this.current.activeSMask; const suspendedCtx = this.suspendedCtx; + const applySMaskInPlace = + this.#knockoutElementDepth > 0 && suspendedCtx === this.ctx; + + this.composeSMask( + applySMaskInPlace ? null : suspendedCtx, + smask, + this.ctx, + dirtyBox + ); + if (applySMaskInPlace) { + return; + } - this.composeSMask(suspendedCtx, smask, this.ctx, dirtyBox); // Whatever was drawn has been moved to the suspended canvas, now clear it - // out of the current canvas. Only the dirty box region needs clearing — + // out of the current canvas. Only the dirty box region needs clearing; // everything outside it is already transparent. this.ctx.save(); this.ctx.setTransform(1, 0, 0, 1, 0, 0); @@ -1574,6 +1946,10 @@ class CanvasGraphics { ); } + if (!ctx) { + return; + } + ctx.save(); ctx.globalAlpha = 1; ctx.globalCompositeOperation = smask.blendMode || "source-over"; @@ -1807,6 +2183,8 @@ class CanvasGraphics { } stroke(opIdx, path, consumePath = true) { + const started = + consumePath && this.#beginKnockoutElement(this.current.strokeAlpha); const ctx = this.ctx; const strokeColor = this.current.strokeColor; // For stroke we want to temporarily change the global alpha to the @@ -1855,6 +2233,7 @@ class CanvasGraphics { // Restore the global alpha to the fill alpha ctx.globalAlpha = this.current.fillAlpha; + this.#endKnockoutElement(started); } closeStroke(opIdx, path) { @@ -1862,6 +2241,8 @@ class CanvasGraphics { } fill(opIdx, path, consumePath = true) { + const started = + consumePath && this.#beginKnockoutElement(this.current.fillAlpha); const ctx = this.ctx; const fillColor = this.current.fillColor; const isPatternFill = this.current.patternFill; @@ -1881,6 +2262,7 @@ class CanvasGraphics { this.consumePath(opIdx, path, intersect); } this.current.tilingPatternDims = null; + this.#endKnockoutElement(started); return; } const baseTransform = fillColor.isModifyingCurrentTransform() @@ -1922,6 +2304,7 @@ class CanvasGraphics { if (consumePath) { this.consumePath(opIdx, path, intersect); } + this.#endKnockoutElement(started); } eoFill(opIdx, path) { @@ -1930,10 +2313,18 @@ class CanvasGraphics { } fillStroke(opIdx, path) { + // Fill and stroke share one KO element so they composite against the + // initial backdrop once, not twice. Use the smaller of the two alpha_s as + // the mask divisor: it's conservative (over-clamps the other pass's + // mask towards 1) but keeps the mask coverage at least as large as the + // union of fill+stroke shapes, which is what KO erasure wants. + const started = this.#beginKnockoutElement( + Math.min(this.current.fillAlpha, this.current.strokeAlpha) + ); this.fill(opIdx, path, false); this.stroke(opIdx, path, false); - this.consumePath(opIdx, path); + this.#endKnockoutElement(started); } eoFillStroke(opIdx, path) { @@ -1955,10 +2346,12 @@ class CanvasGraphics { } rawFillPath(opIdx, path) { + const started = this.#beginKnockoutElement(this.current.fillAlpha); this.ctx.fill(path); this.dependencyTracker ?.recordDependencies(opIdx, Dependencies.rawFillPath) .recordOperation(opIdx); + this.#endKnockoutElement(started); } // Clipping @@ -2332,8 +2725,10 @@ class CanvasGraphics { const current = this.current; const font = current.font; if (font.isType3Font) { + const started = this.#beginKnockoutElement(current.fillAlpha); this.showType3Text(opIdx, glyphs); this.dependencyTracker?.recordShowTextOperation(opIdx); + this.#endKnockoutElement(started); return undefined; } @@ -2343,6 +2738,7 @@ class CanvasGraphics { return undefined; } + const started = this.#beginKnockoutElement(current.fillAlpha); const ctx = this.ctx; const fontSizeScale = current.fontSizeScale; const charSpacing = current.charSpacing; @@ -2456,6 +2852,7 @@ class CanvasGraphics { current.x += width * widthAdvanceScale * textHScale; ctx.restore(); this.compose(); + this.#endKnockoutElement(started); return undefined; } @@ -2571,6 +2968,7 @@ class CanvasGraphics { this.compose(); this.dependencyTracker?.recordShowTextOperation(opIdx); + this.#endKnockoutElement(started); return undefined; } @@ -2763,6 +3161,7 @@ class CanvasGraphics { if (!this.contentVisible) { return; } + const started = this.#beginKnockoutElement(this.current.fillAlpha); const ctx = this.ctx; this.save(opIdx); @@ -2803,6 +3202,7 @@ class CanvasGraphics { this.compose(this.current.getClippedPathBoundingBox()); this.restore(opIdx); + this.#endKnockoutElement(started); } // Images @@ -2864,31 +3264,14 @@ class CanvasGraphics { } const currentCtx = this.ctx; - // TODO non-isolated groups - according to Rik at adobe non-isolated - // group results aren't usually that different and they even have tools - // that ignore this setting. Notes from Rik on implementing: - // - When you encounter an transparency group, create a new canvas with - // the dimensions of the bbox - // - copy the content from the previous canvas to the new canvas - // - draw as usual - // - remove the backdrop alpha: - // alphaNew = 1 - (1 - alpha)/(1 - alphaBackdrop) with 'alpha' the alpha - // value of your transparency group and 'alphaBackdrop' the alpha of the - // backdrop - // - remove background color: - // colorNew = color - alphaNew *colorBackdrop /(1 - alphaNew) - if (!group.isolated) { - info("TODO: Support non-isolated groups."); - } - - // TODO knockout - supposedly possible with the clever use of compositing - // modes. - if (group.knockout) { - warn("Knockout groups not supported."); + if (!group.isolated && !group.knockout && this.#knockoutGroupLevel === 0) { + info("TODO: Fully support non-isolated non-knockout groups."); } if ( !group.needsIsolation && + !group.knockout && + this.#knockoutGroupLevel === 0 && currentCtx.globalAlpha === 1 && currentCtx.globalCompositeOperation === "source-over" && !inSMaskMode @@ -2905,6 +3288,7 @@ class CanvasGraphics { currentCtx.clip(clip); } this.groupStack.push(null); // null = no intermediate canvas + this.#groupStackMeta.push(null); this.groupLevel++; return; } @@ -2952,6 +3336,37 @@ class CanvasGraphics { this.smaskGroupCanvases.push(scratchCanvas); } const groupCtx = scratchCanvas.context; + // Non-isolated KO: keep a reference to the parent ctx (not a copy). It's + // frozen while the group renders, so we can read from it on demand. The + // backdrop is only restored under each element's footprint in + // #compositeKnockoutSurface so it doesn't become part of the group + // source itself. + const backdropCtx = group.knockout && !group.isolated ? currentCtx : null; + // Non-isolated non-KO subgroup inside a KO parent: at endGroup we'll + // blend its elements against the outer KO running canvas (also frozen), + // so just record the flag here and read ctx.canvas at composite time. + const hasInnerBackdrop = + !group.isolated && + !group.knockout && + !group.smask && + this.#knockoutGroupLevel > 0; + + // Pool the per-element shape mask for the lifetime of this KO group. + // Non-KO groups never call #compositeKnockoutSurface for their own + // elements so the entry is unused there. + const knockoutMaskEntry = group.knockout + ? this.canvasFactory.create(drawnWidth, drawnHeight) + : null; + + // For KO groups bump the level so inner elements get KO treatment; for + // non-KO groups reset to 0 so an ancestor KO group doesn't apply to + // them. Restored on endGroup. + const savedKnockoutLevel = this.#knockoutGroupLevel; + if (group.knockout) { + this.#knockoutGroupLevel++; + } else { + this.#knockoutGroupLevel = 0; + } // Since we created a new canvas that is just the size of the bounding box // we have to translate the group ctx. @@ -3032,6 +3447,17 @@ class CanvasGraphics { ["TR", null], ]); this.groupStack.push(currentCtx); + this.#groupStackMeta.push({ + backdropCtx, + savedKnockoutLevel, + offsetX, + offsetY, + hasInnerBackdrop, + knockoutMaskEntry, + // Per-group scratch pools, lazily filled and freed in endGroup. + knockoutTempEntry: null, + knockoutBackdropEntry: null, + }); this.groupLevel++; } @@ -3042,6 +3468,12 @@ class CanvasGraphics { this.groupLevel--; const groupCtx = this.ctx; const ctx = this.groupStack.pop(); + const groupMeta = this.#groupStackMeta.pop(); + // Restore the knockout level that was in effect before this group began. + // Simple groups (groupMeta === null) never modify the level, so skip them. + if (groupMeta) { + this.#knockoutGroupLevel = groupMeta.savedKnockoutLevel; + } if (ctx === null) { // Simple group: content was drawn directly on the parent canvas. this.restore(opIdx); @@ -3068,6 +3500,7 @@ class CanvasGraphics { this.ctx.setTransform(this.suspendedCtx.getTransform()); } } + this.#destroyKnockoutPools(groupMeta); } else { this.ctx.restore(); const currentMtx = getCurrentTransform(this.ctx); @@ -3080,16 +3513,122 @@ class CanvasGraphics { currentMtx, dirtyBox ); - this.ctx.drawImage(groupCtx.canvas, 0, 0); + const parentGroupMeta = this.#groupStackMeta.at(-1); + if (this.#knockoutGroupLevel > 0) { + // The subgroup is one element of the enclosing KO group, so + // composite it with KO semantics. Two coord systems below: + // - `currentMtx` (`destTransform`) places the subgroup canvas in + // the parent on the final draw, like the non-KO `drawImage`. + // - `groupMeta.offsetX/Y` are the pixel origins beginGroup stored + // when sizing the scratch; we use them (not `currentMtx[4]/[5]`, + // which are PDF-transform components) to crop the backdrop. + if (groupMeta.hasInnerBackdrop) { + // Non-isolated subgroup inside a KO parent: blend the elements + // against the subgroup's own initial backdrop for colour, but use + // the elements-only scratch as the alpha mask so transparent + // areas don't erase the parent. `ctx` is the outer KO canvas + // (just popped); its pixels still match the subgroup's + // beginGroup state since the subgroup draws to its own scratch. + const { width, height } = groupCtx.canvas; + const colorEntry = this.canvasFactory.create(width, height); + const colorCtx = colorEntry.context; + colorCtx.drawImage( + ctx.canvas, + groupMeta.offsetX, + groupMeta.offsetY, + width, + height, + 0, + 0, + width, + height + ); + colorCtx.globalCompositeOperation = "source-over"; + colorCtx.drawImage(groupCtx.canvas, 0, 0); + // Clip colorEntry to the subgroup's element footprint so + // backdrop pixels outside the elements don't bleed onto the + // parent. Built with alpha=1 (no scaling) so the mask uses the + // subgroup's composited painted alpha directly as shape - its + // global gstate alpha gets applied at the final draw below. The + // mask is sized to the subgroup canvas, so we can't reuse the + // parent KO group's pooled mask here; allocate a fresh entry + // and reuse it for both the destination-in and the + // destination-out below. + const shapeMaskEntry = this.#createKnockoutMaskCanvas( + groupCtx.canvas + ); + colorCtx.globalCompositeOperation = "destination-in"; + colorCtx.drawImage(shapeMaskEntry.canvas, 0, 0); + // Inline the isolated-path compositing here so we can share + // shapeMaskEntry with the destination-in above. + const sourceCompositeOperation = this.ctx.globalCompositeOperation; + const sourceAlpha = this.ctx.globalAlpha; + const sourceFilter = this.ctx.filter; + this.ctx.save(); + this.ctx.setTransform(...currentMtx); + this.ctx.globalAlpha = 1; + if (FeatureTest.isCanvasFilterSupported) { + this.ctx.filter = "none"; + } + this.ctx.globalCompositeOperation = "destination-out"; + this.ctx.drawImage(shapeMaskEntry.canvas, 0, 0); + this.ctx.globalCompositeOperation = sourceCompositeOperation; + this.ctx.globalAlpha = sourceAlpha; + if (FeatureTest.isCanvasFilterSupported) { + this.ctx.filter = sourceFilter ?? "none"; + } + this.ctx.drawImage(colorEntry.canvas, 0, 0); + this.ctx.restore(); + this.canvasFactory.destroy(shapeMaskEntry); + this.canvasFactory.destroy(colorEntry); + } else { + // For a non-isolated KO parent the backdrop lives one level up. + // Compound the parent's and subgroup's offsets to crop it. + const backdropCtx = parentGroupMeta?.backdropCtx ?? null; + this.#compositeKnockoutSurface(this.ctx, groupCtx.canvas, { + backdropCanvas: backdropCtx?.canvas ?? null, + destTransform: currentMtx, + backdropOffset: backdropCtx + ? [ + parentGroupMeta.offsetX + groupMeta.offsetX, + parentGroupMeta.offsetY + groupMeta.offsetY, + ] + : [0, 0], + sourceAlpha: this.ctx.globalAlpha, + sourceFilter: this.ctx.filter, + }); + } + } else { + this.ctx.drawImage(groupCtx.canvas, 0, 0); + } this.ctx.restore(); this.canvasFactory.destroy({ canvas: groupCtx.canvas, context: groupCtx, }); + this.#destroyKnockoutPools(groupMeta); this.compose(dirtyBox); } } + #destroyKnockoutPools(groupMeta) { + if (!groupMeta) { + return; + } + if (groupMeta.knockoutMaskEntry) { + this.canvasFactory.destroy(groupMeta.knockoutMaskEntry); + groupMeta.knockoutMaskEntry = null; + } + if (groupMeta.knockoutTempEntry) { + this.canvasFactory.destroy(groupMeta.knockoutTempEntry); + groupMeta.knockoutTempEntry = null; + } + if (groupMeta.knockoutBackdropEntry) { + this.canvasFactory.destroy(groupMeta.knockoutBackdropEntry); + groupMeta.knockoutBackdropEntry = null; + } + } + beginAnnotation(opIdx, id, rect, transform, matrix, hasOwnCanvas) { // The annotations are drawn just after the page content. // The page content drawing can potentially have set a transform, @@ -3184,6 +3723,7 @@ class CanvasGraphics { img = this.getObject(opIdx, img.data, img); img.count = count; + const started = this.#beginKnockoutElement(this.current.fillAlpha); const ctx = this.ctx; const mask = this._createMaskCanvas(opIdx, img); const maskCanvas = mask.canvas; @@ -3209,6 +3749,7 @@ class CanvasGraphics { this.canvasFactory.destroy(mask.canvasEntry); } this.compose(); + this.#endKnockoutElement(started); } paintImageMaskXObjectRepeat( @@ -3226,6 +3767,7 @@ class CanvasGraphics { img = this.getObject(opIdx, img.data, img); + const started = this.#beginKnockoutElement(this.current.fillAlpha); const ctx = this.ctx; ctx.save(); const currentTransform = getCurrentTransform(ctx); @@ -3270,12 +3812,14 @@ class CanvasGraphics { this.compose(); this.dependencyTracker?.recordOperation(opIdx); + this.#endKnockoutElement(started); } paintImageMaskXObjectGroup(opIdx, images) { if (!this.contentVisible) { return; } + const started = this.#beginKnockoutElement(this.current.fillAlpha); const ctx = this.ctx; const fillColor = this.current.fillColor; @@ -3332,6 +3876,7 @@ class CanvasGraphics { } this.compose(); this.dependencyTracker?.recordOperation(opIdx); + this.#endKnockoutElement(started); } paintImageXObject(opIdx, objId) { @@ -3401,6 +3946,7 @@ class CanvasGraphics { } const width = imgData.width; const height = imgData.height; + const started = this.#beginKnockoutElement(this.current.fillAlpha); const ctx = this.ctx; this.save(opIdx); @@ -3479,12 +4025,14 @@ class CanvasGraphics { } this.compose(); this.restore(opIdx); + this.#endKnockoutElement(started); } paintInlineImageXObjectGroup(opIdx, imgData, map) { if (!this.contentVisible) { return; } + const started = this.#beginKnockoutElement(this.current.fillAlpha); const ctx = this.ctx; let imgToPaint; let inlineImgCanvas = null; @@ -3526,12 +4074,14 @@ class CanvasGraphics { } this.dependencyTracker?.recordOperation(opIdx); this.compose(); + this.#endKnockoutElement(started); } paintSolidColorImageMask(opIdx) { if (!this.contentVisible) { return; } + const started = this.#beginKnockoutElement(this.current.fillAlpha); this.dependencyTracker ?.resetBBox(opIdx) .recordBBox(opIdx, this.ctx, 0, 1, 0, 1) @@ -3539,6 +4089,7 @@ class CanvasGraphics { .recordOperation(opIdx); this.ctx.fillRect(0, 0, 1, 1); this.compose(); + this.#endKnockoutElement(started); } // Marked content diff --git a/src/display/filter_factory.js b/src/display/filter_factory.js index acec5d4093da4..32f69e4ec5848 100644 --- a/src/display/filter_factory.js +++ b/src/display/filter_factory.js @@ -42,6 +42,10 @@ class BaseFilterFactory { return "none"; } + addKnockoutFilter(alpha = 0) { + return "none"; + } + addHighlightHCMFilter(filterName, fgColor, bgColor, newFgColor, newBgColor) { return "none"; } @@ -329,6 +333,37 @@ class DOMFilterFactory extends BaseFilterFactory { return url; } + addKnockoutFilter(alpha = 0) { + // Shape alpha mask: for translucent elements, remove the opacity constant + // from the painted alpha while preserving antialias coverage. With no + // usable opacity, fall back to a binary mask. + const slope = alpha > 0 ? Math.min(1 / alpha, 1e6) : 1e6; + const key = `knockout_${slope}`; + const value = this.#cache.get(key); + if (value) { + return value; + } + + const id = `g_${this.#docId}_knockout_filter_${this.#id++}`; + const url = this.#createUrl(id); + this.#cache.set(key, url); + + const filter = this.#createFilter(id); + const feComponentTransfer = this.#document.createElementNS( + SVG_NS, + "feComponentTransfer" + ); + filter.append(feComponentTransfer); + const feFuncA = this.#document.createElementNS(SVG_NS, "feFuncA"); + // Linear feFunc clamps to [0, 1]. + feFuncA.setAttribute("type", "linear"); + feFuncA.setAttribute("slope", `${slope}`); + feFuncA.setAttribute("intercept", "0"); + feComponentTransfer.append(feFuncA); + + return url; + } + addHighlightHCMFilter(filterName, fgColor, bgColor, newFgColor, newBgColor) { const key = `${fgColor}-${bgColor}-${newFgColor}-${newBgColor}`; let info = this.#hcmCache.get(filterName); diff --git a/src/shared/util.js b/src/shared/util.js index c42103db197cd..4ce4cf6026721 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -657,6 +657,21 @@ class FeatureTest { }); } + static get isCanvasFilterSupported() { + let ctx; + if (this.isOffscreenCanvasSupported) { + ctx = new OffscreenCanvas(1, 1).getContext("2d"); + } else if (typeof document !== "undefined") { + ctx = document.createElement("canvas").getContext("2d"); + } + // Spec-compliant Canvas2D defaults `ctx.filter` to "none". On + // browsers without filter support (Safari) the property is absent + // until you assign to it, after which it behaves like an ordinary + // JS property and stores whatever string you set without applying + // it. Probing the default lets us detect the difference reliably. + return shadow(this, "isCanvasFilterSupported", ctx?.filter !== undefined); + } + static get isAlphaColorInputSupported() { return shadow( this, diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 3e473651d754b..f114eb7b9424e 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -16,6 +16,12 @@ !bug1727053.pdf !issue18408_reduced.pdf !bug1907000_reduced.pdf +!knockout_blend_multiply.pdf +!knockout_isolated_overlap.pdf +!knockout_nested.pdf +!knockout_nested_group_alpha.pdf +!knockout_nonisolated_sparse.pdf +!knockout_smask.pdf !SimFang-variant.pdf !bug1953099.pdf !issue11913.pdf @@ -913,3 +919,4 @@ !smask_alpha_bc.pdf !smask_luminosity_oob_transfer.pdf !operator_list_cycle.pdf +!knockout_groups_test.pdf diff --git a/test/pdfs/knockout_blend_multiply.pdf b/test/pdfs/knockout_blend_multiply.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ef45cc43a0b1d60436860833c03d22dd96ba5cd0 GIT binary patch literal 900 zcmah|!EW0y487|sc*${<9oKdP6g{MAf}u0oB|Qu%c2E-9bKqK;L=VZnexx0zNi%FV zY)Isz-qWKrc%Do@g;%K>0FSOV9tb@_Uhb%Is6q(9E;s7-7WB)0i=gifnaUQRpG|=t zh76d)-JQaEEkK!{7j!uNSgt+1MgsrNtJtO{2u}(nb6F@f4jJ0<=nU7T8QUGa28&?;I#;sV8DE;=d+~A7Z@hpUEA!RDi4?CJI$Pl)v4B|c`$$MK9Q+Y@Z(SA=- ze=e;j(2HhCtu0gQ-ayO0wJ8KW{;$k`s+l?4Y+IR~@7-K4xDU?Mek-=D_N2J-6)bsa#&lOccXDz`pY zU&Vj}{?J}A?A(g?P3Un3I%_H_x7Gfnvtnmo(@ROuq=*FOELhF4shtGP?VH%YDcL#9 z8K0`)_&^fJQ4Bd8V-c?_57h6m8z%V>3#0TRHXQMh`+$q%(M2o?PdLb#vO?Ehmqqy- bA?2fKt(^43HSuH|wc3*rU%J7=(^UNdeAVwi literal 0 HcmV?d00001 diff --git a/test/pdfs/knockout_groups_test.pdf b/test/pdfs/knockout_groups_test.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1e3db147a998a5c3c14d36a6d6a58eebd9cc4a69 GIT binary patch literal 25286 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 iP5`$Y6$Sn`62xz+1|=qX9;JCC01IsXR)^mG*f literal 0 HcmV?d00001 diff --git a/test/pdfs/knockout_isolated_overlap.pdf b/test/pdfs/knockout_isolated_overlap.pdf new file mode 100644 index 0000000000000000000000000000000000000000..90f57802dd44636ce246d5c91091ec5676a44229 GIT binary patch literal 901 zcmah|!EW0y487|sc*${*6xVhN6a|u|35ITHo9r;4*gNs8Hjn&_o61b>0iG23 zkCXzPDY|co-ZrivLd^8*{d$K*i2H?x3r#l%N6y(ncDAJmg1FI6Eee} zRe{UJ6y!$;uAw&FwSTVb6V_$;4%r2(?Sn(}B7O)tfzrqRDsZJ3DM(065_C*w6kEXp zYc}Ny(o@qTE7RT7^_`VG)u^vd76#NXC9aeB@27>8K1OvkbsUPGCiQP-#a}WW95TsV zsJ8`KnkFf1;SEb@fO$dPV^rWdrjz^~HoBylJBv-nGz(`~mW+EWI8!~Ni|=Js{X)oT ZHm&7R@7{?I#)Zf|yBv$b;P!qleghz-?VA7q literal 0 HcmV?d00001 diff --git a/test/pdfs/knockout_nested.pdf b/test/pdfs/knockout_nested.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6359e909f348b6bae2af9b48ef390c562da3bbbe GIT binary patch literal 1128 zcmcIk!H(K65WV{==EAYqNeD@$)gFL_R@;g#^iXNlg9%O=#KzQ~RBXS#Gf9>W&>kwa zR+QM2H}huR*d9F0=5K;wA_hRS;m!jZkC2x~tep`7D6pHIxVZuO`T1Z#PBrSiEkHi# z!gvS?u!7rLVX7?yd42BC;^cj~_3#)m{3}JVb%hZ09Qn_+BhxrT(3VGjG}}^7>=7On z;X|b$7%PgmGTzoMql7rqFU!r&WZ2A?Fk(((;)jSUq<9{Z&iax#qt5wXqR4f43&u%I5D(NE%g(>w*P5TSyIlp?iKS{soIIfa=OpSbuA-t{lOlHu-% zi#2K_#!Q16#=WL-IsZenS59u{JH5|=6FYVBpDQ;~XUesdg%`Zb0+ukjlJGa;@t*4B zIQ1mBh`juR@JDBIqDzH34H(C96hjUtTSVuP2UM>%YU!mdpp9^83&*4&kDhNFrq{NF tPQnE)9rb+C>2hyeb0w_Hp9WGoe%A77?ydrl+F?`ATNR7J;BGk=zW@odF+>0W literal 0 HcmV?d00001 diff --git a/test/pdfs/knockout_nested_group_alpha.pdf b/test/pdfs/knockout_nested_group_alpha.pdf new file mode 100644 index 0000000000000000000000000000000000000000..358002a5c03df5cf66b5cadf505055ccdd06c81e GIT binary patch literal 1166 zcmb7^&2HN;49D+!3SM$-CEKy%07Zd*B`Depg=286<>YY;?VKEoQh^MwZo<(B*Hy@4=cH_wK>JspSs< zyDv)wmNaO9-|g%;v(hThGn4D>vxGtGr|d1OVTTosl4wbJ)gIIq@yP`6seY*I~cYwcr0{K3rkn^vT{6zxRzCIP+YBIFE5q zuiX3ayX}lx;^?JJ>@P^-IErBjds~2WMHiF@TY{r~WD7#vq9a>4zy^+NQFvMpJ6{@z24nq#Qp&E?>VIa literal 0 HcmV?d00001 diff --git a/test/pdfs/knockout_nonisolated_sparse.pdf b/test/pdfs/knockout_nonisolated_sparse.pdf new file mode 100644 index 0000000000000000000000000000000000000000..572f9d2a80d0a95d5593370781481d86556516ad GIT binary patch literal 870 zcmZuwO>g2b5WV|XybH(0`Pd{NRXtEjwCYCfLJyUyJs5DZL21m|&In7%x&cXDI>KF3#=lQMzdA8fv|!bE-Rud_{6=(VD%hesd&t zWSPr9^+qo98TdDpqA}Et2Uy)nU)^`vR`wU-z^Q7img0^5W=^LNx?)KHnyH`E*^>Qm z1rBjT_naV?rJg0@vjV*68e8w1VISo zT9yRLSnxiOCP|#2#Jeq~7wQJuvu#2(f3k&fc4dpE*VrVYaz0^`Y;v|)E$T*U{~=cO cUx|z!pHl8E4^df~%&F5x3J#a47<=h|aI2T2z_Feg->z@ZE;nz;{) zpBlRXPR}5Q1U~*NncJPu3$MHmbczQ^PwC{=G}|!K%G-{4fpS|_yDhDLu}9h%ndo2D zLeKIs@Xz3FgKpflZ(7*Bu1o(N(u72H-fFdBKZAh+Y{?=4F{*`%jc}~ligDypIz~cD zLSz?+FQiRZs`Hmr>r^_nl)=YiOOBFH40|V;R^FHRO&nyS+>a9_!jOcxZ#GqJT;)-Q z9I~JuAK%~>cxg>NI7n0d^8dv_68GP2%B|Xm4cGY+Wj$ZSFo7~?-)-X7lXpbhS-nEw zh>7EcCMR(MD>%ktJg9U*{T4%?&to!1jm~gUing4^lJqh+jV^m+@+8+=RlRB({uM>_ dTf+#?r7?8OyMKi9%KEULPqAQw!R_Oe{Q;P