diff --git a/src/core/document.js b/src/core/document.js index a3214f3e6..4643c3782 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -425,6 +425,8 @@ class Page { this.resources, this.nonBlendModesSet ), + isOffscreenCanvasSupported: + this.evaluatorOptions.isOffscreenCanvasSupported, pageIndex: this.pageIndex, cacheKey, }); diff --git a/src/core/evaluator.js b/src/core/evaluator.js index d2a5d8e7a..611a43851 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -716,7 +716,12 @@ class PartialEvaluator { }); // We force the use of RGBA_32BPP images here, because we can't handle // any other kind. - imgData = imageObj.createImageData(/* forceRGBA = */ true); + imgData = imageObj.createImageData( + /* forceRGBA = */ true, + /* isOffscreenCanvasSupported = */ false + ); + operatorList.isOffscreenCanvasSupported = + this.options.isOffscreenCanvasSupported; operatorList.addImageOps( OPS.paintInlineImageXObject, [imgData], @@ -756,11 +761,22 @@ class PartialEvaluator { localColorSpaceCache, }) .then(imageObj => { - imgData = imageObj.createImageData(/* forceRGBA = */ false); + imgData = imageObj.createImageData( + /* forceRGBA = */ false, + /* isOffscreenCanvasSupported = */ this.options + .isOffscreenCanvasSupported + ); if (cacheKey && imageRef && cacheGlobally) { - this.globalImageCache.addByteSize(imageRef, imgData.data.length); + let length = 0; + if (imgData.bitmap) { + length = imgData.width * imgData.height * 4; + } else { + length = imgData.data.length; + } + this.globalImageCache.addByteSize(imageRef, length); } + return this._sendImgData(objId, imgData, cacheGlobally); }) .catch(reason => { diff --git a/src/core/image.js b/src/core/image.js index 1cff082cc..b9e32393c 100644 --- a/src/core/image.js +++ b/src/core/image.js @@ -13,8 +13,18 @@ * limitations under the License. */ -import { assert, FormatError, ImageKind, info, warn } from "../shared/util.js"; -import { applyMaskImageData } from "../shared/image_utils.js"; +import { + assert, + FeatureTest, + FormatError, + ImageKind, + info, + warn, +} from "../shared/util.js"; +import { + convertBlackAndWhiteToRGBA, + convertToRGBA, +} from "../shared/image_utils.js"; import { BaseStream } from "./base_stream.js"; import { ColorSpace } from "./colorspace.js"; import { DecodeStream } from "./decode_stream.js"; @@ -364,11 +374,12 @@ class PDFImage { const canvas = new OffscreenCanvas(width, height); const ctx = canvas.getContext("2d"); const imgData = ctx.createImageData(width, height); - applyMaskImageData({ + convertBlackAndWhiteToRGBA({ src: imgArray, dest: imgData.data, width, height, + nonBlackColor: 0, inverseDecode, }); @@ -641,7 +652,7 @@ class PDFImage { } } - createImageData(forceRGBA = false) { + createImageData(forceRGBA = false, isOffscreenCanvasSupported = false) { const drawWidth = this.drawWidth; const drawHeight = this.drawHeight; const imgData = { @@ -686,8 +697,12 @@ class PDFImage { drawWidth === originalWidth && drawHeight === originalHeight ) { + const data = this.getImageBytes(originalHeight * rowBytes, {}); + if (isOffscreenCanvasSupported) { + return this.createBitmap(kind, originalWidth, originalHeight, data); + } imgData.kind = kind; - imgData.data = this.getImageBytes(originalHeight * rowBytes, {}); + imgData.data = data; if (this.needsDecode) { // Invert the buffer (which must be grayscale if we reached here). @@ -704,21 +719,52 @@ class PDFImage { } if (this.image instanceof JpegStream && !this.smask && !this.mask) { let imageLength = originalHeight * rowBytes; - switch (this.colorSpace.name) { - case "DeviceGray": - // Avoid truncating the image, since `JpegImage.getData` - // will expand the image data when `forceRGB === true`. - imageLength *= 3; - /* falls through */ - case "DeviceRGB": - case "DeviceCMYK": - imgData.kind = ImageKind.RGB_24BPP; - imgData.data = this.getImageBytes(imageLength, { + if (isOffscreenCanvasSupported) { + let isHandled = false; + switch (this.colorSpace.name) { + case "DeviceGray": + // Avoid truncating the image, since `JpegImage.getData` + // will expand the image data when `forceRGB === true`. + imageLength *= 4; + isHandled = true; + break; + case "DeviceRGB": + imageLength = (imageLength / 3) * 4; + isHandled = true; + break; + case "DeviceCMYK": + isHandled = true; + break; + } + + if (isHandled) { + const rgba = this.getImageBytes(imageLength, { drawWidth, drawHeight, - forceRGB: true, + forceRGBA: true, }); - return imgData; + return this.createBitmap( + ImageKind.RGBA_32BPP, + drawWidth, + drawHeight, + rgba + ); + } + } else { + switch (this.colorSpace.name) { + case "DeviceGray": + imageLength *= 3; + /* falls through */ + case "DeviceRGB": + case "DeviceCMYK": + imgData.kind = ImageKind.RGB_24BPP; + imgData.data = this.getImageBytes(imageLength, { + drawWidth, + drawHeight, + forceRGB: true, + }); + return imgData; + } } } } @@ -735,32 +781,45 @@ class PDFImage { // If opacity data is present, use RGBA_32BPP form. Otherwise, use the // more compact RGB_24BPP form if allowable. let alpha01, maybeUndoPreblend; + + let canvas, ctx, canvasImgData, data; + if (isOffscreenCanvasSupported) { + canvas = new OffscreenCanvas(drawWidth, drawHeight); + ctx = canvas.getContext("2d"); + canvasImgData = ctx.createImageData(drawWidth, drawHeight); + data = canvasImgData.data; + } + + imgData.kind = ImageKind.RGBA_32BPP; + if (!forceRGBA && !this.smask && !this.mask) { - imgData.kind = ImageKind.RGB_24BPP; - imgData.data = new Uint8ClampedArray(drawWidth * drawHeight * 3); - alpha01 = 0; + if (!isOffscreenCanvasSupported) { + imgData.kind = ImageKind.RGB_24BPP; + data = new Uint8ClampedArray(drawWidth * drawHeight * 3); + alpha01 = 0; + } else { + const arr = new Uint32Array(data.buffer); + arr.fill(FeatureTest.isLittleEndian ? 0xff000000 : 0x000000ff); + alpha01 = 1; + } maybeUndoPreblend = false; } else { - imgData.kind = ImageKind.RGBA_32BPP; - imgData.data = new Uint8ClampedArray(drawWidth * drawHeight * 4); + if (!isOffscreenCanvasSupported) { + data = new Uint8ClampedArray(drawWidth * drawHeight * 4); + } + alpha01 = 1; maybeUndoPreblend = true; // Color key masking (opacity) must be performed before decoding. - this.fillOpacity( - imgData.data, - drawWidth, - drawHeight, - actualHeight, - comps - ); + this.fillOpacity(data, drawWidth, drawHeight, actualHeight, comps); } if (this.needsDecode) { this.decodeBuffer(comps); } this.colorSpace.fillRgb( - imgData.data, + data, originalWidth, originalHeight, drawWidth, @@ -771,9 +830,23 @@ class PDFImage { alpha01 ); if (maybeUndoPreblend) { - this.undoPreblend(imgData.data, drawWidth, actualHeight); + this.undoPreblend(data, drawWidth, actualHeight); } + if (isOffscreenCanvasSupported) { + ctx.putImageData(canvasImgData, 0, 0); + const bitmap = canvas.transferToImageBitmap(); + + return { + data: null, + width: drawWidth, + height: drawHeight, + bitmap, + interpolate: this.interpolate, + }; + } + + imgData.data = data; return imgData; } @@ -833,13 +906,49 @@ class PDFImage { } } + createBitmap(kind, width, height, src) { + const canvas = new OffscreenCanvas(width, height); + const ctx = canvas.getContext("2d"); + let imgData; + if (kind === ImageKind.RGBA_32BPP) { + imgData = new ImageData(src, width, height); + } else { + imgData = ctx.createImageData(width, height); + convertToRGBA({ + kind, + src, + dest: new Uint32Array(imgData.data.buffer), + width, + height, + inverseDecode: this.needsDecode, + }); + } + ctx.putImageData(imgData, 0, 0); + const bitmap = canvas.transferToImageBitmap(); + + return { + data: null, + width, + height, + bitmap, + interpolate: this.interpolate, + }; + } + getImageBytes( length, - { drawWidth, drawHeight, forceRGB = false, internal = false } + { + drawWidth, + drawHeight, + forceRGBA = false, + forceRGB = false, + internal = false, + } ) { this.image.reset(); this.image.drawWidth = drawWidth || this.width; this.image.drawHeight = drawHeight || this.height; + this.image.forceRGBA = !!forceRGBA; this.image.forceRGB = !!forceRGB; const imageBytes = this.image.getBytes(length); diff --git a/src/core/jpeg_stream.js b/src/core/jpeg_stream.js index 11bdf5e43..fcfe3df17 100644 --- a/src/core/jpeg_stream.js +++ b/src/core/jpeg_stream.js @@ -63,7 +63,7 @@ class JpegStream extends DecodeStream { // Checking if values need to be transformed before conversion. const decodeArr = this.dict.getArray("D", "Decode"); - if (this.forceRGB && Array.isArray(decodeArr)) { + if ((this.forceRGBA || this.forceRGB) && Array.isArray(decodeArr)) { const bitsPerComponent = this.dict.get("BPC", "BitsPerComponent") || 8; const decodeArrLength = decodeArr.length; const transform = new Int32Array(decodeArrLength); @@ -93,6 +93,7 @@ class JpegStream extends DecodeStream { const data = jpegImage.getData({ width: this.drawWidth, height: this.drawHeight, + forceRGBA: this.forceRGBA, forceRGB: this.forceRGB, isSourcePDF: true, }); diff --git a/src/core/jpg.js b/src/core/jpg.js index 290d4dead..841305d22 100644 --- a/src/core/jpg.js +++ b/src/core/jpg.js @@ -14,6 +14,7 @@ */ import { assert, BaseException, warn } from "../shared/util.js"; +import { grayToRGBA } from "../shared/image_utils.js"; import { readUint16 } from "./core_utils.js"; class JpegError extends BaseException { @@ -1217,6 +1218,19 @@ class JpegImage { return data; } + _convertYccToRgba(data, out) { + for (let i = 0, j = 0, length = data.length; i < length; i += 3, j += 4) { + const Y = data[i]; + const Cb = data[i + 1]; + const Cr = data[i + 2]; + out[j] = Y - 179.456 + 1.402 * Cr; + out[j + 1] = Y + 135.459 - 0.344 * Cb - 0.714 * Cr; + out[j + 2] = Y - 226.816 + 1.772 * Cb; + out[j + 3] = 255; + } + return out; + } + _convertYcckToRgb(data) { let Y, Cb, Cr, k; let offset = 0; @@ -1287,6 +1301,74 @@ class JpegImage { return data.subarray(0, offset); } + _convertYcckToRgba(data) { + for (let i = 0, length = data.length; i < length; i += 4) { + const Y = data[i]; + const Cb = data[i + 1]; + const Cr = data[i + 2]; + const k = data[i + 3]; + + data[i] = + -122.67195406894 + + Cb * + (-6.60635669420364e-5 * Cb + + 0.000437130475926232 * Cr - + 5.4080610064599e-5 * Y + + 0.00048449797120281 * k - + 0.154362151871126) + + Cr * + (-0.000957964378445773 * Cr + + 0.000817076911346625 * Y - + 0.00477271405408747 * k + + 1.53380253221734) + + Y * + (0.000961250184130688 * Y - + 0.00266257332283933 * k + + 0.48357088451265) + + k * (-0.000336197177618394 * k + 0.484791561490776); + + data[i + 1] = + 107.268039397724 + + Cb * + (2.19927104525741e-5 * Cb - + 0.000640992018297945 * Cr + + 0.000659397001245577 * Y + + 0.000426105652938837 * k - + 0.176491792462875) + + Cr * + (-0.000778269941513683 * Cr + + 0.00130872261408275 * Y + + 0.000770482631801132 * k - + 0.151051492775562) + + Y * + (0.00126935368114843 * Y - + 0.00265090189010898 * k + + 0.25802910206845) + + k * (-0.000318913117588328 * k - 0.213742400323665); + + data[i + 2] = + -20.810012546947 + + Cb * + (-0.000570115196973677 * Cb - + 2.63409051004589e-5 * Cr + + 0.0020741088115012 * Y - + 0.00288260236853442 * k + + 0.814272968359295) + + Cr * + (-1.53496057440975e-5 * Cr - + 0.000132689043961446 * Y + + 0.000560833691242812 * k - + 0.195152027534049) + + Y * + (0.00174418132927582 * Y - + 0.00255243321439347 * k + + 0.116935020465145) + + k * (-0.000343531996510555 * k + 0.24165260232407); + data[i + 3] = 255; + } + return data; + } + _convertYcckToCmyk(data) { let Y, Cb, Cr; for (let i = 0, length = data.length; i < length; i += 4) { @@ -1371,7 +1453,81 @@ class JpegImage { return data.subarray(0, offset); } - getData({ width, height, forceRGB = false, isSourcePDF = false }) { + _convertCmykToRgba(data) { + for (let i = 0, length = data.length; i < length; i += 4) { + const c = data[i]; + const m = data[i + 1]; + const y = data[i + 2]; + const k = data[i + 3]; + + data[i] = + 255 + + c * + (-0.00006747147073602441 * c + + 0.0008379262121013727 * m + + 0.0002894718188643294 * y + + 0.003264231057537806 * k - + 1.1185611867203937) + + m * + (0.000026374107616089405 * m - + 0.00008626949158638572 * y - + 0.0002748769067499491 * k - + 0.02155688794978967) + + y * + (-0.00003878099212869363 * y - + 0.0003267808279485286 * k + + 0.0686742238595345) - + k * (0.0003361971776183937 * k + 0.7430659151342254); + + data[i + 1] = + 255 + + c * + (0.00013596372813588848 * c + + 0.000924537132573585 * m + + 0.00010567359618683593 * y + + 0.0004791864687436512 * k - + 0.3109689587515875) + + m * + (-0.00023545346108370344 * m + + 0.0002702845253534714 * y + + 0.0020200308977307156 * k - + 0.7488052167015494) + + y * + (0.00006834815998235662 * y + + 0.00015168452363460973 * k - + 0.09751927774728933) - + k * (0.0003189131175883281 * k + 0.7364883807733168); + + data[i + 2] = + 255 + + c * + (0.000013598650411385307 * c + + 0.00012423956175490851 * m + + 0.0004751985097583589 * y - + 0.0000036729317476630422 * k - + 0.05562186980264034) + + m * + (0.00016141380598724676 * m + + 0.0009692239130725186 * y + + 0.0007782692450036253 * k - + 0.44015232367526463) + + y * + (5.068882914068769e-7 * y + + 0.0017778369011375071 * k - + 0.7591454649749609) - + k * (0.0003435319965105553 * k + 0.7063770186160144); + data[i + 3] = 255; + } + return data; + } + + getData({ + width, + height, + forceRGBA = false, + forceRGB = false, + isSourcePDF = false, + }) { if ( typeof PDFJSDev === "undefined" || PDFJSDev.test("!PRODUCTION || TESTING") @@ -1387,23 +1543,37 @@ class JpegImage { // Type of data: Uint8ClampedArray(width * height * numComponents) const data = this._getLinearizedBlockData(width, height, isSourcePDF); - if (this.numComponents === 1 && forceRGB) { - const rgbData = new Uint8ClampedArray(data.length * 3); + if (this.numComponents === 1 && (forceRGBA || forceRGB)) { + const len = data.length * (forceRGBA ? 4 : 3); + const rgbaData = new Uint8ClampedArray(len); let offset = 0; - for (const grayColor of data) { - rgbData[offset++] = grayColor; - rgbData[offset++] = grayColor; - rgbData[offset++] = grayColor; + if (forceRGBA) { + grayToRGBA(data, new Uint32Array(rgbaData.buffer)); + } else { + for (const grayColor of data) { + rgbaData[offset++] = grayColor; + rgbaData[offset++] = grayColor; + rgbaData[offset++] = grayColor; + } } - return rgbData; + return rgbaData; } else if (this.numComponents === 3 && this._isColorConversionNeeded) { + if (forceRGBA) { + const rgbaData = new Uint8ClampedArray((data.length / 3) * 4); + return this._convertYccToRgba(data, rgbaData); + } return this._convertYccToRgb(data); } else if (this.numComponents === 4) { if (this._isColorConversionNeeded) { + if (forceRGBA) { + return this._convertYcckToRgba(data); + } if (forceRGB) { return this._convertYcckToRgb(data); } return this._convertYcckToCmyk(data); + } else if (forceRGBA) { + return this._convertCmykToRgba(data); } else if (forceRGB) { return this._convertCmykToRgb(data); } diff --git a/src/core/operator_list.js b/src/core/operator_list.js index 063249f62..2815923b7 100644 --- a/src/core/operator_list.js +++ b/src/core/operator_list.js @@ -136,17 +136,32 @@ addState( } } + const img = { + width: imgWidth, + height: imgHeight, + }; + if (context.isOffscreenCanvasSupported) { + const canvas = new OffscreenCanvas(imgWidth, imgHeight); + const ctx = canvas.getContext("2d"); + ctx.putImageData( + new ImageData( + new Uint8ClampedArray(imgData.buffer), + imgWidth, + imgHeight + ), + 0, + 0 + ); + img.bitmap = canvas.transferToImageBitmap(); + img.data = null; + } else { + img.kind = ImageKind.RGBA_32BPP; + img.data = imgData; + } + // Replace queue items. fnArray.splice(iFirstSave, count * 4, OPS.paintInlineImageXObjectGroup); - argsArray.splice(iFirstSave, count * 4, [ - { - width: imgWidth, - height: imgHeight, - kind: ImageKind.RGBA_32BPP, - data: imgData, - }, - map, - ]); + argsArray.splice(iFirstSave, count * 4, [img, map]); return iFirstSave + 1; } @@ -487,11 +502,17 @@ class QueueOptimizer extends NullOptimizer { iCurr: 0, fnArray: queue.fnArray, argsArray: queue.argsArray, + isOffscreenCanvasSupported: false, }; this.match = null; this.lastProcessed = 0; } + // eslint-disable-next-line accessor-pairs + set isOffscreenCanvasSupported(value) { + this.context.isOffscreenCanvasSupported = value; + } + _optimize() { // Process new fnArray item(s) chunk. const fnArray = this.queue.fnArray; @@ -589,6 +610,11 @@ class OperatorList { this._resolved = streamSink ? null : Promise.resolve(); } + // eslint-disable-next-line accessor-pairs + set isOffscreenCanvasSupported(value) { + this.optimizer.isOffscreenCanvasSupported = value; + } + get length() { return this.argsArray.length; } diff --git a/src/display/api.js b/src/display/api.js index a13d9d63b..6416a1ef6 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -46,6 +46,7 @@ import { DOMCanvasFactory, DOMCMapReaderFactory, DOMStandardFontDataFactory, + FilterFactory, isDataScheme, isValidFetchUrl, loadScript, @@ -232,6 +233,8 @@ if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("PRODUCTION")) { * (see `web/debugger.js`). The default value is `false`. * @property {Object} [canvasFactory] - The factory instance that will be used * when creating canvases. The default value is {new DOMCanvasFactory()}. + * @property {Object} [filterFactory] - A factory instance that will be used + * to create SVG filters when rendering some images on the main canvas. */ /** @@ -341,6 +344,8 @@ function getDocument(src) { isValidFetchUrl(standardFontDataUrl, document.baseURI)); const canvasFactory = src.canvasFactory || new DefaultCanvasFactory({ ownerDocument }); + const filterFactory = + src.filterFactory || new FilterFactory({ ownerDocument }); // Parameters only intended for development/testing purposes. const styleElement = @@ -355,6 +360,7 @@ function getDocument(src) { // since the user may provide *custom* ones. const transportFactory = { canvasFactory, + filterFactory, }; if (!useWorkerFetch) { transportFactory.cMapReaderFactory = new CMapReaderFactory({ @@ -1514,6 +1520,7 @@ class PDFPageProxy { operatorList: intentState.operatorList, pageIndex: this._pageIndex, canvasFactory: canvasFactory || this._transport.canvasFactory, + filterFactory: this._transport.filterFactory, useRequestAnimationFrame: !intentPrint, pdfBug: this._pdfBug, pageColors, @@ -1526,19 +1533,25 @@ class PDFPageProxy { intentState.displayReadyCapability.promise, optionalContentConfigPromise, ]) - .then(([transparency, optionalContentConfig]) => { - if (this.pendingCleanup) { - complete(); - return; - } - this._stats?.time("Rendering"); - - internalRenderTask.initializeGraphics({ - transparency, + .then( + ([ + { transparency, isOffscreenCanvasSupported }, optionalContentConfig, - }); - internalRenderTask.operatorListChanged(); - }) + ]) => { + if (this.pendingCleanup) { + complete(); + return; + } + this._stats?.time("Rendering"); + + internalRenderTask.initializeGraphics({ + transparency, + isOffscreenCanvasSupported, + optionalContentConfig, + }); + internalRenderTask.operatorListChanged(); + } + ) .catch(complete); return renderTask; @@ -1739,7 +1752,7 @@ class PDFPageProxy { /** * @private */ - _startRenderPage(transparency, cacheKey) { + _startRenderPage(transparency, isOffscreenCanvasSupported, cacheKey) { const intentState = this._intentStates.get(cacheKey); if (!intentState) { return; // Rendering was cancelled. @@ -1748,7 +1761,10 @@ class PDFPageProxy { // TODO Refactor RenderPageRequest to separate rendering // and operator list logic - intentState.displayReadyCapability?.resolve(transparency); + intentState.displayReadyCapability?.resolve({ + transparency, + isOffscreenCanvasSupported, + }); } /** @@ -2357,6 +2373,7 @@ class WorkerTransport { this._params = params; this.canvasFactory = factory.canvasFactory; + this.filterFactory = factory.filterFactory; this.cMapReaderFactory = factory.cMapReaderFactory; this.standardFontDataFactory = factory.standardFontDataFactory; @@ -2489,6 +2506,7 @@ class WorkerTransport { this.commonObjs.clear(); this.fontLoader.clear(); this.#methodPromises.clear(); + this.filterFactory.destroy(); if (this._networkStream) { this._networkStream.cancelAllRequests( @@ -2709,7 +2727,11 @@ class WorkerTransport { } const page = this.#pageCache.get(data.pageIndex); - page._startRenderPage(data.transparency, data.cacheKey); + page._startRenderPage( + data.transparency, + data.isOffscreenCanvasSupported, + data.cacheKey + ); }); messageHandler.on("commonobj", ([id, type, exportedData]) => { @@ -3079,6 +3101,7 @@ class WorkerTransport { this.fontLoader.clear(); } this.#methodPromises.clear(); + this.filterFactory.destroy(); } get loadingParams() { @@ -3246,6 +3269,7 @@ class InternalRenderTask { operatorList, pageIndex, canvasFactory, + filterFactory, useRequestAnimationFrame = false, pdfBug = false, pageColors = null, @@ -3259,6 +3283,7 @@ class InternalRenderTask { this.operatorList = operatorList; this._pageIndex = pageIndex; this.canvasFactory = canvasFactory; + this.filterFactory = filterFactory; this._pdfBug = pdfBug; this.pageColors = pageColors; @@ -3285,7 +3310,11 @@ class InternalRenderTask { }); } - initializeGraphics({ transparency = false, optionalContentConfig }) { + initializeGraphics({ + transparency = false, + isOffscreenCanvasSupported = false, + optionalContentConfig, + }) { if (this.cancelled) { return; } @@ -3312,6 +3341,7 @@ class InternalRenderTask { this.commonObjs, this.objs, this.canvasFactory, + isOffscreenCanvasSupported ? this.filterFactory : null, { optionalContentConfig }, this.annotationCanvasMap, this.pageColors diff --git a/src/display/canvas.js b/src/display/canvas.js index cd2f6c3bd..74da9d151 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -37,7 +37,7 @@ import { PathType, TilingPattern, } from "./pattern_helper.js"; -import { applyMaskImageData } from "../shared/image_utils.js"; +import { convertBlackAndWhiteToRGBA } from "../shared/image_utils.js"; // contexts store most of the state we need natively. // However, PDF needs a bit more state, which we store here. @@ -812,12 +812,13 @@ function putBinaryImageMask(ctx, imgData) { // Expand the mask so it can be used by the canvas. Any required // inversion has already been handled. - ({ srcPos } = applyMaskImageData({ + ({ srcPos } = convertBlackAndWhiteToRGBA({ src, srcPos, dest, width, height: thisChunkHeight, + nonBlackColor: 0, })); ctx.putImageData(chunkImgData, 0, i * FULL_CHUNK_HEIGHT); @@ -1015,6 +1016,7 @@ class CanvasGraphics { commonObjs, objs, canvasFactory, + filterFactory, { optionalContentConfig, markedContentStack = null }, annotationCanvasMap, pageColors @@ -1032,6 +1034,7 @@ class CanvasGraphics { this.commonObjs = commonObjs; this.objs = objs; this.canvasFactory = canvasFactory; + this.filterFactory = filterFactory; this.groupStack = []; this.processingType3 = null; // Patterns are painted relative to the initial page/form transform, see @@ -1573,7 +1576,10 @@ class CanvasGraphics { this.checkSMaskState(); break; case "TR": - this.current.transferMaps = value; + this.current.transferMaps = this.filterFactory + ? this.filterFactory.addFilter(value) + : value; + break; } } } @@ -2463,6 +2469,7 @@ class CanvasGraphics { this.commonObjs, this.objs, this.canvasFactory, + this.filterFactory, { optionalContentConfig: this.optionalContentConfig, markedContentStack: this.markedContentStack, @@ -3017,6 +3024,24 @@ class CanvasGraphics { this.paintInlineImageXObjectGroup(imgData, map); } + applyTransferMapsToBitmap(imgData) { + if (!this.current.transferMaps) { + return imgData.bitmap; + } + const { bitmap, width, height } = imgData; + const tmpCanvas = this.cachedCanvases.getCanvas( + "inlineImage", + width, + height + ); + const tmpCtx = tmpCanvas.context; + tmpCtx.filter = this.current.transferMaps; + tmpCtx.drawImage(bitmap, 0, 0); + tmpCtx.filter = ""; + + return tmpCanvas.canvas; + } + paintInlineImageXObject(imgData) { if (!this.contentVisible) { return; @@ -3030,11 +3055,13 @@ class CanvasGraphics { ctx.scale(1 / width, -1 / height); let imgToPaint; - // typeof check is needed due to node.js support, see issue #8489 - if ( + if (imgData.bitmap) { + imgToPaint = this.applyTransferMapsToBitmap(imgData); + } else if ( (typeof HTMLElement === "function" && imgData instanceof HTMLElement) || !imgData.data ) { + // typeof check is needed due to node.js support, see issue #8489 imgToPaint = imgData; } else { const tmpCanvas = this.cachedCanvases.getCanvas( @@ -3077,12 +3104,18 @@ class CanvasGraphics { return; } const ctx = this.ctx; - const w = imgData.width; - const h = imgData.height; + let imgToPaint; + if (imgData.bitmap) { + imgToPaint = this.applyTransferMapsToBitmap(imgData); + } else { + const w = imgData.width; + const h = imgData.height; - const tmpCanvas = this.cachedCanvases.getCanvas("inlineImage", w, h); - const tmpCtx = tmpCanvas.context; - putBinaryImageData(tmpCtx, imgData, this.current.transferMaps); + const tmpCanvas = this.cachedCanvases.getCanvas("inlineImage", w, h); + const tmpCtx = tmpCanvas.context; + putBinaryImageData(tmpCtx, imgData, this.current.transferMaps); + imgToPaint = tmpCanvas.canvas; + } for (const entry of map) { ctx.save(); @@ -3090,7 +3123,7 @@ class CanvasGraphics { ctx.scale(1, -1); drawImageAtIntegerCoords( ctx, - tmpCanvas.canvas, + imgToPaint, entry.x, entry.y, entry.w, diff --git a/src/display/display_utils.js b/src/display/display_utils.js index 554170c62..1e33b34d1 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -39,6 +39,139 @@ class PixelsPerInch { static PDF_TO_CSS_UNITS = this.CSS / this.PDF; } +/** + * FilterFactory aims to create some SVG filters we can use when drawing an + * image (or whatever) on a canvas. + * Filters aren't applied with ctx.putImageData because it just overwrites the + * underlying pixels. + * With these filters, it's possible for example to apply some transfer maps on + * an image without the need to apply them on the pixel arrays: the renderer + * does the magic for us. + */ +class FilterFactory { + #_cache; + + #_defs; + + #document; + + #id = 0; + + constructor({ ownerDocument = globalThis.document } = {}) { + this.#document = ownerDocument; + } + + get #cache() { + return (this.#_cache ||= new Map()); + } + + get #defs() { + if (!this.#_defs) { + const svg = this.#document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("width", 0); + svg.setAttribute("height", 0); + svg.style.visibility = "hidden"; + svg.style.contain = "strict"; + this.#_defs = this.#document.createElementNS(SVG_NS, "defs"); + svg.append(this.#_defs); + this.#document.body.append(svg); + } + return this.#_defs; + } + + addFilter(maps) { + if (!maps) { + return ""; + } + + // When a page is zoomed the page is re-drawn but the maps are likely + // the same. + let value = this.#cache.get(maps); + if (value) { + 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}`; + } + + value = this.#cache.get(key); + if (value) { + this.#cache.set(maps, value); + return value; + } + + // We create a SVG filter: feComponentTransferElement + // https://www.w3.org/TR/SVG11/filters.html#feComponentTransferElement + + const id = `transfer_map_${this.#id++}`; + const url = `url(#${id})`; + this.#cache.set(maps, url); + this.#cache.set(key, url); + + const filter = this.#document.createElementNS(SVG_NS, "filter", SVG_NS); + filter.setAttribute("id", id); + filter.setAttribute("color-interpolation-filters", "sRGB"); + const feComponentTransfer = this.#document.createElementNS( + SVG_NS, + "feComponentTransfer" + ); + filter.append(feComponentTransfer); + + const type = "discrete"; + const feFuncR = this.#document.createElementNS(SVG_NS, "feFuncR"); + feFuncR.setAttribute("type", type); + feFuncR.setAttribute("tableValues", tableR); + feComponentTransfer.append(feFuncR); + + const feFuncG = this.#document.createElementNS(SVG_NS, "feFuncG"); + feFuncG.setAttribute("type", type); + feFuncG.setAttribute("tableValues", tableG); + feComponentTransfer.append(feFuncG); + + const feFuncB = this.#document.createElementNS(SVG_NS, "feFuncB"); + feFuncB.setAttribute("type", type); + feFuncB.setAttribute("tableValues", tableB); + feComponentTransfer.append(feFuncB); + + this.#defs.append(filter); + + return url; + } + + destroy() { + if (this.#_defs) { + this.#_defs.parentNode.remove(); + this.#_defs = null; + } + if (this.#_cache) { + this.#_cache.clear(); + this.#_cache = null; + } + this.#id = 0; + } +} + class DOMCanvasFactory extends BaseCanvasFactory { constructor({ ownerDocument = globalThis.document } = {}) { super(); @@ -681,6 +814,7 @@ export { DOMCMapReaderFactory, DOMStandardFontDataFactory, DOMSVGFactory, + FilterFactory, getColorValues, getCurrentTransform, getCurrentTransformInverse, diff --git a/src/pdf.js b/src/pdf.js index 44a0dfe67..292ec405b 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -52,6 +52,7 @@ import { version, } from "./display/api.js"; import { + FilterFactory, getFilenameFromUrl, getPdfFilenameFromUrl, getXfaPageViewport, @@ -91,6 +92,7 @@ export { createPromiseCapability, createValidAbsoluteUrl, FeatureTest, + FilterFactory, getDocument, getFilenameFromUrl, getPdfFilenameFromUrl, diff --git a/src/shared/image_utils.js b/src/shared/image_utils.js index ecc07bc36..e2e5c04e6 100644 --- a/src/shared/image_utils.js +++ b/src/shared/image_utils.js @@ -13,23 +13,37 @@ * limitations under the License. */ -import { FeatureTest } from "./util.js"; +import { FeatureTest, ImageKind } from "./util.js"; -function applyMaskImageData({ +function convertToRGBA(params) { + switch (params.kind) { + case ImageKind.GRAYSCALE_1BPP: + return convertBlackAndWhiteToRGBA(params); + case ImageKind.RGB_24BPP: + return convertRGBToRGBA(params); + } + + return null; +} + +function convertBlackAndWhiteToRGBA({ src, srcPos = 0, dest, - destPos = 0, width, height, + nonBlackColor = 0xffffffff, inverseDecode = false, }) { - const opaque = FeatureTest.isLittleEndian ? 0xff000000 : 0x000000ff; - const [zeroMapping, oneMapping] = !inverseDecode ? [opaque, 0] : [0, opaque]; + const black = FeatureTest.isLittleEndian ? 0xff000000 : 0x000000ff; + const [zeroMapping, oneMapping] = inverseDecode + ? [nonBlackColor, black] + : [black, nonBlackColor]; const widthInSource = width >> 3; const widthRemainder = width & 7; const srcLength = src.length; dest = new Uint32Array(dest.buffer); + let destPos = 0; for (let i = 0; i < height; i++) { for (const max = srcPos + widthInSource; srcPos < max; srcPos++) { @@ -51,8 +65,70 @@ function applyMaskImageData({ dest[destPos++] = elem & (1 << (7 - j)) ? oneMapping : zeroMapping; } } + return { srcPos, destPos }; +} + +function convertRGBToRGBA({ + src, + srcPos = 0, + dest, + destPos = 0, + width, + height, +}) { + let i = 0; + const len32 = src.length >> 2; + const src32 = new Uint32Array(src.buffer, srcPos, len32); + + if (FeatureTest.isLittleEndian) { + // It's a way faster to do the shuffle manually instead of working + // component by component with some Uint8 arrays. + for (; i < len32 - 2; i += 3, destPos += 4) { + const s1 = src32[i]; // R2B1G1R1 + const s2 = src32[i + 1]; // G3R3B2G2 + const s3 = src32[i + 2]; // B4G4R4B3 + + dest[destPos] = s1 | 0xff000000; + dest[destPos + 1] = (s1 >>> 24) | (s2 << 8) | 0xff000000; + dest[destPos + 2] = (s2 >>> 16) | (s3 << 16) | 0xff000000; + dest[destPos + 3] = (s3 >>> 8) | 0xff000000; + } + + for (let j = i * 4, jj = src.length; j < jj; j += 3) { + dest[destPos++] = + src[j] | (src[j + 1] << 8) | (src[j + 2] << 16) | 0xff000000; + } + } else { + for (; i < len32 - 2; i += 3, destPos += 4) { + const s1 = src32[i]; // R1G1B1R2 + const s2 = src32[i + 1]; // G2B2R3G3 + const s3 = src32[i + 2]; // B3R4G4B4 + + dest[destPos] = s1 | 0xff; + dest[destPos + 1] = (s1 << 24) | (s2 >>> 8) | 0xff; + dest[destPos + 2] = (s2 << 16) | (s3 >>> 16) | 0xff; + dest[destPos + 3] = (s3 << 8) | 0xff; + } + + for (let j = i * 4, jj = src.length; j < jj; j += 3) { + dest[destPos++] = + (src[j] << 24) | (src[j + 1] << 16) | (src[j + 2] << 8) | 0xff; + } + } return { srcPos, destPos }; } -export { applyMaskImageData }; +function grayToRGBA(src, dest) { + if (FeatureTest.isLittleEndian) { + for (let i = 0, ii = src.length; i < ii; i++) { + dest[i] = (src[i] * 0x10101) | 0xff000000; + } + } else { + for (let i = 0, ii = src.length; i < ii; i++) { + dest[i] = (src[i] * 0x1010100) | 0x000000ff; + } + } +} + +export { convertBlackAndWhiteToRGBA, convertToRGBA, grayToRGBA }; diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index 52ae70450..00b940a94 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -2655,7 +2655,11 @@ Caron Broadcasting, Inc., an Ohio corporation (“Lessee”).`) }); it("gets operatorList with JPEG image (issue 4888)", async function () { - const loadingTask = getDocument(buildGetDocumentParams("cmykjpeg.pdf")); + const loadingTask = getDocument( + buildGetDocumentParams("cmykjpeg.pdf", { + isOffscreenCanvasSupported: false, + }) + ); const pdfDoc = await loadingTask.promise; const pdfPage = await pdfDoc.getPage(1); @@ -3089,7 +3093,11 @@ Caron Broadcasting, Inc., an Ohio corporation (“Lessee”).`) EXPECTED_WIDTH = 2550, EXPECTED_HEIGHT = 3300; - const loadingTask = getDocument(buildGetDocumentParams("issue11878.pdf")); + const loadingTask = getDocument( + buildGetDocumentParams("issue11878.pdf", { + isOffscreenCanvasSupported: false, + }) + ); const pdfDoc = await loadingTask.promise; let firstImgData = null; diff --git a/test/unit/display_svg_spec.js b/test/unit/display_svg_spec.js index 305b03a30..d612fc13f 100644 --- a/test/unit/display_svg_spec.js +++ b/test/unit/display_svg_spec.js @@ -61,7 +61,11 @@ describe("SVGGraphics", function () { let page; beforeAll(async function () { - loadingTask = getDocument(buildGetDocumentParams("xobject-image.pdf")); + loadingTask = getDocument( + buildGetDocumentParams("xobject-image.pdf", { + isOffscreenCanvasSupported: false, + }) + ); const doc = await loadingTask.promise; page = await doc.getPage(1); });