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.tempSMask = null;
this.smaskGroupCanvases = [];
this.smaskPreparedEntry = null;
this.smaskPreparedFor = null;
this.smaskPreparedOffsetX = 0;
this.smaskPreparedOffsetY = 0;
this.suspendedCtx = null;
this.contentVisible = true;
this.markedContentStack = markedContentStack || [];
@ -845,6 +849,7 @@ class CanvasGraphics {
this.canvasFactory.destroy(canvas);
}
this.smaskGroupCanvases.length = 0;
this._clearPreparedSMask();
this.tempSMask = null;
this.smaskStack.length = 0;
@ -1257,31 +1262,138 @@ class CanvasGraphics {
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) {
const inSMaskMode = this.inSMaskMode;
if (this.current.activeSMask && !inSMaskMode) {
this.beginSMaskMode(opIdx);
} else if (!this.current.activeSMask && inSMaskMode) {
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
* a temporary canvas. Any drawing operations that happen on the temporary
* canvas need to be composed with the main canvas that was suspended (see
* `compose()`). The temporary canvas also duplicates many of its operations
* on the suspended canvas to keep them in sync, so that when the soft mask
* mode ends any clipping paths or transformations will still be active and in
* the right order on the canvas' graphics state stack.
* Backdrop cases use a layer-sized canvas so that the backdrop color
* correctly extends to pixels outside the mask canvas bounds.
* Filter-only cases use a mask-sized canvas to avoid a large allocation when
* the mask is small relative to the page; `composeSMask` then uses
* `smaskPreparedOffsetX/Y` to translate dirty-box coordinates into the
* smaller canvas's coordinate space. Plain-alpha masks with no backdrop or
* 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) {
if (this.inSMaskMode) {
throw new Error("beginSMaskMode called while already in smask mode");
}
const drawnWidth = this.ctx.canvas.width;
const drawnHeight = this.ctx.canvas.height;
const { width: drawnWidth, height: drawnHeight } = this.ctx.canvas;
const scratchCanvas = this.canvasFactory.create(drawnWidth, drawnHeight);
this.smaskScratchCanvas = scratchCanvas;
this.suspendedCtx = this.ctx;
@ -1290,6 +1402,12 @@ class CanvasGraphics {
copyCtxState(this.suspendedCtx, ctx);
mirrorContextOperations(ctx, this.suspendedCtx);
this._ensurePreparedSMask(
this.current.activeSMask,
drawnWidth,
drawnHeight
);
this.setGState(opIdx, [["BM", "source-over"]]);
}
@ -1306,6 +1424,7 @@ class CanvasGraphics {
this.suspendedCtx = null;
this.canvasFactory.destroy(this.smaskScratchCanvas);
this.smaskScratchCanvas = null;
this._clearPreparedSMask();
}
compose(dirtyBox) {
@ -1326,10 +1445,16 @@ class CanvasGraphics {
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.
// 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);
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();
}
@ -1341,24 +1466,67 @@ class CanvasGraphics {
if (layerWidth === 0 || layerHeight === 0) {
return;
}
this.genericComposeSMask(
smask.context,
layerCtx,
layerWidth,
layerHeight,
smask.subtype,
smask.backdrop,
smask.transferMap,
layerOffsetX,
layerOffsetY,
smask.offsetX,
smask.offsetY
);
const preparedEntry = this.smaskPreparedEntry;
if (preparedEntry) {
// Fast path: backdrop and/or filter pre-applied. For layer-sized entries
// (backdrop cases) smaskPreparedOffsetX/Y are 0 so source and destination
// coordinates are identical. For mask-sized entries (filter-only) we
// subtract the mask's layer offset to convert the dirty-box position into
// the smaller canvas's coordinate space.
// Out-of-bounds source pixels are treated as transparent by the specs,
// which is correct for a no-backdrop mask.
const srcX = layerOffsetX - this.smaskPreparedOffsetX;
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.globalAlpha = 1;
ctx.globalCompositeOperation = smask.blendMode || "source-over";
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();
}
@ -1367,60 +1535,21 @@ class CanvasGraphics {
layerCtx,
width,
height,
subtype,
backdrop,
transferMap,
layerOffsetX,
layerOffsetY,
maskOffsetX,
maskOffsetY
) {
let maskCanvas = maskCtx.canvas;
let maskX = layerOffsetX - maskOffsetX;
let maskY = layerOffsetY - maskOffsetY;
let maskExtensionEntry = null;
if (backdrop) {
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();
}
}
// This path is only reached when there is no backdrop and no filter
// (those cases are handled by the _prepareSMaskCanvas fast path).
// A simple destination-in blit of the mask onto the layer suffices.
const maskCanvas = maskCtx.canvas;
const maskX = layerOffsetX - maskOffsetX;
const maskY = layerOffsetY - maskOffsetY;
layerCtx.save();
layerCtx.globalAlpha = 1;
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();
clip.rect(layerOffsetX, layerOffsetY, width, height);
layerCtx.clip(clip);
@ -1437,9 +1566,6 @@ class CanvasGraphics {
height
);
layerCtx.restore();
if (maskExtensionEntry) {
this.canvasFactory.destroy(maskExtensionEntry);
}
}
save(opIdx) {