From 82989e6790421917880ebd4b3e3281a39c5ed543 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Tue, 30 Apr 2024 16:55:58 +0200 Subject: [PATCH] [api-minor] Remove the use of (get/put)ImageData when drawing SMasks (bug 1874013) and implement then in using some SVG filters and composition. Composing in using destination-in in order to multiply RGB components by the alpha from the mask isn't perfect: it'd be a way better to natively have alpha masks support, it induces some small rounding errors and consequently computed RGB are approximatively correct. In term of performance, it's a real improvement, for example, the pdf in issue #17779 is now rendered in few seconds. There are still some room for improvement, but overall it should be a way better. --- src/display/base_factory.js | 8 ++ src/display/canvas.js | 229 +++++++++++++++++----------------- src/display/display_utils.js | 135 ++++++++++++++++---- test/pdfs/issue17779.pdf.link | 2 + test/test_manifest.json | 8 ++ 5 files changed, 242 insertions(+), 140 deletions(-) create mode 100644 test/pdfs/issue17779.pdf.link diff --git a/src/display/base_factory.js b/src/display/base_factory.js index 70457ff203edd..09280cf903e3a 100644 --- a/src/display/base_factory.js +++ b/src/display/base_factory.js @@ -30,6 +30,14 @@ class BaseFilterFactory { return "none"; } + addAlphaFilter(map) { + return "none"; + } + + addLuminosityFilter(map) { + return "none"; + } + addHighlightHCMFilter(filterName, fgColor, bgColor, newFgColor, newBgColor) { return "none"; } diff --git a/src/display/canvas.js b/src/display/canvas.js index f5ae6642d5654..f0e97845abd6d 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -796,122 +796,6 @@ function resetCtxToDefault(ctx) { } } -function composeSMaskBackdrop(bytes, r0, g0, b0) { - const length = bytes.length; - for (let i = 3; i < length; i += 4) { - const alpha = bytes[i]; - if (alpha === 0) { - bytes[i - 3] = r0; - bytes[i - 2] = g0; - bytes[i - 1] = b0; - } else if (alpha < 255) { - const alpha_ = 255 - alpha; - bytes[i - 3] = (bytes[i - 3] * alpha + r0 * alpha_) >> 8; - bytes[i - 2] = (bytes[i - 2] * alpha + g0 * alpha_) >> 8; - bytes[i - 1] = (bytes[i - 1] * alpha + b0 * alpha_) >> 8; - } - } -} - -function composeSMaskAlpha(maskData, layerData, transferMap) { - const length = maskData.length; - const scale = 1 / 255; - for (let i = 3; i < length; i += 4) { - const alpha = transferMap ? transferMap[maskData[i]] : maskData[i]; - layerData[i] = (layerData[i] * alpha * scale) | 0; - } -} - -function composeSMaskLuminosity(maskData, layerData, transferMap) { - const length = maskData.length; - for (let i = 3; i < length; i += 4) { - const y = - maskData[i - 3] * 77 + // * 0.3 / 255 * 0x10000 - maskData[i - 2] * 152 + // * 0.59 .... - maskData[i - 1] * 28; // * 0.11 .... - layerData[i] = transferMap - ? (layerData[i] * transferMap[y >> 8]) >> 8 - : (layerData[i] * y) >> 16; - } -} - -function genericComposeSMask( - maskCtx, - layerCtx, - width, - height, - subtype, - backdrop, - transferMap, - layerOffsetX, - layerOffsetY, - maskOffsetX, - maskOffsetY -) { - const hasBackdrop = !!backdrop; - const r0 = hasBackdrop ? backdrop[0] : 0; - const g0 = hasBackdrop ? backdrop[1] : 0; - const b0 = hasBackdrop ? backdrop[2] : 0; - - const composeFn = - subtype === "Luminosity" ? composeSMaskLuminosity : composeSMaskAlpha; - - // processing image in chunks to save memory - const PIXELS_TO_PROCESS = 1048576; - const chunkSize = Math.min(height, Math.ceil(PIXELS_TO_PROCESS / width)); - for (let row = 0; row < height; row += chunkSize) { - const chunkHeight = Math.min(chunkSize, height - row); - const maskData = maskCtx.getImageData( - layerOffsetX - maskOffsetX, - row + (layerOffsetY - maskOffsetY), - width, - chunkHeight - ); - const layerData = layerCtx.getImageData( - layerOffsetX, - row + layerOffsetY, - width, - chunkHeight - ); - - if (hasBackdrop) { - composeSMaskBackdrop(maskData.data, r0, g0, b0); - } - composeFn(maskData.data, layerData.data, transferMap); - - layerCtx.putImageData(layerData, layerOffsetX, row + layerOffsetY); - } -} - -function composeSMask(ctx, smask, layerCtx, layerBox) { - const layerOffsetX = layerBox[0]; - const layerOffsetY = layerBox[1]; - const layerWidth = layerBox[2] - layerOffsetX; - const layerHeight = layerBox[3] - layerOffsetY; - if (layerWidth === 0 || layerHeight === 0) { - return; - } - genericComposeSMask( - smask.context, - layerCtx, - layerWidth, - layerHeight, - smask.subtype, - smask.backdrop, - smask.transferMap, - layerOffsetX, - layerOffsetY, - smask.offsetX, - smask.offsetY - ); - ctx.save(); - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.drawImage(layerCtx.canvas, 0, 0); - ctx.restore(); -} - function getImageSmoothingEnabled(transform, interpolate) { // In section 8.9.5.3 of the PDF spec, it's mentioned that the interpolate // flag should be used when the image is upscaled. @@ -1556,7 +1440,7 @@ class CanvasGraphics { const smask = this.current.activeSMask; const suspendedCtx = this.suspendedCtx; - 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 // out of the current canvas. this.ctx.save(); @@ -1565,6 +1449,117 @@ class CanvasGraphics { this.ctx.restore(); } + composeSMask(ctx, smask, layerCtx, layerBox) { + const layerOffsetX = layerBox[0]; + const layerOffsetY = layerBox[1]; + const layerWidth = layerBox[2] - layerOffsetX; + const layerHeight = layerBox[3] - layerOffsetY; + 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 + ); + ctx.save(); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.drawImage(layerCtx.canvas, 0, 0); + ctx.restore(); + } + + genericComposeSMask( + maskCtx, + layerCtx, + width, + height, + subtype, + backdrop, + transferMap, + layerOffsetX, + layerOffsetY, + maskOffsetX, + maskOffsetY + ) { + let maskCanvas = maskCtx.canvas; + let maskX = layerOffsetX - maskOffsetX; + let maskY = layerOffsetY - maskOffsetY; + + if (backdrop) { + if ( + maskX < 0 || + maskY < 0 || + maskX + width > maskCanvas.width || + maskY + height > maskCanvas.height + ) { + const canvas = this.cachedCanvases.getCanvas( + "maskExtension", + width, + height + ); + const ctx = canvas.context; + ctx.drawImage(maskCanvas, -maskX, -maskY); + if (backdrop.some(c => c !== 0)) { + ctx.globalCompositeOperation = "destination-atop"; + ctx.fillStyle = Util.makeHexColor(...backdrop); + ctx.fillRect(0, 0, width, height); + ctx.globalCompositeOperation = "source-over"; + } + + maskCanvas = canvas.canvas; + maskX = maskY = 0; + } else if (backdrop.some(c => c !== 0)) { + 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 = Util.makeHexColor(...backdrop); + maskCtx.fillRect(maskX, maskY, width, height); + maskCtx.restore(); + } + } + + 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); + layerCtx.globalCompositeOperation = "destination-in"; + layerCtx.drawImage( + maskCanvas, + maskX, + maskY, + width, + height, + layerOffsetX, + layerOffsetY, + width, + height + ); + layerCtx.restore(); + } + save() { if (this.inSMaskMode) { // SMask mode may be turned on/off causing us to lose graphics state. diff --git a/src/display/display_utils.js b/src/display/display_utils.js index 98c8e47141e67..67213232306ce 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -97,6 +97,30 @@ class DOMFilterFactory extends BaseFilterFactory { return this.#_defs; } + #createTables(maps) { + if (maps.length === 1) { + const mapR = maps[0]; + const buffer = new Array(256); + for (let i = 0; i < 256; i++) { + buffer[i] = mapR[i] / 255; + } + + const table = buffer.join(","); + return [table, table, table]; + } + + const [mapR, mapG, mapB] = maps; + const bufferR = new Array(256); + const bufferG = new Array(256); + const bufferB = new Array(256); + for (let i = 0; i < 256; i++) { + bufferR[i] = mapR[i] / 255; + bufferG[i] = mapG[i] / 255; + bufferB[i] = mapB[i] / 255; + } + return [bufferR.join(","), bufferG.join(","), bufferB.join(",")]; + } + addFilter(maps) { if (!maps) { return "none"; @@ -109,29 +133,8 @@ class DOMFilterFactory extends BaseFilterFactory { return value; } - let tableR, tableG, tableB, key; - if (maps.length === 1) { - const mapR = maps[0]; - const buffer = new Array(256); - for (let i = 0; i < 256; i++) { - buffer[i] = mapR[i] / 255; - } - key = tableR = tableG = tableB = buffer.join(","); - } else { - const [mapR, mapG, mapB] = maps; - const bufferR = new Array(256); - const bufferG = new Array(256); - const bufferB = new Array(256); - for (let i = 0; i < 256; i++) { - bufferR[i] = mapR[i] / 255; - bufferG[i] = mapG[i] / 255; - bufferB[i] = mapB[i] / 255; - } - tableR = bufferR.join(","); - tableG = bufferG.join(","); - tableB = bufferB.join(","); - key = `${tableR}${tableG}${tableB}`; - } + const [tableR, tableG, tableB] = this.#createTables(maps); + const key = maps.length === 1 ? tableR : `${tableR}${tableG}${tableB}`; value = this.#cache.get(key); if (value) { @@ -233,6 +236,70 @@ class DOMFilterFactory extends BaseFilterFactory { return info.url; } + addAlphaFilter(map) { + // When a page is zoomed the page is re-drawn but the maps are likely + // the same. + let value = this.#cache.get(map); + if (value) { + return value; + } + + const [tableA] = this.#createTables([map]); + const key = `alpha_${tableA}`; + + value = this.#cache.get(key); + if (value) { + this.#cache.set(map, value); + return value; + } + + const id = `g_${this.#docId}_alpha_map_${this.#id++}`; + const url = `url(#${id})`; + this.#cache.set(map, url); + this.#cache.set(key, url); + + const filter = this.#createFilter(id); + this.#addTransferMapAlphaConversion(tableA, filter); + + return url; + } + + addLuminosityFilter(map) { + // When a page is zoomed the page is re-drawn but the maps are likely + // the same. + let value = this.#cache.get(map || "luminosity"); + if (value) { + return value; + } + + let tableA, key; + if (map) { + [tableA] = this.#createTables([map]); + key = `luminosity_${tableA}`; + } else { + key = "luminosity"; + } + + value = this.#cache.get(key); + if (value) { + this.#cache.set(map, value); + return value; + } + + const id = `g_${this.#docId}_luminosity_map_${this.#id++}`; + const url = `url(#${id})`; + this.#cache.set(map, url); + this.#cache.set(key, url); + + const filter = this.#createFilter(id); + this.#addLuminosityConversion(filter); + if (map) { + this.#addTransferMapAlphaConversion(tableA, filter); + } + + return url; + } + addHighlightHCMFilter(filterName, fgColor, bgColor, newFgColor, newBgColor) { const key = `${fgColor}-${bgColor}-${newFgColor}-${newBgColor}`; let info = this.#hcmCache.get(filterName); @@ -341,6 +408,19 @@ class DOMFilterFactory extends BaseFilterFactory { this.#id = 0; } + #addLuminosityConversion(filter) { + const feColorMatrix = this.#document.createElementNS( + SVG_NS, + "feColorMatrix" + ); + feColorMatrix.setAttribute("type", "matrix"); + feColorMatrix.setAttribute( + "values", + "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0.59 0.11 0 0" + ); + filter.append(feColorMatrix); + } + #addGrayConversion(filter) { const feColorMatrix = this.#document.createElementNS( SVG_NS, @@ -381,6 +461,15 @@ class DOMFilterFactory extends BaseFilterFactory { this.#appendFeFunc(feComponentTransfer, "feFuncB", bTable); } + #addTransferMapAlphaConversion(aTable, filter) { + const feComponentTransfer = this.#document.createElementNS( + SVG_NS, + "feComponentTransfer" + ); + filter.append(feComponentTransfer); + this.#appendFeFunc(feComponentTransfer, "feFuncA", aTable); + } + #getRGB(color) { this.#defs.style.color = color; return getRGB(getComputedStyle(this.#defs).getPropertyValue("color")); diff --git a/test/pdfs/issue17779.pdf.link b/test/pdfs/issue17779.pdf.link new file mode 100644 index 0000000000000..e75edf58fa9d5 --- /dev/null +++ b/test/pdfs/issue17779.pdf.link @@ -0,0 +1,2 @@ +https://github.com/mozilla/pdf.js/files/14522359/p95.pdf + diff --git a/test/test_manifest.json b/test/test_manifest.json index 54059eb07051e..b67f1d569f03f 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -9957,5 +9957,13 @@ "id": "51R" } } + }, + { + "id": "issue17779", + "file": "pdfs/issue17779.pdf", + "md5": "764b72e8e56e22662b321b308254fd2b", + "rounds": 1, + "link": true, + "type": "eq" } ]