From b8232710c8c07b1d3287383d98edb7b8705d3709 Mon Sep 17 00:00:00 2001 From: calixteman Date: Thu, 7 May 2026 22:19:13 +0200 Subject: [PATCH] Fall back to pixel-buffer SMask filtering when ctx.filter is unavailable For now OffscreenCanvas in worker threads doesn't support ctx.filter, so we need to fall back to a more expensive pixel-buffer SMask filtering in that case. As a side effect, this also allows to support correctly smask in Safari. --- src/display/canvas.js | 79 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 16 deletions(-) diff --git a/src/display/canvas.js b/src/display/canvas.js index 976db45cd..59b9ab2cc 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -1279,13 +1279,20 @@ class CanvasGraphics { 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); - } + // Bundle the filter URL with the spec needed for the pixel-buffer + // fallback (see _bakeSMaskCanvas). subtype + transferMap let the + // fallback reproduce the SVG filter without an extra round-trip + // through the filter factory. + const filterSpec = hasFilter + ? { + url: + subtype === "Alpha" + ? this.filterFactory.addAlphaFilter(transferMap) + : this.filterFactory.addLuminosityFilter(transferMap), + subtype, + transferMap, + } + : null; // Alpha SMasks must not bake /BC into the prepared canvas (see // filteredOOBAlpha comment above). @@ -1300,7 +1307,7 @@ class CanvasGraphics { layerW, layerH, bakedBackdrop, - filterUrl + filterSpec ); offsetX = 0; offsetY = 0; @@ -1312,7 +1319,7 @@ class CanvasGraphics { maskCanvas.width, maskCanvas.height, bakedBackdrop, - filterUrl + filterSpec ); offsetX = smask.offsetX; offsetY = smask.offsetY; @@ -1330,8 +1337,9 @@ class CanvasGraphics { /** * 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. + * mask drawn at (drawX, drawY), then optionally pipe through the SVG + * filter described by `filterSpec`. 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 @@ -1344,9 +1352,17 @@ class CanvasGraphics { * 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. + * + * Some browsers (e.g. older Safari) silently ignore SVG `url(#id)` + * filters on a 2D canvas: the assignment is accepted but + * `ctx.filter` reads back as "none" and `drawImage` produces an + * unfiltered copy. We detect that and fall back to a pixel-buffer + * loop that reproduces the SVG filter exactly (matrix luminance and + * `feFuncA` transferMap, both with sRGB color-interpolation, i.e. + * straight on gamma-encoded byte values). */ - _bakeSMaskCanvas(maskCanvas, drawX, drawY, w, h, backdrop, filterUrl) { - if (!backdrop && !filterUrl) { + _bakeSMaskCanvas(maskCanvas, drawX, drawY, w, h, backdrop, filterSpec) { + if (!backdrop && !filterSpec) { // 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"); @@ -1359,14 +1375,45 @@ class CanvasGraphics { sCtx.fillStyle = backdrop; sCtx.fillRect(0, 0, w, h); } - if (!filterUrl) { + if (!filterSpec) { return srcEntry; } const preparedEntry = this.canvasFactory.create(w, h); const pCtx = preparedEntry.context; - pCtx.filter = filterUrl; + // Pre-assign read: undefined means no canvas filter API (assigning + // would just set a JS property and post-assign read would lie). + // Post-assign "none"/"" means the URL was rejected (Firefox + // normalizes accepted url(#id) to an absolute URL). + const filterSupported = pCtx.filter !== undefined; + pCtx.filter = filterSpec.url; + const filterApplied = + filterSupported && pCtx.filter !== "none" && pCtx.filter !== ""; pCtx.drawImage(srcEntry.canvas, 0, 0); - pCtx.filter = "none"; + if (filterSupported) { + pCtx.filter = "none"; + } + if (!filterApplied) { + const img = pCtx.getImageData(0, 0, w, h); + const { data } = img; + const { transferMap } = filterSpec; + if (filterSpec.subtype === "Luminosity") { + for (let i = 0, ii = data.length; i < ii; i += 4) { + // Match #addLuminosityConversion: a' = 0.3*R + 0.59*G + 0.11*B, + // RGB -> 0; then optional transferMap on alpha. + const a = + (0.3 * data[i] + 0.59 * data[i + 1] + 0.11 * data[i + 2] + 0.5) | 0; + data[i] = data[i + 1] = data[i + 2] = 0; + data[i + 3] = transferMap?.[a] ?? a; + } + } else { + // Alpha: transferMap is guaranteed by _prepareSMaskCanvas's + // hasFilter gate. + for (let i = 3, ii = data.length; i < ii; i += 4) { + data[i] = transferMap[data[i]]; + } + } + pCtx.putImageData(img, 0, 0); + } this.canvasFactory.destroy(srcEntry); return preparedEntry; }