Merge pull request #21101 from calixteman/improve_smask

Improve SMask compositing by pre-baking backdrop and filter
This commit is contained in:
calixteman 2026-04-14 20:57:08 +02:00 committed by GitHub
commit 59908ccad3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -651,6 +651,10 @@ class CanvasGraphics {
this.smaskStack = []; this.smaskStack = [];
this.tempSMask = null; this.tempSMask = null;
this.smaskGroupCanvases = []; this.smaskGroupCanvases = [];
this.smaskPreparedEntry = null;
this.smaskPreparedFor = null;
this.smaskPreparedOffsetX = 0;
this.smaskPreparedOffsetY = 0;
this.suspendedCtx = null; this.suspendedCtx = null;
this.contentVisible = true; this.contentVisible = true;
this.markedContentStack = markedContentStack || []; this.markedContentStack = markedContentStack || [];
@ -845,6 +849,7 @@ class CanvasGraphics {
this.canvasFactory.destroy(canvas); this.canvasFactory.destroy(canvas);
} }
this.smaskGroupCanvases.length = 0; this.smaskGroupCanvases.length = 0;
this._clearPreparedSMask();
this.tempSMask = null; this.tempSMask = null;
this.smaskStack.length = 0; this.smaskStack.length = 0;
@ -1257,31 +1262,138 @@ class CanvasGraphics {
return !!this.suspendedCtx; return !!this.suspendedCtx;
} }
_clearPreparedSMask() {
if (this.smaskPreparedEntry) {
this.canvasFactory.destroy(this.smaskPreparedEntry);
this.smaskPreparedEntry = null;
}
this.smaskPreparedFor = null;
this.smaskPreparedOffsetX = 0;
this.smaskPreparedOffsetY = 0;
}
_ensurePreparedSMask(smask, width, height) {
if (smask === this.smaskPreparedFor) {
return;
}
this._clearPreparedSMask();
this._prepareSMaskCanvas(smask, width, height);
}
checkSMaskState(opIdx) { checkSMaskState(opIdx) {
const inSMaskMode = this.inSMaskMode; const inSMaskMode = this.inSMaskMode;
if (this.current.activeSMask && !inSMaskMode) { if (this.current.activeSMask && !inSMaskMode) {
this.beginSMaskMode(opIdx); this.beginSMaskMode(opIdx);
} else if (!this.current.activeSMask && inSMaskMode) { } else if (!this.current.activeSMask && inSMaskMode) {
this.endSMaskMode(); this.endSMaskMode();
} else if (this.current.activeSMask && inSMaskMode) {
// The active SMask may have changed while SMask mode stayed active
// (e.g. a direct SMask A->B replacement, or a restore() that surfaces
// a different saved mask). _ensurePreparedSMask is a no-op when the
// same mask object is re-encountered.
this._ensurePreparedSMask(
this.current.activeSMask,
this.ctx.canvas.width,
this.ctx.canvas.height
);
} }
// Else, the state is okay and nothing needs to be done.
} }
/** /**
* Soft mask mode takes the current main drawing canvas and replaces it with * Backdrop cases use a layer-sized canvas so that the backdrop color
* a temporary canvas. Any drawing operations that happen on the temporary * correctly extends to pixels outside the mask canvas bounds.
* canvas need to be composed with the main canvas that was suspended (see * Filter-only cases use a mask-sized canvas to avoid a large allocation when
* `compose()`). The temporary canvas also duplicates many of its operations * the mask is small relative to the page; `composeSMask` then uses
* on the suspended canvas to keep them in sync, so that when the soft mask * `smaskPreparedOffsetX/Y` to translate dirty-box coordinates into the
* mode ends any clipping paths or transformations will still be active and in * smaller canvas's coordinate space. Plain-alpha masks with no backdrop or
* the right order on the canvas' graphics state stack. * transfer map need no canvas at all.
*/
_prepareSMaskCanvas(smask, width, height) {
const { canvas: maskCanvas, subtype, backdrop, transferMap } = smask;
const hasFilter =
subtype === "Luminosity" || (subtype === "Alpha" && transferMap);
if (!backdrop && !hasFilter) {
// No canvas to prepare, but record the mask so that checkSMaskState's
// identity check does not keep re-entering the rebuild path for the same
// plain-alpha mask on every restore()/setGState() call.
this.smaskPreparedFor = smask;
return;
}
let preparedEntry, offsetX, offsetY;
if (backdrop && hasFilter) {
// Both backdrop and filter: must apply backdrop BEFORE filter (spec
// order). Use a layer-sized intermediate so that pixels outside the
// mask canvas bounds get the backdrop color before filtering.
const srcEntry = this.canvasFactory.create(width, height);
const sCtx = srcEntry.context;
sCtx.drawImage(maskCanvas, smask.offsetX, smask.offsetY);
sCtx.globalCompositeOperation = "destination-atop";
sCtx.fillStyle = backdrop;
sCtx.fillRect(0, 0, width, height);
sCtx.globalCompositeOperation = "source-over";
preparedEntry = this.canvasFactory.create(width, height);
const pCtx = preparedEntry.context;
pCtx.filter =
subtype === "Alpha"
? this.filterFactory.addAlphaFilter(transferMap)
: this.filterFactory.addLuminosityFilter(transferMap);
pCtx.drawImage(srcEntry.canvas, 0, 0);
pCtx.filter = "none";
this.canvasFactory.destroy(srcEntry);
offsetX = offsetY = 0;
} else if (hasFilter) {
// Filter only, no backdrop: use a mask-sized canvas to avoid allocating
// a full width × height page canvas for what may be a small mask. The
// mask is drawn at (0, 0) and composeSMask compensates via
// smaskPreparedOffsetX/Y.
preparedEntry = this.canvasFactory.create(
maskCanvas.width,
maskCanvas.height
);
const pCtx = preparedEntry.context;
pCtx.filter =
subtype === "Alpha"
? this.filterFactory.addAlphaFilter(transferMap)
: this.filterFactory.addLuminosityFilter(transferMap);
pCtx.drawImage(maskCanvas, 0, 0);
pCtx.filter = "none";
({ offsetX, offsetY } = smask);
} else {
// Backdrop only (no filter): layer-sized canvas. destination-atop on
// the full width × height fills every transparent pixel — including those
// outside the mask canvas bounds — with the backdrop color.
preparedEntry = this.canvasFactory.create(width, height);
const pCtx = preparedEntry.context;
pCtx.drawImage(maskCanvas, smask.offsetX, smask.offsetY);
pCtx.globalCompositeOperation = "destination-atop";
pCtx.fillStyle = backdrop;
pCtx.fillRect(0, 0, width, height);
pCtx.globalCompositeOperation = "source-over";
offsetX = offsetY = 0;
}
this.smaskPreparedEntry = preparedEntry;
this.smaskPreparedFor = smask;
this.smaskPreparedOffsetX = offsetX;
this.smaskPreparedOffsetY = offsetY;
}
/**
* Replaces the current drawing canvas with a temporary scratch canvas and
* suspends the main context. Drawing operations on the scratch canvas are
* composited back via `compose()`. The scratch canvas mirrors many operations
* onto the suspended canvas to keep their graphics-state stacks in sync, so
* that clipping paths and transformations remain correct when soft mask mode
* ends.
*/ */
beginSMaskMode(opIdx) { beginSMaskMode(opIdx) {
if (this.inSMaskMode) { if (this.inSMaskMode) {
throw new Error("beginSMaskMode called while already in smask mode"); throw new Error("beginSMaskMode called while already in smask mode");
} }
const drawnWidth = this.ctx.canvas.width; const { width: drawnWidth, height: drawnHeight } = this.ctx.canvas;
const drawnHeight = this.ctx.canvas.height;
const scratchCanvas = this.canvasFactory.create(drawnWidth, drawnHeight); const scratchCanvas = this.canvasFactory.create(drawnWidth, drawnHeight);
this.smaskScratchCanvas = scratchCanvas; this.smaskScratchCanvas = scratchCanvas;
this.suspendedCtx = this.ctx; this.suspendedCtx = this.ctx;
@ -1290,6 +1402,12 @@ class CanvasGraphics {
copyCtxState(this.suspendedCtx, ctx); copyCtxState(this.suspendedCtx, ctx);
mirrorContextOperations(ctx, this.suspendedCtx); mirrorContextOperations(ctx, this.suspendedCtx);
this._ensurePreparedSMask(
this.current.activeSMask,
drawnWidth,
drawnHeight
);
this.setGState(opIdx, [["BM", "source-over"]]); this.setGState(opIdx, [["BM", "source-over"]]);
} }
@ -1306,6 +1424,7 @@ class CanvasGraphics {
this.suspendedCtx = null; this.suspendedCtx = null;
this.canvasFactory.destroy(this.smaskScratchCanvas); this.canvasFactory.destroy(this.smaskScratchCanvas);
this.smaskScratchCanvas = null; this.smaskScratchCanvas = null;
this._clearPreparedSMask();
} }
compose(dirtyBox) { compose(dirtyBox) {
@ -1326,10 +1445,16 @@ class CanvasGraphics {
this.composeSMask(suspendedCtx, smask, this.ctx, dirtyBox); this.composeSMask(suspendedCtx, smask, this.ctx, dirtyBox);
// Whatever was drawn has been moved to the suspended canvas, now clear it // Whatever was drawn has been moved to the suspended canvas, now clear it
// out of the current canvas. // out of the current canvas. Only the dirty box region needs clearing —
// everything outside it is already transparent.
this.ctx.save(); this.ctx.save();
this.ctx.setTransform(1, 0, 0, 1, 0, 0); this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); this.ctx.clearRect(
dirtyBox[0],
dirtyBox[1],
dirtyBox[2] - dirtyBox[0],
dirtyBox[3] - dirtyBox[1]
);
this.ctx.restore(); this.ctx.restore();
} }
@ -1341,24 +1466,67 @@ class CanvasGraphics {
if (layerWidth === 0 || layerHeight === 0) { if (layerWidth === 0 || layerHeight === 0) {
return; return;
} }
this.genericComposeSMask(
smask.context, const preparedEntry = this.smaskPreparedEntry;
layerCtx, if (preparedEntry) {
layerWidth, // Fast path: backdrop and/or filter pre-applied. For layer-sized entries
layerHeight, // (backdrop cases) smaskPreparedOffsetX/Y are 0 so source and destination
smask.subtype, // coordinates are identical. For mask-sized entries (filter-only) we
smask.backdrop, // subtract the mask's layer offset to convert the dirty-box position into
smask.transferMap, // the smaller canvas's coordinate space.
layerOffsetX, // Out-of-bounds source pixels are treated as transparent by the specs,
layerOffsetY, // which is correct for a no-backdrop mask.
smask.offsetX, const srcX = layerOffsetX - this.smaskPreparedOffsetX;
smask.offsetY const srcY = layerOffsetY - this.smaskPreparedOffsetY;
); layerCtx.save();
layerCtx.globalAlpha = 1;
layerCtx.setTransform(1, 0, 0, 1, 0, 0);
const clip = new Path2D();
clip.rect(layerOffsetX, layerOffsetY, layerWidth, layerHeight);
layerCtx.clip(clip);
layerCtx.globalCompositeOperation = "destination-in";
layerCtx.drawImage(
preparedEntry.canvas,
srcX,
srcY,
layerWidth,
layerHeight,
layerOffsetX,
layerOffsetY,
layerWidth,
layerHeight
);
layerCtx.restore();
} else {
this.genericComposeSMask(
smask.context,
layerCtx,
layerWidth,
layerHeight,
layerOffsetX,
layerOffsetY,
smask.offsetX,
smask.offsetY
);
}
ctx.save(); ctx.save();
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.globalCompositeOperation = smask.blendMode || "source-over"; ctx.globalCompositeOperation = smask.blendMode || "source-over";
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(layerCtx.canvas, 0, 0); // Only blit the dirty box region — the rest of the scratch canvas is
// still transparent from the clearRect in compose().
ctx.drawImage(
layerCtx.canvas,
layerOffsetX,
layerOffsetY,
layerWidth,
layerHeight,
layerOffsetX,
layerOffsetY,
layerWidth,
layerHeight
);
ctx.restore(); ctx.restore();
} }
@ -1367,60 +1535,21 @@ class CanvasGraphics {
layerCtx, layerCtx,
width, width,
height, height,
subtype,
backdrop,
transferMap,
layerOffsetX, layerOffsetX,
layerOffsetY, layerOffsetY,
maskOffsetX, maskOffsetX,
maskOffsetY maskOffsetY
) { ) {
let maskCanvas = maskCtx.canvas; // This path is only reached when there is no backdrop and no filter
let maskX = layerOffsetX - maskOffsetX; // (those cases are handled by the _prepareSMaskCanvas fast path).
let maskY = layerOffsetY - maskOffsetY; // A simple destination-in blit of the mask onto the layer suffices.
const maskCanvas = maskCtx.canvas;
let maskExtensionEntry = null; const maskX = layerOffsetX - maskOffsetX;
if (backdrop) { const maskY = layerOffsetY - maskOffsetY;
if (
maskX < 0 ||
maskY < 0 ||
maskX + width > maskCanvas.width ||
maskY + height > maskCanvas.height
) {
maskExtensionEntry = this.canvasFactory.create(width, height);
const ctx = maskExtensionEntry.context;
ctx.drawImage(maskCanvas, -maskX, -maskY);
ctx.globalCompositeOperation = "destination-atop";
ctx.fillStyle = backdrop;
ctx.fillRect(0, 0, width, height);
ctx.globalCompositeOperation = "source-over";
maskCanvas = maskExtensionEntry.canvas;
maskX = maskY = 0;
} else {
maskCtx.save();
maskCtx.globalAlpha = 1;
maskCtx.setTransform(1, 0, 0, 1, 0, 0);
const clip = new Path2D();
clip.rect(maskX, maskY, width, height);
maskCtx.clip(clip);
maskCtx.globalCompositeOperation = "destination-atop";
maskCtx.fillStyle = backdrop;
maskCtx.fillRect(maskX, maskY, width, height);
maskCtx.restore();
}
}
layerCtx.save(); layerCtx.save();
layerCtx.globalAlpha = 1; layerCtx.globalAlpha = 1;
layerCtx.setTransform(1, 0, 0, 1, 0, 0); layerCtx.setTransform(1, 0, 0, 1, 0, 0);
if (subtype === "Alpha" && transferMap) {
layerCtx.filter = this.filterFactory.addAlphaFilter(transferMap);
} else if (subtype === "Luminosity") {
layerCtx.filter = this.filterFactory.addLuminosityFilter(transferMap);
}
const clip = new Path2D(); const clip = new Path2D();
clip.rect(layerOffsetX, layerOffsetY, width, height); clip.rect(layerOffsetX, layerOffsetY, width, height);
layerCtx.clip(clip); layerCtx.clip(clip);
@ -1437,9 +1566,6 @@ class CanvasGraphics {
height height
); );
layerCtx.restore(); layerCtx.restore();
if (maskExtensionEntry) {
this.canvasFactory.destroy(maskExtensionEntry);
}
} }
save(opIdx) { save(opIdx) {