Improve soft mask composition performance (bug 2033095)

Prepare reusable soft-mask canvases for filtered and backdrop-dependent masks,
and use a faster destination-in composition path where possible.
Handle Alpha SMask /BC correctly, preserve OOB alpha behavior, and mirror canvas path
operations needed while rendering inside soft-mask mode (mirrored clip was buggy).

Add reftest PDFs covering Alpha masks, transfer functions, backdrop/OOB
alpha, and the optimized composition paths.
This commit is contained in:
Calixte Denizet 2026-05-05 20:08:16 +02:00
parent e8d3d19f67
commit 1658a792ce
7 changed files with 615 additions and 244 deletions

View File

@ -34,6 +34,7 @@ import {
import { import {
getCurrentTransform, getCurrentTransform,
getCurrentTransformInverse, getCurrentTransformInverse,
getRGBA,
makePathFromDrawOPS, makePathFromDrawOPS,
OutputScale, OutputScale,
PixelsPerInch, PixelsPerInch,
@ -85,120 +86,45 @@ function mirrorContextOperations(ctx, destCtx) {
if (ctx._removeMirroring) { if (ctx._removeMirroring) {
throw new Error("Context is already forwarding operations."); throw new Error("Context is already forwarding operations.");
} }
ctx.__originalSave = ctx.save; const originalMethods = new Map();
ctx.__originalRestore = ctx.restore; for (const name of [
ctx.__originalRotate = ctx.rotate; "save",
ctx.__originalScale = ctx.scale; "restore",
ctx.__originalTranslate = ctx.translate; "rotate",
ctx.__originalTransform = ctx.transform; "scale",
ctx.__originalSetTransform = ctx.setTransform; "translate",
ctx.__originalResetTransform = ctx.resetTransform; "transform",
ctx.__originalClip = ctx.clip; "setTransform",
ctx.__originalMoveTo = ctx.moveTo; "resetTransform",
ctx.__originalLineTo = ctx.lineTo; "clip",
ctx.__originalBezierCurveTo = ctx.bezierCurveTo; "moveTo",
ctx.__originalRect = ctx.rect; "lineTo",
ctx.__originalClosePath = ctx.closePath; "bezierCurveTo",
ctx.__originalBeginPath = ctx.beginPath; "quadraticCurveTo",
"arc",
"arcTo",
"ellipse",
"rect",
"roundRect",
"closePath",
"beginPath",
]) {
const original = ctx[name];
if (typeof original !== "function" || typeof destCtx[name] !== "function") {
continue;
}
originalMethods.set(name, original);
ctx[name] = function (...args) {
destCtx[name](...args);
return original.apply(this, args);
};
}
ctx._removeMirroring = () => { ctx._removeMirroring = () => {
ctx.save = ctx.__originalSave; for (const [name, original] of originalMethods) {
ctx.restore = ctx.__originalRestore; ctx[name] = original;
ctx.rotate = ctx.__originalRotate;
ctx.scale = ctx.__originalScale;
ctx.translate = ctx.__originalTranslate;
ctx.transform = ctx.__originalTransform;
ctx.setTransform = ctx.__originalSetTransform;
ctx.resetTransform = ctx.__originalResetTransform;
ctx.clip = ctx.__originalClip;
ctx.moveTo = ctx.__originalMoveTo;
ctx.lineTo = ctx.__originalLineTo;
ctx.bezierCurveTo = ctx.__originalBezierCurveTo;
ctx.rect = ctx.__originalRect;
ctx.closePath = ctx.__originalClosePath;
ctx.beginPath = ctx.__originalBeginPath;
delete ctx._removeMirroring;
};
ctx.save = function () {
destCtx.save();
this.__originalSave();
};
ctx.restore = function () {
destCtx.restore();
this.__originalRestore();
};
ctx.translate = function (x, y) {
destCtx.translate(x, y);
this.__originalTranslate(x, y);
};
ctx.scale = function (x, y) {
destCtx.scale(x, y);
this.__originalScale(x, y);
};
ctx.transform = function (a, b, c, d, e, f) {
destCtx.transform(a, b, c, d, e, f);
this.__originalTransform(a, b, c, d, e, f);
};
ctx.setTransform = function (a, b, c, d, e, f) {
if (b === undefined) {
destCtx.setTransform(a);
this.__originalSetTransform(a);
} else {
destCtx.setTransform(a, b, c, d, e, f);
this.__originalSetTransform(a, b, c, d, e, f);
} }
}; delete ctx._removeMirroring;
ctx.resetTransform = function () {
destCtx.resetTransform();
this.__originalResetTransform();
};
ctx.rotate = function (angle) {
destCtx.rotate(angle);
this.__originalRotate(angle);
};
ctx.clip = function (rule) {
destCtx.clip(rule);
this.__originalClip(rule);
};
ctx.moveTo = function (x, y) {
destCtx.moveTo(x, y);
this.__originalMoveTo(x, y);
};
ctx.lineTo = function (x, y) {
destCtx.lineTo(x, y);
this.__originalLineTo(x, y);
};
ctx.bezierCurveTo = function (cp1x, cp1y, cp2x, cp2y, x, y) {
destCtx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
this.__originalBezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
};
ctx.rect = function (x, y, width, height) {
destCtx.rect(x, y, width, height);
this.__originalRect(x, y, width, height);
};
ctx.closePath = function () {
destCtx.closePath();
this.__originalClosePath();
};
ctx.beginPath = function () {
destCtx.beginPath();
this.__originalBeginPath();
}; };
} }
@ -655,6 +581,18 @@ class CanvasGraphics {
this.smaskPreparedFor = null; this.smaskPreparedFor = null;
this.smaskPreparedOffsetX = 0; this.smaskPreparedOffsetX = 0;
this.smaskPreparedOffsetY = 0; this.smaskPreparedOffsetY = 0;
// For mask-size prebakes with non-zero OOB alpha, the constant
// alpha applied to OOB pixels (dirty box outside the mask canvas)
// at compose time. Null when no compose-time OOB work is needed:
// - layer-size prebake bakes OOB inline; or
// - OOB alpha is 0 and destination-in's transparent source
// samples clear OOB layer pixels for free.
// Compose-time behavior splits on this:
// null -> clip = full dirty box; OOB cleared or baked.
// 255 -> clip excludes OOB; OOB survives unchanged.
// intermediate -> clip excludes OOB, then a fade pass applies
// this constant alpha.
this.smaskPreparedOOBAlpha = null;
this.suspendedCtx = null; this.suspendedCtx = null;
this.contentVisible = true; this.contentVisible = true;
this.markedContentStack = markedContentStack || []; this.markedContentStack = markedContentStack || [];
@ -1270,14 +1208,15 @@ class CanvasGraphics {
this.smaskPreparedFor = null; this.smaskPreparedFor = null;
this.smaskPreparedOffsetX = 0; this.smaskPreparedOffsetX = 0;
this.smaskPreparedOffsetY = 0; this.smaskPreparedOffsetY = 0;
this.smaskPreparedOOBAlpha = null;
} }
_ensurePreparedSMask(smask, width, height) { _ensurePreparedSMask(smask) {
if (smask === this.smaskPreparedFor) { if (smask === this.smaskPreparedFor) {
return; return;
} }
this._clearPreparedSMask(); this._clearPreparedSMask();
this._prepareSMaskCanvas(smask, width, height); this._prepareSMaskCanvas(smask);
} }
checkSMaskState(opIdx) { checkSMaskState(opIdx) {
@ -1291,94 +1230,145 @@ class CanvasGraphics {
// (e.g. a direct SMask A->B replacement, or a restore() that surfaces // (e.g. a direct SMask A->B replacement, or a restore() that surfaces
// a different saved mask). _ensurePreparedSMask is a no-op when the // a different saved mask). _ensurePreparedSMask is a no-op when the
// same mask object is re-encountered. // same mask object is re-encountered.
this._ensurePreparedSMask( this._ensurePreparedSMask(this.current.activeSMask);
this.current.activeSMask,
this.ctx.canvas.width,
this.ctx.canvas.height
);
} }
} }
/** _prepareSMaskCanvas(smask) {
* 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 { canvas: maskCanvas, subtype, backdrop, transferMap } = smask;
const hasFilter = const hasFilter =
subtype === "Luminosity" || (subtype === "Alpha" && transferMap); subtype === "Luminosity" || (subtype === "Alpha" && transferMap);
if (!backdrop && !hasFilter) {
// No canvas to prepare, but record the mask so that checkSMaskState's // Nothing to amortize unless we have a filter or a Luminosity
// identity check does not keep re-entering the rebuild path for the same // backdrop -- Alpha SMasks ignore /BC for the alpha output, and
// plain-alpha mask on every restore()/setGState() call. // unknown subtypes have no defined backdrop semantics. Record the
// mask so checkSMaskState's identity check skips the rebuild path
// on subsequent restore()/setGState() calls.
if (!hasFilter && !(subtype === "Luminosity" && backdrop)) {
this.smaskPreparedFor = smask; this.smaskPreparedFor = smask;
return; return;
} }
let preparedEntry, offsetX, offsetY; // Constant alpha OOB pixels receive after the spec backdrop+filter
// chain (see smaskPreparedOOBAlpha field doc for the compose-time
if (backdrop && hasFilter) { // table). /BC only feeds the alpha output for Luminosity (its
// Both backdrop and filter: must apply backdrop BEFORE filter (spec // color enters the luminance computation). Alpha SMasks treat /BC
// order). Use a layer-sized intermediate so that pixels outside the // as a pure color-space backdrop and must not bake it into the
// mask canvas bounds get the backdrop color before filtering. // alpha output.
const srcEntry = this.canvasFactory.create(width, height); let filteredOOBAlpha;
const sCtx = srcEntry.context; if (subtype === "Luminosity" && backdrop) {
sCtx.drawImage(maskCanvas, smask.offsetX, smask.offsetY); // backdrop is "#RRGGBB" (see Evaluator#handleSMask).
sCtx.globalCompositeOperation = "destination-atop"; const [r, g, b] = getRGBA(backdrop);
sCtx.fillStyle = backdrop; const inputAlpha = Math.round(0.3 * r + 0.59 * g + 0.11 * b);
sCtx.fillRect(0, 0, width, height); filteredOOBAlpha = transferMap?.[inputAlpha] ?? inputAlpha;
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 { } else {
// Backdrop only (no filter): layer-sized canvas. destination-atop on // Alpha, or Luminosity with no backdrop: OOB input is transparent,
// the full width × height fills every transparent pixel — including those // and both filters map alpha=0 to alpha=0; only transferMap[0] can
// outside the mask canvas bounds — with the backdrop color. // produce a non-zero result.
preparedEntry = this.canvasFactory.create(width, height); filteredOOBAlpha = transferMap?.[0] ?? 0;
const pCtx = preparedEntry.context; }
pCtx.drawImage(maskCanvas, smask.offsetX, smask.offsetY);
pCtx.globalCompositeOperation = "destination-atop"; // Use a layer-size prebake when the layer is at most this many
pCtx.fillStyle = backdrop; // times bigger than the mask: layer-size avoids compose-time OOB
pCtx.fillRect(0, 0, width, height); // work and hits the same-size drawImage GPU fast path, but the
pCtx.globalCompositeOperation = "source-over"; // alloc cost grows with the layer. The crossover is empirical;
offsetX = offsetY = 0; // tuned against the bug-2033095 corpus.
const SMASK_LAYER_TO_MASK_AREA_RATIO = 4;
const { width: layerW, height: layerH } = this.ctx.canvas;
const maskArea = maskCanvas.width * maskCanvas.height;
const useLayerSize =
layerW * layerH < SMASK_LAYER_TO_MASK_AREA_RATIO * maskArea;
let filterUrl = null;
if (hasFilter) {
filterUrl =
subtype === "Alpha"
? this.filterFactory.addAlphaFilter(transferMap)
: this.filterFactory.addLuminosityFilter(transferMap);
}
// Alpha SMasks must not bake /BC into the prepared canvas (see
// filteredOOBAlpha comment above).
const bakedBackdrop = subtype === "Luminosity" ? backdrop : null;
let preparedEntry, offsetX, offsetY;
if (useLayerSize) {
preparedEntry = this._bakeSMaskCanvas(
maskCanvas,
smask.offsetX,
smask.offsetY,
layerW,
layerH,
bakedBackdrop,
filterUrl
);
offsetX = 0;
offsetY = 0;
} else {
preparedEntry = this._bakeSMaskCanvas(
maskCanvas,
0,
0,
maskCanvas.width,
maskCanvas.height,
bakedBackdrop,
filterUrl
);
offsetX = smask.offsetX;
offsetY = smask.offsetY;
} }
this.smaskPreparedEntry = preparedEntry; this.smaskPreparedEntry = preparedEntry;
this.smaskPreparedFor = smask; this.smaskPreparedFor = smask;
this.smaskPreparedOffsetX = offsetX; this.smaskPreparedOffsetX = offsetX;
this.smaskPreparedOffsetY = offsetY; this.smaskPreparedOffsetY = offsetY;
// Only mask-size prebakes with non-zero OOB alpha need compose-time
// OOB work (see field doc).
this.smaskPreparedOOBAlpha =
!useLayerSize && filteredOOBAlpha !== 0 ? filteredOOBAlpha : null;
}
/**
* Bake the mask plus optional backdrop into a (w x h) canvas with the
* mask drawn at (drawX, drawY), then optionally pipe through
* `filterUrl`. Returns the prepared canvas-factory entry.
*
* The backdrop fill uses destination-atop so transparent / partial-
* alpha pixels inside the mask see the backdrop *before* filtering
* (per PDF spec). Filtering the raw mask would yield filter(0)
* instead of filter(backdrop) -- wrong for "keep" Luminosity and for
* Alpha masks whose transferMap[255] differs from transferMap[0].
*
* In the no-backdrop layer-size case the OOB region of srcEntry
* stays transparent and the filter outputs filter(transparent) =
* transferMap[0], matching the spec's transparent extension of the
* mask group. No-backdrop mask-size prebakes have no OOB region;
* destination-in handles OOB at compose time.
*/
_bakeSMaskCanvas(maskCanvas, drawX, drawY, w, h, backdrop, filterUrl) {
if (!backdrop && !filterUrl) {
// Caller (_prepareSMaskCanvas) gates on this; without either,
// the prebake would just be a wasted copy of the mask.
unreachable("_bakeSMaskCanvas with neither backdrop nor filter");
}
const srcEntry = this.canvasFactory.create(w, h);
const sCtx = srcEntry.context;
sCtx.drawImage(maskCanvas, drawX, drawY);
if (backdrop) {
sCtx.globalCompositeOperation = "destination-atop";
sCtx.fillStyle = backdrop;
sCtx.fillRect(0, 0, w, h);
}
if (!filterUrl) {
return srcEntry;
}
const preparedEntry = this.canvasFactory.create(w, h);
const pCtx = preparedEntry.context;
pCtx.filter = filterUrl;
pCtx.drawImage(srcEntry.canvas, 0, 0);
pCtx.filter = "none";
this.canvasFactory.destroy(srcEntry);
return preparedEntry;
} }
/** /**
@ -1402,11 +1392,7 @@ class CanvasGraphics {
copyCtxState(this.suspendedCtx, ctx); copyCtxState(this.suspendedCtx, ctx);
mirrorContextOperations(ctx, this.suspendedCtx); mirrorContextOperations(ctx, this.suspendedCtx);
this._ensurePreparedSMask( this._ensurePreparedSMask(this.current.activeSMask);
this.current.activeSMask,
drawnWidth,
drawnHeight
);
this.setGState(opIdx, [["BM", "source-over"]]); this.setGState(opIdx, [["BM", "source-over"]]);
} }
@ -1432,14 +1418,16 @@ class CanvasGraphics {
return; return;
} }
if (!dirtyBox) { // Don't mutate the caller's box -- callers (e.g. consumePath) may
dirtyBox = [0, 0, this.ctx.canvas.width, this.ctx.canvas.height]; // hold on to it.
} else { dirtyBox = dirtyBox
dirtyBox[0] = Math.floor(dirtyBox[0]); ? [
dirtyBox[1] = Math.floor(dirtyBox[1]); Math.floor(dirtyBox[0]),
dirtyBox[2] = Math.ceil(dirtyBox[2]); Math.floor(dirtyBox[1]),
dirtyBox[3] = Math.ceil(dirtyBox[3]); Math.ceil(dirtyBox[2]),
} Math.ceil(dirtyBox[3]),
]
: [0, 0, this.ctx.canvas.width, this.ctx.canvas.height];
const smask = this.current.activeSMask; const smask = this.current.activeSMask;
const suspendedCtx = this.suspendedCtx; const suspendedCtx = this.suspendedCtx;
@ -1469,44 +1457,73 @@ class CanvasGraphics {
const preparedEntry = this.smaskPreparedEntry; const preparedEntry = this.smaskPreparedEntry;
if (preparedEntry) { if (preparedEntry) {
// Fast path: backdrop and/or filter pre-applied. For layer-sized entries // Fast path: prepared-mask destination-in drawImage. See
// (backdrop cases) smaskPreparedOffsetX/Y are 0 so source and destination // smaskPreparedOOBAlpha field doc for the OOB handling table.
// coordinates are identical. For mask-sized entries (filter-only) we let clipX = layerOffsetX;
// subtract the mask's layer offset to convert the dirty-box position into let clipY = layerOffsetY;
// the smaller canvas's coordinate space. let clipW = layerWidth;
// Out-of-bounds source pixels are treated as transparent by the specs, let clipH = layerHeight;
// which is correct for a no-backdrop mask. const oobAlpha = this.smaskPreparedOOBAlpha;
const srcX = layerOffsetX - this.smaskPreparedOffsetX; const hasOOBAlpha = oobAlpha !== null;
const srcY = layerOffsetY - this.smaskPreparedOffsetY; if (hasOOBAlpha) {
layerCtx.save(); clipX = Math.max(layerOffsetX, smask.offsetX);
layerCtx.globalAlpha = 1; clipY = Math.max(layerOffsetY, smask.offsetY);
layerCtx.setTransform(1, 0, 0, 1, 0, 0); const x1 = Math.min(
const clip = new Path2D(); layerOffsetX + layerWidth,
clip.rect(layerOffsetX, layerOffsetY, layerWidth, layerHeight); smask.offsetX + smask.canvas.width
layerCtx.clip(clip); );
layerCtx.globalCompositeOperation = "destination-in"; const y1 = Math.min(
layerCtx.drawImage( layerOffsetY + layerHeight,
preparedEntry.canvas, smask.offsetY + smask.canvas.height
srcX, );
srcY, clipW = x1 - clipX;
layerWidth, clipH = y1 - clipY;
layerHeight, }
layerOffsetX, if (clipW > 0 && clipH > 0) {
layerOffsetY, const srcX = clipX - this.smaskPreparedOffsetX;
layerWidth, const srcY = clipY - this.smaskPreparedOffsetY;
layerHeight layerCtx.save();
); layerCtx.globalAlpha = 1;
layerCtx.restore(); layerCtx.setTransform(1, 0, 0, 1, 0, 0);
const clip = new Path2D();
clip.rect(clipX, clipY, clipW, clipH);
layerCtx.clip(clip);
layerCtx.globalCompositeOperation = "destination-in";
layerCtx.drawImage(
preparedEntry.canvas,
srcX,
srcY,
clipW,
clipH,
clipX,
clipY,
clipW,
clipH
);
layerCtx.restore();
}
if (hasOOBAlpha && oobAlpha < 255) {
this._applySMaskOOBAlpha(
layerCtx,
layerOffsetX,
layerOffsetY,
layerWidth,
layerHeight,
clipX,
clipY,
clipX + clipW,
clipY + clipH,
oobAlpha
);
}
} else { } else {
this.genericComposeSMask( this.genericComposeSMask(
smask.context, smask,
layerCtx, layerCtx,
layerWidth, layerWidth,
layerHeight, layerHeight,
layerOffsetX, layerOffsetX,
layerOffsetY, layerOffsetY
smask.offsetX,
smask.offsetY
); );
} }
@ -1514,8 +1531,8 @@ class CanvasGraphics {
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);
// Only blit the dirty box region — the rest of the scratch canvas is // Blit only the dirty box -- the rest of the scratch canvas was
// still transparent from the clearRect in compose(). // cleared in compose().
ctx.drawImage( ctx.drawImage(
layerCtx.canvas, layerCtx.canvas,
layerOffsetX, layerOffsetX,
@ -1530,22 +1547,75 @@ class CanvasGraphics {
ctx.restore(); ctx.restore();
} }
/**
* Fade the dirty box's OOB region by a constant alpha. Called from
* composeSMask when smaskPreparedOOBAlpha is in (0, 255).
*
* destination-in clears every destination pixel outside the source's
* footprint, so four fillRects (one per strip) would each clear the
* others. Instead one fillRect covers the dirty box, restricted by
* an even-odd clip enclosing exactly (dirty_box XOR mask_region);
* within the clip the source covers everything so no "outside
* source" pixels exist.
*/
_applySMaskOOBAlpha(
layerCtx,
layerOffsetX,
layerOffsetY,
layerWidth,
layerHeight,
maskX0,
maskY0,
maskX1,
maskY1,
alpha
) {
const hasInnerCutout = maskX0 < maskX1 && maskY0 < maskY1;
if (
hasInnerCutout &&
maskX0 === layerOffsetX &&
maskY0 === layerOffsetY &&
maskX1 === layerOffsetX + layerWidth &&
maskY1 === layerOffsetY + layerHeight
) {
// Dirty box is entirely inside the mask -- no OOB region to fade.
return;
}
const path = new Path2D();
path.rect(layerOffsetX, layerOffsetY, layerWidth, layerHeight);
if (hasInnerCutout) {
path.rect(maskX0, maskY0, maskX1 - maskX0, maskY1 - maskY0);
}
layerCtx.save();
layerCtx.globalAlpha = alpha / 255;
layerCtx.setTransform(1, 0, 0, 1, 0, 0);
layerCtx.clip(path, "evenodd");
layerCtx.globalCompositeOperation = "destination-in";
// MUST be fully opaque -- destination-in scales dst_a by src_a, and
// globalAlpha must be the only thing scaling source alpha.
layerCtx.fillStyle = "#000000";
layerCtx.fillRect(layerOffsetX, layerOffsetY, layerWidth, layerHeight);
layerCtx.restore();
}
genericComposeSMask( genericComposeSMask(
maskCtx, smask,
layerCtx, layerCtx,
width, width,
height, height,
layerOffsetX, layerOffsetX,
layerOffsetY, layerOffsetY
maskOffsetX,
maskOffsetY
) { ) {
// This path is only reached when there is no backdrop and no filter // composeSMask helper, reached only for plain-alpha masks (no
// (those cases are handled by the _prepareSMaskCanvas fast path). // filter, no backdrop); every backdrop/filter case prebakes in
// A simple destination-in blit of the mask onto the layer suffices. // _prepareSMaskCanvas. A single destination-in blit suffices:
const maskCanvas = maskCtx.canvas; // transparent OOB mask samples clear OOB layer pixels.
const maskX = layerOffsetX - maskOffsetX; const {
const maskY = layerOffsetY - maskOffsetY; context: maskCtx,
offsetX: maskOffsetX,
offsetY: maskOffsetY,
} = smask;
layerCtx.save(); layerCtx.save();
layerCtx.globalAlpha = 1; layerCtx.globalAlpha = 1;
@ -1555,9 +1625,9 @@ class CanvasGraphics {
layerCtx.clip(clip); layerCtx.clip(clip);
layerCtx.globalCompositeOperation = "destination-in"; layerCtx.globalCompositeOperation = "destination-in";
layerCtx.drawImage( layerCtx.drawImage(
maskCanvas, maskCtx.canvas,
maskX, layerOffsetX - maskOffsetX,
maskY, layerOffsetY - maskOffsetY,
width, width,
height, height,
layerOffsetX, layerOffsetX,

View File

@ -908,3 +908,7 @@
!issue21126.pdf !issue21126.pdf
!bug2035197_1.pdf !bug2035197_1.pdf
!bug2035197_2.pdf !bug2035197_2.pdf
!smask_alpha_oob.pdf
!smask_alpha_oob_transfer.pdf
!smask_alpha_bc.pdf
!smask_luminosity_oob_transfer.pdf

View File

@ -0,0 +1,66 @@
%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 220 160]
/Resources << /ExtGState << /GS1 4 0 R >> >>
/Contents 6 0 R >>
endobj
4 0 obj
<< /Type /ExtGState
/SMask << /Type /Mask /S /Alpha /G 5 0 R /BC [1] >>
>>
endobj
5 0 obj
<< /Type /XObject /Subtype /Form /FormType 1
/BBox [40 30 100 90]
/Resources << >>
/Group << /Type /Group /S /Transparency /CS /DeviceGray >>
/Length 20 >>
stream
1 g
40 30 60 60 re
f
endstream
endobj
6 0 obj
<< /Length 118 >>
stream
q
0.95 0.95 0.95 rg
0 0 220 160 re
f
Q
q
/GS1 gs
0.2 0.6 0.9 rg
10 10 200 140 re
f
Q
q
0 0 0 RG
1 w
40 30 60 60 re
S
Q
endstream
endobj
xref
0 7
0000000000 65535 f
0000000015 00000 n
0000000064 00000 n
0000000121 00000 n
0000000259 00000 n
0000000352 00000 n
0000000573 00000 n
trailer
<< /Size 7 /Root 1 0 R >>
startxref
742
%%EOF

View File

@ -0,0 +1,65 @@
%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 600 600]
/Resources << /ExtGState << /GS1 4 0 R >> >>
/Contents 7 0 R >>
endobj
4 0 obj
<< /Type /ExtGState
/SMask << /Type /Mask /S /Alpha /G 5 0 R /BC [0.5] /TR 6 0 R >>
>>
endobj
5 0 obj
<< /Type /XObject /Subtype /Form /FormType 1
/BBox [50 50 150 150]
/Resources << >>
/Group << /Type /Group /S /Transparency /CS /DeviceGray >>
/Length 25 >>
stream
1.0 g
50 50 100 100 re
f
endstream
endobj
6 0 obj
<< /FunctionType 2 /Domain [0 1] /Range [0 1] /N 1 /C0 [0] /C1 [0.5] >>
endobj
7 0 obj
<< /Length 82 >>
stream
q
/GS1 gs
0.2 0.6 0.9 rg
0 0 600 600 re
f
Q
q
0 0 0 rg
0.5 w
50 50 100 100 re
S
Q
endstream
endobj
xref
0 8
0000000000 65535 f
0000000015 00000 n
0000000064 00000 n
0000000121 00000 n
0000000259 00000 n
0000000364 00000 n
0000000590 00000 n
0000000677 00000 n
trailer
<< /Size 8 /Root 1 0 R >>
startxref
808
%%EOF

View File

@ -0,0 +1,65 @@
%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 600 600]
/Resources << /ExtGState << /GS1 4 0 R >> >>
/Contents 7 0 R >>
endobj
4 0 obj
<< /Type /ExtGState
/SMask << /Type /Mask /S /Alpha /G 5 0 R /BC [0.5] /TR 6 0 R >>
>>
endobj
5 0 obj
<< /Type /XObject /Subtype /Form /FormType 1
/BBox [50 50 150 150]
/Resources << >>
/Group << /Type /Group /S /Transparency /CS /DeviceGray >>
/Length 25 >>
stream
1.0 g
50 50 100 100 re
f
endstream
endobj
6 0 obj
<< /FunctionType 2 /Domain [0 1] /Range [0 1] /N 1 /C0[0.5]/C1[1] >>
endobj
7 0 obj
<< /Length 82 >>
stream
q
/GS1 gs
0.2 0.6 0.9 rg
0 0 600 600 re
f
Q
q
0 0 0 rg
0.5 w
50 50 100 100 re
S
Q
endstream
endobj
xref
0 8
0000000000 65535 f
0000000015 00000 n
0000000064 00000 n
0000000121 00000 n
0000000259 00000 n
0000000364 00000 n
0000000590 00000 n
0000000677 00000 n
trailer
<< /Size 8 /Root 1 0 R >>
startxref
808
%%EOF

View File

@ -0,0 +1,73 @@
%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 500 300]
/Resources << /ExtGState << /GS1 4 0 R >> >>
/Contents 7 0 R >>
endobj
4 0 obj
<< /Type /ExtGState
/SMask << /Type /Mask /S /Luminosity /G 5 0 R /BC [1] /TR 6 0 R >>
>>
endobj
5 0 obj
<< /Type /XObject /Subtype /Form /FormType 1
/BBox [70 80 150 140]
/Resources << >>
/Group << /Type /Group /S /Transparency /CS /DeviceGray >>
/Length 42 >>
stream
0 g
70 80 40 60 re
f
1 g
110 80 40 60 re
f
endstream
endobj
6 0 obj
<< /FunctionType 2 /Domain [0 1] /Range [0 1] /N 1 /C0 [0.25] /C1 [0.75] >>
endobj
7 0 obj
<< /Length 117 >>
stream
q
0.95 0.95 0.95 rg
0 0 500 300 re
f
Q
q
/GS1 gs
0.85 0.2 0.1 rg
0 0 500 300 re
f
Q
q
0 0 0 RG
1 w
70 80 80 60 re
S
Q
endstream
endobj
xref
0 8
0000000000 65535 f
0000000015 00000 n
0000000064 00000 n
0000000121 00000 n
0000000259 00000 n
0000000367 00000 n
0000000611 00000 n
0000000702 00000 n
trailer
<< /Size 8 /Root 1 0 R >>
startxref
870
%%EOF

View File

@ -14168,5 +14168,33 @@
} }
}, },
"type": "eq" "type": "eq"
},
{
"id": "smask_alpha_oob",
"file": "pdfs/smask_alpha_oob.pdf",
"md5": "51b86d51d5311db6805a7e5be2ee0aeb",
"rounds": 1,
"type": "eq"
},
{
"id": "smask_alpha_oob_transfer",
"file": "pdfs/smask_alpha_oob_transfer.pdf",
"md5": "4c5fa755f6cc26283f1b21f9b5f59ada",
"rounds": 1,
"type": "eq"
},
{
"id": "smask_alpha_bc",
"file": "pdfs/smask_alpha_bc.pdf",
"md5": "493e7aeef054aab09f325f48703d5ba8",
"rounds": 1,
"type": "eq"
},
{
"id": "smask_luminosity_oob_transfer",
"file": "pdfs/smask_luminosity_oob_transfer.pdf",
"md5": "267dc76f33cc0c0b6d36ff4605f60907",
"rounds": 1,
"type": "eq"
} }
] ]