From 1658a792ce43c9506c35e90ce6e2c51d8c2d7ee1 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Tue, 5 May 2026 20:08:16 +0200 Subject: [PATCH] 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. --- src/display/canvas.js | 558 +++++++++++--------- test/pdfs/.gitignore | 4 + test/pdfs/smask_alpha_bc.pdf | 66 +++ test/pdfs/smask_alpha_oob.pdf | 65 +++ test/pdfs/smask_alpha_oob_transfer.pdf | 65 +++ test/pdfs/smask_luminosity_oob_transfer.pdf | 73 +++ test/test_manifest.json | 28 + 7 files changed, 615 insertions(+), 244 deletions(-) create mode 100644 test/pdfs/smask_alpha_bc.pdf create mode 100644 test/pdfs/smask_alpha_oob.pdf create mode 100644 test/pdfs/smask_alpha_oob_transfer.pdf create mode 100644 test/pdfs/smask_luminosity_oob_transfer.pdf diff --git a/src/display/canvas.js b/src/display/canvas.js index a25f6a271..976db45cd 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -34,6 +34,7 @@ import { import { getCurrentTransform, getCurrentTransformInverse, + getRGBA, makePathFromDrawOPS, OutputScale, PixelsPerInch, @@ -85,120 +86,45 @@ function mirrorContextOperations(ctx, destCtx) { if (ctx._removeMirroring) { throw new Error("Context is already forwarding operations."); } - ctx.__originalSave = ctx.save; - ctx.__originalRestore = ctx.restore; - ctx.__originalRotate = ctx.rotate; - ctx.__originalScale = ctx.scale; - ctx.__originalTranslate = ctx.translate; - ctx.__originalTransform = ctx.transform; - ctx.__originalSetTransform = ctx.setTransform; - ctx.__originalResetTransform = ctx.resetTransform; - ctx.__originalClip = ctx.clip; - ctx.__originalMoveTo = ctx.moveTo; - ctx.__originalLineTo = ctx.lineTo; - ctx.__originalBezierCurveTo = ctx.bezierCurveTo; - ctx.__originalRect = ctx.rect; - ctx.__originalClosePath = ctx.closePath; - ctx.__originalBeginPath = ctx.beginPath; + const originalMethods = new Map(); + for (const name of [ + "save", + "restore", + "rotate", + "scale", + "translate", + "transform", + "setTransform", + "resetTransform", + "clip", + "moveTo", + "lineTo", + "bezierCurveTo", + "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.save = ctx.__originalSave; - ctx.restore = ctx.__originalRestore; - 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); + for (const [name, original] of originalMethods) { + ctx[name] = original; } - }; - - 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(); + delete ctx._removeMirroring; }; } @@ -655,6 +581,18 @@ class CanvasGraphics { this.smaskPreparedFor = null; this.smaskPreparedOffsetX = 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.contentVisible = true; this.markedContentStack = markedContentStack || []; @@ -1270,14 +1208,15 @@ class CanvasGraphics { this.smaskPreparedFor = null; this.smaskPreparedOffsetX = 0; this.smaskPreparedOffsetY = 0; + this.smaskPreparedOOBAlpha = null; } - _ensurePreparedSMask(smask, width, height) { + _ensurePreparedSMask(smask) { if (smask === this.smaskPreparedFor) { return; } this._clearPreparedSMask(); - this._prepareSMaskCanvas(smask, width, height); + this._prepareSMaskCanvas(smask); } checkSMaskState(opIdx) { @@ -1291,94 +1230,145 @@ class CanvasGraphics { // (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 - ); + this._ensurePreparedSMask(this.current.activeSMask); } } - /** - * 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) { + _prepareSMaskCanvas(smask) { 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. + + // Nothing to amortize unless we have a filter or a Luminosity + // backdrop -- Alpha SMasks ignore /BC for the alpha output, and + // 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; 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); + // Constant alpha OOB pixels receive after the spec backdrop+filter + // chain (see smaskPreparedOOBAlpha field doc for the compose-time + // table). /BC only feeds the alpha output for Luminosity (its + // color enters the luminance computation). Alpha SMasks treat /BC + // as a pure color-space backdrop and must not bake it into the + // alpha output. + let filteredOOBAlpha; + if (subtype === "Luminosity" && backdrop) { + // backdrop is "#RRGGBB" (see Evaluator#handleSMask). + const [r, g, b] = getRGBA(backdrop); + const inputAlpha = Math.round(0.3 * r + 0.59 * g + 0.11 * b); + filteredOOBAlpha = transferMap?.[inputAlpha] ?? inputAlpha; } 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; + // Alpha, or Luminosity with no backdrop: OOB input is transparent, + // and both filters map alpha=0 to alpha=0; only transferMap[0] can + // produce a non-zero result. + filteredOOBAlpha = transferMap?.[0] ?? 0; + } + + // Use a layer-size prebake when the layer is at most this many + // times bigger than the mask: layer-size avoids compose-time OOB + // work and hits the same-size drawImage GPU fast path, but the + // alloc cost grows with the layer. The crossover is empirical; + // 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.smaskPreparedFor = smask; this.smaskPreparedOffsetX = offsetX; 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); mirrorContextOperations(ctx, this.suspendedCtx); - this._ensurePreparedSMask( - this.current.activeSMask, - drawnWidth, - drawnHeight - ); + this._ensurePreparedSMask(this.current.activeSMask); this.setGState(opIdx, [["BM", "source-over"]]); } @@ -1432,14 +1418,16 @@ class CanvasGraphics { return; } - if (!dirtyBox) { - dirtyBox = [0, 0, this.ctx.canvas.width, this.ctx.canvas.height]; - } else { - dirtyBox[0] = Math.floor(dirtyBox[0]); - dirtyBox[1] = Math.floor(dirtyBox[1]); - dirtyBox[2] = Math.ceil(dirtyBox[2]); - dirtyBox[3] = Math.ceil(dirtyBox[3]); - } + // Don't mutate the caller's box -- callers (e.g. consumePath) may + // hold on to it. + dirtyBox = dirtyBox + ? [ + Math.floor(dirtyBox[0]), + Math.floor(dirtyBox[1]), + Math.ceil(dirtyBox[2]), + Math.ceil(dirtyBox[3]), + ] + : [0, 0, this.ctx.canvas.width, this.ctx.canvas.height]; const smask = this.current.activeSMask; const suspendedCtx = this.suspendedCtx; @@ -1469,44 +1457,73 @@ class CanvasGraphics { 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(); + // Fast path: prepared-mask destination-in drawImage. See + // smaskPreparedOOBAlpha field doc for the OOB handling table. + let clipX = layerOffsetX; + let clipY = layerOffsetY; + let clipW = layerWidth; + let clipH = layerHeight; + const oobAlpha = this.smaskPreparedOOBAlpha; + const hasOOBAlpha = oobAlpha !== null; + if (hasOOBAlpha) { + clipX = Math.max(layerOffsetX, smask.offsetX); + clipY = Math.max(layerOffsetY, smask.offsetY); + const x1 = Math.min( + layerOffsetX + layerWidth, + smask.offsetX + smask.canvas.width + ); + const y1 = Math.min( + layerOffsetY + layerHeight, + smask.offsetY + smask.canvas.height + ); + clipW = x1 - clipX; + clipH = y1 - clipY; + } + if (clipW > 0 && clipH > 0) { + const srcX = clipX - this.smaskPreparedOffsetX; + const srcY = clipY - this.smaskPreparedOffsetY; + layerCtx.save(); + layerCtx.globalAlpha = 1; + 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 { this.genericComposeSMask( - smask.context, + smask, layerCtx, layerWidth, layerHeight, layerOffsetX, - layerOffsetY, - smask.offsetX, - smask.offsetY + layerOffsetY ); } @@ -1514,8 +1531,8 @@ class CanvasGraphics { ctx.globalAlpha = 1; ctx.globalCompositeOperation = smask.blendMode || "source-over"; ctx.setTransform(1, 0, 0, 1, 0, 0); - // Only blit the dirty box region — the rest of the scratch canvas is - // still transparent from the clearRect in compose(). + // Blit only the dirty box -- the rest of the scratch canvas was + // cleared in compose(). ctx.drawImage( layerCtx.canvas, layerOffsetX, @@ -1530,22 +1547,75 @@ class CanvasGraphics { 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( - maskCtx, + smask, layerCtx, width, height, layerOffsetX, - layerOffsetY, - maskOffsetX, - maskOffsetY + layerOffsetY ) { - // 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; + // composeSMask helper, reached only for plain-alpha masks (no + // filter, no backdrop); every backdrop/filter case prebakes in + // _prepareSMaskCanvas. A single destination-in blit suffices: + // transparent OOB mask samples clear OOB layer pixels. + const { + context: maskCtx, + offsetX: maskOffsetX, + offsetY: maskOffsetY, + } = smask; layerCtx.save(); layerCtx.globalAlpha = 1; @@ -1555,9 +1625,9 @@ class CanvasGraphics { layerCtx.clip(clip); layerCtx.globalCompositeOperation = "destination-in"; layerCtx.drawImage( - maskCanvas, - maskX, - maskY, + maskCtx.canvas, + layerOffsetX - maskOffsetX, + layerOffsetY - maskOffsetY, width, height, layerOffsetX, diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 0a1c92288..2aaf8dc8b 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -908,3 +908,7 @@ !issue21126.pdf !bug2035197_1.pdf !bug2035197_2.pdf +!smask_alpha_oob.pdf +!smask_alpha_oob_transfer.pdf +!smask_alpha_bc.pdf +!smask_luminosity_oob_transfer.pdf diff --git a/test/pdfs/smask_alpha_bc.pdf b/test/pdfs/smask_alpha_bc.pdf new file mode 100644 index 000000000..b39b8efac --- /dev/null +++ b/test/pdfs/smask_alpha_bc.pdf @@ -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 diff --git a/test/pdfs/smask_alpha_oob.pdf b/test/pdfs/smask_alpha_oob.pdf new file mode 100644 index 000000000..16a7edc39 --- /dev/null +++ b/test/pdfs/smask_alpha_oob.pdf @@ -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 diff --git a/test/pdfs/smask_alpha_oob_transfer.pdf b/test/pdfs/smask_alpha_oob_transfer.pdf new file mode 100644 index 000000000..fef4a8267 --- /dev/null +++ b/test/pdfs/smask_alpha_oob_transfer.pdf @@ -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 diff --git a/test/pdfs/smask_luminosity_oob_transfer.pdf b/test/pdfs/smask_luminosity_oob_transfer.pdf new file mode 100644 index 000000000..da11e6a5a --- /dev/null +++ b/test/pdfs/smask_luminosity_oob_transfer.pdf @@ -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 diff --git a/test/test_manifest.json b/test/test_manifest.json index d28f03180..99f981ae8 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -14168,5 +14168,33 @@ } }, "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" } ]