[api-minor] Generate images in the worker instead of the main thread.

We introduced the use of OffscreenCanvas in #14754 and this patch aims
to use them for all kind of images.
It'll slightly improve performances (and maybe slightly decrease memory use).
Since an image can be rendered in using some transfer maps but because of
OffscreenCanvas we don't have the underlying pixels array the transfer maps
stuff is re-implemented in using the SVG filter feComponentTransfer.
This commit is contained in:
Calixte Denizet 2023-02-15 17:14:04 +01:00
parent 9640add1f7
commit fd03cd5493
13 changed files with 700 additions and 89 deletions

View File

@ -425,6 +425,8 @@ class Page {
this.resources,
this.nonBlendModesSet
),
isOffscreenCanvasSupported:
this.evaluatorOptions.isOffscreenCanvasSupported,
pageIndex: this.pageIndex,
cacheKey,
});

View File

@ -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 => {

View File

@ -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);

View File

@ -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,
});

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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

View File

@ -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";
// <canvas> 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,

View File

@ -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,

View File

@ -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,

View File

@ -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 };

View File

@ -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;

View File

@ -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);
});