From c8afd6ce8ce2e23aee947f56cc3e44b18415ab8d Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Wed, 4 May 2022 15:37:13 +0200 Subject: [PATCH] [api-minor] Improve pdf reading in high contrast mode - Use Canvas & CanvasText color when they don't have their default value as background and foreground colors. - The colors used to draw (stroke/fill) in a pdf are replaced by the bg/fg ones according to their luminance. --- extensions/chromium/preferences_schema.json | 10 +++ src/display/api.js | 13 +++- src/display/canvas.js | 73 +++++++++++++++++---- test/driver.js | 5 +- test/test_manifest.json | 32 +++++++++ web/app.js | 4 ++ web/app_options.js | 10 +++ web/base_viewer.js | 20 ++++++ web/pdf_page_view.js | 5 ++ 9 files changed, 159 insertions(+), 13 deletions(-) diff --git a/extensions/chromium/preferences_schema.json b/extensions/chromium/preferences_schema.json index c8bd5e0c19b72..6f7f2004f096b 100644 --- a/extensions/chromium/preferences_schema.json +++ b/extensions/chromium/preferences_schema.json @@ -208,6 +208,16 @@ 2 ], "default": -1 + }, + "pageBackgroundColor": { + "description": "The color is a string as defined in CSS. Its goal is to help improve readability in high contrast mode", + "type": "string", + "default": "Canvas" + }, + "pageForegroundColor": { + "description": "The color is a string as defined in CSS. Its goal is to help improve readability in high contrast mode", + "type": "string", + "default": "CanvasText" } } } diff --git a/src/display/api.js b/src/display/api.js index 9c4c5f0b56428..b3b388a29b593 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -1169,6 +1169,12 @@ class PDFDocumentProxy { * value, a `CanvasGradient` object (a linear or radial gradient) or * a `CanvasPattern` object (a repetitive image). The default value is * 'rgb(255,255,255)'. + * + * NOTE: This option may be partially, or completely, ignored when the + * `pageColors`-option is used. + * @property {Object} [pageColors] - Overwrites background and foreground colors + * with user defined ones in order to improve readability in high contrast + * mode. * @property {Promise} [optionalContentConfigPromise] - * A promise that should resolve with an {@link OptionalContentConfig} * created from `PDFDocumentProxy.getOptionalContentConfig`. If `null`, @@ -1393,6 +1399,7 @@ class PDFPageProxy { background = null, optionalContentConfigPromise = null, annotationCanvasMap = null, + pageColors = null, }) { if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("GENERIC")) { if (arguments[0]?.renderInteractiveForms !== undefined) { @@ -1516,6 +1523,7 @@ class PDFPageProxy { canvasFactory: canvasFactoryInstance, useRequestAnimationFrame: !intentPrint, pdfBug: this._pdfBug, + pageColors, }); (intentState.renderTasks ||= new Set()).add(internalRenderTask); @@ -3219,6 +3227,7 @@ class InternalRenderTask { canvasFactory, useRequestAnimationFrame = false, pdfBug = false, + pageColors = null, }) { this.callback = callback; this.params = params; @@ -3230,6 +3239,7 @@ class InternalRenderTask { this._pageIndex = pageIndex; this.canvasFactory = canvasFactory; this._pdfBug = pdfBug; + this.pageColors = pageColors; this.running = false; this.graphicsReadyCallback = null; @@ -3284,7 +3294,8 @@ class InternalRenderTask { this.canvasFactory, imageLayer, optionalContentConfig, - this.annotationCanvasMap + this.annotationCanvasMap, + this.pageColors ); this.gfx.beginDrawing({ transform, diff --git a/src/display/canvas.js b/src/display/canvas.js index c56dff19b1868..b34aba22e833e 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -1042,9 +1042,8 @@ function copyCtxState(sourceCtx, destCtx) { } } -function resetCtxToDefault(ctx) { - ctx.strokeStyle = "#000000"; - ctx.fillStyle = "#000000"; +function resetCtxToDefault(ctx, foregroundColor) { + ctx.strokeStyle = ctx.fillStyle = foregroundColor || "#000000"; ctx.fillRule = "nonzero"; ctx.globalAlpha = 1; ctx.lineWidth = 1; @@ -1212,7 +1211,8 @@ class CanvasGraphics { canvasFactory, imageLayer, optionalContentConfig, - annotationCanvasMap + annotationCanvasMap, + pageColors ) { this.ctx = canvasCtx; this.current = new CanvasExtraState( @@ -1248,6 +1248,8 @@ class CanvasGraphics { this.viewportScale = 1; this.outputScaleX = 1; this.outputScaleY = 1; + this.backgroundColor = pageColors?.background || null; + this.foregroundColor = pageColors?.foreground || null; if (canvasCtx) { // NOTE: if mozCurrentTransform is polyfilled, then the current state of // the transformation must already be set in canvasCtx._transformMatrix. @@ -1280,9 +1282,58 @@ 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(); - this.ctx.fillStyle = background || "rgb(255, 255, 255)"; + + 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") || !isValidDefaultBg) { + 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 cB = parseInt(defaultBg.slice(1), 16); + const rB = (cB && 0xff0000) >> 16; + const gB = (cB && 0x00ff00) >> 8; + const bB = cB && 0x0000ff; + 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; this.ctx.fillRect(0, 0, width, height); this.ctx.restore(); @@ -1303,7 +1354,7 @@ class CanvasGraphics { } this.ctx.save(); - resetCtxToDefault(this.ctx); + resetCtxToDefault(this.ctx, this.foregroundColor); if (transform) { this.ctx.transform.apply(this.ctx, transform); this.outputScaleX = transform[0]; @@ -2636,13 +2687,13 @@ class CanvasGraphics { } setStrokeRGBColor(r, g, b) { - const color = Util.makeHexColor(r, g, b); + const color = this.selectColor?.(r, g, b) || Util.makeHexColor(r, g, b); this.ctx.strokeStyle = color; this.current.strokeColor = color; } setFillRGBColor(r, g, b) { - const color = Util.makeHexColor(r, g, b); + const color = this.selectColor?.(r, g, b) || Util.makeHexColor(r, g, b); this.ctx.fillStyle = color; this.current.fillColor = color; this.current.patternFill = false; @@ -2964,9 +3015,9 @@ class CanvasGraphics { this.ctx.setTransform(scaleX, 0, 0, -scaleY, 0, height * scaleY); addContextCurrentTransform(this.ctx); - resetCtxToDefault(this.ctx); + resetCtxToDefault(this.ctx, this.foregroundColor); } else { - resetCtxToDefault(this.ctx); + resetCtxToDefault(this.ctx, this.foregroundColor); this.ctx.rect(rect[0], rect[1], width, height); this.ctx.clip(); diff --git a/test/driver.js b/test/driver.js index 50a6110c1c54b..803db20a7a890 100644 --- a/test/driver.js +++ b/test/driver.js @@ -648,7 +648,8 @@ class Driver { renderForms = false, renderPrint = false, renderXfa = false, - annotationCanvasMap = null; + annotationCanvasMap = null, + pageColors = null; if (task.annotationStorage) { const entries = Object.entries(task.annotationStorage), @@ -699,6 +700,7 @@ class Driver { renderForms = !!task.forms; renderPrint = !!task.print; renderXfa = !!task.enableXfa; + pageColors = task.pageColors || null; // Render the annotation layer if necessary. if (renderAnnotations || renderForms || renderXfa) { @@ -746,6 +748,7 @@ class Driver { viewport, optionalContentConfigPromise: task.optionalContentConfigPromise, annotationCanvasMap, + pageColors, transform, }; if (renderForms) { diff --git a/test/test_manifest.json b/test/test_manifest.json index 10de596d10f71..451a32281e233 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -60,6 +60,38 @@ "enhance": true, "type": "text" }, + { "id": "tracemonkey-forced-colors-eq", + "file": "pdfs/tracemonkey.pdf", + "md5": "9a192d8b1a7dc652a19835f6f08098bd", + "rounds": 1, + "pageColors": { + "background": "black", + "foreground": "#00FF00" + }, + "type": "eq" + }, + { "id": "tracemonkey-viewer-colors-eq", + "file": "pdfs/tracemonkey.pdf", + "md5": "9a192d8b1a7dc652a19835f6f08098bd", + "rounds": 1, + "pageColors": { + "background": "Canvas", + "foreground": "CanvasText" + }, + "type": "eq", + "about": "Uses the same pageColors as the viewer." + }, + { "id": "tracemonkey-default-colors-eq", + "file": "pdfs/tracemonkey.pdf", + "md5": "9a192d8b1a7dc652a19835f6f08098bd", + "rounds": 1, + "pageColors": { + "background": "white", + "foreground": "black" + }, + "type": "eq", + "about": "Uses the default colors." + }, { "id": "issue3925", "file": "pdfs/issue3925.pdf", "md5": "c5c895deecf7a7565393587e0d61be2b", diff --git a/web/app.js b/web/app.js index a34449fe81f01..f0fe39c682cf3 100644 --- a/web/app.js +++ b/web/app.js @@ -525,6 +525,10 @@ const PDFViewerApplication = { useOnlyCssZoom: AppOptions.get("useOnlyCssZoom"), maxCanvasPixels: AppOptions.get("maxCanvasPixels"), enablePermissions: AppOptions.get("enablePermissions"), + pageColors: { + background: AppOptions.get("pageBackgroundColor"), + foreground: AppOptions.get("pageForegroundColor"), + }, }); pdfRenderingQueue.setViewer(this.pdfViewer); pdfLinkService.setViewer(this.pdfViewer); diff --git a/web/app_options.js b/web/app_options.js index dfb83a01aa7b9..e73e0c429936e 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -129,6 +129,16 @@ const defaultOptions = { compatibility: compatibilityParams.maxCanvasPixels, kind: OptionKind.VIEWER, }, + pageBackgroundColor: { + /** @type {string} */ + value: "Canvas", + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, + pageForegroundColor: { + /** @type {string} */ + value: "CanvasText", + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, pdfBugEnabled: { /** @type {boolean} */ value: typeof PDFJSDev === "undefined" || !PDFJSDev.test("PRODUCTION"), diff --git a/web/base_viewer.js b/web/base_viewer.js index 7db0d193fa544..93f276649cc94 100644 --- a/web/base_viewer.js +++ b/web/base_viewer.js @@ -116,6 +116,9 @@ const PagesCountLimit = { * @property {IL10n} l10n - Localization service. * @property {boolean} [enablePermissions] - Enables PDF document permissions, * when they exist. The default value is `false`. + * @property {Object} [pageColors] - Overwrites background and foreground colors + * with user defined ones in order to improve readability in high contrast + * mode. */ class PDFPageViewBuffer { @@ -262,6 +265,22 @@ class BaseViewer { this.maxCanvasPixels = options.maxCanvasPixels; this.l10n = options.l10n || NullL10n; this.#enablePermissions = options.enablePermissions || false; + this.pageColors = options.pageColors || null; + + if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) { + if ( + options.pageColors && + (!CSS.supports("color", options.pageColors.background) || + !CSS.supports("color", options.pageColors.foreground)) + ) { + if (options.pageColors.background || options.pageColors.foreground) { + console.warn( + "Ignoring `pageColors`-option, since the browser doesn't support the values used." + ); + } + this.pageColors = null; + } + } this.defaultRenderingQueue = !options.renderingQueue; if (this.defaultRenderingQueue) { @@ -698,6 +717,7 @@ class BaseViewer { renderer: this.renderer, useOnlyCssZoom: this.useOnlyCssZoom, maxCanvasPixels: this.maxCanvasPixels, + pageColors: this.pageColors, l10n: this.l10n, }); this._pages.push(pageView); diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index e769077f5eed2..e014b793b26c7 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -82,6 +82,9 @@ import { NullL10n } from "./l10n_utils.js"; * @property {number} [maxCanvasPixels] - The maximum supported canvas size in * total pixels, i.e. width * height. Use -1 for no limit. The default value * is 4096 * 4096 (16 mega-pixels). + * @property {Object} [pageColors] - Overwrites background and foreground colors + * with user defined ones in order to improve readability in high contrast + * mode. * @property {IL10n} l10n - Localization service. */ @@ -118,6 +121,7 @@ class PDFPageView { this.imageResourcesPath = options.imageResourcesPath || ""; this.useOnlyCssZoom = options.useOnlyCssZoom || false; this.maxCanvasPixels = options.maxCanvasPixels || MAX_CANVAS_PIXELS; + this.pageColors = options.pageColors || null; this.eventBus = options.eventBus; this.renderingQueue = options.renderingQueue; @@ -832,6 +836,7 @@ class PDFPageView { annotationMode: this.#annotationMode, optionalContentConfigPromise: this._optionalContentConfigPromise, annotationCanvasMap: this._annotationCanvasMap, + pageColors: this.pageColors, }; const renderTask = this.pdfPage.render(renderContext); renderTask.onContinue = function (cont) {