Add support for function-based shadings (bug 1254066)

It fixes #5046.
We just generate a mesh for the pattern rectangle where the color of each vertex is computed from the function.
Since the mesh is generated in the worker we don't really take into account the current transform when it's drawn.
That being said, there are maybe some possible improvements in using directly the gpu for the shading creation
which could then take into account the current transform, but it could only work with ps function we can convert
ino wgsl language and simple enough color spaces (gray and rgb).
This commit is contained in:
Calixte Denizet 2026-03-31 14:03:06 +02:00
parent 58b807d8e8
commit 3727b7095a
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));
});
});
});