From 526b69651f6983336d14b2094b20e4b93b3665f4 Mon Sep 17 00:00:00 2001 From: calixteman Date: Sun, 26 Jul 2026 15:46:16 +0200 Subject: [PATCH] Convert shading colors in bulk, rather than one at a time The axial/radial ramps and the function-based lattice converted one color per call, i.e. one Wasm round-trip each for `IccColorSpace` (hence for `/DeviceCMYK`). Add `ColorSpace.getRgbItems`, overridden by `IccColorSpace` and `AlternateCS`, to convert a whole batch at once. --- src/core/colorspace.js | 29 ++++++++ src/core/icc_colorspace.js | 15 ++++ src/core/pattern.js | 80 +++++++++++++++++----- test/pdfs/.gitignore | 1 + test/pdfs/function_based_shading_cmyk.pdf | Bin 0 -> 3266 bytes test/test_manifest.json | 8 +++ test/unit/colorspace_spec.js | 15 ++++ test/unit/pattern_spec.js | 45 +++++++++++- 8 files changed, 173 insertions(+), 20 deletions(-) create mode 100644 test/pdfs/function_based_shading_cmyk.pdf 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 0000000000000000000000000000000000000000..a5629fc850840cf10657c0c44e4a8b37b5388fe3 GIT binary patch literal 3266 zcmcIn%Wl&^6lLwNxSK4gXU4JPNL7WJ(oHFp1PP(43zJL}qts4q7Zp}~8h^loKY$M@ z=Z+t-<0O3rXPne`&dfdcId13d==i1F>sg&Ye}Db9BngT?f3f!W$^P(dp~)TuSul^M zWS<37ost4zNCyX&jwTG`=jMVOXP<kyy}}9`wev(-y~zuV!oqUFbSh+@wWnq(O5p3DM=XRgCbR$0V+%@%AhSJ z*Fcu+nD=AZN#7SFl_&1u=Lo_mC^?ca$_GVG&JWBThcL>SE;+`RY?I>>2G78Th8bQV zEn~!}(u_Acnr1VS!bBm>5*=Jw*LXGqj3n+v(?^Po;{>HMdd~>gKs-ShHGr%Dm}g9T z50yihPNA?!9wxd%Oi72+qav1v(G#q;t`}MgVqVuodJSYr4dLKySc56JU7Km_vqS_RQmR{|Cm6Ql4u z1WS5cmYW{tJ>u&k=svRQ9dzUeK3^h=A2%T=7&5xgVYZ%m9 zOoM@HIA@z<1|n}A@H1coiKjgnw%5n);}$N&Z{f#f?IRC!!_V!JUIa8BHvI&i~_verz`Dg9M+Boj%=eP$jfqeU=ATdnP|Cc>vdmV{_`Tul5iMTH+` zU%G$`Hn09lcRg0m){Q*o4}S8}->$kIq|K_0Q_+#zIlakZw`nq4SU-M@e|F|>F6`W9 z(=Jp~H_tS5@7Uc--FZvf>Tq*H{1?Mb2fNSovN&qz?$fh;#qF?uyXo~~d2_Bku*B-h zP2avxd;K)ssrK^d*lPT3qAxAHI(U|-F7jL#uL74;$s|bfg+siOd2ri`OsZZ>ru_EW zuHRnkIqkKqvBl+qvc5n$HTg;5;tgR%CjGX|5pB*`_GB*4SSuawn=72x_)mIbxBhzu zXTx?SZnD?T4e7h>81;Ltcqo3mT=9WY?fsPFqK_Eu$kQwd!nsb&zaHkz!SV2_6XUq8 WntqxENoFcQb$wxVI 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],