Merge pull request #21012 from calixteman/shading_function

Add support for function-based shadings (bug 1254066)
This commit is contained in:
calixteman 2026-03-31 22:08:17 +02:00 committed by GitHub
commit f33f816991
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 669 additions and 59 deletions

View File

@ -294,7 +294,7 @@ function compilePatternInfo(ir) {
}
const nCoord = Math.floor(coords.length / 2);
const nColor = Math.floor(colors.length / 3);
const nColor = Math.floor(colors.length / 4);
const nStop = colorStops.length;
const nFigures = figures.length;
@ -312,7 +312,7 @@ function compilePatternInfo(ir) {
const byteLen =
20 +
nCoord * 8 +
nColor * 3 +
nColor * 4 +
nStop * 8 +
(bbox ? 16 : 0) +
(background ? 3 : 0) +
@ -336,7 +336,7 @@ function compilePatternInfo(ir) {
offset += nCoord * 8;
u8data.set(colors, offset);
offset += nColor * 3;
offset += nColor * 4;
for (const [pos, hex] of colorStops) {
dataView.setFloat32(offset, pos, true);

View File

@ -29,6 +29,7 @@ import {
isNumberArray,
lookupMatrix,
lookupNormalRect,
lookupRect,
MissingDataException,
} from "./core_utils.js";
import { BaseStream } from "./base_stream.js";
@ -63,6 +64,16 @@ class Pattern {
try {
switch (type) {
case ShadingType.FUNCTION_BASED:
prepareWebGPU?.();
return new FunctionBasedShading(
dict,
xref,
res,
pdfFunctionFactory,
globalColorSpaceCache,
localColorSpaceCache
);
case ShadingType.AXIAL:
case ShadingType.RADIAL:
return new RadialAxialShading(
@ -312,6 +323,183 @@ class RadialAxialShading extends BaseShading {
}
}
// Helpers for MeshShading, which builds its mesh from a stream.
function meshUpdateBounds(self) {
let minX = self.coords[0][0],
minY = self.coords[0][1],
maxX = minX,
maxY = minY;
for (let i = 1, ii = self.coords.length; i < ii; i++) {
const x = self.coords[i][0],
y = self.coords[i][1];
minX = minX > x ? x : minX;
minY = minY > y ? y : minY;
maxX = maxX < x ? x : maxX;
maxY = maxY < y ? y : maxY;
}
self.bounds = [minX, minY, maxX, maxY];
}
function meshPackData(self) {
let i, j, ii;
const coords = self.coords;
const coordsPacked = new Float32Array(coords.length * 2);
for (i = 0, j = 0, ii = coords.length; i < ii; i++) {
const xy = coords[i];
coordsPacked[j++] = xy[0];
coordsPacked[j++] = xy[1];
}
self.coords = coordsPacked;
// Stride 4 (RGB + 1 padding byte) so each color fits in one u32, letting
// the WebGPU vertex shader read colors as array<u32> without repacking.
const colors = self.colors;
const colorsPacked = new Uint8Array(colors.length * 4);
for (i = 0, j = 0, ii = colors.length; i < ii; i++) {
const c = colors[i];
colorsPacked[j++] = c[0];
colorsPacked[j++] = c[1];
colorsPacked[j++] = c[2];
j++; // alpha — unused, stays 0
}
self.colors = colorsPacked;
// Store raw vertex indices (not byte offsets) so the GPU shader can
// address coords / colors without knowing their strides, and so the
// arrays are transferable Uint32Arrays.
for (const figure of self.figures) {
figure.coords = new Uint32Array(figure.coords);
figure.colors = new Uint32Array(figure.colors);
}
}
// Type 1 shading: a 2-in, n-out function sampled over a rectangular domain.
class FunctionBasedShading extends BaseShading {
// Maximum grid steps per axis to avoid huge meshes.
static MAX_STEP_COUNT = 512;
constructor(
dict,
xref,
resources,
pdfFunctionFactory,
globalColorSpaceCache,
localColorSpaceCache
) {
super();
this.bbox = lookupNormalRect(dict.getArray("BBox"), null);
const cs = ColorSpaceUtils.parse({
cs: dict.getRaw("CS") || dict.getRaw("ColorSpace"),
xref,
resources,
pdfFunctionFactory,
globalColorSpaceCache,
localColorSpaceCache,
});
this.background = dict.has("Background")
? cs.getRgb(dict.get("Background"), 0)
: null;
const fnObj = dict.getRaw("Function");
if (!fnObj) {
throw new FormatError("FunctionBasedShading: missing /Function");
}
const fn = pdfFunctionFactory.create(fnObj, /* parseArray = */ true);
// Domain [x0, x1, y0, y1]; defaults to [0, 1, 0, 1].
let x0 = 0,
x1 = 1,
y0 = 0,
y1 = 1;
const domainArr = lookupRect(dict.getArray("Domain"), null);
if (domainArr) {
[x0, x1, y0, y1] = domainArr;
}
// Matrix maps shading (domain) space to user space; defaults to identity.
const matrix = lookupMatrix(dict.getArray("Matrix"), IDENTITY_MATRIX);
// Transform the four domain corners to find the user-space bounding box.
this.bounds = [Infinity, Infinity, -Infinity, -Infinity];
Util.axialAlignedBoundingBox([x0, y0, x1, y1], matrix, this.bounds);
const bboxW = this.bounds[2] - this.bounds[0];
const bboxH = this.bounds[3] - this.bounds[1];
// 1 step per user-space unit, capped for performance.
const stepsX = MathClamp(
Math.ceil(bboxW),
1,
FunctionBasedShading.MAX_STEP_COUNT
);
const stepsY = MathClamp(
Math.ceil(bboxH),
1,
FunctionBasedShading.MAX_STEP_COUNT
);
const verticesPerRow = stepsX + 1;
const totalVertices = (stepsY + 1) * verticesPerRow;
const coords = (this.coords = new Float32Array(totalVertices * 2));
const colors = (this.colors = new Uint8ClampedArray(totalVertices * 4));
const xyBuf = new Float32Array(2);
const colorBuf = new Float32Array(cs.numComps);
const rangeX = (x1 - x0) / stepsX;
const rangeY = (y1 - y0) / stepsY;
const halfStepX = rangeX / 2;
const halfStepY = rangeY / 2;
let coordOffset = 0;
let colorOffset = 0;
for (let row = 0; row <= stepsY; row++) {
const yDomain = y0 + rangeY * row;
// Evaluate half a step inside at boundary vertices to avoid a spurious
// strip for discontinuous functions; vertex positions stay unchanged.
xyBuf[1] = row === stepsY ? yDomain - halfStepY : yDomain;
for (let col = 0; col <= stepsX; col++) {
const xDomain = x0 + rangeX * col;
xyBuf[0] = col === stepsX ? xDomain - halfStepX : xDomain;
fn(xyBuf, 0, colorBuf, 0);
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
}
}
const ps = new Uint32Array(totalVertices);
for (let i = 0; i < totalVertices; i++) {
ps[i] = i;
}
this.figures = [
{
type: MeshFigureType.LATTICE,
coords: ps,
colors: new Uint32Array(ps),
verticesPerRow,
},
];
}
getIR() {
return [
"Mesh",
ShadingType.FUNCTION_BASED,
this.coords,
this.colors,
this.figures,
this.bounds,
this.bbox,
this.background,
];
}
}
// All mesh shadings. For now, they will be presented as set of the triangles
// to be drawn on the canvas and rgb color for each vertex.
class MeshStreamReader {
@ -920,55 +1108,11 @@ class MeshShading extends BaseShading {
}
_updateBounds() {
let minX = this.coords[0][0],
minY = this.coords[0][1],
maxX = minX,
maxY = minY;
for (let i = 1, ii = this.coords.length; i < ii; i++) {
const x = this.coords[i][0],
y = this.coords[i][1];
minX = minX > x ? x : minX;
minY = minY > y ? y : minY;
maxX = maxX < x ? x : maxX;
maxY = maxY < y ? y : maxY;
}
this.bounds = [minX, minY, maxX, maxY];
meshUpdateBounds(this);
}
_packData() {
let i, ii, j;
const coords = this.coords;
const coordsPacked = new Float32Array(coords.length * 2);
for (i = 0, j = 0, ii = coords.length; i < ii; i++) {
const xy = coords[i];
coordsPacked[j++] = xy[0];
coordsPacked[j++] = xy[1];
}
this.coords = coordsPacked;
// Stride 4 (RGBA layout, alpha unused) so the buffer maps directly to
// array<u32> in the WebGPU vertex shader without any repacking.
const colors = this.colors;
const colorsPacked = new Uint8Array(colors.length * 4);
for (i = 0, j = 0, ii = colors.length; i < ii; i++) {
const c = colors[i];
colorsPacked[j++] = c[0];
colorsPacked[j++] = c[1];
colorsPacked[j++] = c[2];
j++; // alpha — unused, stays 0
}
this.colors = colorsPacked;
// Store raw vertex indices (not byte offsets) so the GPU shader can
// address coords / colors without knowing their strides, and so the
// arrays are transferable Uint32Arrays.
const figures = this.figures;
for (i = 0, ii = figures.length; i < ii; i++) {
const figure = figures[i];
figure.coords = new Uint32Array(figure.coords);
figure.colors = new Uint32Array(figure.colors);
}
meshPackData(this);
}
getIR() {

View File

@ -353,8 +353,8 @@ class PatternInfo {
let offset = 20;
const coords = new Float32Array(this.buffer, offset, nCoord * 2);
offset += nCoord * 8;
const colors = new Uint8Array(this.buffer, offset, nColor * 3);
offset += nColor * 3;
const colors = new Uint8Array(this.buffer, offset, nColor * 4);
offset += nColor * 4;
const stops = [];
for (let i = 0; i < nStop; ++i) {
const p = dataView.getFloat32(offset, true);

View File

@ -61,7 +61,7 @@ class PATTERN_INFO {
static N_COORD = 4; // number of coordinate pairs
static N_COLOR = 8; // number of rgb triplets
static N_COLOR = 8; // number of RGBA-stride color entries
static N_STOP = 12; // number of gradient stops

View File

@ -895,3 +895,4 @@
!bug2025674.pdf
!bug2026037.pdf
!tiling_patterns_variations.pdf
!function_based_shading.pdf

View File

@ -0,0 +1,315 @@
%PDF-1.4
%âãÏÓ
14 0 obj
<<
/FunctionType 4
/Domain [0 1 0 1]
/Range [0 1]
/Length 7
>>
stream
{ pop }
endstream
endobj
15 0 obj
<<
/FunctionType 4
/Domain [0 1 0 1]
/Range [0 1]
/Length 13
>>
stream
{ add 2 div }
endstream
endobj
16 0 obj
<<
/FunctionType 4
/Domain [0 1 0 1]
/Range [0 1 0 1 0 1]
/Length 5
>>
stream
{ 0 }
endstream
endobj
17 0 obj
<<
/FunctionType 4
/Domain [0 1 0 1]
/Range [0 1]
/Length 65
>>
stream
{ 0.5 sub exch 0.5 sub dup mul exch dup mul add sqrt 2 sqrt mul }
endstream
endobj
18 0 obj
<<
/FunctionType 4
/Domain [0 100 0 100]
/Range [0 1]
/Length 15
>>
stream
{ pop 100 div }
endstream
endobj
19 0 obj
<<
/FunctionType 4
/Domain [0 1 0 1]
/Range [0 1 0 1 0 1]
/Length 14
>>
stream
{ 2 copy mul }
endstream
endobj
20 0 obj
<<
/FunctionType 4
/Domain [0 1 0 1]
/Range [0 1]
/Length 42
>>
stream
{ 4 mul floor exch 4 mul floor add 2 mod }
endstream
endobj
21 0 obj
<<
/FunctionType 4
/Domain [0 1 0 1]
/Range [0 1]
/Length 58
>>
stream
{ dup mul exch dup mul add sqrt 1440 mul sin 1 add 2 div }
endstream
endobj
22 0 obj
<<
/FunctionType 4
/Domain [0 1 0 1]
/Range [0 1 0 1 0 1]
/Length 101
>>
stream
{ 2 copy add 90 mul sin dup mul 3 1 roll 180 mul sin dup mul exch 180 mul sin dup mul 3 1 roll exch }
endstream
endobj
5 0 obj
<<
/ShadingType 1
/ColorSpace /DeviceGray
/Domain [0 1 0 1]
/Matrix [170 0 0 170 30 582]
/Function 14 0 R
>>
endobj
6 0 obj
<<
/ShadingType 1
/ColorSpace /DeviceGray
/Domain [0 1 0 1]
/Matrix [170 0 0 170 230 582]
/Function 15 0 R
>>
endobj
7 0 obj
<<
/ShadingType 1
/ColorSpace /DeviceRGB
/Domain [0 1 0 1]
/Matrix [170 0 0 170 430 582]
/Function 16 0 R
>>
endobj
8 0 obj
<<
/ShadingType 1
/ColorSpace /DeviceGray
/Domain [0 1 0 1]
/Matrix [170 0 0 170 30 382]
/BBox [30 382 200 552]
/Function 17 0 R
>>
endobj
9 0 obj
<<
/ShadingType 1
/ColorSpace /DeviceGray
/Domain [0 100 0 100]
/Matrix [1.7 0 0 1.7 230 382]
/Function 18 0 R
>>
endobj
10 0 obj
<<
/ShadingType 1
/ColorSpace /DeviceRGB
/Domain [0 1 0 1]
/Matrix [85 85 -85 85 515 382]
/Function 19 0 R
>>
endobj
11 0 obj
<<
/ShadingType 1
/ColorSpace /DeviceGray
/Domain [0 1 0 1]
/Matrix [170 0 0 170 30 182]
/Function 20 0 R
>>
endobj
12 0 obj
<<
/ShadingType 1
/ColorSpace /DeviceGray
/Domain [0 1 0 1]
/Matrix [170 0 0 170 230 182]
/Function 21 0 R
>>
endobj
13 0 obj
<<
/ShadingType 1
/ColorSpace /DeviceRGB
/Domain [0 1 0 1]
/Matrix [170 0 0 170 430 182]
/Function 22 0 R
>>
endobj
4 0 obj
<<
/Length 312
>>
stream
q
30 582 170 170 re W n
/SH1 sh
Q
q
230 582 170 170 re W n
/SH2 sh
Q
q
430 582 170 170 re W n
/SH3 sh
Q
q
30 382 170 170 re W n
/SH4 sh
Q
q
230 382 170 170 re W n
/SH5 sh
Q
q
430 382 170 170 re W n
/SH6 sh
Q
q
30 182 170 170 re W n
/SH7 sh
Q
q
230 182 170 170 re W n
/SH8 sh
Q
q
430 182 170 170 re W n
/SH9 sh
Q
endstream
endobj
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
/Contents 4 0 R
/Resources <<
/Shading <<
/SH1 5 0 R
/SH2 6 0 R
/SH3 7 0 R
/SH4 8 0 R
/SH5 9 0 R
/SH6 10 0 R
/SH7 11 0 R
/SH8 12 0 R
/SH9 13 0 R
>>
>>
>>
endobj
2 0 obj
<<
/Type /Pages
/Kids [3 0 R]
/Count 1
>>
endobj
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
xref
0 23
0000000000 65535 f
0000003285 00000 n
0000003221 00000 n
0000002942 00000 n
0000002577 00000 n
0000001325 00000 n
0000001460 00000 n
0000001596 00000 n
0000001731 00000 n
0000001891 00000 n
0000002031 00000 n
0000002168 00000 n
0000002304 00000 n
0000002441 00000 n
0000000015 00000 n
0000000128 00000 n
0000000248 00000 n
0000000367 00000 n
0000000539 00000 n
0000000665 00000 n
0000000794 00000 n
0000000943 00000 n
0000001108 00000 n
trailer
<<
/Size 23
/Root 1 0 R
>>
startxref
3339
%%EOF

View File

@ -14035,5 +14035,12 @@
"md5": "2870c3136be00ddd975149b2c7d1e6df",
"rounds": 1,
"type": "eq"
},
{
"id": "function-based-shading",
"file": "pdfs/function_based_shading.pdf",
"md5": "7796f0131e7d6428c1bf24a73ff13f95",
"rounds": 1,
"type": "eq"
}
]

View File

@ -36,6 +36,7 @@
"node_stream_spec.js",
"obj_bin_transform_spec.js",
"parser_spec.js",
"pattern_spec.js",
"pdf.image_decoders_spec.js",
"pdf.worker_spec.js",
"pdf_find_controller_spec.js",

View File

@ -79,6 +79,7 @@ async function initializePDFJS(callback) {
"pdfjs-test/unit/network_utils_spec.js",
"pdfjs-test/unit/obj_bin_transform_spec.js",
"pdfjs-test/unit/parser_spec.js",
"pdfjs-test/unit/pattern_spec.js",
"pdfjs-test/unit/pdf.image_decoders_spec.js",
"pdfjs-test/unit/pdf.worker_spec.js",
"pdfjs-test/unit/pdf_find_controller_spec.js",

View File

@ -215,8 +215,8 @@ describe("obj_bin_transform", function () {
0, 0, 50, 0, 100, 0, 0, 50, 50, 50, 100, 50, 0, 100, 50, 100, 100, 100,
]),
new Uint8Array([
255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0, 128, 128, 128, 255, 0,
255, 0, 255, 255, 255, 128, 0, 128, 0, 128,
255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 255, 255, 0, 0, 128, 128, 128,
0, 255, 0, 255, 0, 0, 255, 255, 0, 255, 128, 0, 0, 128, 0, 128, 0,
]),
[
{
@ -335,7 +335,7 @@ describe("obj_bin_transform", function () {
"Mesh",
4,
new Float32Array([0, 0, 10, 10]),
new Uint8Array([255, 0, 0]),
new Uint8Array([255, 0, 0, 0]),
[],
[0, 0, 10, 10],
[0, 0, 10, 10],
@ -383,7 +383,7 @@ describe("obj_bin_transform", function () {
"Mesh",
6,
new Float32Array([0, 0, 10, 10]),
new Uint8Array([255, 128, 64]),
new Uint8Array([255, 128, 64, 0]),
[
{
type: MeshFigureType.PATCH,
@ -415,7 +415,7 @@ describe("obj_bin_transform", function () {
"Mesh",
4,
new Float32Array([0, 0, 10, 10]),
new Uint8Array([255, 0, 0]),
new Uint8Array([255, 0, 0, 0]),
[],
[0, 0, 10, 10],
[0, 0, 10, 10],
@ -432,7 +432,7 @@ describe("obj_bin_transform", function () {
"Mesh",
5,
new Float32Array([0, 0, 5, 5]),
new Uint8Array([0, 255, 0]),
new Uint8Array([0, 255, 0, 0]),
[],
[0, 0, 5, 5],
null,
@ -451,7 +451,7 @@ describe("obj_bin_transform", function () {
"Mesh",
4,
new Float32Array([-10, -5, 20, 15, 0, 30]),
new Uint8Array([255, 0, 0, 0, 255, 0, 0, 0, 255]),
new Uint8Array([255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0]),
[],
null,
null,

141
test/unit/pattern_spec.js Normal file
View File

@ -0,0 +1,141 @@
/* Copyright 2026 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Dict, Name } from "../../src/core/primitives.js";
import {
GlobalColorSpaceCache,
LocalColorSpaceCache,
} from "../../src/core/image_utils.js";
import { compilePatternInfo } from "../../src/core/obj_bin_transform_core.js";
import { Pattern } from "../../src/core/pattern.js";
import { PatternInfo } from "../../src/display/obj_bin_transform_display.js";
describe("pattern", function () {
describe("FunctionBasedShading", function () {
function createFunctionBasedShading({
colorSpace = "DeviceRGB",
domain = [0, 1, 0, 1],
matrix = [2, 0, 0, 3, 10, 20],
background = null,
fn = (src, srcOffset, dest, destOffset) => {
dest[destOffset] = src[srcOffset];
dest[destOffset + 1] = src[srcOffset + 1];
dest[destOffset + 2] = 0;
},
} = {}) {
const dict = new Dict();
dict.set("ShadingType", 1);
dict.set("ColorSpace", Name.get(colorSpace));
dict.set("Domain", domain);
dict.set("Matrix", matrix);
if (background) {
dict.set("Background", background);
}
dict.set("Function", {
fn,
});
const pdfFunctionFactory = {
create(fnObj) {
return fnObj.fn;
},
};
const xref = {
fetchIfRef(obj) {
return obj;
},
};
return Pattern.parseShading(
dict,
xref,
/* res = */ null,
pdfFunctionFactory,
new GlobalColorSpaceCache(),
new LocalColorSpaceCache()
);
}
it("must convert Type 1 shading into packed mesh IR", function () {
const shading = createFunctionBasedShading();
const ir = shading.getIR();
expect(ir[0]).toEqual("Mesh");
expect(ir[1]).toEqual(1);
expect(ir[2]).toBeInstanceOf(Float32Array);
expect(ir[2].length).toEqual(24);
expect(Array.from(ir[2].slice(0, 6))).toEqual([10, 20, 11, 20, 12, 20]);
expect(Array.from(ir[2].slice(-6))).toEqual([10, 23, 11, 23, 12, 23]);
expect(ir[3]).toBeInstanceOf(Uint8ClampedArray);
expect(ir[3].length).toEqual(48);
expect(Array.from(ir[3].slice(0, 12))).toEqual([
0, 0, 0, 0, 128, 0, 0, 0, 191, 0, 0, 0,
]);
expect(Array.from(ir[3].slice(-12))).toEqual([
0, 212, 0, 0, 128, 212, 0, 0, 191, 212, 0, 0,
]);
expect(ir[4]).toEqual([
jasmine.objectContaining({
verticesPerRow: 3,
}),
]);
expect(ir[4][0].coords).toBeInstanceOf(Uint32Array);
expect(Array.from(ir[4][0].coords)).toEqual([
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
]);
expect(ir[4][0].colors).toBeInstanceOf(Uint32Array);
expect(Array.from(ir[4][0].colors)).toEqual([
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
]);
expect(ir[5]).toEqual([10, 20, 12, 23]);
expect(ir[6]).toBeNull();
expect(ir[7]).toBeNull();
});
it("must keep mesh colors intact through binary serialization", function () {
const shading = createFunctionBasedShading({
background: [0.25, 0.5, 0.75],
});
const buffer = compilePatternInfo(shading.getIR());
const reconstructedIR = new PatternInfo(buffer).getIR();
expect(reconstructedIR[0]).toEqual("Mesh");
expect(reconstructedIR[1]).toEqual(1);
expect(Array.from(reconstructedIR[2])).toEqual(
Array.from(shading.coords)
);
expect(Array.from(reconstructedIR[3])).toEqual(
Array.from(shading.colors)
);
expect(Array.from(reconstructedIR[7])).toEqual([64, 128, 191]);
});
it("must sample the upper and right edges half a step inside", function () {
const shading = createFunctionBasedShading({
colorSpace: "DeviceGray",
fn(src, srcOffset, dest, destOffset) {
dest[destOffset] =
src[srcOffset] === 1 || src[srcOffset + 1] === 1 ? 1 : 0;
},
});
const [, , , colors] = shading.getIR();
expect(colors.length).toEqual(48);
expect(Array.from(colors)).toEqual(new Array(48).fill(0));
});
});
});