diff --git a/src/core/colorspace.js b/src/core/colorspace.js index 3db3e6ca6..979a4af0d 100644 --- a/src/core/colorspace.js +++ b/src/core/colorspace.js @@ -183,6 +183,21 @@ class ColorSpace { unreachable("Should not call ColorSpace.getRgbBuffer"); } + /** + * Converts `count` unscaled colors to RGB, starting at `destOffset`. + * Components use the native color-space ranges expected by `getRgbItem`, + * and each output has a `3 + alpha01` byte stride. + * Subclasses may override this to batch expensive conversions. + */ + getRgbItems(src, count, dest, destOffset, alpha01) { + const { numComps } = this; + + for (let i = 0, srcOffset = 0; i < count; i++, srcOffset += numComps) { + this.getRgbItem(src, srcOffset, dest, destOffset); + destOffset += 3 + alpha01; + } + } + /** * Determines the number of bytes required to store the result of the * conversion done by the getRgbBuffer method. As in getRgbBuffer, @@ -379,6 +394,20 @@ class AlternateCS extends ColorSpace { this.base.getRgbItem(tmpBuf, 0, dest, destOffset); } + getRgbItems(src, count, dest, destOffset, alpha01) { + const { base, numComps, tintFn } = this; + const baseNumComps = base.numComps; + // Tint first so the base color space can convert the batch at once. + const tinted = new Float32Array(count * baseNumComps); + + for (let i = 0, srcOffset = 0, tintedOffset = 0; i < count; i++) { + tintFn(src, srcOffset, tinted, tintedOffset); + srcOffset += numComps; + tintedOffset += baseNumComps; + } + base.getRgbItems(tinted, count, dest, destOffset, alpha01); + } + getRgbBuffer(src, srcOffset, count, dest, destOffset, bits, alpha01) { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { assert( diff --git a/src/core/icc_colorspace.js b/src/core/icc_colorspace.js index b1b929c55..04ad9fc36 100644 --- a/src/core/icc_colorspace.js +++ b/src/core/icc_colorspace.js @@ -117,6 +117,21 @@ class IccColorSpace extends ColorSpace { QCMS._destBuffer = null; } + getRgbItems(src, count, dest, destOffset, alpha01) { + const { numComps } = this; + const length = count * numComps; + const scaled = new Uint8Array(length); + // Uint8Array matches the truncation and wrapping of the scalar Wasm calls. + for (let i = 0; i < length; i++) { + scaled[i] = src[i] * 255; + } + QCMS._destBuffer = dest; + QCMS._destOffset = destOffset; + QCMS._destLength = count * (3 + alpha01); + qcms_convert_array(this.#transformer, scaled); + QCMS._destBuffer = null; + } + getRgbBuffer(src, srcOffset, count, dest, destOffset, bits, alpha01) { src = src.subarray(srcOffset, srcOffset + count * this.numComps); if (bits !== 8) { diff --git a/src/core/pattern.js b/src/core/pattern.js index 468fd75c9..3f804d39a 100644 --- a/src/core/pattern.js +++ b/src/core/pattern.js @@ -46,6 +46,17 @@ const ShadingType = { TENSOR_PATCH_MESH: 7, }; +// Bound temporary buffers; DeviceN component counts come from the PDF. +const MAX_SAMPLED_COLOR_COMPONENTS = 1 << 16; + +function getColorConversionBatchSize(count, numComps) { + return MathClamp( + Math.floor(MAX_SAMPLED_COLOR_COMPONENTS / numComps), + 1, + count + ); +} + class Pattern { // eslint-disable-next-line no-unused-private-class-members static #hasGPU = false; @@ -202,22 +213,32 @@ class RadialAxialShading extends BaseShading { return; } - const color = new Float32Array(cs.numComps), - ratio = new Float32Array(1); + const { numComps } = cs; + const ratio = new Float32Array(1); + + // Batch colors to reduce ICC Wasm calls and bound temporary memory. + const batchSize = getColorConversionBatchSize(NUMBER_OF_SAMPLES, numComps); + const comps = new Float32Array(batchSize * numComps); + const rgb = new Uint8ClampedArray(NUMBER_OF_SAMPLES * 3); + for (let start = 0; start < NUMBER_OF_SAMPLES; start += batchSize) { + const count = Math.min(batchSize, NUMBER_OF_SAMPLES - start); + for (let i = 0, offset = 0; i < count; i++, offset += numComps) { + ratio[0] = t0 + (start + i) * step; + fn(ratio, 0, comps, offset); + } + cs.getRgbItems(comps, count, rgb, start * 3, /* alpha01 = */ 0); + } let iBase = 0; - ratio[0] = t0; - fn(ratio, 0, color, 0); - const rgbBuffer = new Uint8ClampedArray(3); - cs.getRgb(color, 0, rgbBuffer); - let [rBase, gBase, bBase] = rgbBuffer; + let rBase = rgb[0], + gBase = rgb[1], + bBase = rgb[2]; colorStops.push([0, Util.makeHexColor(rBase, gBase, bBase)]); let iPrev = 1; - ratio[0] = t0 + step; - fn(ratio, 0, color, 0); - cs.getRgb(color, 0, rgbBuffer); - let [rPrev, gPrev, bPrev] = rgbBuffer; + let rPrev = rgb[3], + gPrev = rgb[4], + bPrev = rgb[5]; // Slopes are rise / run. // A max slope is from the least value the base component could have been @@ -236,10 +257,10 @@ class RadialAxialShading extends BaseShading { let minSlopeB = bPrev - bBase - 1; for (let i = 2; i < NUMBER_OF_SAMPLES; i++) { - ratio[0] = t0 + i * step; - fn(ratio, 0, color, 0); - cs.getRgb(color, 0, rgbBuffer); - const [r, g, b] = rgbBuffer; + const rgbOffset = i * 3; + const r = rgb[rgbOffset], + g = rgb[rgbOffset + 1], + b = rgb[rgbOffset + 2]; // Keep going if the maximum minimum slope <= the minimum maximum slope. // Otherwise add a rgbPrev color stop and make it the new base. @@ -499,13 +520,18 @@ class FunctionBasedShading extends BaseShading { const coords = (this.coords = new Float32Array(totalVertices * 2)); const colors = (this.colors = new Uint8ClampedArray(totalVertices * 4)); + const { numComps } = cs; const xyBuf = new Float32Array(2); - const colorBuf = new Float32Array(cs.numComps); + // Batch colors to reduce ICC Wasm calls and bound temporary memory. + const batchSize = getColorConversionBatchSize(totalVertices, numComps); + const comps = new Float32Array(batchSize * numComps); const rangeX = (x1 - x0) / stepsX; const rangeY = (y1 - y0) / stepsY; const halfStepX = rangeX / 2; const halfStepY = rangeY / 2; let coordOffset = 0; + let compOffset = 0; + let batchCount = 0; let colorOffset = 0; for (let row = 0; row <= stepsY; row++) { const yDomain = y0 + rangeY * row; @@ -515,16 +541,32 @@ class FunctionBasedShading extends BaseShading { for (let col = 0; col <= stepsX; col++) { const xDomain = x0 + rangeX * col; xyBuf[0] = col === stepsX ? xDomain - halfStepX : xDomain; - fn(xyBuf, 0, colorBuf, 0); + fn(xyBuf, 0, comps, compOffset); + compOffset += numComps; + batchCount++; + coords[coordOffset] = xDomain; coords[coordOffset + 1] = yDomain; Util.applyTransform(coords, matrix, coordOffset); coordOffset += 2; - cs.getRgbItem(colorBuf, 0, colors, colorOffset); - colorOffset += 4; // alpha — unused, stays 0 + if (batchCount === batchSize) { + cs.getRgbItems( + comps, + batchCount, + colors, + colorOffset, + /* alpha01 = */ 1 + ); + colorOffset += batchCount * 4; + compOffset = batchCount = 0; + } } } + if (batchCount > 0) { + cs.getRgbItems(comps, batchCount, colors, colorOffset, /* alpha01 = */ 1); + } + // Alpha stays zero. const ps = new Uint32Array(totalVertices); for (let i = 0; i < totalVertices; i++) { diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 11aa0c6bf..74fba7171 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -949,3 +949,4 @@ !jpx_smaskindata.pdf !nonisolated_blend_smask.pdf !signed_verified.pdf +!function_based_shading_cmyk.pdf diff --git a/test/pdfs/function_based_shading_cmyk.pdf b/test/pdfs/function_based_shading_cmyk.pdf new file mode 100644 index 000000000..a5629fc85 Binary files /dev/null and b/test/pdfs/function_based_shading_cmyk.pdf differ diff --git a/test/test_manifest.json b/test/test_manifest.json index e31a37d37..20cbae712 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -14508,5 +14508,13 @@ "md5": "4b87611416be34894eeb19c158fafbad", "rounds": 1, "type": "eq" + }, + { + "id": "function_based_shading_cmyk", + "file": "pdfs/function_based_shading_cmyk.pdf", + "md5": "6c2b4d131a007a6f40ac7a892ad5f2a1", + "rounds": 1, + "lastPage": 1, + "type": "eq" } ] diff --git a/test/unit/colorspace_spec.js b/test/unit/colorspace_spec.js index f3bdd8950..b42da714b 100644 --- a/test/unit/colorspace_spec.js +++ b/test/unit/colorspace_spec.js @@ -53,6 +53,21 @@ describe("colorspace", function () { }); }); + describe("ColorSpace.getRgbItems", function () { + it("should honor the destination offset and alpha stride", function () { + const src = new Float32Array([0, 0.25, 0.5, 1, 0.75, 0.5, 0.1, 0.2, 0.3]); + const dest = new Uint8ClampedArray(14).fill(9); + + ColorSpaceUtils.rgb.getRgbItems(src, 3, dest, 1, /* alpha01 = */ 1); + + expect(dest).toEqual( + new Uint8ClampedArray([ + 9, 0, 64, 128, 9, 255, 191, 128, 9, 26, 51, 77, 9, 9, + ]) + ); + }); + }); + describe("ColorSpace caching", function () { let globalColorSpaceCache, localColorSpaceCache; diff --git a/test/unit/pattern_spec.js b/test/unit/pattern_spec.js index 6b36caa1d..cf7390154 100644 --- a/test/unit/pattern_spec.js +++ b/test/unit/pattern_spec.js @@ -37,7 +37,12 @@ describe("pattern", function () { } = {}) { const dict = new Dict(); dict.set("ShadingType", 1); - dict.set("ColorSpace", Name.get(colorSpace)); + if (Array.isArray(colorSpace)) { + // `setIfName` only accepts a name, and would silently drop an Array. + dict.set("ColorSpace", colorSpace); + } else { + dict.setIfName("ColorSpace", colorSpace); + } dict.set("Domain", domain); dict.set("Matrix", matrix); if (background) { @@ -86,6 +91,44 @@ describe("pattern", function () { expect(ir[7]).toBeNull(); }); + it("must bound the color component buffer while batching", function () { + const numComps = 32; + let batchStarts = 0; + let componentBufferLength = 0; + const colorSpace = [ + Name.get("DeviceN"), + Array.from({ length: numComps }, (_, i) => Name.get(`Colorant${i}`)), + Name.get("DeviceRGB"), + { + fn(src, srcOffset, dest, destOffset) { + dest[destOffset] = src[srcOffset]; + dest[destOffset + 1] = src[srcOffset + 1]; + dest[destOffset + 2] = src[srcOffset + 2]; + }, + }, + ]; + const shading = createFunctionBasedShading({ + colorSpace, + matrix: [64, 0, 0, 64, 0, 0], + fn(src, srcOffset, dest, destOffset) { + if (destOffset === 0) { + batchStarts++; + componentBufferLength = dest.length; + } + dest[destOffset] = src[srcOffset]; + dest[destOffset + 1] = src[srcOffset + 1]; + dest[destOffset + 2] = 0.5; + dest.fill(0, destOffset + 3, destOffset + numComps); + }, + }); + const [, , , colors] = shading.getIR(); + + const totalVertices = (64 + 1) ** 2; + expect(batchStarts).toBeGreaterThan(1); + expect(componentBufferLength).toBeLessThan(totalVertices * numComps); + expect(Array.from(colors.subarray(0, 4))).toEqual([0, 0, 128, 0]); + }); + it("must keep mesh colors intact through binary serialization", function () { const shading = createFunctionBasedShading({ background: [0.25, 0.5, 0.75],