From da080cc26ead8653f615ba42d197a2e33ef2a1ea Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Mon, 6 Mar 2023 19:09:56 +0100 Subject: [PATCH] [api-minor] Use a SVG filter when rendering pages in HCM The idea is to apply an overall filter on each page: the main advantage is to have some filtered images which could help to make them visible for some users. --- src/display/api.js | 6 +- src/display/base_factory.js | 6 +- src/display/canvas.js | 101 +++++++------------------- src/display/display_utils.js | 137 ++++++++++++++++++++++++++++++++++- web/pdf_page_view.js | 16 ++-- 5 files changed, 181 insertions(+), 85 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index 2fe2c2d5a..96b7243cf 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -3082,7 +3082,7 @@ class WorkerTransport { this.fontLoader.clear(); } this.#methodPromises.clear(); - this.filterFactory.destroy(); + this.filterFactory.destroy(/* keepHCM = */ true); } get loadingParams() { @@ -3337,7 +3337,7 @@ class InternalRenderTask { cancel(error = null, extraDelay = 0) { this.running = false; this.cancelled = true; - this.gfx?.endDrawing(); + this.gfx?.endDrawing(this.pageColors); if (this._canvas) { InternalRenderTask.#canvasInUse.delete(this._canvas); @@ -3402,7 +3402,7 @@ class InternalRenderTask { if (this.operatorListIdx === this.operatorList.argsArray.length) { this.running = false; if (this.operatorList.lastChunk) { - this.gfx.endDrawing(); + this.gfx.endDrawing(this.pageColors); if (this._canvas) { InternalRenderTask.#canvasInUse.delete(this._canvas); } diff --git a/src/display/base_factory.js b/src/display/base_factory.js index 181289f1c..696cc15c9 100644 --- a/src/display/base_factory.js +++ b/src/display/base_factory.js @@ -26,7 +26,11 @@ class BaseFilterFactory { return "none"; } - destroy() {} + addHCMFilter(fgColor, bgColor) { + return "none"; + } + + destroy(keepHCM = false) {} } class BaseCanvasFactory { diff --git a/src/display/canvas.js b/src/display/canvas.js index 7e758fbf7..c900d57f9 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -29,7 +29,6 @@ import { import { getCurrentTransform, getCurrentTransformInverse, - getRGB, PixelsPerInch, } from "./display_utils.js"; import { @@ -773,8 +772,8 @@ function copyCtxState(sourceCtx, destCtx) { } } -function resetCtxToDefault(ctx, foregroundColor) { - ctx.strokeStyle = ctx.fillStyle = foregroundColor || "#000000"; +function resetCtxToDefault(ctx) { + ctx.strokeStyle = ctx.fillStyle = "#000000"; ctx.fillRule = "nonzero"; ctx.globalAlpha = 1; ctx.lineWidth = 1; @@ -948,8 +947,7 @@ class CanvasGraphics { canvasFactory, filterFactory, { optionalContentConfig, markedContentStack = null }, - annotationCanvasMap, - pageColors + annotationCanvasMap ) { this.ctx = canvasCtx; this.current = new CanvasExtraState( @@ -985,8 +983,6 @@ class CanvasGraphics { this.viewportScale = 1; this.outputScaleX = 1; this.outputScaleY = 1; - this.backgroundColor = pageColors?.background || null; - this.foregroundColor = pageColors?.foreground || null; this._cachedScaleForStroking = null; this._cachedGetSinglePixelWidth = null; @@ -1015,69 +1011,11 @@ class CanvasGraphics { // transparent canvas when we have blend modes. const width = this.ctx.canvas.width; const height = this.ctx.canvas.height; - const defaultBackgroundColor = background || "#ffffff"; - this.ctx.save(); - if (this.foregroundColor && this.backgroundColor) { - // Get the #RRGGBB value of the color. If it's a name (e.g. CanvasText) - // then it'll be converted to its rgb value. - this.ctx.fillStyle = this.foregroundColor; - const fg = (this.foregroundColor = this.ctx.fillStyle); - this.ctx.fillStyle = this.backgroundColor; - const bg = (this.backgroundColor = this.ctx.fillStyle); - let isValidDefaultBg = true; - let defaultBg = defaultBackgroundColor; - - if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) { - this.ctx.fillStyle = defaultBackgroundColor; - defaultBg = this.ctx.fillStyle; - isValidDefaultBg = - typeof defaultBg === "string" && /^#[0-9A-Fa-f]{6}$/.test(defaultBg); - } - - if ( - (fg === "#000000" && bg === "#ffffff") || - fg === bg || - !isValidDefaultBg - ) { - // Ignore the `pageColors`-option when: - // - The computed background/foreground colors have their default - // values, i.e. white/black. - // - The computed background/foreground colors are identical, - // since that'd render the `canvas` mostly blank. - // - The `background`-option has a value that's incompatible with - // the `pageColors`-values. - // - this.foregroundColor = this.backgroundColor = null; - } else { - // https://developer.mozilla.org/en-US/docs/Web/Accessibility/Understanding_Colors_and_Luminance - // - // Relative luminance: - // https://www.w3.org/TR/WCAG20/#relativeluminancedef - // - // We compute the rounded luminance of the default background color. - // Then for every color in the pdf, if its rounded luminance is the - // same as the background one then it's replaced by the new - // background color else by the foreground one. - const [rB, gB, bB] = getRGB(defaultBg); - const newComp = x => { - x /= 255; - return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; - }; - const lumB = Math.round( - 0.2126 * newComp(rB) + 0.7152 * newComp(gB) + 0.0722 * newComp(bB) - ); - this.selectColor = (r, g, b) => { - const lumC = - 0.2126 * newComp(r) + 0.7152 * newComp(g) + 0.0722 * newComp(b); - return Math.round(lumC) === lumB ? bg : fg; - }; - } - } - - this.ctx.fillStyle = this.backgroundColor || defaultBackgroundColor; + const savedFillStyle = this.ctx.fillStyle; + this.ctx.fillStyle = background || "#ffffff"; this.ctx.fillRect(0, 0, width, height); - this.ctx.restore(); + this.ctx.fillStyle = savedFillStyle; if (transparency) { const transparentCanvas = this.cachedCanvases.getCanvas( @@ -1095,7 +1033,7 @@ class CanvasGraphics { } this.ctx.save(); - resetCtxToDefault(this.ctx, this.foregroundColor); + resetCtxToDefault(this.ctx); if (transform) { this.ctx.transform(...transform); this.outputScaleX = transform[0]; @@ -1197,7 +1135,7 @@ class CanvasGraphics { } } - endDrawing() { + endDrawing(pageColors = null) { this.#restoreInitialState(); this.cachedCanvases.clear(); @@ -1215,6 +1153,19 @@ class CanvasGraphics { cache.clear(); } this._cachedBitmapsMap.clear(); + + if (pageColors) { + const hcmFilterId = this.filterFactory.addHCMFilter( + pageColors.foreground, + pageColors.background + ); + if (hcmFilterId !== "none") { + const savedFilter = this.ctx.filter; + this.ctx.filter = hcmFilterId; + this.ctx.drawImage(this.ctx.canvas, 0, 0); + this.ctx.filter = savedFilter; + } + } } _scaleImage(img, inverseTransform) { @@ -2439,13 +2390,13 @@ class CanvasGraphics { } setStrokeRGBColor(r, g, b) { - const color = this.selectColor?.(r, g, b) || Util.makeHexColor(r, g, b); + const color = Util.makeHexColor(r, g, b); this.ctx.strokeStyle = color; this.current.strokeColor = color; } setFillRGBColor(r, g, b) { - const color = this.selectColor?.(r, g, b) || Util.makeHexColor(r, g, b); + const color = Util.makeHexColor(r, g, b); this.ctx.fillStyle = color; this.current.fillColor = color; this.current.patternFill = false; @@ -2719,7 +2670,7 @@ class CanvasGraphics { // a clipping path, whatever... // So in order to have something clean, we restore the initial state. this.#restoreInitialState(); - resetCtxToDefault(this.ctx, this.foregroundColor); + resetCtxToDefault(this.ctx); this.ctx.save(); this.save(); @@ -2763,9 +2714,9 @@ class CanvasGraphics { this.ctx = context; this.ctx.setTransform(scaleX, 0, 0, -scaleY, 0, height * scaleY); - resetCtxToDefault(this.ctx, this.foregroundColor); + resetCtxToDefault(this.ctx); } else { - resetCtxToDefault(this.ctx, this.foregroundColor); + resetCtxToDefault(this.ctx); this.ctx.rect(rect[0], rect[1], width, height); this.ctx.clip(); diff --git a/src/display/display_utils.js b/src/display/display_utils.js index 945e0db70..d46e82e8c 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -58,6 +58,12 @@ class DOMFilterFactory extends BaseFilterFactory { #document; + #hcmFilter; + + #hcmKey; + + #hcmUrl; + #id = 0; constructor({ docId, ownerDocument = globalThis.document } = {}) { @@ -172,7 +178,136 @@ class DOMFilterFactory extends BaseFilterFactory { return url; } - destroy() { + addHCMFilter(fgColor, bgColor) { + const key = `${fgColor}-${bgColor}`; + if (this.#hcmKey === key) { + return this.#hcmUrl; + } + + this.#hcmKey = key; + this.#hcmUrl = "none"; + this.#hcmFilter?.remove(); + + if (!fgColor || !bgColor) { + return this.#hcmUrl; + } + + this.#defs.style.color = fgColor; + fgColor = getComputedStyle(this.#defs).getPropertyValue("color"); + const fgRGB = getRGB(fgColor); + fgColor = Util.makeHexColor(...fgRGB); + this.#defs.style.color = bgColor; + bgColor = getComputedStyle(this.#defs).getPropertyValue("color"); + const bgRGB = getRGB(bgColor); + bgColor = Util.makeHexColor(...bgRGB); + this.#defs.style.color = ""; + + if ( + (fgColor === "#000000" && bgColor === "#ffffff") || + fgColor === bgColor + ) { + return this.#hcmUrl; + } + + // https://developer.mozilla.org/en-US/docs/Web/Accessibility/Understanding_Colors_and_Luminance + // + // Relative luminance: + // https://www.w3.org/TR/WCAG20/#relativeluminancedef + // + // We compute the rounded luminance of the default background color. + // Then for every color in the pdf, if its rounded luminance is the + // same as the background one then it's replaced by the new + // background color else by the foreground one. + const map = new Array(256); + for (let i = 0; i <= 255; i++) { + const x = i / 255; + map[i] = x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; + } + const table = map.join(","); + + const id = `g_${this.#docId}_hcm_filter`; + const filter = (this.#hcmFilter = this.#document.createElementNS( + SVG_NS, + "filter", + SVG_NS + )); + filter.setAttribute("id", id); + filter.setAttribute("color-interpolation-filters", "sRGB"); + let feComponentTransfer = this.#document.createElementNS( + SVG_NS, + "feComponentTransfer" + ); + filter.append(feComponentTransfer); + + let type = "discrete"; + let feFuncR = this.#document.createElementNS(SVG_NS, "feFuncR"); + feFuncR.setAttribute("type", type); + feFuncR.setAttribute("tableValues", table); + feComponentTransfer.append(feFuncR); + + let feFuncG = this.#document.createElementNS(SVG_NS, "feFuncG"); + feFuncG.setAttribute("type", type); + feFuncG.setAttribute("tableValues", table); + feComponentTransfer.append(feFuncG); + + let feFuncB = this.#document.createElementNS(SVG_NS, "feFuncB"); + feFuncB.setAttribute("type", type); + feFuncB.setAttribute("tableValues", table); + feComponentTransfer.append(feFuncB); + + const feColorMatrix = this.#document.createElementNS( + SVG_NS, + "feColorMatrix" + ); + feColorMatrix.setAttribute("type", "matrix"); + feColorMatrix.setAttribute( + "values", + "0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0" + ); + filter.append(feColorMatrix); + + feComponentTransfer = this.#document.createElementNS( + SVG_NS, + "feComponentTransfer" + ); + filter.append(feComponentTransfer); + + const getSteps = (c, n) => { + const start = fgRGB[c] / 255; + const end = bgRGB[c] / 255; + const arr = new Array(n + 1); + for (let i = 0; i <= n; i++) { + arr[i] = start + (i / n) * (end - start); + } + return arr.join(","); + }; + + type = "discrete"; + feFuncR = this.#document.createElementNS(SVG_NS, "feFuncR"); + feFuncR.setAttribute("type", type); + feFuncR.setAttribute("tableValues", `${getSteps(0, 5)}`); + feComponentTransfer.append(feFuncR); + + feFuncG = this.#document.createElementNS(SVG_NS, "feFuncG"); + feFuncG.setAttribute("type", type); + feFuncG.setAttribute("tableValues", `${getSteps(1, 5)}`); + feComponentTransfer.append(feFuncG); + + feFuncB = this.#document.createElementNS(SVG_NS, "feFuncB"); + feFuncB.setAttribute("type", type); + feFuncB.setAttribute("tableValues", `${getSteps(2, 5)}`); + feComponentTransfer.append(feFuncB); + + this.#defs.append(filter); + + this.#hcmUrl = `url(#${id})`; + return this.#hcmUrl; + } + + destroy(keepHCM = false) { + if (keepHCM && this.#hcmUrl) { + return; + } if (this.#_defs) { this.#_defs.parentNode.parentNode.remove(); this.#_defs = null; diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index ed413a06c..49a41dcde 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -1002,8 +1002,14 @@ class PDFPageView { // is complete when `!this.renderingQueue`, to prevent black flickering. canvas.hidden = true; let isCanvasHidden = true; - const showCanvas = function () { - if (isCanvasHidden) { + const hasHCM = !!( + this.pageColors?.background && this.pageColors?.foreground + ); + const showCanvas = function (isLastShow) { + // In HCM, a final filter is applied on the canvas which means that + // before it's applied we've normal colors. Consequently, to avoid to have + // a final flash we just display it once all the drawing is done. + if (isCanvasHidden && (!hasHCM || isLastShow)) { canvas.hidden = false; isCanvasHidden = false; } @@ -1064,7 +1070,7 @@ class PDFPageView { }; const renderTask = this.pdfPage.render(renderContext); renderTask.onContinue = function (cont) { - showCanvas(); + showCanvas(false); if (result.onRenderContinue) { result.onRenderContinue(cont); } else { @@ -1074,7 +1080,7 @@ class PDFPageView { renderTask.promise.then( function () { - showCanvas(); + showCanvas(true); renderCapability.resolve(); }, function (error) { @@ -1082,7 +1088,7 @@ class PDFPageView { // a black canvas if rendering was cancelled before the `onContinue`- // callback had been invoked at least once. if (!(error instanceof RenderingCancelledException)) { - showCanvas(); + showCanvas(true); } renderCapability.reject(error); }