@smallpen/local-package 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/fonts/SourceSansPro-Bold.ttf +0 -0
- package/assets/fonts/SourceSansPro-Regular.ttf +0 -0
- package/assets/fonts/SourceSansPro-Semibold.ttf +0 -0
- package/assets/fonts/WorkSans-Regular.ttf +0 -0
- package/package.json +23 -0
- package/src/draft-import.mjs +282 -0
- package/src/font-convert.mjs +132 -0
- package/src/index.mjs +34 -0
- package/src/initialize.mjs +533 -0
- package/src/kiwi-runtime.mjs +526 -0
- package/src/local-package-backend.mjs +460 -0
- package/src/local-package.mjs +842 -0
- package/src/local-workspace-session.mjs +486 -0
- package/src/media-inspect.mjs +184 -0
- package/src/remote-library.mjs +301 -0
- package/src/render.mjs +3605 -0
- package/src/webp-decode.mjs +73 -0
- package/src/workspace.mjs +591 -0
package/src/render.mjs
ADDED
|
@@ -0,0 +1,3605 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { deflateSync, inflateSync } from "node:zlib";
|
|
3
|
+
|
|
4
|
+
import opentype from "opentype.js";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createSemanticTree,
|
|
8
|
+
projectDesignView,
|
|
9
|
+
resolveDesignView,
|
|
10
|
+
sha256Hex,
|
|
11
|
+
SmallPenError,
|
|
12
|
+
} from "@smallpen/core";
|
|
13
|
+
|
|
14
|
+
import { initWebpDecoder, decodeWebpSync } from "./webp-decode.mjs";
|
|
15
|
+
|
|
16
|
+
const MAX_DIMENSION = 8192;
|
|
17
|
+
const MAX_PIXELS = 32 * 1024 * 1024;
|
|
18
|
+
const CURVE_STEPS = 8;
|
|
19
|
+
const GLYPH_COVERAGE_EXPONENT = 1 / 1.8;
|
|
20
|
+
const GLYPH_SAMPLES = [0.125, 0.375, 0.625, 0.875];
|
|
21
|
+
|
|
22
|
+
const BUILTIN_FONT_FILES = new Map([
|
|
23
|
+
[400, "SourceSansPro-Regular.ttf"],
|
|
24
|
+
[600, "SourceSansPro-Semibold.ttf"],
|
|
25
|
+
[700, "SourceSansPro-Bold.ttf"],
|
|
26
|
+
]);
|
|
27
|
+
const builtinFonts = new Map();
|
|
28
|
+
let symbolFont;
|
|
29
|
+
|
|
30
|
+
const NAMED_COLORS = new Map([
|
|
31
|
+
["black", [0, 0, 0, 255]],
|
|
32
|
+
["blue", [0, 0, 255, 255]],
|
|
33
|
+
["gray", [128, 128, 128, 255]],
|
|
34
|
+
["green", [0, 128, 0, 255]],
|
|
35
|
+
["grey", [128, 128, 128, 255]],
|
|
36
|
+
["red", [255, 0, 0, 255]],
|
|
37
|
+
["transparent", [0, 0, 0, 0]],
|
|
38
|
+
["white", [255, 255, 255, 255]],
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
function clamp(value, lower, upper) {
|
|
42
|
+
return Math.min(Math.max(value, lower), upper);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseHexColor(value) {
|
|
46
|
+
const source = value.slice(1);
|
|
47
|
+
if (![3, 4, 6, 8].includes(source.length) || !/^[a-f0-9]+$/i.test(source)) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const pairs =
|
|
51
|
+
source.length <= 4
|
|
52
|
+
? [...source].map((part) => part + part)
|
|
53
|
+
: source.match(/../g);
|
|
54
|
+
return [
|
|
55
|
+
Number.parseInt(pairs[0], 16),
|
|
56
|
+
Number.parseInt(pairs[1], 16),
|
|
57
|
+
Number.parseInt(pairs[2], 16),
|
|
58
|
+
pairs[3] === undefined ? 255 : Number.parseInt(pairs[3], 16),
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseColor(value, diagnostics, path) {
|
|
63
|
+
if (typeof value !== "string") return [255, 0, 255, 255];
|
|
64
|
+
const source = value.trim().toLowerCase();
|
|
65
|
+
if (source.startsWith("#")) {
|
|
66
|
+
const parsed = parseHexColor(source);
|
|
67
|
+
if (parsed) return parsed;
|
|
68
|
+
}
|
|
69
|
+
if (NAMED_COLORS.has(source)) return [...NAMED_COLORS.get(source)];
|
|
70
|
+
const match = /^rgba?\(([^)]+)\)$/.exec(source);
|
|
71
|
+
if (match) {
|
|
72
|
+
const parts = match[1].split(",").map((part) => Number(part.trim()));
|
|
73
|
+
if (
|
|
74
|
+
(parts.length === 3 || parts.length === 4) &&
|
|
75
|
+
parts.every(Number.isFinite)
|
|
76
|
+
) {
|
|
77
|
+
return [
|
|
78
|
+
clamp(Math.round(parts[0]), 0, 255),
|
|
79
|
+
clamp(Math.round(parts[1]), 0, 255),
|
|
80
|
+
clamp(Math.round(parts[2]), 0, 255),
|
|
81
|
+
parts[3] === undefined
|
|
82
|
+
? 255
|
|
83
|
+
: clamp(Math.round(parts[3] * 255), 0, 255),
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
diagnostics.push({
|
|
88
|
+
code: "unsupported_render_color",
|
|
89
|
+
message: `Renderer substituted unsupported color ${value}`,
|
|
90
|
+
path,
|
|
91
|
+
});
|
|
92
|
+
return [255, 0, 255, 255];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function crc32(bytes) {
|
|
96
|
+
let crc = 0xffffffff;
|
|
97
|
+
for (const byte of bytes) {
|
|
98
|
+
crc ^= byte;
|
|
99
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
100
|
+
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pngChunk(type, data) {
|
|
107
|
+
const name = Buffer.from(type, "ascii");
|
|
108
|
+
const length = Buffer.alloc(4);
|
|
109
|
+
length.writeUInt32BE(data.length);
|
|
110
|
+
const checksum = Buffer.alloc(4);
|
|
111
|
+
checksum.writeUInt32BE(crc32(Buffer.concat([name, data])));
|
|
112
|
+
return Buffer.concat([length, name, data, checksum]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function encodePng(width, height, pixels) {
|
|
116
|
+
const scanlines = Buffer.alloc((width * 4 + 1) * height);
|
|
117
|
+
for (let row = 0; row < height; row += 1) {
|
|
118
|
+
const target = row * (width * 4 + 1);
|
|
119
|
+
scanlines[target] = 0;
|
|
120
|
+
Buffer.from(
|
|
121
|
+
pixels.buffer,
|
|
122
|
+
pixels.byteOffset + row * width * 4,
|
|
123
|
+
width * 4,
|
|
124
|
+
).copy(scanlines, target + 1);
|
|
125
|
+
}
|
|
126
|
+
const header = Buffer.alloc(13);
|
|
127
|
+
header.writeUInt32BE(width, 0);
|
|
128
|
+
header.writeUInt32BE(height, 4);
|
|
129
|
+
header[8] = 8;
|
|
130
|
+
header[9] = 6;
|
|
131
|
+
return new Uint8Array(
|
|
132
|
+
Buffer.concat([
|
|
133
|
+
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
134
|
+
pngChunk("IHDR", header),
|
|
135
|
+
pngChunk("IDAT", deflateSync(scanlines, { level: 9 })),
|
|
136
|
+
pngChunk("IEND", Buffer.alloc(0)),
|
|
137
|
+
]),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function multiply(left, right) {
|
|
142
|
+
return [
|
|
143
|
+
left[0] * right[0] + left[2] * right[1],
|
|
144
|
+
left[1] * right[0] + left[3] * right[1],
|
|
145
|
+
left[0] * right[2] + left[2] * right[3],
|
|
146
|
+
left[1] * right[2] + left[3] * right[3],
|
|
147
|
+
left[0] * right[4] + left[2] * right[5] + left[4],
|
|
148
|
+
left[1] * right[4] + left[3] * right[5] + left[5],
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function translate(x, y) {
|
|
153
|
+
return [1, 0, 0, 1, x, y];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function nodeTransform(node) {
|
|
157
|
+
const angle = ((node.rotation ?? 0) * Math.PI) / 180;
|
|
158
|
+
const cosine = Math.cos(angle) * (node.flipX ? -1 : 1);
|
|
159
|
+
const sine = Math.sin(angle) * (node.flipX ? -1 : 1);
|
|
160
|
+
const vertical = node.flipY ? -1 : 1;
|
|
161
|
+
const centered = multiply(
|
|
162
|
+
translate(node.width / 2, node.height / 2),
|
|
163
|
+
[cosine, sine, -Math.sin(angle) * vertical, Math.cos(angle) * vertical, 0, 0],
|
|
164
|
+
);
|
|
165
|
+
return multiply(
|
|
166
|
+
translate(node.x, node.y),
|
|
167
|
+
multiply(centered, translate(-node.width / 2, -node.height / 2)),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function inverse(matrix) {
|
|
172
|
+
const determinant = matrix[0] * matrix[3] - matrix[1] * matrix[2];
|
|
173
|
+
if (Math.abs(determinant) < 1e-12) return undefined;
|
|
174
|
+
return [
|
|
175
|
+
matrix[3] / determinant,
|
|
176
|
+
-matrix[1] / determinant,
|
|
177
|
+
-matrix[2] / determinant,
|
|
178
|
+
matrix[0] / determinant,
|
|
179
|
+
(matrix[2] * matrix[5] - matrix[3] * matrix[4]) / determinant,
|
|
180
|
+
(matrix[1] * matrix[4] - matrix[0] * matrix[5]) / determinant,
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function point(matrix, x, y) {
|
|
185
|
+
return {
|
|
186
|
+
x: matrix[0] * x + matrix[2] * y + matrix[4],
|
|
187
|
+
y: matrix[1] * x + matrix[3] * y + matrix[5],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function transformedBounds(matrix, width, height) {
|
|
192
|
+
const corners = [
|
|
193
|
+
point(matrix, 0, 0),
|
|
194
|
+
point(matrix, width, 0),
|
|
195
|
+
point(matrix, 0, height),
|
|
196
|
+
point(matrix, width, height),
|
|
197
|
+
];
|
|
198
|
+
return {
|
|
199
|
+
bottom: Math.max(...corners.map(({ y }) => y)),
|
|
200
|
+
left: Math.min(...corners.map(({ x }) => x)),
|
|
201
|
+
right: Math.max(...corners.map(({ x }) => x)),
|
|
202
|
+
top: Math.min(...corners.map(({ y }) => y)),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function blend(pixels, index, color, opacity) {
|
|
207
|
+
const sourceAlpha = clamp((color[3] / 255) * opacity, 0, 1);
|
|
208
|
+
const targetAlpha = pixels[index + 3] / 255;
|
|
209
|
+
const outputAlpha = sourceAlpha + targetAlpha * (1 - sourceAlpha);
|
|
210
|
+
if (outputAlpha <= 0) return;
|
|
211
|
+
for (let channel = 0; channel < 3; channel += 1) {
|
|
212
|
+
pixels[index + channel] = Math.round(
|
|
213
|
+
(color[channel] * sourceAlpha +
|
|
214
|
+
pixels[index + channel] * targetAlpha * (1 - sourceAlpha)) /
|
|
215
|
+
outputAlpha,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
pixels[index + 3] = Math.round(outputAlpha * 255);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function fontIdentity(value) {
|
|
222
|
+
return String(value ?? "")
|
|
223
|
+
.toLowerCase()
|
|
224
|
+
.replaceAll(/[^a-z0-9]/g, "");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function nearestWeight(weight, choices) {
|
|
228
|
+
return [...choices].sort(
|
|
229
|
+
(left, right) =>
|
|
230
|
+
Math.abs(left - weight) - Math.abs(right - weight) || right - left,
|
|
231
|
+
)[0];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function parseFont(bytes) {
|
|
235
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
236
|
+
return opentype.parse(
|
|
237
|
+
view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function loadBuiltinFont(weight) {
|
|
242
|
+
if (!builtinFonts.has(weight)) {
|
|
243
|
+
builtinFonts.set(
|
|
244
|
+
weight,
|
|
245
|
+
readFile(
|
|
246
|
+
new URL(`../assets/fonts/${BUILTIN_FONT_FILES.get(weight)}`, import.meta.url),
|
|
247
|
+
).then(parseFont),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return builtinFonts.get(weight);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function loadSymbolFont() {
|
|
254
|
+
symbolFont ??= readFile(
|
|
255
|
+
new URL("../assets/fonts/WorkSans-Regular.ttf", import.meta.url),
|
|
256
|
+
).then(parseFont);
|
|
257
|
+
return symbolFont;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function createFontLibrary(product, foundation, libraries, diagnostics) {
|
|
261
|
+
const builtins = new Map(
|
|
262
|
+
await Promise.all(
|
|
263
|
+
[...BUILTIN_FONT_FILES.keys()].map(async (weight) => [
|
|
264
|
+
weight,
|
|
265
|
+
await loadBuiltinFont(weight),
|
|
266
|
+
]),
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
const custom = [];
|
|
270
|
+
for (const snapshot of [foundation, ...libraries, product]) {
|
|
271
|
+
if (!snapshot) continue;
|
|
272
|
+
for (const entry of snapshot.manifest.entries.assets) {
|
|
273
|
+
for (const family of snapshot.entries[entry].fonts ?? []) {
|
|
274
|
+
for (const variant of family.variants) {
|
|
275
|
+
const descriptor =
|
|
276
|
+
variant.files.ttf ?? variant.files.otf ?? variant.files.woff;
|
|
277
|
+
const bytes = descriptor && snapshot.blobs?.get(descriptor.blob);
|
|
278
|
+
if (!bytes) continue;
|
|
279
|
+
try {
|
|
280
|
+
custom.push({
|
|
281
|
+
family: fontIdentity(family.family),
|
|
282
|
+
font: parseFont(bytes),
|
|
283
|
+
id: fontIdentity(family.id),
|
|
284
|
+
style: variant.style,
|
|
285
|
+
weight: variant.weight,
|
|
286
|
+
});
|
|
287
|
+
} catch (cause) {
|
|
288
|
+
diagnostics.push({
|
|
289
|
+
code: "font_render_parse_failed",
|
|
290
|
+
message: `Renderer could not parse ${family.family} ${variant.name}`,
|
|
291
|
+
path: `${entry}.fonts.${family.id}.${variant.id}`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const symbols = await loadSymbolFont();
|
|
299
|
+
return { builtins, custom, symbols };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function resolveFont(state, style, nodeId, path) {
|
|
303
|
+
const id = fontIdentity(style.fontId);
|
|
304
|
+
const family = fontIdentity(style.fontFamily);
|
|
305
|
+
const weight = Number(style.fontWeight ?? 400);
|
|
306
|
+
const variantStyle = style.fontStyle ?? "normal";
|
|
307
|
+
const familyCandidates = state.fonts.custom.filter(
|
|
308
|
+
(item) => item.id === id || item.family === family,
|
|
309
|
+
);
|
|
310
|
+
const styleCandidates = familyCandidates.filter(
|
|
311
|
+
(item) => item.style === variantStyle,
|
|
312
|
+
);
|
|
313
|
+
const candidates = styleCandidates.length > 0 ? styleCandidates : familyCandidates;
|
|
314
|
+
if (candidates.length > 0) {
|
|
315
|
+
const selectedWeight = nearestWeight(
|
|
316
|
+
weight,
|
|
317
|
+
candidates.map((item) => item.weight),
|
|
318
|
+
);
|
|
319
|
+
return candidates.find((item) => item.weight === selectedWeight).font;
|
|
320
|
+
}
|
|
321
|
+
const selectedWeight = nearestWeight(weight, state.fonts.builtins.keys());
|
|
322
|
+
if (
|
|
323
|
+
id !== "sourcesanspro" &&
|
|
324
|
+
family !== "sourcesanspro" &&
|
|
325
|
+
family !== "sourcesanspro2"
|
|
326
|
+
) {
|
|
327
|
+
const key = `${nodeId}:${id || family}`;
|
|
328
|
+
if (!state.fontFallbacks.has(key)) {
|
|
329
|
+
state.fontFallbacks.add(key);
|
|
330
|
+
state.diagnostics.push({
|
|
331
|
+
code: "font_render_fallback",
|
|
332
|
+
message: `Renderer substituted Source Sans Pro for ${style.fontFamily ?? style.fontId ?? "unknown font"}`,
|
|
333
|
+
nodeId,
|
|
334
|
+
path,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return state.fonts.builtins.get(selectedWeight);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function addCurvePoints(contour, command, current) {
|
|
342
|
+
for (let step = 1; step <= CURVE_STEPS; step += 1) {
|
|
343
|
+
const amount = step / CURVE_STEPS;
|
|
344
|
+
const inverseAmount = 1 - amount;
|
|
345
|
+
if (command.type === "Q") {
|
|
346
|
+
contour.push({
|
|
347
|
+
x:
|
|
348
|
+
inverseAmount * inverseAmount * current.x +
|
|
349
|
+
2 * inverseAmount * amount * command.x1 +
|
|
350
|
+
amount * amount * command.x,
|
|
351
|
+
y:
|
|
352
|
+
inverseAmount * inverseAmount * current.y +
|
|
353
|
+
2 * inverseAmount * amount * command.y1 +
|
|
354
|
+
amount * amount * command.y,
|
|
355
|
+
});
|
|
356
|
+
} else {
|
|
357
|
+
contour.push({
|
|
358
|
+
x:
|
|
359
|
+
inverseAmount ** 3 * current.x +
|
|
360
|
+
3 * inverseAmount * inverseAmount * amount * command.x1 +
|
|
361
|
+
3 * inverseAmount * amount * amount * command.x2 +
|
|
362
|
+
amount ** 3 * command.x,
|
|
363
|
+
y:
|
|
364
|
+
inverseAmount ** 3 * current.y +
|
|
365
|
+
3 * inverseAmount * inverseAmount * amount * command.y1 +
|
|
366
|
+
3 * inverseAmount * amount * amount * command.y2 +
|
|
367
|
+
amount ** 3 * command.y,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function flattenPath(path) {
|
|
374
|
+
const contours = [];
|
|
375
|
+
let contour = [];
|
|
376
|
+
let current;
|
|
377
|
+
for (const command of path.commands) {
|
|
378
|
+
if (command.type === "M") {
|
|
379
|
+
if (contour.length > 2) contours.push(contour);
|
|
380
|
+
contour = [{ x: command.x, y: command.y }];
|
|
381
|
+
current = contour[0];
|
|
382
|
+
} else if (command.type === "L") {
|
|
383
|
+
current = { x: command.x, y: command.y };
|
|
384
|
+
contour.push(current);
|
|
385
|
+
} else if ((command.type === "Q" || command.type === "C") && current) {
|
|
386
|
+
addCurvePoints(contour, command, current);
|
|
387
|
+
current = contour.at(-1);
|
|
388
|
+
} else if (command.type === "Z") {
|
|
389
|
+
if (contour.length > 2) contours.push(contour);
|
|
390
|
+
contour = [];
|
|
391
|
+
current = undefined;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (contour.length > 2) contours.push(contour);
|
|
395
|
+
return contours;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function contourBounds(contours) {
|
|
399
|
+
const points = contours.flat();
|
|
400
|
+
return {
|
|
401
|
+
bottom: Math.max(...points.map(({ y }) => y)),
|
|
402
|
+
left: Math.min(...points.map(({ x }) => x)),
|
|
403
|
+
right: Math.max(...points.map(({ x }) => x)),
|
|
404
|
+
top: Math.min(...points.map(({ y }) => y)),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function transformedRectangleBounds(matrix, bounds) {
|
|
409
|
+
const corners = [
|
|
410
|
+
point(matrix, bounds.left, bounds.top),
|
|
411
|
+
point(matrix, bounds.right, bounds.top),
|
|
412
|
+
point(matrix, bounds.left, bounds.bottom),
|
|
413
|
+
point(matrix, bounds.right, bounds.bottom),
|
|
414
|
+
];
|
|
415
|
+
return {
|
|
416
|
+
bottom: Math.max(...corners.map(({ y }) => y)),
|
|
417
|
+
left: Math.min(...corners.map(({ x }) => x)),
|
|
418
|
+
right: Math.max(...corners.map(({ x }) => x)),
|
|
419
|
+
top: Math.min(...corners.map(({ y }) => y)),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function windingNumber(contours, x, y) {
|
|
424
|
+
let winding = 0;
|
|
425
|
+
for (const contour of contours) {
|
|
426
|
+
for (let index = 0; index < contour.length; index += 1) {
|
|
427
|
+
const start = contour[index];
|
|
428
|
+
const end = contour[(index + 1) % contour.length];
|
|
429
|
+
const cross =
|
|
430
|
+
(end.x - start.x) * (y - start.y) -
|
|
431
|
+
(x - start.x) * (end.y - start.y);
|
|
432
|
+
if (start.y <= y && end.y > y && cross > 0) winding += 1;
|
|
433
|
+
if (start.y > y && end.y <= y && cross < 0) winding -= 1;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return winding;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function contourWindingCount(contours, x, y) {
|
|
440
|
+
// Parity test for SVG fill-rule="evenodd": counts crossings, not signed winding.
|
|
441
|
+
let crossings = 0;
|
|
442
|
+
for (const contour of contours) {
|
|
443
|
+
for (let index = 0; index < contour.length; index += 1) {
|
|
444
|
+
const start = contour[index];
|
|
445
|
+
const end = contour[(index + 1) % contour.length];
|
|
446
|
+
if (
|
|
447
|
+
(start.y <= y && end.y > y) ||
|
|
448
|
+
(start.y > y && end.y <= y)
|
|
449
|
+
) {
|
|
450
|
+
const intersectX =
|
|
451
|
+
start.x + ((y - start.y) / (end.y - start.y)) * (end.x - start.x);
|
|
452
|
+
if (intersectX > x) crossings += 1;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return crossings;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const PATH_CURVE_STEPS = 16;
|
|
460
|
+
const PATH_TOKEN =
|
|
461
|
+
/[MmLlHhVvCcSsQqTtAaZz]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g;
|
|
462
|
+
|
|
463
|
+
function arcToCubicSegments(current, rx, ry, angleDegrees, largeArc, sweep, next) {
|
|
464
|
+
if (
|
|
465
|
+
current.x === next.x &&
|
|
466
|
+
current.y === next.y ||
|
|
467
|
+
rx === 0 ||
|
|
468
|
+
ry === 0
|
|
469
|
+
) {
|
|
470
|
+
return [];
|
|
471
|
+
}
|
|
472
|
+
const angle = (angleDegrees * Math.PI) / 180;
|
|
473
|
+
const cosAngle = Math.cos(angle);
|
|
474
|
+
const sinAngle = Math.sin(angle);
|
|
475
|
+
let radiusX = Math.abs(rx);
|
|
476
|
+
let radiusY = Math.abs(ry);
|
|
477
|
+
const deltaX = (current.x - next.x) / 2;
|
|
478
|
+
const deltaY = (current.y - next.y) / 2;
|
|
479
|
+
const transformedX = cosAngle * deltaX + sinAngle * deltaY;
|
|
480
|
+
const transformedY = -sinAngle * deltaX + cosAngle * deltaY;
|
|
481
|
+
const radiusCheck =
|
|
482
|
+
(transformedX * transformedX) / (radiusX * radiusX) +
|
|
483
|
+
(transformedY * transformedY) / (radiusY * radiusY);
|
|
484
|
+
if (radiusCheck > 1) {
|
|
485
|
+
const scale = Math.sqrt(radiusCheck);
|
|
486
|
+
radiusX *= scale;
|
|
487
|
+
radiusY *= scale;
|
|
488
|
+
}
|
|
489
|
+
const sign = largeArc !== sweep ? 1 : -1;
|
|
490
|
+
const numerator =
|
|
491
|
+
radiusX * radiusX * radiusY * radiusY -
|
|
492
|
+
radiusX * radiusX * transformedY * transformedY -
|
|
493
|
+
radiusY * radiusY * transformedX * transformedX;
|
|
494
|
+
const denominator =
|
|
495
|
+
radiusX * radiusX * transformedY * transformedY +
|
|
496
|
+
radiusY * radiusY * transformedX * transformedX;
|
|
497
|
+
const centerCoefficient = sign * Math.sqrt(Math.max(0, numerator / denominator));
|
|
498
|
+
const centerPrimeX = (centerCoefficient * radiusX * transformedY) / radiusY;
|
|
499
|
+
const centerPrimeY = (-centerCoefficient * radiusY * transformedX) / radiusX;
|
|
500
|
+
const centerX =
|
|
501
|
+
cosAngle * centerPrimeX - sinAngle * centerPrimeY + (current.x + next.x) / 2;
|
|
502
|
+
const centerY =
|
|
503
|
+
sinAngle * centerPrimeX + cosAngle * centerPrimeY + (current.y + next.y) / 2;
|
|
504
|
+
const startVector = {
|
|
505
|
+
x: (transformedX - centerPrimeX) / radiusX,
|
|
506
|
+
y: (transformedY - centerPrimeY) / radiusY,
|
|
507
|
+
};
|
|
508
|
+
const endVector = {
|
|
509
|
+
x: (-transformedX - centerPrimeX) / radiusX,
|
|
510
|
+
y: (-transformedY - centerPrimeY) / radiusY,
|
|
511
|
+
};
|
|
512
|
+
const startAngle = Math.atan2(startVector.y, startVector.x);
|
|
513
|
+
let endAngle = Math.atan2(endVector.y, endVector.x);
|
|
514
|
+
let span = endAngle - startAngle;
|
|
515
|
+
if (!sweep && span > 0) span -= 2 * Math.PI;
|
|
516
|
+
if (sweep && span < 0) span += 2 * Math.PI;
|
|
517
|
+
const segmentCount = Math.max(1, Math.ceil(Math.abs(span) / (Math.PI / 4)));
|
|
518
|
+
const segments = [];
|
|
519
|
+
const delta = span / segmentCount;
|
|
520
|
+
const coefficient = (4 / 3) * Math.tan(delta / 4);
|
|
521
|
+
let angleCursor = startAngle;
|
|
522
|
+
for (let index = 0; index < segmentCount; index += 1) {
|
|
523
|
+
const nextAngle = angleCursor + delta;
|
|
524
|
+
const startCos = Math.cos(angleCursor);
|
|
525
|
+
const startSin = Math.sin(angleCursor);
|
|
526
|
+
const endCos = Math.cos(nextAngle);
|
|
527
|
+
const endSin = Math.sin(nextAngle);
|
|
528
|
+
const toPoint = (cosine, sine) => ({
|
|
529
|
+
x: centerX +
|
|
530
|
+
radiusX * cosine * cosAngle -
|
|
531
|
+
radiusY * sine * sinAngle,
|
|
532
|
+
y: centerY +
|
|
533
|
+
radiusX * cosine * sinAngle +
|
|
534
|
+
radiusY * sine * cosAngle,
|
|
535
|
+
});
|
|
536
|
+
const toControl = (cosine, sine, amount) => ({
|
|
537
|
+
x: centerX +
|
|
538
|
+
radiusX * (cosine - amount * sine) * cosAngle -
|
|
539
|
+
radiusY * (sine + amount * cosine) * sinAngle,
|
|
540
|
+
y: centerY +
|
|
541
|
+
radiusX * (cosine - amount * sine) * sinAngle +
|
|
542
|
+
radiusY * (sine + amount * cosine) * cosAngle,
|
|
543
|
+
});
|
|
544
|
+
segments.push({
|
|
545
|
+
x1: toControl(startCos, startSin, coefficient).x,
|
|
546
|
+
y1: toControl(startCos, startSin, coefficient).y,
|
|
547
|
+
x2: toControl(endCos, endSin, -coefficient).x,
|
|
548
|
+
y2: toControl(endCos, endSin, -coefficient).y,
|
|
549
|
+
x: toPoint(endCos, endSin).x,
|
|
550
|
+
y: toPoint(endCos, endSin).y,
|
|
551
|
+
});
|
|
552
|
+
angleCursor = nextAngle;
|
|
553
|
+
}
|
|
554
|
+
return segments;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Parses the full SVG path grammar (M L H V C S Q T A Z, absolute and relative)
|
|
558
|
+
// into flattened contours and polyline segments. Used for canonical PATH nodes
|
|
559
|
+
// and SVG media paths.
|
|
560
|
+
function parsePathData(value) {
|
|
561
|
+
if (typeof value !== "string") return undefined;
|
|
562
|
+
const tokens = value.match(PATH_TOKEN);
|
|
563
|
+
if (!tokens) return undefined;
|
|
564
|
+
const segments = [];
|
|
565
|
+
const contours = [];
|
|
566
|
+
const closedContours = [];
|
|
567
|
+
let contour = [];
|
|
568
|
+
let command;
|
|
569
|
+
let index = 0;
|
|
570
|
+
let cursor = { x: 0, y: 0 };
|
|
571
|
+
let subpathStart = cursor;
|
|
572
|
+
let previousControl;
|
|
573
|
+
const readNumber = () => {
|
|
574
|
+
const token = tokens[index++];
|
|
575
|
+
const parsed = Number.parseFloat(token);
|
|
576
|
+
if (!Number.isFinite(parsed)) throw new Error(`invalid path number ${token}`);
|
|
577
|
+
return parsed;
|
|
578
|
+
};
|
|
579
|
+
const pushLine = (next) => {
|
|
580
|
+
segments.push({ end: next, start: cursor });
|
|
581
|
+
contour.push(next);
|
|
582
|
+
cursor = next;
|
|
583
|
+
};
|
|
584
|
+
const pushCubic = (cubic, current) => {
|
|
585
|
+
for (let step = 1; step <= PATH_CURVE_STEPS; step += 1) {
|
|
586
|
+
const amount = step / PATH_CURVE_STEPS;
|
|
587
|
+
const inverseAmount = 1 - amount;
|
|
588
|
+
contour.push({
|
|
589
|
+
x:
|
|
590
|
+
inverseAmount ** 3 * current.x +
|
|
591
|
+
3 * inverseAmount * inverseAmount * amount * cubic.x1 +
|
|
592
|
+
3 * inverseAmount * amount * amount * cubic.x2 +
|
|
593
|
+
amount ** 3 * cubic.x,
|
|
594
|
+
y:
|
|
595
|
+
inverseAmount ** 3 * current.y +
|
|
596
|
+
3 * inverseAmount * inverseAmount * amount * cubic.y1 +
|
|
597
|
+
3 * inverseAmount * amount * amount * cubic.y2 +
|
|
598
|
+
amount ** 3 * cubic.y,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
segments.push({ end: contour.at(-1), start: current });
|
|
602
|
+
return contour.at(-1);
|
|
603
|
+
};
|
|
604
|
+
try {
|
|
605
|
+
while (index < tokens.length) {
|
|
606
|
+
if (/^[a-zA-Z]$/.test(tokens[index])) command = tokens[index++];
|
|
607
|
+
else if (!command) return undefined;
|
|
608
|
+
const relative = command === command.toLowerCase();
|
|
609
|
+
const normalized = command.toUpperCase();
|
|
610
|
+
const reflect = (x, y) =>
|
|
611
|
+
previousControl
|
|
612
|
+
? { x: 2 * cursor.x - previousControl.x, y: 2 * cursor.y - previousControl.y }
|
|
613
|
+
: { x: cursor.x, y: cursor.y };
|
|
614
|
+
if (normalized === "Z") {
|
|
615
|
+
if (contour.length > 0) {
|
|
616
|
+
segments.push({ end: subpathStart, start: cursor });
|
|
617
|
+
if (contour.length > 2) {
|
|
618
|
+
contours.push(contour);
|
|
619
|
+
closedContours.push(true);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
contour = [];
|
|
623
|
+
cursor = subpathStart;
|
|
624
|
+
command = undefined;
|
|
625
|
+
previousControl = undefined;
|
|
626
|
+
} else if (normalized === "M") {
|
|
627
|
+
if (contour.length > 2) {
|
|
628
|
+
contours.push(contour);
|
|
629
|
+
closedContours.push(false);
|
|
630
|
+
}
|
|
631
|
+
contour = [];
|
|
632
|
+
const next = {
|
|
633
|
+
x: readNumber() + (relative ? cursor.x : 0),
|
|
634
|
+
y: readNumber() + (relative ? cursor.y : 0),
|
|
635
|
+
};
|
|
636
|
+
contour.push(next);
|
|
637
|
+
cursor = next;
|
|
638
|
+
subpathStart = next;
|
|
639
|
+
previousControl = undefined;
|
|
640
|
+
command = relative ? "l" : "L";
|
|
641
|
+
} else if (normalized === "L") {
|
|
642
|
+
pushLine({
|
|
643
|
+
x: readNumber() + (relative ? cursor.x : 0),
|
|
644
|
+
y: readNumber() + (relative ? cursor.y : 0),
|
|
645
|
+
});
|
|
646
|
+
previousControl = undefined;
|
|
647
|
+
} else if (normalized === "H") {
|
|
648
|
+
pushLine({ x: readNumber() + (relative ? cursor.x : 0), y: cursor.y });
|
|
649
|
+
previousControl = undefined;
|
|
650
|
+
} else if (normalized === "V") {
|
|
651
|
+
pushLine({ x: cursor.x, y: readNumber() + (relative ? cursor.y : 0) });
|
|
652
|
+
previousControl = undefined;
|
|
653
|
+
} else if (normalized === "C") {
|
|
654
|
+
const cubic = {
|
|
655
|
+
x1: readNumber() + (relative ? cursor.x : 0),
|
|
656
|
+
y1: readNumber() + (relative ? cursor.y : 0),
|
|
657
|
+
x2: readNumber() + (relative ? cursor.x : 0),
|
|
658
|
+
y2: readNumber() + (relative ? cursor.y : 0),
|
|
659
|
+
x: readNumber() + (relative ? cursor.x : 0),
|
|
660
|
+
y: readNumber() + (relative ? cursor.y : 0),
|
|
661
|
+
};
|
|
662
|
+
previousControl = { x: cubic.x2, y: cubic.y2 };
|
|
663
|
+
cursor = pushCubic(cubic, cursor);
|
|
664
|
+
} else if (normalized === "S") {
|
|
665
|
+
const first = reflect(cursor.x, cursor.y);
|
|
666
|
+
const cubic = {
|
|
667
|
+
x1: first.x,
|
|
668
|
+
y1: first.y,
|
|
669
|
+
x2: readNumber() + (relative ? cursor.x : 0),
|
|
670
|
+
y2: readNumber() + (relative ? cursor.y : 0),
|
|
671
|
+
x: readNumber() + (relative ? cursor.x : 0),
|
|
672
|
+
y: readNumber() + (relative ? cursor.y : 0),
|
|
673
|
+
};
|
|
674
|
+
previousControl = { x: cubic.x2, y: cubic.y2 };
|
|
675
|
+
cursor = pushCubic(cubic, cursor);
|
|
676
|
+
} else if (normalized === "Q") {
|
|
677
|
+
const control = {
|
|
678
|
+
x: readNumber() + (relative ? cursor.x : 0),
|
|
679
|
+
y: readNumber() + (relative ? cursor.y : 0),
|
|
680
|
+
};
|
|
681
|
+
const cubic = {
|
|
682
|
+
x1: control.x,
|
|
683
|
+
y1: control.y,
|
|
684
|
+
x2: control.x,
|
|
685
|
+
y2: control.y,
|
|
686
|
+
x: readNumber() + (relative ? cursor.x : 0),
|
|
687
|
+
y: readNumber() + (relative ? cursor.y : 0),
|
|
688
|
+
};
|
|
689
|
+
previousControl = control;
|
|
690
|
+
cursor = pushCubic(cubic, cursor);
|
|
691
|
+
} else if (normalized === "T") {
|
|
692
|
+
const control = reflect(cursor.x, cursor.y);
|
|
693
|
+
const cubic = {
|
|
694
|
+
x1: control.x,
|
|
695
|
+
y1: control.y,
|
|
696
|
+
x2: control.x,
|
|
697
|
+
y2: control.y,
|
|
698
|
+
x: readNumber() + (relative ? cursor.x : 0),
|
|
699
|
+
y: readNumber() + (relative ? cursor.y : 0),
|
|
700
|
+
};
|
|
701
|
+
previousControl = control;
|
|
702
|
+
cursor = pushCubic(cubic, cursor);
|
|
703
|
+
} else if (normalized === "A") {
|
|
704
|
+
const rx = readNumber();
|
|
705
|
+
const ry = readNumber();
|
|
706
|
+
const rotation = readNumber();
|
|
707
|
+
const largeArc = readNumber() !== 0;
|
|
708
|
+
const sweep = readNumber() !== 0;
|
|
709
|
+
const next = {
|
|
710
|
+
x: readNumber() + (relative ? cursor.x : 0),
|
|
711
|
+
y: readNumber() + (relative ? cursor.y : 0),
|
|
712
|
+
};
|
|
713
|
+
for (const cubic of arcToCubicSegments(
|
|
714
|
+
cursor,
|
|
715
|
+
rx,
|
|
716
|
+
ry,
|
|
717
|
+
rotation,
|
|
718
|
+
largeArc,
|
|
719
|
+
sweep,
|
|
720
|
+
next,
|
|
721
|
+
)) {
|
|
722
|
+
cursor = pushCubic(cubic, cursor);
|
|
723
|
+
}
|
|
724
|
+
cursor = next;
|
|
725
|
+
previousControl = undefined;
|
|
726
|
+
} else {
|
|
727
|
+
return undefined;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
} catch {
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
if (contour.length > 2) {
|
|
734
|
+
contours.push(contour);
|
|
735
|
+
closedContours.push(false);
|
|
736
|
+
}
|
|
737
|
+
if (segments.length === 0) return undefined;
|
|
738
|
+
const cumulative = [];
|
|
739
|
+
let totalLength = 0;
|
|
740
|
+
for (const segment of segments) {
|
|
741
|
+
totalLength += Math.hypot(segment.end.x - segment.start.x, segment.end.y - segment.start.y);
|
|
742
|
+
cumulative.push(totalLength);
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
closedContours,
|
|
746
|
+
contours,
|
|
747
|
+
cumulative,
|
|
748
|
+
end: segments.at(-1).end,
|
|
749
|
+
segments,
|
|
750
|
+
start: segments[0].start,
|
|
751
|
+
totalLength,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function directionAt(geometry, position) {
|
|
756
|
+
const index = Math.min(
|
|
757
|
+
geometry.segments.length - 1,
|
|
758
|
+
typeof position === "object" ? position.segmentIndex ?? 0 : position,
|
|
759
|
+
);
|
|
760
|
+
const segment = geometry.segments[index];
|
|
761
|
+
const dx = segment.end.x - segment.start.x;
|
|
762
|
+
const dy = segment.end.y - segment.start.y;
|
|
763
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
764
|
+
return { x: dx / length, y: dy / length };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function pathFillContains(geometry, x, y, evenOdd = false) {
|
|
768
|
+
return evenOdd
|
|
769
|
+
? contourWindingCount(geometry.contours, x, y) % 2 === 1
|
|
770
|
+
: windingNumber(geometry.contours, x, y) !== 0;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function dashPattern(stroke, width) {
|
|
774
|
+
const style = stroke.style ?? "solid";
|
|
775
|
+
if (style === "solid") return undefined;
|
|
776
|
+
const scale = Math.max(1, width);
|
|
777
|
+
if (style === "dashed") {
|
|
778
|
+
return { on: stroke.dash ?? scale * 3, period: (stroke.dash ?? scale * 3) + (stroke.gap ?? scale * 3) };
|
|
779
|
+
}
|
|
780
|
+
if (style === "dotted") {
|
|
781
|
+
const on = stroke.dash ?? scale;
|
|
782
|
+
return { on, period: on + (stroke.gap ?? scale) };
|
|
783
|
+
}
|
|
784
|
+
// "mixed": dash, gap, dot, gap — deterministic alternating pattern.
|
|
785
|
+
const dash = stroke.dash ?? scale * 3;
|
|
786
|
+
const dot = scale;
|
|
787
|
+
const gap = stroke.gap ?? scale * 2;
|
|
788
|
+
return { on: dash, period: dash + gap + dot + gap, mixed: { dash, dot, gap } };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function dashOnAt(pattern, distance) {
|
|
792
|
+
if (!pattern) return true;
|
|
793
|
+
let offset = distance % pattern.period;
|
|
794
|
+
if (offset < 0) offset += pattern.period;
|
|
795
|
+
if (!pattern.mixed) return offset < pattern.on;
|
|
796
|
+
if (offset < pattern.mixed.dash) return true;
|
|
797
|
+
offset -= pattern.mixed.dash + pattern.mixed.gap;
|
|
798
|
+
if (offset < 0) return false;
|
|
799
|
+
return offset < pattern.mixed.dot;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function pathStrokeContains(
|
|
803
|
+
geometry,
|
|
804
|
+
x,
|
|
805
|
+
y,
|
|
806
|
+
width,
|
|
807
|
+
pattern,
|
|
808
|
+
alignment,
|
|
809
|
+
fillInside,
|
|
810
|
+
) {
|
|
811
|
+
const halfWidth = alignment === "center" || alignment === undefined
|
|
812
|
+
? width / 2
|
|
813
|
+
: width;
|
|
814
|
+
for (let index = 0; index < geometry.segments.length; index += 1) {
|
|
815
|
+
const segment = geometry.segments[index];
|
|
816
|
+
const dx = segment.end.x - segment.start.x;
|
|
817
|
+
const dy = segment.end.y - segment.start.y;
|
|
818
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
819
|
+
const amount = lengthSquared === 0
|
|
820
|
+
? 0
|
|
821
|
+
: clamp(((x - segment.start.x) * dx + (y - segment.start.y) * dy) / lengthSquared, 0, 1);
|
|
822
|
+
const distance = Math.hypot(
|
|
823
|
+
x - (segment.start.x + amount * dx),
|
|
824
|
+
y - (segment.start.y + amount * dy),
|
|
825
|
+
);
|
|
826
|
+
if (distance > halfWidth) continue;
|
|
827
|
+
const startLength = index === 0
|
|
828
|
+
? 0
|
|
829
|
+
: geometry.cumulative[index - 1];
|
|
830
|
+
if (!dashOnAt(pattern, startLength + amount * Math.sqrt(lengthSquared))) {
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
if (alignment === "inner" && !fillInside) continue;
|
|
834
|
+
if (alignment === "outer" && fillInside) continue;
|
|
835
|
+
return true;
|
|
836
|
+
}
|
|
837
|
+
return false;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const MARKER_SIDES = 24;
|
|
841
|
+
|
|
842
|
+
function markerGeometry(cap, point, direction, width) {
|
|
843
|
+
const size = Math.max(3, width * 2.5);
|
|
844
|
+
const normal = { x: -direction.y, y: direction.x };
|
|
845
|
+
const at = (along, across) => ({
|
|
846
|
+
x: point.x + direction.x * along + normal.x * across,
|
|
847
|
+
y: point.y + direction.y * along + normal.y * across,
|
|
848
|
+
});
|
|
849
|
+
if (cap === "round") {
|
|
850
|
+
const radius = width / 2;
|
|
851
|
+
const contour = [];
|
|
852
|
+
for (let index = 0; index < MARKER_SIDES; index += 1) {
|
|
853
|
+
const angle = (index / MARKER_SIDES) * Math.PI * 2;
|
|
854
|
+
contour.push({
|
|
855
|
+
x: point.x + Math.cos(angle) * radius,
|
|
856
|
+
y: point.y + Math.sin(angle) * radius,
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
return { contours: [contour], segments: [] };
|
|
860
|
+
}
|
|
861
|
+
if (cap === "square") {
|
|
862
|
+
const half = width / 2;
|
|
863
|
+
return {
|
|
864
|
+
contours: [[
|
|
865
|
+
at(0, -half),
|
|
866
|
+
at(half, -half),
|
|
867
|
+
at(half, half),
|
|
868
|
+
at(0, half),
|
|
869
|
+
]],
|
|
870
|
+
segments: [],
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
if (cap === "triangle-arrow") {
|
|
874
|
+
const length = Math.max(6, width * 4);
|
|
875
|
+
const half = size * 0.9;
|
|
876
|
+
return {
|
|
877
|
+
contours: [[
|
|
878
|
+
point,
|
|
879
|
+
at(-length, half),
|
|
880
|
+
at(-length, -half),
|
|
881
|
+
]],
|
|
882
|
+
segments: [],
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
if (cap === "circle-marker") {
|
|
886
|
+
const contour = [];
|
|
887
|
+
for (let index = 0; index < MARKER_SIDES; index += 1) {
|
|
888
|
+
const angle = (index / MARKER_SIDES) * Math.PI * 2;
|
|
889
|
+
contour.push({
|
|
890
|
+
x: point.x + Math.cos(angle) * size,
|
|
891
|
+
y: point.y + Math.sin(angle) * size,
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
return { contours: [contour], segments: [] };
|
|
895
|
+
}
|
|
896
|
+
if (cap === "square-marker") {
|
|
897
|
+
return {
|
|
898
|
+
contours: [[
|
|
899
|
+
at(-size, size),
|
|
900
|
+
at(-size, -size),
|
|
901
|
+
at(size, -size),
|
|
902
|
+
at(size, size),
|
|
903
|
+
]],
|
|
904
|
+
segments: [],
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
if (cap === "diamond-marker") {
|
|
908
|
+
return {
|
|
909
|
+
contours: [[
|
|
910
|
+
at(0, size),
|
|
911
|
+
at(-size, 0),
|
|
912
|
+
at(0, -size),
|
|
913
|
+
at(size, 0),
|
|
914
|
+
]],
|
|
915
|
+
segments: [],
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
if (cap === "line-arrow") {
|
|
919
|
+
const length = Math.max(6, width * 4);
|
|
920
|
+
const spread = Math.PI / 7;
|
|
921
|
+
const back = {
|
|
922
|
+
x: -direction.x * Math.cos(spread) - -direction.y * Math.sin(spread),
|
|
923
|
+
y: -direction.y * Math.cos(spread) + -direction.x * Math.sin(spread),
|
|
924
|
+
};
|
|
925
|
+
const backOther = {
|
|
926
|
+
x: -direction.x * Math.cos(spread) - direction.y * Math.sin(spread),
|
|
927
|
+
y: -direction.y * Math.cos(spread) + direction.x * Math.sin(spread),
|
|
928
|
+
};
|
|
929
|
+
return {
|
|
930
|
+
contours: [],
|
|
931
|
+
segments: [
|
|
932
|
+
{
|
|
933
|
+
end: { x: point.x + back.x * length, y: point.y + back.y * length },
|
|
934
|
+
start: point,
|
|
935
|
+
},
|
|
936
|
+
{
|
|
937
|
+
end: {
|
|
938
|
+
x: point.x + backOther.x * length,
|
|
939
|
+
y: point.y + backOther.y * length,
|
|
940
|
+
},
|
|
941
|
+
start: point,
|
|
942
|
+
},
|
|
943
|
+
],
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function capExtensions(geometry, width, pattern) {
|
|
950
|
+
// "square" caps extend the open ends by half the stroke width; "round" caps
|
|
951
|
+
// already fall out of the distance test.
|
|
952
|
+
if (!pattern && width <= 0) return undefined;
|
|
953
|
+
const segments = [];
|
|
954
|
+
const half = width / 2;
|
|
955
|
+
const first = geometry.segments[0];
|
|
956
|
+
if (first) {
|
|
957
|
+
const direction = directionAt(geometry, { segmentIndex: 0 });
|
|
958
|
+
segments.push({
|
|
959
|
+
end: first.start,
|
|
960
|
+
start: {
|
|
961
|
+
x: first.start.x - direction.x * half,
|
|
962
|
+
y: first.start.y - direction.y * half,
|
|
963
|
+
},
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
const last = geometry.segments.at(-1);
|
|
967
|
+
if (last) {
|
|
968
|
+
const direction = directionAt(geometry, {
|
|
969
|
+
segmentIndex: geometry.segments.length - 1,
|
|
970
|
+
});
|
|
971
|
+
segments.push({
|
|
972
|
+
end: {
|
|
973
|
+
x: last.end.x + direction.x * half,
|
|
974
|
+
y: last.end.y + direction.y * half,
|
|
975
|
+
},
|
|
976
|
+
start: last.end,
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
return segments.length ? { contours: [], segments } : undefined;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const WHITESPACE = /\s/u;
|
|
983
|
+
|
|
984
|
+
function spaceAdvance(font) {
|
|
985
|
+
const space = font.charToGlyph(" ");
|
|
986
|
+
const advance = space.advanceWidth ?? 0;
|
|
987
|
+
return advance > 0 ? advance : font.unitsPerEm * 0.25;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function lineGlyphs(
|
|
991
|
+
font,
|
|
992
|
+
symbolFont,
|
|
993
|
+
text,
|
|
994
|
+
x,
|
|
995
|
+
baseline,
|
|
996
|
+
size,
|
|
997
|
+
letterSpacing,
|
|
998
|
+
outlines = true,
|
|
999
|
+
missing,
|
|
1000
|
+
) {
|
|
1001
|
+
const glyphs = [...text].map((character) => {
|
|
1002
|
+
if (WHITESPACE.test(character)) {
|
|
1003
|
+
// Whitespace never draws ink, even when the font lacks the codepoint.
|
|
1004
|
+
return {
|
|
1005
|
+
advanceWidth: spaceAdvance(font),
|
|
1006
|
+
font,
|
|
1007
|
+
glyph: font.charToGlyph(" "),
|
|
1008
|
+
whitespace: true,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
const glyph = font.charToGlyph(character);
|
|
1012
|
+
if (glyph.index === 0 && symbolFont.charToGlyphIndex(character) !== 0) {
|
|
1013
|
+
return {
|
|
1014
|
+
advanceWidth: symbolFont.unitsPerEm,
|
|
1015
|
+
font: symbolFont,
|
|
1016
|
+
glyph: symbolFont.charToGlyph(character),
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
if (glyph.index === 0) {
|
|
1020
|
+
missing?.add(character);
|
|
1021
|
+
return {
|
|
1022
|
+
advanceWidth: spaceAdvance(font),
|
|
1023
|
+
font,
|
|
1024
|
+
glyph,
|
|
1025
|
+
missing: true,
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
return { font, glyph };
|
|
1029
|
+
});
|
|
1030
|
+
const result = [];
|
|
1031
|
+
let cursor = x;
|
|
1032
|
+
for (let index = 0; index < glyphs.length; index += 1) {
|
|
1033
|
+
const { advanceWidth, font: glyphFont, glyph, missing: isMissing, whitespace } = glyphs[index];
|
|
1034
|
+
if (index > 0 && glyphs[index - 1].font === glyphFont) {
|
|
1035
|
+
cursor +=
|
|
1036
|
+
(glyphFont.getKerningValue(glyphs[index - 1].glyph, glyph) * size) /
|
|
1037
|
+
glyphFont.unitsPerEm;
|
|
1038
|
+
}
|
|
1039
|
+
if (outlines && !isMissing && !whitespace) {
|
|
1040
|
+
const contours = flattenPath(glyph.getPath(cursor, baseline, size));
|
|
1041
|
+
if (contours.length > 0) result.push({ bounds: contourBounds(contours), contours });
|
|
1042
|
+
}
|
|
1043
|
+
cursor +=
|
|
1044
|
+
((advanceWidth ?? glyph.advanceWidth ?? glyphFont.unitsPerEm) * size) /
|
|
1045
|
+
glyphFont.unitsPerEm +
|
|
1046
|
+
letterSpacing;
|
|
1047
|
+
}
|
|
1048
|
+
return { glyphs: result, width: Math.max(0, cursor - x - letterSpacing) };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function wrapTextLines(source, width, measure) {
|
|
1052
|
+
const lines = [];
|
|
1053
|
+
for (const paragraph of source.split("\n")) {
|
|
1054
|
+
let line = "";
|
|
1055
|
+
// Preserve spaces and tabs, allowing a break after each (break-spaces).
|
|
1056
|
+
// Runs without these break opportunities keep their existing overflow.
|
|
1057
|
+
for (const run of paragraph.split(/(?<=[ \t])/u)) {
|
|
1058
|
+
const candidate = line + run;
|
|
1059
|
+
if (line && measure(candidate) > width) {
|
|
1060
|
+
lines.push(line);
|
|
1061
|
+
line = run;
|
|
1062
|
+
} else {
|
|
1063
|
+
line = candidate;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
lines.push(line);
|
|
1067
|
+
}
|
|
1068
|
+
return lines;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function typographicAscender(font) {
|
|
1072
|
+
const value = Number(font.tables?.os2?.sTypoAscender);
|
|
1073
|
+
return Number.isFinite(value) && value > 0 ? value : font.ascender;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function renderGlyph(
|
|
1077
|
+
state,
|
|
1078
|
+
node,
|
|
1079
|
+
matrix,
|
|
1080
|
+
inverseMatrix,
|
|
1081
|
+
glyph,
|
|
1082
|
+
color,
|
|
1083
|
+
opacity,
|
|
1084
|
+
clips,
|
|
1085
|
+
) {
|
|
1086
|
+
const bounds = transformedRectangleBounds(matrix, glyph.bounds);
|
|
1087
|
+
const left = clamp(Math.floor(bounds.left * state.scale), 0, state.width);
|
|
1088
|
+
const right = clamp(Math.ceil(bounds.right * state.scale), 0, state.width);
|
|
1089
|
+
const top = clamp(Math.floor(bounds.top * state.scale), 0, state.height);
|
|
1090
|
+
const bottom = clamp(Math.ceil(bounds.bottom * state.scale), 0, state.height);
|
|
1091
|
+
for (let pixelY = top; pixelY < bottom; pixelY += 1) {
|
|
1092
|
+
for (let pixelX = left; pixelX < right; pixelX += 1) {
|
|
1093
|
+
let covered = 0;
|
|
1094
|
+
for (const sampleY of GLYPH_SAMPLES) {
|
|
1095
|
+
for (const sampleX of GLYPH_SAMPLES) {
|
|
1096
|
+
const local = point(
|
|
1097
|
+
inverseMatrix,
|
|
1098
|
+
(pixelX + sampleX) / state.scale,
|
|
1099
|
+
(pixelY + sampleY) / state.scale,
|
|
1100
|
+
);
|
|
1101
|
+
if (
|
|
1102
|
+
local.x >= 0 &&
|
|
1103
|
+
local.y >= 0 &&
|
|
1104
|
+
local.x <= node.width &&
|
|
1105
|
+
local.y <= node.height &&
|
|
1106
|
+
windingNumber(glyph.contours, local.x, local.y) !== 0 &&
|
|
1107
|
+
clipsAllow(clips, (pixelX + sampleX) / state.scale, (pixelY + sampleY) / state.scale)
|
|
1108
|
+
) {
|
|
1109
|
+
covered += 1;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
if (covered > 0) {
|
|
1114
|
+
const coverage =
|
|
1115
|
+
covered / (GLYPH_SAMPLES.length * GLYPH_SAMPLES.length);
|
|
1116
|
+
blend(
|
|
1117
|
+
state.pixels,
|
|
1118
|
+
(pixelY * state.width + pixelX) * 4,
|
|
1119
|
+
color,
|
|
1120
|
+
opacity * coverage ** GLYPH_COVERAGE_EXPONENT,
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function renderTextDecoration(
|
|
1128
|
+
state,
|
|
1129
|
+
node,
|
|
1130
|
+
matrix,
|
|
1131
|
+
inverseMatrix,
|
|
1132
|
+
font,
|
|
1133
|
+
style,
|
|
1134
|
+
x,
|
|
1135
|
+
baseline,
|
|
1136
|
+
width,
|
|
1137
|
+
color,
|
|
1138
|
+
opacity,
|
|
1139
|
+
clips,
|
|
1140
|
+
) {
|
|
1141
|
+
const underline = style.textDecoration === "underline";
|
|
1142
|
+
if (!underline && style.textDecoration !== "line-through") return;
|
|
1143
|
+
if (width <= 0) return;
|
|
1144
|
+
const size = Number(style.fontSize ?? 14);
|
|
1145
|
+
const units = size / font.unitsPerEm;
|
|
1146
|
+
const metricPosition = Number(
|
|
1147
|
+
underline
|
|
1148
|
+
? font.tables?.post?.underlinePosition
|
|
1149
|
+
: font.tables?.os2?.yStrikeoutPosition,
|
|
1150
|
+
);
|
|
1151
|
+
const metricThickness = Number(
|
|
1152
|
+
underline
|
|
1153
|
+
? font.tables?.post?.underlineThickness
|
|
1154
|
+
: font.tables?.os2?.yStrikeoutSize,
|
|
1155
|
+
);
|
|
1156
|
+
const position = Number.isFinite(metricPosition)
|
|
1157
|
+
? metricPosition * units
|
|
1158
|
+
: size * (underline ? -0.1 : 0.3);
|
|
1159
|
+
const thickness = Math.max(
|
|
1160
|
+
1,
|
|
1161
|
+
Number.isFinite(metricThickness) && metricThickness > 0
|
|
1162
|
+
? metricThickness * units
|
|
1163
|
+
: size * 0.05,
|
|
1164
|
+
);
|
|
1165
|
+
const top = baseline - position - thickness / 2;
|
|
1166
|
+
const bounds = { bottom: top + thickness, left: x, right: x + width, top };
|
|
1167
|
+
const contours = [
|
|
1168
|
+
[
|
|
1169
|
+
{ x: bounds.left, y: bounds.top },
|
|
1170
|
+
{ x: bounds.right, y: bounds.top },
|
|
1171
|
+
{ x: bounds.right, y: bounds.bottom },
|
|
1172
|
+
{ x: bounds.left, y: bounds.bottom },
|
|
1173
|
+
],
|
|
1174
|
+
];
|
|
1175
|
+
renderGlyph(
|
|
1176
|
+
state,
|
|
1177
|
+
node,
|
|
1178
|
+
matrix,
|
|
1179
|
+
inverseMatrix,
|
|
1180
|
+
{ bounds, contours },
|
|
1181
|
+
color,
|
|
1182
|
+
opacity,
|
|
1183
|
+
clips,
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function richTextRun(state, node, style, fills, path) {
|
|
1188
|
+
const font = resolveFont(state, style, node.id, path);
|
|
1189
|
+
const size = Number(style.fontSize ?? 14);
|
|
1190
|
+
const height = Number(style.lineHeight ?? 1.2);
|
|
1191
|
+
const lineHeight = height <= 4 ? size * height : height;
|
|
1192
|
+
const ascent =
|
|
1193
|
+
(lineHeight - size) / 2 +
|
|
1194
|
+
(typographicAscender(font) * size) / font.unitsPerEm;
|
|
1195
|
+
const color = fills?.[0]
|
|
1196
|
+
? paintColor(
|
|
1197
|
+
fills[0],
|
|
1198
|
+
0,
|
|
1199
|
+
0,
|
|
1200
|
+
node,
|
|
1201
|
+
state.colors,
|
|
1202
|
+
state.diagnostics,
|
|
1203
|
+
path,
|
|
1204
|
+
state.productPackageId,
|
|
1205
|
+
)
|
|
1206
|
+
: [32, 33, 36, 255];
|
|
1207
|
+
return {
|
|
1208
|
+
ascent,
|
|
1209
|
+
color,
|
|
1210
|
+
descent: lineHeight - ascent,
|
|
1211
|
+
font,
|
|
1212
|
+
size,
|
|
1213
|
+
spacing: Number(style.letterSpacing ?? 0),
|
|
1214
|
+
style,
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function richLineLayout(characters) {
|
|
1219
|
+
let width = 0;
|
|
1220
|
+
let previous;
|
|
1221
|
+
const placed = characters.map((character) => {
|
|
1222
|
+
if (previous) {
|
|
1223
|
+
width += previous.run.spacing;
|
|
1224
|
+
if (
|
|
1225
|
+
previous.font === character.font &&
|
|
1226
|
+
previous.run.size === character.run.size
|
|
1227
|
+
) {
|
|
1228
|
+
width +=
|
|
1229
|
+
(character.font.getKerningValue(previous.glyph, character.glyph) *
|
|
1230
|
+
character.run.size) /
|
|
1231
|
+
character.font.unitsPerEm;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
const x = width;
|
|
1235
|
+
width += character.advance;
|
|
1236
|
+
previous = character;
|
|
1237
|
+
return { ...character, x };
|
|
1238
|
+
});
|
|
1239
|
+
return { placed, width: Math.max(0, width) };
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function richTextLines(state, node, path, missing) {
|
|
1243
|
+
const lines = [];
|
|
1244
|
+
for (const block of node.textBlocks) {
|
|
1245
|
+
const blockStyle = {
|
|
1246
|
+
...(node.textStyle ?? {}),
|
|
1247
|
+
...(block.textStyle ?? {}),
|
|
1248
|
+
};
|
|
1249
|
+
const paragraphs = [{ characters: [], emptyRun: undefined }];
|
|
1250
|
+
for (const sourceRun of block.runs ?? []) {
|
|
1251
|
+
const style = { ...blockStyle, ...(sourceRun.textStyle ?? {}) };
|
|
1252
|
+
const run = richTextRun(
|
|
1253
|
+
state,
|
|
1254
|
+
node,
|
|
1255
|
+
style,
|
|
1256
|
+
sourceRun.fills ?? node.fills,
|
|
1257
|
+
path,
|
|
1258
|
+
);
|
|
1259
|
+
const source =
|
|
1260
|
+
style.textTransform === "uppercase"
|
|
1261
|
+
? sourceRun.text.toUpperCase()
|
|
1262
|
+
: style.textTransform === "lowercase"
|
|
1263
|
+
? sourceRun.text.toLowerCase()
|
|
1264
|
+
: sourceRun.text;
|
|
1265
|
+
paragraphs.at(-1).emptyRun ??= run;
|
|
1266
|
+
for (const character of source) {
|
|
1267
|
+
if (character === "\n") {
|
|
1268
|
+
paragraphs.push({ characters: [], emptyRun: run });
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
if (WHITESPACE.test(character)) {
|
|
1272
|
+
paragraphs
|
|
1273
|
+
.at(-1)
|
|
1274
|
+
.characters.push({
|
|
1275
|
+
character,
|
|
1276
|
+
run,
|
|
1277
|
+
font: run.font,
|
|
1278
|
+
glyph: run.font.charToGlyph(" "),
|
|
1279
|
+
advance: (spaceAdvance(run.font) * run.size) / run.font.unitsPerEm,
|
|
1280
|
+
whitespace: true,
|
|
1281
|
+
});
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
let font = run.font;
|
|
1285
|
+
let glyph = font.charToGlyph(character);
|
|
1286
|
+
let advanceWidth = glyph.advanceWidth ?? font.unitsPerEm;
|
|
1287
|
+
if (
|
|
1288
|
+
glyph.index === 0 &&
|
|
1289
|
+
state.fonts.symbols.charToGlyphIndex(character) !== 0
|
|
1290
|
+
) {
|
|
1291
|
+
font = state.fonts.symbols;
|
|
1292
|
+
glyph = font.charToGlyph(character);
|
|
1293
|
+
advanceWidth = font.unitsPerEm;
|
|
1294
|
+
} else if (glyph.index === 0) {
|
|
1295
|
+
missing?.add(character);
|
|
1296
|
+
advanceWidth = spaceAdvance(font);
|
|
1297
|
+
}
|
|
1298
|
+
paragraphs
|
|
1299
|
+
.at(-1)
|
|
1300
|
+
.characters.push({
|
|
1301
|
+
character,
|
|
1302
|
+
run,
|
|
1303
|
+
font,
|
|
1304
|
+
glyph,
|
|
1305
|
+
whitespace: glyph.index === 0,
|
|
1306
|
+
advance: (advanceWidth * run.size) / font.unitsPerEm,
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
const finish = (characters, emptyRun) => {
|
|
1311
|
+
const runs = characters.length
|
|
1312
|
+
? characters.map(({ run }) => run)
|
|
1313
|
+
: [emptyRun ?? richTextRun(state, node, blockStyle, node.fills, path)];
|
|
1314
|
+
const ascent = runs.reduce(
|
|
1315
|
+
(maximum, run) => Math.max(maximum, run.ascent),
|
|
1316
|
+
-Infinity,
|
|
1317
|
+
);
|
|
1318
|
+
const descent = runs.reduce(
|
|
1319
|
+
(maximum, run) => Math.max(maximum, run.descent),
|
|
1320
|
+
-Infinity,
|
|
1321
|
+
);
|
|
1322
|
+
lines.push({
|
|
1323
|
+
...richLineLayout(characters),
|
|
1324
|
+
ascent,
|
|
1325
|
+
height: ascent + descent,
|
|
1326
|
+
align: blockStyle.textAlign,
|
|
1327
|
+
});
|
|
1328
|
+
};
|
|
1329
|
+
for (const paragraph of paragraphs) {
|
|
1330
|
+
let line = [];
|
|
1331
|
+
let word = [];
|
|
1332
|
+
const appendWord = () => {
|
|
1333
|
+
const candidate = line.concat(word);
|
|
1334
|
+
if (
|
|
1335
|
+
line.length &&
|
|
1336
|
+
node.growType !== "auto-width" &&
|
|
1337
|
+
richLineLayout(candidate).width > node.width
|
|
1338
|
+
) {
|
|
1339
|
+
finish(line);
|
|
1340
|
+
line = word;
|
|
1341
|
+
} else {
|
|
1342
|
+
line = candidate;
|
|
1343
|
+
}
|
|
1344
|
+
word = [];
|
|
1345
|
+
};
|
|
1346
|
+
for (const character of paragraph.characters) {
|
|
1347
|
+
word.push(character);
|
|
1348
|
+
if (character.character === " " || character.character === "\t")
|
|
1349
|
+
appendWord();
|
|
1350
|
+
}
|
|
1351
|
+
if (word.length) appendWord();
|
|
1352
|
+
finish(line, paragraph.emptyRun);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
return lines;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function renderRichText(state, node, matrix, opacity, path, clips) {
|
|
1359
|
+
const inverseMatrix = inverse(matrix);
|
|
1360
|
+
if (!inverseMatrix) return;
|
|
1361
|
+
const missing = new Set();
|
|
1362
|
+
const lines = richTextLines(state, node, path, missing);
|
|
1363
|
+
if (missing.size > 0) recordMissingGlyphs(state, node, path, missing);
|
|
1364
|
+
const height = lines.reduce((sum, line) => sum + line.height, 0);
|
|
1365
|
+
if (node.growType === "auto-height" && height > node.height) {
|
|
1366
|
+
node.height = height;
|
|
1367
|
+
}
|
|
1368
|
+
let top =
|
|
1369
|
+
node.textStyle?.verticalAlign === "center"
|
|
1370
|
+
? (node.height - height) / 2
|
|
1371
|
+
: node.textStyle?.verticalAlign === "bottom"
|
|
1372
|
+
? node.height - height
|
|
1373
|
+
: 0;
|
|
1374
|
+
for (const line of lines) {
|
|
1375
|
+
const x =
|
|
1376
|
+
line.align === "center"
|
|
1377
|
+
? (node.width - line.width) / 2
|
|
1378
|
+
: line.align === "right"
|
|
1379
|
+
? node.width - line.width
|
|
1380
|
+
: 0;
|
|
1381
|
+
const baseline = top + line.ascent;
|
|
1382
|
+
const spans = [];
|
|
1383
|
+
for (const character of line.placed) {
|
|
1384
|
+
const { run } = character;
|
|
1385
|
+
if (character.whitespace) {
|
|
1386
|
+
pushRichSpan(spans, run, character.x, character.x + character.advance);
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
const contours = flattenPath(
|
|
1390
|
+
character.glyph.getPath(x + character.x, baseline, run.size),
|
|
1391
|
+
);
|
|
1392
|
+
if (contours.length) {
|
|
1393
|
+
renderGlyph(
|
|
1394
|
+
state,
|
|
1395
|
+
node,
|
|
1396
|
+
matrix,
|
|
1397
|
+
inverseMatrix,
|
|
1398
|
+
{ bounds: contourBounds(contours), contours },
|
|
1399
|
+
run.color,
|
|
1400
|
+
opacity,
|
|
1401
|
+
clips,
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
pushRichSpan(spans, run, character.x, character.x + character.advance);
|
|
1405
|
+
}
|
|
1406
|
+
for (const span of spans) {
|
|
1407
|
+
renderTextDecoration(
|
|
1408
|
+
state,
|
|
1409
|
+
node,
|
|
1410
|
+
matrix,
|
|
1411
|
+
inverseMatrix,
|
|
1412
|
+
span.run.font,
|
|
1413
|
+
span.run.style,
|
|
1414
|
+
x + span.left,
|
|
1415
|
+
baseline,
|
|
1416
|
+
span.right - span.left,
|
|
1417
|
+
span.run.color,
|
|
1418
|
+
opacity,
|
|
1419
|
+
clips,
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
top += line.height;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function pushRichSpan(spans, run, left, right) {
|
|
1427
|
+
const previous = spans.at(-1);
|
|
1428
|
+
if (previous?.run === run) previous.right = right;
|
|
1429
|
+
else spans.push({ run, left, right });
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function recordMissingGlyphs(state, node, path, missing) {
|
|
1433
|
+
const existing = state.missingGlyphs.get(node.id) ?? new Set();
|
|
1434
|
+
for (const character of missing) existing.add(character);
|
|
1435
|
+
state.missingGlyphs.set(node.id, existing);
|
|
1436
|
+
state.diagnostics.push({
|
|
1437
|
+
code: "text_missing_glyph",
|
|
1438
|
+
message: `Text node is missing glyphs for: ${[...missing]
|
|
1439
|
+
.map((character) => describeCodepoint(character))
|
|
1440
|
+
.join(", ")}`,
|
|
1441
|
+
nodeId: node.id,
|
|
1442
|
+
path,
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function describeCodepoint(character) {
|
|
1447
|
+
const codePoint = character.codePointAt(0);
|
|
1448
|
+
return `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function renderText(state, node, matrix, opacity, path, clips) {
|
|
1452
|
+
if (!node.text) return;
|
|
1453
|
+
if (node.textBlocks?.length) {
|
|
1454
|
+
renderRichText(state, node, matrix, opacity, path, clips);
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
const style = node.textStyle ?? {};
|
|
1458
|
+
const font = resolveFont(state, style, node.id, path);
|
|
1459
|
+
const fontSize = Number(style.fontSize ?? 14);
|
|
1460
|
+
const lineHeightValue = Number(style.lineHeight ?? 1.2);
|
|
1461
|
+
const lineHeight = lineHeightValue <= 4 ? fontSize * lineHeightValue : lineHeightValue;
|
|
1462
|
+
const letterSpacing = Number(style.letterSpacing ?? 0);
|
|
1463
|
+
const source =
|
|
1464
|
+
style.textTransform === "uppercase"
|
|
1465
|
+
? node.text.toUpperCase()
|
|
1466
|
+
: style.textTransform === "lowercase"
|
|
1467
|
+
? node.text.toLowerCase()
|
|
1468
|
+
: node.text;
|
|
1469
|
+
const missing = new Set();
|
|
1470
|
+
const lines =
|
|
1471
|
+
node.growType === "auto-width"
|
|
1472
|
+
? source.split("\n")
|
|
1473
|
+
: wrapTextLines(
|
|
1474
|
+
source,
|
|
1475
|
+
node.width,
|
|
1476
|
+
(text) =>
|
|
1477
|
+
lineGlyphs(
|
|
1478
|
+
font,
|
|
1479
|
+
state.fonts.symbols,
|
|
1480
|
+
text,
|
|
1481
|
+
0,
|
|
1482
|
+
0,
|
|
1483
|
+
fontSize,
|
|
1484
|
+
letterSpacing,
|
|
1485
|
+
false,
|
|
1486
|
+
).width,
|
|
1487
|
+
);
|
|
1488
|
+
if (node.growType === "auto-height") {
|
|
1489
|
+
const blockHeight = lines.length * lineHeight;
|
|
1490
|
+
if (blockHeight > node.height) node.height = blockHeight;
|
|
1491
|
+
}
|
|
1492
|
+
const blockHeight = lines.length * lineHeight;
|
|
1493
|
+
const verticalOffset =
|
|
1494
|
+
style.verticalAlign === "center"
|
|
1495
|
+
? (node.height - blockHeight) / 2
|
|
1496
|
+
: style.verticalAlign === "bottom"
|
|
1497
|
+
? node.height - blockHeight
|
|
1498
|
+
: 0;
|
|
1499
|
+
const ascender = (typographicAscender(font) * fontSize) / font.unitsPerEm;
|
|
1500
|
+
const textColor = node.fills?.[0]
|
|
1501
|
+
? paintColor(
|
|
1502
|
+
node.fills[0],
|
|
1503
|
+
0,
|
|
1504
|
+
0,
|
|
1505
|
+
node,
|
|
1506
|
+
state.colors,
|
|
1507
|
+
state.diagnostics,
|
|
1508
|
+
path,
|
|
1509
|
+
state.productPackageId,
|
|
1510
|
+
)
|
|
1511
|
+
: [32, 33, 36, 255];
|
|
1512
|
+
const inverseMatrix = inverse(matrix);
|
|
1513
|
+
if (!inverseMatrix) return;
|
|
1514
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1515
|
+
const line = lineGlyphs(
|
|
1516
|
+
font,
|
|
1517
|
+
state.fonts.symbols,
|
|
1518
|
+
lines[index],
|
|
1519
|
+
0,
|
|
1520
|
+
0,
|
|
1521
|
+
fontSize,
|
|
1522
|
+
letterSpacing,
|
|
1523
|
+
false,
|
|
1524
|
+
);
|
|
1525
|
+
const x =
|
|
1526
|
+
style.textAlign === "center"
|
|
1527
|
+
? (node.width - line.width) / 2
|
|
1528
|
+
: style.textAlign === "right"
|
|
1529
|
+
? node.width - line.width
|
|
1530
|
+
: 0;
|
|
1531
|
+
const baseline =
|
|
1532
|
+
verticalOffset + index * lineHeight + (lineHeight - fontSize) / 2 + ascender;
|
|
1533
|
+
const placed = lineGlyphs(
|
|
1534
|
+
font,
|
|
1535
|
+
state.fonts.symbols,
|
|
1536
|
+
lines[index],
|
|
1537
|
+
x,
|
|
1538
|
+
baseline,
|
|
1539
|
+
fontSize,
|
|
1540
|
+
letterSpacing,
|
|
1541
|
+
true,
|
|
1542
|
+
missing,
|
|
1543
|
+
);
|
|
1544
|
+
for (const glyph of placed.glyphs) {
|
|
1545
|
+
renderGlyph(state, node, matrix, inverseMatrix, glyph, textColor, opacity, clips);
|
|
1546
|
+
}
|
|
1547
|
+
renderTextDecoration(
|
|
1548
|
+
state,
|
|
1549
|
+
node,
|
|
1550
|
+
matrix,
|
|
1551
|
+
inverseMatrix,
|
|
1552
|
+
font,
|
|
1553
|
+
style,
|
|
1554
|
+
x,
|
|
1555
|
+
baseline,
|
|
1556
|
+
line.width,
|
|
1557
|
+
textColor,
|
|
1558
|
+
opacity,
|
|
1559
|
+
clips,
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
if (missing.size > 0) recordMissingGlyphs(state, node, path, missing);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function assetReferenceKey(reference, localPackageId) {
|
|
1566
|
+
return reference && typeof reference === "object"
|
|
1567
|
+
? `${reference.packageId}#${reference.assetId}`
|
|
1568
|
+
: `${localPackageId}#${reference}`;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function colorLibrary(product, foundation, libraries, diagnostics) {
|
|
1572
|
+
const result = new Map();
|
|
1573
|
+
for (const snapshot of [foundation, ...libraries, product]) {
|
|
1574
|
+
if (!snapshot) continue;
|
|
1575
|
+
for (const entry of snapshot.manifest.entries.assets) {
|
|
1576
|
+
for (const color of snapshot.entries[entry].colors) {
|
|
1577
|
+
if (color.paint.type === "solid") {
|
|
1578
|
+
result.set(
|
|
1579
|
+
assetReferenceKey(color.id, snapshot.manifest.packageId),
|
|
1580
|
+
parseColor(color.paint.color, diagnostics, `${entry}.colors.${color.id}`),
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
return result;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
function paintColor(
|
|
1590
|
+
paint,
|
|
1591
|
+
x,
|
|
1592
|
+
y,
|
|
1593
|
+
node,
|
|
1594
|
+
colors,
|
|
1595
|
+
diagnostics,
|
|
1596
|
+
path,
|
|
1597
|
+
localPackageId,
|
|
1598
|
+
) {
|
|
1599
|
+
const colorKey = paint.colorRef
|
|
1600
|
+
? assetReferenceKey(paint.colorRef, localPackageId)
|
|
1601
|
+
: undefined;
|
|
1602
|
+
if (colorKey && colors.has(colorKey)) {
|
|
1603
|
+
return [...colors.get(colorKey)];
|
|
1604
|
+
}
|
|
1605
|
+
if (paint.type === "solid") {
|
|
1606
|
+
return parseColor(paint.color, diagnostics, `${path}.color`);
|
|
1607
|
+
}
|
|
1608
|
+
if (paint.type === "image") return [225, 228, 234, 255];
|
|
1609
|
+
const stops = [...paint.stops].sort((left, right) => left.offset - right.offset);
|
|
1610
|
+
let offset;
|
|
1611
|
+
if (paint.type === "radial-gradient") {
|
|
1612
|
+
const radius = Math.max(1e-9, paint.gradientWidth * Math.max(node.width, node.height));
|
|
1613
|
+
offset = Math.hypot(x - paint.startX * node.width, y - paint.startY * node.height) / radius;
|
|
1614
|
+
} else {
|
|
1615
|
+
const startX = paint.startX * node.width;
|
|
1616
|
+
const startY = paint.startY * node.height;
|
|
1617
|
+
const endX = paint.endX * node.width;
|
|
1618
|
+
const endY = paint.endY * node.height;
|
|
1619
|
+
const dx = endX - startX;
|
|
1620
|
+
const dy = endY - startY;
|
|
1621
|
+
offset = ((x - startX) * dx + (y - startY) * dy) / Math.max(1e-9, dx * dx + dy * dy);
|
|
1622
|
+
}
|
|
1623
|
+
offset = clamp(offset, 0, 1);
|
|
1624
|
+
const rightIndex = stops.findIndex((stop) => stop.offset >= offset);
|
|
1625
|
+
const right = stops[rightIndex < 0 ? stops.length - 1 : rightIndex];
|
|
1626
|
+
const left = stops[Math.max(0, (rightIndex < 0 ? stops.length : rightIndex) - 1)];
|
|
1627
|
+
const span = Math.max(1e-9, right.offset - left.offset);
|
|
1628
|
+
const amount = clamp((offset - left.offset) / span, 0, 1);
|
|
1629
|
+
const leftColor = parseColor(left.color, diagnostics, `${path}.stops`);
|
|
1630
|
+
const rightColor = parseColor(right.color, diagnostics, `${path}.stops`);
|
|
1631
|
+
return leftColor.map((value, index) =>
|
|
1632
|
+
Math.round(
|
|
1633
|
+
value * (1 - amount) + rightColor[index] * amount,
|
|
1634
|
+
),
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function svgAttributes(source) {
|
|
1639
|
+
return Object.fromEntries(
|
|
1640
|
+
[...source.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)].map(
|
|
1641
|
+
(match) => [match[1], match[2] ?? match[3]],
|
|
1642
|
+
),
|
|
1643
|
+
);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function svgNumber(value, fallback = 0) {
|
|
1647
|
+
const result = Number.parseFloat(value);
|
|
1648
|
+
return Number.isFinite(result) ? result : fallback;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function svgOpacity(attributes, name = "opacity") {
|
|
1652
|
+
return clamp(svgNumber(attributes[name], 1), 0, 1);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function svgPaint(value, opacity, diagnostics, path) {
|
|
1656
|
+
if (!value || value === "none") return undefined;
|
|
1657
|
+
const color = parseColor(value, diagnostics, path);
|
|
1658
|
+
color[3] = Math.round(color[3] * opacity);
|
|
1659
|
+
return color;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function distanceToSegment(x, y, start, end) {
|
|
1663
|
+
const dx = end.x - start.x;
|
|
1664
|
+
const dy = end.y - start.y;
|
|
1665
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
1666
|
+
const amount = lengthSquared === 0
|
|
1667
|
+
? 0
|
|
1668
|
+
: clamp(((x - start.x) * dx + (y - start.y) * dy) / lengthSquared, 0, 1);
|
|
1669
|
+
return Math.hypot(x - (start.x + amount * dx), y - (start.y + amount * dy));
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
function parseSvgMedia(bytes, descriptor, diagnostics, path) {
|
|
1673
|
+
const source = new TextDecoder().decode(bytes);
|
|
1674
|
+
if (/<(?:script|foreignObject)\b/i.test(source)) {
|
|
1675
|
+
diagnostics.push({
|
|
1676
|
+
code: "unsafe_svg_media",
|
|
1677
|
+
message: `Renderer rejected active SVG content in ${descriptor.name}`,
|
|
1678
|
+
path,
|
|
1679
|
+
});
|
|
1680
|
+
return undefined;
|
|
1681
|
+
}
|
|
1682
|
+
const rootMatch = /<svg\b([^>]*)>/i.exec(source);
|
|
1683
|
+
if (!rootMatch) return undefined;
|
|
1684
|
+
const root = svgAttributes(rootMatch[1]);
|
|
1685
|
+
const viewBox = (root.viewBox ?? "")
|
|
1686
|
+
.trim()
|
|
1687
|
+
.split(/[\s,]+/)
|
|
1688
|
+
.map(Number);
|
|
1689
|
+
const bounds = viewBox.length === 4 && viewBox.every(Number.isFinite)
|
|
1690
|
+
? { height: viewBox[3], width: viewBox[2], x: viewBox[0], y: viewBox[1] }
|
|
1691
|
+
: {
|
|
1692
|
+
height: svgNumber(root.height, descriptor.height),
|
|
1693
|
+
width: svgNumber(root.width, descriptor.width),
|
|
1694
|
+
x: 0,
|
|
1695
|
+
y: 0,
|
|
1696
|
+
};
|
|
1697
|
+
if (bounds.width <= 0 || bounds.height <= 0) return undefined;
|
|
1698
|
+
const shapes = [];
|
|
1699
|
+
for (const match of source.matchAll(/<(rect|circle|ellipse|line|path)\b([^>]*)\/?\s*>/gi)) {
|
|
1700
|
+
const type = match[1].toLowerCase();
|
|
1701
|
+
const attributes = svgAttributes(match[2]);
|
|
1702
|
+
const opacity = svgOpacity(attributes);
|
|
1703
|
+
const fill = svgPaint(
|
|
1704
|
+
attributes.fill ?? (type === "path" ? "#000000" : undefined),
|
|
1705
|
+
opacity * svgOpacity(attributes, "fill-opacity"),
|
|
1706
|
+
diagnostics,
|
|
1707
|
+
`${path}.${type}.fill`,
|
|
1708
|
+
);
|
|
1709
|
+
const stroke = svgPaint(
|
|
1710
|
+
attributes.stroke,
|
|
1711
|
+
opacity * svgOpacity(attributes, "stroke-opacity"),
|
|
1712
|
+
diagnostics,
|
|
1713
|
+
`${path}.${type}.stroke`,
|
|
1714
|
+
);
|
|
1715
|
+
const strokeWidth = svgNumber(attributes["stroke-width"], 1);
|
|
1716
|
+
if (type === "rect") {
|
|
1717
|
+
shapes.push({
|
|
1718
|
+
fill,
|
|
1719
|
+
height: svgNumber(attributes.height),
|
|
1720
|
+
rx: svgNumber(attributes.rx),
|
|
1721
|
+
ry: svgNumber(attributes.ry, svgNumber(attributes.rx)),
|
|
1722
|
+
stroke,
|
|
1723
|
+
strokeWidth,
|
|
1724
|
+
type,
|
|
1725
|
+
width: svgNumber(attributes.width),
|
|
1726
|
+
x: svgNumber(attributes.x),
|
|
1727
|
+
y: svgNumber(attributes.y),
|
|
1728
|
+
});
|
|
1729
|
+
} else if (type === "circle" || type === "ellipse") {
|
|
1730
|
+
shapes.push({
|
|
1731
|
+
cx: svgNumber(attributes.cx),
|
|
1732
|
+
cy: svgNumber(attributes.cy),
|
|
1733
|
+
fill,
|
|
1734
|
+
rx: svgNumber(attributes.rx, svgNumber(attributes.r)),
|
|
1735
|
+
ry: svgNumber(attributes.ry, svgNumber(attributes.r)),
|
|
1736
|
+
stroke,
|
|
1737
|
+
strokeWidth,
|
|
1738
|
+
type,
|
|
1739
|
+
});
|
|
1740
|
+
} else {
|
|
1741
|
+
const geometry = type === "line"
|
|
1742
|
+
? {
|
|
1743
|
+
contours: [],
|
|
1744
|
+
cumulative: [Math.hypot(
|
|
1745
|
+
svgNumber(attributes.x2) - svgNumber(attributes.x1),
|
|
1746
|
+
svgNumber(attributes.y2) - svgNumber(attributes.y1),
|
|
1747
|
+
)],
|
|
1748
|
+
end: { x: svgNumber(attributes.x2), y: svgNumber(attributes.y2) },
|
|
1749
|
+
segments: [{
|
|
1750
|
+
start: { x: svgNumber(attributes.x1), y: svgNumber(attributes.y1) },
|
|
1751
|
+
end: { x: svgNumber(attributes.x2), y: svgNumber(attributes.y2) },
|
|
1752
|
+
}],
|
|
1753
|
+
start: { x: svgNumber(attributes.x1), y: svgNumber(attributes.y1) },
|
|
1754
|
+
totalLength: Math.hypot(
|
|
1755
|
+
svgNumber(attributes.x2) - svgNumber(attributes.x1),
|
|
1756
|
+
svgNumber(attributes.y2) - svgNumber(attributes.y1),
|
|
1757
|
+
),
|
|
1758
|
+
}
|
|
1759
|
+
: parsePathData(attributes.d ?? "");
|
|
1760
|
+
if (!geometry) {
|
|
1761
|
+
diagnostics.push({
|
|
1762
|
+
code: "unsupported_svg_path",
|
|
1763
|
+
message: `Renderer could not rasterize a complex path in ${descriptor.name}`,
|
|
1764
|
+
path,
|
|
1765
|
+
});
|
|
1766
|
+
} else {
|
|
1767
|
+
shapes.push({
|
|
1768
|
+
evenOdd: (attributes["fill-rule"] ?? "").toLowerCase() === "evenodd",
|
|
1769
|
+
fill,
|
|
1770
|
+
geometry,
|
|
1771
|
+
stroke,
|
|
1772
|
+
strokeWidth,
|
|
1773
|
+
type: "path",
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
return { bounds, shapes };
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function compositeColor(target, source) {
|
|
1782
|
+
if (!source || source[3] === 0) return target;
|
|
1783
|
+
const result = [...target];
|
|
1784
|
+
blend(result, 0, source, 1);
|
|
1785
|
+
return result;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// --- Raster image decoding (PNG / GIF / baseline JPEG) ---------------------
|
|
1789
|
+
|
|
1790
|
+
function pushDecodeDiagnostic(diagnostics, path, name, detail) {
|
|
1791
|
+
diagnostics.push({
|
|
1792
|
+
code: "media_decode_failed",
|
|
1793
|
+
message: `Renderer could not decode ${name}: ${detail}`,
|
|
1794
|
+
path,
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function decodePng(bytes, diagnostics, path, name) {
|
|
1799
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
1800
|
+
const signature = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
1801
|
+
if (
|
|
1802
|
+
view.length < signature.length + 12 ||
|
|
1803
|
+
signature.some((byte, index) => view[index] !== byte)
|
|
1804
|
+
) {
|
|
1805
|
+
pushDecodeDiagnostic(diagnostics, path, name, "not a PNG file");
|
|
1806
|
+
return undefined;
|
|
1807
|
+
}
|
|
1808
|
+
const data = new DataView(view.buffer, view.byteOffset, view.byteLength);
|
|
1809
|
+
let offset = 8;
|
|
1810
|
+
let header;
|
|
1811
|
+
let palette;
|
|
1812
|
+
let transparency;
|
|
1813
|
+
const idat = [];
|
|
1814
|
+
while (offset + 12 <= view.length) {
|
|
1815
|
+
const length = data.getUint32(offset);
|
|
1816
|
+
const type = String.fromCharCode(
|
|
1817
|
+
view[offset + 4],
|
|
1818
|
+
view[offset + 5],
|
|
1819
|
+
view[offset + 6],
|
|
1820
|
+
view[offset + 7],
|
|
1821
|
+
);
|
|
1822
|
+
const body = view.subarray(offset + 8, offset + 8 + length);
|
|
1823
|
+
if (type === "IHDR") {
|
|
1824
|
+
header = {
|
|
1825
|
+
colorType: body[9],
|
|
1826
|
+
compression: body[10],
|
|
1827
|
+
depth: body[8],
|
|
1828
|
+
filter: body[11],
|
|
1829
|
+
height: data.getUint32(offset + 12),
|
|
1830
|
+
interlace: body[12],
|
|
1831
|
+
width: data.getUint32(offset + 8),
|
|
1832
|
+
};
|
|
1833
|
+
} else if (type === "PLTE") {
|
|
1834
|
+
palette = body;
|
|
1835
|
+
} else if (type === "tRNS") {
|
|
1836
|
+
transparency = body;
|
|
1837
|
+
} else if (type === "IDAT") {
|
|
1838
|
+
idat.push(body);
|
|
1839
|
+
} else if (type === "IEND") {
|
|
1840
|
+
break;
|
|
1841
|
+
}
|
|
1842
|
+
offset += 12 + length;
|
|
1843
|
+
}
|
|
1844
|
+
if (
|
|
1845
|
+
!header ||
|
|
1846
|
+
idat.length === 0 ||
|
|
1847
|
+
header.width <= 0 ||
|
|
1848
|
+
header.height <= 0 ||
|
|
1849
|
+
header.width > MAX_DIMENSION ||
|
|
1850
|
+
header.height > MAX_DIMENSION
|
|
1851
|
+
) {
|
|
1852
|
+
pushDecodeDiagnostic(diagnostics, path, name, "missing or invalid PNG header");
|
|
1853
|
+
return undefined;
|
|
1854
|
+
}
|
|
1855
|
+
if (
|
|
1856
|
+
header.depth !== 8 ||
|
|
1857
|
+
![0, 2, 3, 4, 6].includes(header.colorType) ||
|
|
1858
|
+
header.compression !== 0 ||
|
|
1859
|
+
header.filter !== 0 ||
|
|
1860
|
+
header.interlace !== 0
|
|
1861
|
+
) {
|
|
1862
|
+
pushDecodeDiagnostic(
|
|
1863
|
+
diagnostics,
|
|
1864
|
+
path,
|
|
1865
|
+
name,
|
|
1866
|
+
`unsupported PNG pixel format (depth ${header.depth}, color ${header.colorType}, interlace ${header.interlace})`,
|
|
1867
|
+
);
|
|
1868
|
+
return undefined;
|
|
1869
|
+
}
|
|
1870
|
+
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[header.colorType];
|
|
1871
|
+
let raw;
|
|
1872
|
+
try {
|
|
1873
|
+
raw = inflateSync(Buffer.concat(idat));
|
|
1874
|
+
} catch {
|
|
1875
|
+
pushDecodeDiagnostic(diagnostics, path, name, "corrupt PNG pixel data");
|
|
1876
|
+
return undefined;
|
|
1877
|
+
}
|
|
1878
|
+
const stride = header.width * channels;
|
|
1879
|
+
if (raw.length < (stride + 1) * header.height) {
|
|
1880
|
+
pushDecodeDiagnostic(diagnostics, path, name, "truncated PNG pixel data");
|
|
1881
|
+
return undefined;
|
|
1882
|
+
}
|
|
1883
|
+
const lines = Buffer.alloc(stride * header.height);
|
|
1884
|
+
const previous = Buffer.alloc(stride);
|
|
1885
|
+
for (let row = 0; row < header.height; row += 1) {
|
|
1886
|
+
const filter = raw[row * (stride + 1)];
|
|
1887
|
+
const source = raw.subarray(row * (stride + 1) + 1, (row + 1) * (stride + 1));
|
|
1888
|
+
const target = lines.subarray(row * stride, (row + 1) * stride);
|
|
1889
|
+
for (let column = 0; column < stride; column += 1) {
|
|
1890
|
+
const left = column >= channels ? target[column - channels] : 0;
|
|
1891
|
+
const up = previous[column];
|
|
1892
|
+
const upLeft = column >= channels ? previous[column - channels] : 0;
|
|
1893
|
+
let value = source[column];
|
|
1894
|
+
if (filter === 1) value += left;
|
|
1895
|
+
else if (filter === 2) value += up;
|
|
1896
|
+
else if (filter === 3) value += (left + up) >> 1;
|
|
1897
|
+
else if (filter === 4) {
|
|
1898
|
+
const prediction = left + up - upLeft;
|
|
1899
|
+
const distances = [
|
|
1900
|
+
Math.abs(prediction - left),
|
|
1901
|
+
Math.abs(prediction - up),
|
|
1902
|
+
Math.abs(prediction - upLeft),
|
|
1903
|
+
];
|
|
1904
|
+
value += distances[0] <= distances[1] && distances[0] <= distances[2]
|
|
1905
|
+
? left
|
|
1906
|
+
: distances[1] <= distances[2]
|
|
1907
|
+
? up
|
|
1908
|
+
: upLeft;
|
|
1909
|
+
}
|
|
1910
|
+
target[column] = value & 0xff;
|
|
1911
|
+
}
|
|
1912
|
+
previous.set(target);
|
|
1913
|
+
}
|
|
1914
|
+
const pixels = new Uint8ClampedArray(header.width * header.height * 4);
|
|
1915
|
+
for (let index = 0; index < header.width * header.height; index += 1) {
|
|
1916
|
+
const source = index * channels;
|
|
1917
|
+
const target = index * 4;
|
|
1918
|
+
if (header.colorType === 0) {
|
|
1919
|
+
const gray = lines[source];
|
|
1920
|
+
pixels[target] = gray;
|
|
1921
|
+
pixels[target + 1] = gray;
|
|
1922
|
+
pixels[target + 2] = gray;
|
|
1923
|
+
pixels[target + 3] =
|
|
1924
|
+
transparency && transparency[0] !== undefined && gray === transparency[0]
|
|
1925
|
+
? 0
|
|
1926
|
+
: 255;
|
|
1927
|
+
} else if (header.colorType === 2) {
|
|
1928
|
+
pixels[target] = lines[source];
|
|
1929
|
+
pixels[target + 1] = lines[source + 1];
|
|
1930
|
+
pixels[target + 2] = lines[source + 2];
|
|
1931
|
+
pixels[target + 3] = 255;
|
|
1932
|
+
} else if (header.colorType === 3) {
|
|
1933
|
+
const entry = lines[source] * 3;
|
|
1934
|
+
if (!palette || entry + 2 >= palette.length) {
|
|
1935
|
+
pushDecodeDiagnostic(diagnostics, path, name, "PNG palette entry missing");
|
|
1936
|
+
return undefined;
|
|
1937
|
+
}
|
|
1938
|
+
pixels[target] = palette[entry];
|
|
1939
|
+
pixels[target + 1] = palette[entry + 1];
|
|
1940
|
+
pixels[target + 2] = palette[entry + 2];
|
|
1941
|
+
pixels[target + 3] =
|
|
1942
|
+
transparency && lines[source] < transparency.length
|
|
1943
|
+
? transparency[lines[source]]
|
|
1944
|
+
: 255;
|
|
1945
|
+
} else if (header.colorType === 4) {
|
|
1946
|
+
const gray = lines[source];
|
|
1947
|
+
pixels[target] = gray;
|
|
1948
|
+
pixels[target + 1] = gray;
|
|
1949
|
+
pixels[target + 2] = gray;
|
|
1950
|
+
pixels[target + 3] = lines[source + 1];
|
|
1951
|
+
} else {
|
|
1952
|
+
pixels[target] = lines[source];
|
|
1953
|
+
pixels[target + 1] = lines[source + 1];
|
|
1954
|
+
pixels[target + 2] = lines[source + 2];
|
|
1955
|
+
pixels[target + 3] = lines[source + 3];
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
return { height: header.height, pixels, width: header.width };
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
function decodeGif(bytes, diagnostics, path, name) {
|
|
1962
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
1963
|
+
const signature = String.fromCharCode(...view.subarray(0, 6));
|
|
1964
|
+
if (!/^GIF8[79]a$/.test(signature) || view.length < 14) {
|
|
1965
|
+
pushDecodeDiagnostic(diagnostics, path, name, "not a GIF file");
|
|
1966
|
+
return undefined;
|
|
1967
|
+
}
|
|
1968
|
+
const data = new DataView(view.buffer, view.byteOffset, view.byteLength);
|
|
1969
|
+
const width = data.getUint16(6, true);
|
|
1970
|
+
const height = data.getUint16(8, true);
|
|
1971
|
+
const packed = view[10];
|
|
1972
|
+
let offset = 13;
|
|
1973
|
+
let globalTable;
|
|
1974
|
+
if (packed & 0x80) {
|
|
1975
|
+
const size = 2 ** ((packed & 0x07) + 1);
|
|
1976
|
+
globalTable = view.subarray(offset, offset + size * 3);
|
|
1977
|
+
offset += size * 3;
|
|
1978
|
+
}
|
|
1979
|
+
let transparentIndex = -1;
|
|
1980
|
+
const readSubBlocks = () => {
|
|
1981
|
+
const chunks = [];
|
|
1982
|
+
while (offset < view.length) {
|
|
1983
|
+
const size = view[offset++];
|
|
1984
|
+
if (size === 0) break;
|
|
1985
|
+
chunks.push(view.subarray(offset, offset + size));
|
|
1986
|
+
offset += size;
|
|
1987
|
+
}
|
|
1988
|
+
return Buffer.concat(chunks);
|
|
1989
|
+
};
|
|
1990
|
+
let frame;
|
|
1991
|
+
while (offset < view.length) {
|
|
1992
|
+
const block = view[offset++];
|
|
1993
|
+
if (block === 0x3b) break;
|
|
1994
|
+
if (block === 0x21) {
|
|
1995
|
+
const label = view[offset++];
|
|
1996
|
+
if (label === 0xf9) {
|
|
1997
|
+
const block_ = readSubBlocks();
|
|
1998
|
+
if (block_.length >= 4 && block_[0] & 0x01) transparentIndex = block_[3];
|
|
1999
|
+
} else {
|
|
2000
|
+
readSubBlocks();
|
|
2001
|
+
}
|
|
2002
|
+
continue;
|
|
2003
|
+
}
|
|
2004
|
+
if (block !== 0x2c) {
|
|
2005
|
+
pushDecodeDiagnostic(diagnostics, path, name, "corrupt GIF block structure");
|
|
2006
|
+
return undefined;
|
|
2007
|
+
}
|
|
2008
|
+
const frameX = data.getUint16(offset, true);
|
|
2009
|
+
const frameY = data.getUint16(offset + 2, true);
|
|
2010
|
+
const frameWidth = data.getUint16(offset + 4, true);
|
|
2011
|
+
const frameHeight = data.getUint16(offset + 6, true);
|
|
2012
|
+
const framePacked = view[offset + 8];
|
|
2013
|
+
offset += 9;
|
|
2014
|
+
let table = globalTable;
|
|
2015
|
+
if (framePacked & 0x80) {
|
|
2016
|
+
const size = 2 ** ((framePacked & 0x07) + 1);
|
|
2017
|
+
table = view.subarray(offset, offset + size * 3);
|
|
2018
|
+
offset += size * 3;
|
|
2019
|
+
}
|
|
2020
|
+
const interlaced = (framePacked & 0x40) !== 0;
|
|
2021
|
+
const minCodeSize = view[offset++];
|
|
2022
|
+
const compressed = readSubBlocks();
|
|
2023
|
+
const indices = lzwDecode(
|
|
2024
|
+
compressed,
|
|
2025
|
+
minCodeSize,
|
|
2026
|
+
frameWidth * frameHeight,
|
|
2027
|
+
diagnostics,
|
|
2028
|
+
path,
|
|
2029
|
+
name,
|
|
2030
|
+
);
|
|
2031
|
+
if (!indices || !table) return undefined;
|
|
2032
|
+
const pixels = new Uint8ClampedArray(width * height * 4);
|
|
2033
|
+
const rowOrder = [];
|
|
2034
|
+
if (interlaced) {
|
|
2035
|
+
for (const start of [0, 4, 2, 1]) {
|
|
2036
|
+
for (let row = start; row < frameHeight; row += 8) rowOrder.push(row);
|
|
2037
|
+
}
|
|
2038
|
+
} else {
|
|
2039
|
+
for (let row = 0; row < frameHeight; row += 1) rowOrder.push(row);
|
|
2040
|
+
}
|
|
2041
|
+
for (let index = 0; index < indices.length; index += 1) {
|
|
2042
|
+
const colorIndex = indices[index];
|
|
2043
|
+
const row = rowOrder[Math.floor(index / frameWidth)];
|
|
2044
|
+
const column = index % frameWidth;
|
|
2045
|
+
const target = ((frameY + row) * width + frameX + column) * 4;
|
|
2046
|
+
if (colorIndex === transparentIndex) continue;
|
|
2047
|
+
pixels[target] = table[colorIndex * 3];
|
|
2048
|
+
pixels[target + 1] = table[colorIndex * 3 + 1];
|
|
2049
|
+
pixels[target + 2] = table[colorIndex * 3 + 2];
|
|
2050
|
+
pixels[target + 3] = 255;
|
|
2051
|
+
}
|
|
2052
|
+
frame = { height, pixels, width };
|
|
2053
|
+
// Only the first composed frame is rendered; additional frames are reported.
|
|
2054
|
+
let blockCursor = offset;
|
|
2055
|
+
while (blockCursor < view.length) {
|
|
2056
|
+
const marker = view[blockCursor];
|
|
2057
|
+
if (marker === 0x3b) break;
|
|
2058
|
+
if (marker === 0x21) {
|
|
2059
|
+
blockCursor += 2;
|
|
2060
|
+
while (blockCursor < view.length && view[blockCursor] !== 0) {
|
|
2061
|
+
blockCursor += view[blockCursor] + 1;
|
|
2062
|
+
}
|
|
2063
|
+
blockCursor += 1;
|
|
2064
|
+
continue;
|
|
2065
|
+
}
|
|
2066
|
+
if (marker === 0x2c) {
|
|
2067
|
+
diagnostics.push({
|
|
2068
|
+
code: "media_animation_frames_ignored",
|
|
2069
|
+
message: `Renderer composed only the first frame of animated media ${name}`,
|
|
2070
|
+
path,
|
|
2071
|
+
});
|
|
2072
|
+
break;
|
|
2073
|
+
}
|
|
2074
|
+
break;
|
|
2075
|
+
}
|
|
2076
|
+
break;
|
|
2077
|
+
}
|
|
2078
|
+
if (!frame) {
|
|
2079
|
+
pushDecodeDiagnostic(diagnostics, path, name, "GIF contains no image frame");
|
|
2080
|
+
return undefined;
|
|
2081
|
+
}
|
|
2082
|
+
return frame;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
function lzwDecode(compressed, minCodeSize, expectedPixels, diagnostics, path, name) {
|
|
2086
|
+
const clearCode = 2 ** minCodeSize;
|
|
2087
|
+
const endCode = clearCode + 1;
|
|
2088
|
+
let codeSize = minCodeSize + 1;
|
|
2089
|
+
let nextCode = endCode + 1;
|
|
2090
|
+
let dictionary = new Map();
|
|
2091
|
+
const reset = () => {
|
|
2092
|
+
dictionary = new Map();
|
|
2093
|
+
for (let code = 0; code < clearCode; code += 1) dictionary.set(code, [code]);
|
|
2094
|
+
codeSize = minCodeSize + 1;
|
|
2095
|
+
nextCode = endCode + 1;
|
|
2096
|
+
};
|
|
2097
|
+
reset();
|
|
2098
|
+
const output = [];
|
|
2099
|
+
let bitCursor = 0;
|
|
2100
|
+
let previous;
|
|
2101
|
+
const totalBits = compressed.length * 8;
|
|
2102
|
+
while (bitCursor + codeSize <= totalBits && output.length < expectedPixels + 4096) {
|
|
2103
|
+
let code = 0;
|
|
2104
|
+
for (let bit = 0; bit < codeSize; bit += 1) {
|
|
2105
|
+
const byteIndex = Math.floor((bitCursor + bit) / 8);
|
|
2106
|
+
code |= ((compressed[byteIndex] >> ((bitCursor + bit) % 8)) & 1) << bit;
|
|
2107
|
+
}
|
|
2108
|
+
bitCursor += codeSize;
|
|
2109
|
+
if (code === endCode) break;
|
|
2110
|
+
if (code === clearCode) {
|
|
2111
|
+
reset();
|
|
2112
|
+
previous = undefined;
|
|
2113
|
+
continue;
|
|
2114
|
+
}
|
|
2115
|
+
let entry = dictionary.get(code);
|
|
2116
|
+
if (entry === undefined) {
|
|
2117
|
+
if (previous === undefined) {
|
|
2118
|
+
pushDecodeDiagnostic(diagnostics, path, name, "corrupt GIF LZW stream");
|
|
2119
|
+
return undefined;
|
|
2120
|
+
}
|
|
2121
|
+
entry = [...previous, previous[0]];
|
|
2122
|
+
}
|
|
2123
|
+
for (const pixel of entry) output.push(pixel);
|
|
2124
|
+
if (previous && nextCode < 4096) {
|
|
2125
|
+
dictionary.set(nextCode, [...previous, entry[0]]);
|
|
2126
|
+
nextCode += 1;
|
|
2127
|
+
if (nextCode === 2 ** codeSize && codeSize < 12) codeSize += 1;
|
|
2128
|
+
}
|
|
2129
|
+
previous = entry;
|
|
2130
|
+
}
|
|
2131
|
+
if (output.length < expectedPixels) {
|
|
2132
|
+
pushDecodeDiagnostic(diagnostics, path, name, "truncated GIF pixel data");
|
|
2133
|
+
return undefined;
|
|
2134
|
+
}
|
|
2135
|
+
return output.slice(0, expectedPixels);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// Minimal baseline (sequential DCT, Huffman) JPEG decoder. Progressive and
|
|
2139
|
+
// arithmetic-coded JPEGs are rejected with a typed diagnostic instead.
|
|
2140
|
+
function decodeJpeg(bytes, diagnostics, path, name) {
|
|
2141
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
2142
|
+
const data = new DataView(view.buffer, view.byteOffset, view.byteLength);
|
|
2143
|
+
if (view.length < 4 || view[0] !== 0xff || view[1] !== 0xd8) {
|
|
2144
|
+
pushDecodeDiagnostic(diagnostics, path, name, "not a JPEG file");
|
|
2145
|
+
return undefined;
|
|
2146
|
+
}
|
|
2147
|
+
let offset = 2;
|
|
2148
|
+
const quant = new Map();
|
|
2149
|
+
const huffman = new Map();
|
|
2150
|
+
let frame;
|
|
2151
|
+
let restartInterval = 0;
|
|
2152
|
+
const readLength = () => {
|
|
2153
|
+
const length = data.getUint16(offset);
|
|
2154
|
+
offset += 2;
|
|
2155
|
+
return length;
|
|
2156
|
+
};
|
|
2157
|
+
while (offset + 4 <= view.length) {
|
|
2158
|
+
if (view[offset] !== 0xff) {
|
|
2159
|
+
pushDecodeDiagnostic(diagnostics, path, name, "corrupt JPEG marker structure");
|
|
2160
|
+
return undefined;
|
|
2161
|
+
}
|
|
2162
|
+
const marker = view[offset + 1];
|
|
2163
|
+
offset += 2;
|
|
2164
|
+
if (marker === 0xd9) break;
|
|
2165
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
|
2166
|
+
const length = readLength();
|
|
2167
|
+
const body = offset;
|
|
2168
|
+
if (marker === 0xdb) {
|
|
2169
|
+
let cursor = body;
|
|
2170
|
+
while (cursor < body + length - 2) {
|
|
2171
|
+
const tableInfo = view[cursor++];
|
|
2172
|
+
const tableId = tableInfo & 0x0f;
|
|
2173
|
+
const values = new Int32Array(64);
|
|
2174
|
+
if (tableInfo & 0xf0) {
|
|
2175
|
+
for (let index = 0; index < 64; index += 1) {
|
|
2176
|
+
values[index] = data.getUint16(cursor);
|
|
2177
|
+
cursor += 2;
|
|
2178
|
+
}
|
|
2179
|
+
} else {
|
|
2180
|
+
for (let index = 0; index < 64; index += 1) {
|
|
2181
|
+
values[index] = view[cursor++];
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
quant.set(tableId, values);
|
|
2185
|
+
}
|
|
2186
|
+
} else if (marker === 0xc4) {
|
|
2187
|
+
let cursor = body;
|
|
2188
|
+
while (cursor < body + length - 2) {
|
|
2189
|
+
const tableInfo = view[cursor++];
|
|
2190
|
+
const classBit = tableInfo >> 4;
|
|
2191
|
+
const tableId = tableInfo & 0x0f;
|
|
2192
|
+
const counts = view.subarray(cursor, cursor + 16);
|
|
2193
|
+
cursor += 16;
|
|
2194
|
+
let total = 0;
|
|
2195
|
+
for (const count of counts) total += count;
|
|
2196
|
+
const symbols = view.subarray(cursor, cursor + total);
|
|
2197
|
+
cursor += total;
|
|
2198
|
+
const codes = new Map();
|
|
2199
|
+
let code = 0;
|
|
2200
|
+
let symbolCursor = 0;
|
|
2201
|
+
for (let bits = 1; bits <= 16; bits += 1) {
|
|
2202
|
+
for (let index = 0; index < counts[bits - 1]; index += 1) {
|
|
2203
|
+
codes.set(`${bits}:${code}`, symbols[symbolCursor++]);
|
|
2204
|
+
code += 1;
|
|
2205
|
+
}
|
|
2206
|
+
code *= 2;
|
|
2207
|
+
}
|
|
2208
|
+
huffman.set(`${classBit}:${tableId}`, codes);
|
|
2209
|
+
}
|
|
2210
|
+
} else if (marker === 0xc0 || marker === 0xc1) {
|
|
2211
|
+
frame = {
|
|
2212
|
+
components: [],
|
|
2213
|
+
// SOF body: precision(1), height(2), width(2), component count(1).
|
|
2214
|
+
height: data.getUint16(body + 1),
|
|
2215
|
+
width: data.getUint16(body + 3),
|
|
2216
|
+
};
|
|
2217
|
+
const componentCount = view[body + 5];
|
|
2218
|
+
let cursor = body + 6;
|
|
2219
|
+
for (let index = 0; index < componentCount; index += 1) {
|
|
2220
|
+
// Quantization tables may be emitted after SOF; keep the id and
|
|
2221
|
+
// resolve it once the scan starts.
|
|
2222
|
+
frame.components.push({
|
|
2223
|
+
h: view[cursor + 1] >> 4,
|
|
2224
|
+
id: view[cursor],
|
|
2225
|
+
quantId: view[cursor + 2] & 0x0f,
|
|
2226
|
+
v: view[cursor + 1] & 0x0f,
|
|
2227
|
+
});
|
|
2228
|
+
cursor += 3;
|
|
2229
|
+
}
|
|
2230
|
+
if (frame.width <= 0 || frame.height <= 0) frame = undefined;
|
|
2231
|
+
} else if (marker === 0xc2 || marker === 0xc3 || (marker >= 0xc5 && marker <= 0xcf)) {
|
|
2232
|
+
pushDecodeDiagnostic(
|
|
2233
|
+
diagnostics,
|
|
2234
|
+
path,
|
|
2235
|
+
name,
|
|
2236
|
+
"unsupported JPEG coding (progressive/lossless/arithmetic)",
|
|
2237
|
+
);
|
|
2238
|
+
return undefined;
|
|
2239
|
+
} else if (marker === 0xc9 || marker === 0xca) {
|
|
2240
|
+
pushDecodeDiagnostic(diagnostics, path, name, "unsupported JPEG coding (arithmetic)");
|
|
2241
|
+
return undefined;
|
|
2242
|
+
} else if (marker === 0xdd) {
|
|
2243
|
+
restartInterval = data.getUint16(body);
|
|
2244
|
+
}
|
|
2245
|
+
offset = body + length - 2;
|
|
2246
|
+
if (marker === 0xda) {
|
|
2247
|
+
if (!frame) {
|
|
2248
|
+
pushDecodeDiagnostic(diagnostics, path, name, "JPEG scan without frame");
|
|
2249
|
+
return undefined;
|
|
2250
|
+
}
|
|
2251
|
+
const componentCount = view[body];
|
|
2252
|
+
let cursor = body + 1;
|
|
2253
|
+
for (let index = 0; index < componentCount; index += 1) {
|
|
2254
|
+
const component = frame.components.find((item) => item.id === view[cursor]);
|
|
2255
|
+
if (component) {
|
|
2256
|
+
component.dc = huffman.get(`0:${view[cursor + 1] >> 4}`);
|
|
2257
|
+
component.ac = huffman.get(`1:${view[cursor + 1] & 0x0f}`);
|
|
2258
|
+
component.quant = quant.get(component.quantId);
|
|
2259
|
+
}
|
|
2260
|
+
cursor += 2;
|
|
2261
|
+
}
|
|
2262
|
+
// Skip Ss, Se, and Ah/Al before the entropy-coded data.
|
|
2263
|
+
const scanStart = cursor + 3;
|
|
2264
|
+
return jpegDecodeScan(
|
|
2265
|
+
view,
|
|
2266
|
+
data,
|
|
2267
|
+
frame,
|
|
2268
|
+
scanStart,
|
|
2269
|
+
restartInterval,
|
|
2270
|
+
diagnostics,
|
|
2271
|
+
path,
|
|
2272
|
+
name,
|
|
2273
|
+
);
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
pushDecodeDiagnostic(diagnostics, path, name, "JPEG ended without a scan");
|
|
2277
|
+
return undefined;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
const JPEG_ZIGZAG = [
|
|
2281
|
+
0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40,
|
|
2282
|
+
48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29,
|
|
2283
|
+
22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54,
|
|
2284
|
+
47, 55, 62, 63,
|
|
2285
|
+
];
|
|
2286
|
+
|
|
2287
|
+
function jpegHuffmanSymbol(codes, reader) {
|
|
2288
|
+
let code = 0;
|
|
2289
|
+
for (let bits = 1; bits <= 16; bits += 1) {
|
|
2290
|
+
code = (code << 1) | reader.readBit();
|
|
2291
|
+
const symbol = codes.get(`${bits}:${code}`);
|
|
2292
|
+
if (symbol !== undefined) return symbol;
|
|
2293
|
+
}
|
|
2294
|
+
return undefined;
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
function jpegReceiveExtend(reader, magnitude) {
|
|
2298
|
+
if (magnitude === 0) return 0;
|
|
2299
|
+
let value = reader.readBits(magnitude);
|
|
2300
|
+
if (value < 2 ** (magnitude - 1)) value -= 2 ** magnitude - 1;
|
|
2301
|
+
return value;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
function jpegDecodeScan(
|
|
2305
|
+
view,
|
|
2306
|
+
data,
|
|
2307
|
+
frame,
|
|
2308
|
+
start,
|
|
2309
|
+
restartInterval,
|
|
2310
|
+
diagnostics,
|
|
2311
|
+
path,
|
|
2312
|
+
name,
|
|
2313
|
+
) {
|
|
2314
|
+
const scanComponents = frame.components.filter((component) => component.ac);
|
|
2315
|
+
if (scanComponents.length === 0) {
|
|
2316
|
+
pushDecodeDiagnostic(diagnostics, path, name, "JPEG scan has no decodable components");
|
|
2317
|
+
return undefined;
|
|
2318
|
+
}
|
|
2319
|
+
const hMax = Math.max(...frame.components.map((component) => component.h));
|
|
2320
|
+
const vMax = Math.max(...frame.components.map((component) => component.v));
|
|
2321
|
+
const mcuWidth = hMax * 8;
|
|
2322
|
+
const mcuHeight = vMax * 8;
|
|
2323
|
+
const mcusX = Math.ceil(frame.width / mcuWidth);
|
|
2324
|
+
const mcusY = Math.ceil(frame.height / mcuHeight);
|
|
2325
|
+
const planes = frame.components.map((component) => new Float32Array(
|
|
2326
|
+
Math.ceil((frame.width * component.h) / hMax) *
|
|
2327
|
+
Math.ceil((frame.height * component.v) / vMax) +
|
|
2328
|
+
64,
|
|
2329
|
+
));
|
|
2330
|
+
const planeWidths = frame.components.map((component) =>
|
|
2331
|
+
Math.ceil((frame.width * component.h) / hMax),
|
|
2332
|
+
);
|
|
2333
|
+
const reader = {
|
|
2334
|
+
cursor: start,
|
|
2335
|
+
bitBuffer: 0,
|
|
2336
|
+
bitCount: 0,
|
|
2337
|
+
readBit() {
|
|
2338
|
+
while (this.bitCount === 0) {
|
|
2339
|
+
if (this.cursor >= view.length) throw new Error("truncated JPEG scan");
|
|
2340
|
+
const byte = view[this.cursor++];
|
|
2341
|
+
if (byte === 0xff) {
|
|
2342
|
+
const next = view[this.cursor] ?? 0;
|
|
2343
|
+
if (next === 0x00) this.cursor += 1;
|
|
2344
|
+
else if (next >= 0xd0 && next <= 0xd7) {
|
|
2345
|
+
// Restart marker: handled by the MCU loop; skip here defensively.
|
|
2346
|
+
this.cursor += 1;
|
|
2347
|
+
continue;
|
|
2348
|
+
} else throw new Error("unexpected JPEG marker in scan");
|
|
2349
|
+
}
|
|
2350
|
+
this.bitBuffer = byte;
|
|
2351
|
+
this.bitCount = 8;
|
|
2352
|
+
}
|
|
2353
|
+
this.bitCount -= 1;
|
|
2354
|
+
return (this.bitBuffer >> this.bitCount) & 1;
|
|
2355
|
+
},
|
|
2356
|
+
readBits(count) {
|
|
2357
|
+
let value = 0;
|
|
2358
|
+
for (let index = 0; index < count; index += 1) {
|
|
2359
|
+
value = (value << 1) | this.readBit();
|
|
2360
|
+
}
|
|
2361
|
+
return value;
|
|
2362
|
+
},
|
|
2363
|
+
reset() {
|
|
2364
|
+
this.bitCount = 0;
|
|
2365
|
+
},
|
|
2366
|
+
};
|
|
2367
|
+
const coefficients = frame.components.map(() => new Int32Array(64));
|
|
2368
|
+
const predictions = frame.components.map(() => 0);
|
|
2369
|
+
let mcuCount = 0;
|
|
2370
|
+
try {
|
|
2371
|
+
for (let mcuY = 0; mcuY < mcusY; mcuY += 1) {
|
|
2372
|
+
for (let mcuX = 0; mcuX < mcusX; mcuX += 1) {
|
|
2373
|
+
if (
|
|
2374
|
+
restartInterval > 0 &&
|
|
2375
|
+
mcuCount > 0 &&
|
|
2376
|
+
mcuCount % restartInterval === 0
|
|
2377
|
+
) {
|
|
2378
|
+
predictions.fill(0);
|
|
2379
|
+
reader.reset();
|
|
2380
|
+
// Consume the restart marker.
|
|
2381
|
+
while (reader.cursor + 1 < view.length) {
|
|
2382
|
+
if (view[reader.cursor] === 0xff && view[reader.cursor + 1] !== 0x00) {
|
|
2383
|
+
reader.cursor += 2;
|
|
2384
|
+
break;
|
|
2385
|
+
}
|
|
2386
|
+
reader.cursor += 1;
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
for (let componentIndex = 0; componentIndex < scanComponents.length; componentIndex += 1) {
|
|
2390
|
+
const component = scanComponents[componentIndex];
|
|
2391
|
+
const blocksX = Math.ceil((mcuWidth * component.h) / hMax / 8);
|
|
2392
|
+
const blocksY = Math.ceil((mcuHeight * component.v) / vMax / 8);
|
|
2393
|
+
for (let blockY = 0; blockY < blocksY; blockY += 1) {
|
|
2394
|
+
for (let blockX = 0; blockX < blocksX; blockX += 1) {
|
|
2395
|
+
const coefficients_ = coefficients[componentIndex];
|
|
2396
|
+
coefficients_.fill(0);
|
|
2397
|
+
const dcSymbol = jpegHuffmanSymbol(component.dc, reader);
|
|
2398
|
+
if (dcSymbol === undefined) throw new Error("invalid DC code");
|
|
2399
|
+
predictions[componentIndex] += jpegReceiveExtend(reader, dcSymbol);
|
|
2400
|
+
coefficients_[0] = predictions[componentIndex] * component.quant[0];
|
|
2401
|
+
let cursor = 1;
|
|
2402
|
+
while (cursor < 64) {
|
|
2403
|
+
const acSymbol = jpegHuffmanSymbol(component.ac, reader);
|
|
2404
|
+
if (acSymbol === undefined) throw new Error("invalid AC code");
|
|
2405
|
+
const run = acSymbol >> 4;
|
|
2406
|
+
const magnitude = acSymbol & 0x0f;
|
|
2407
|
+
if (magnitude === 0) {
|
|
2408
|
+
if (run === 15) {
|
|
2409
|
+
cursor += 16;
|
|
2410
|
+
continue;
|
|
2411
|
+
}
|
|
2412
|
+
break;
|
|
2413
|
+
}
|
|
2414
|
+
cursor += run;
|
|
2415
|
+
if (cursor > 63) throw new Error("AC run overflows block");
|
|
2416
|
+
coefficients_[JPEG_ZIGZAG[cursor]] =
|
|
2417
|
+
jpegReceiveExtend(reader, magnitude) * component.quant[cursor];
|
|
2418
|
+
cursor += 1;
|
|
2419
|
+
}
|
|
2420
|
+
const plane = planes[frame.components.indexOf(component)];
|
|
2421
|
+
const planeWidth = planeWidths[frame.components.indexOf(component)];
|
|
2422
|
+
const originX = mcuX * ((mcuWidth * component.h) / hMax) + blockX * 8;
|
|
2423
|
+
const originY = mcuY * ((mcuHeight * component.v) / vMax) + blockY * 8;
|
|
2424
|
+
jpegIdctInto(coefficients_, plane, planeWidth, originX, originY);
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
mcuCount += 1;
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
} catch (cause) {
|
|
2432
|
+
pushDecodeDiagnostic(diagnostics, path, name, `corrupt JPEG scan (${cause.message})`);
|
|
2433
|
+
return undefined;
|
|
2434
|
+
}
|
|
2435
|
+
// Color transform and upsample to RGBA.
|
|
2436
|
+
const pixels = new Uint8ClampedArray(frame.width * frame.height * 4);
|
|
2437
|
+
const hasChroma = scanComponents.length >= 3;
|
|
2438
|
+
const luminance = planes[0];
|
|
2439
|
+
const indexY = 0;
|
|
2440
|
+
const indexCb = hasChroma ? 1 : -1;
|
|
2441
|
+
const indexCr = hasChroma ? 2 : -1;
|
|
2442
|
+
for (let y = 0; y < frame.height; y += 1) {
|
|
2443
|
+
for (let x = 0; x < frame.width; x += 1) {
|
|
2444
|
+
const target = (y * frame.width + x) * 4;
|
|
2445
|
+
if (!hasChroma) {
|
|
2446
|
+
const value = clamp(
|
|
2447
|
+
Math.round(luminance[y * planeWidths[0] + x] + 128),
|
|
2448
|
+
0,
|
|
2449
|
+
255,
|
|
2450
|
+
);
|
|
2451
|
+
pixels[target] = value;
|
|
2452
|
+
pixels[target + 1] = value;
|
|
2453
|
+
pixels[target + 2] = value;
|
|
2454
|
+
pixels[target + 3] = 255;
|
|
2455
|
+
continue;
|
|
2456
|
+
}
|
|
2457
|
+
const sampleY = luminance[y * planeWidths[indexY] + x];
|
|
2458
|
+
const sampleCb = planes[indexCb][
|
|
2459
|
+
Math.floor((y * frame.components[indexCb].v) / vMax) *
|
|
2460
|
+
planeWidths[indexCb] +
|
|
2461
|
+
Math.floor((x * frame.components[indexCb].h) / hMax)
|
|
2462
|
+
];
|
|
2463
|
+
const sampleCr = planes[indexCr][
|
|
2464
|
+
Math.floor((y * frame.components[indexCr].v) / vMax) *
|
|
2465
|
+
planeWidths[indexCr] +
|
|
2466
|
+
Math.floor((x * frame.components[indexCr].h) / hMax)
|
|
2467
|
+
];
|
|
2468
|
+
pixels[target] = clamp(
|
|
2469
|
+
Math.round(sampleY + 1.402 * sampleCr + 128),
|
|
2470
|
+
0,
|
|
2471
|
+
255,
|
|
2472
|
+
);
|
|
2473
|
+
pixels[target + 1] = clamp(
|
|
2474
|
+
Math.round(
|
|
2475
|
+
sampleY - 0.344136 * sampleCb - 0.714136 * sampleCr + 128,
|
|
2476
|
+
),
|
|
2477
|
+
0,
|
|
2478
|
+
255,
|
|
2479
|
+
);
|
|
2480
|
+
pixels[target + 2] = clamp(
|
|
2481
|
+
Math.round(sampleY + 1.772 * sampleCb + 128),
|
|
2482
|
+
0,
|
|
2483
|
+
255,
|
|
2484
|
+
);
|
|
2485
|
+
pixels[target + 3] = 255;
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
return { height: frame.height, pixels, width: frame.width };
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
function jpegIdctInto(coefficients, plane, planeWidth, originX, originY) {
|
|
2492
|
+
const temporary = new Float32Array(64);
|
|
2493
|
+
// Rows.
|
|
2494
|
+
for (let row = 0; row < 8; row += 1) {
|
|
2495
|
+
for (let column = 0; column < 8; column += 1) {
|
|
2496
|
+
let sum = 0;
|
|
2497
|
+
for (let frequency = 0; frequency < 8; frequency += 1) {
|
|
2498
|
+
const cosine =
|
|
2499
|
+
frequency === 0
|
|
2500
|
+
? 1 / Math.SQRT2
|
|
2501
|
+
: 1;
|
|
2502
|
+
sum +=
|
|
2503
|
+
cosine *
|
|
2504
|
+
coefficients[row * 8 + frequency] *
|
|
2505
|
+
Math.cos(((2 * column + 1) * frequency * Math.PI) / 16);
|
|
2506
|
+
}
|
|
2507
|
+
temporary[row * 8 + column] = sum / 2;
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
// Columns.
|
|
2511
|
+
for (let column = 0; column < 8; column += 1) {
|
|
2512
|
+
for (let row = 0; row < 8; row += 1) {
|
|
2513
|
+
let sum = 0;
|
|
2514
|
+
for (let frequency = 0; frequency < 8; frequency += 1) {
|
|
2515
|
+
const cosine = frequency === 0 ? 1 / Math.SQRT2 : 1;
|
|
2516
|
+
sum +=
|
|
2517
|
+
cosine *
|
|
2518
|
+
temporary[frequency * 8 + column] *
|
|
2519
|
+
Math.cos(((2 * row + 1) * frequency * Math.PI) / 16);
|
|
2520
|
+
}
|
|
2521
|
+
const value = clamp(Math.round(sum / 2), -128, 2047);
|
|
2522
|
+
const targetX = originX + column;
|
|
2523
|
+
const targetY = originY + row;
|
|
2524
|
+
if (targetX < planeWidth && targetY * planeWidth + targetX < plane.length) {
|
|
2525
|
+
plane[targetY * planeWidth + targetX] = value;
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
function svgSample(image, x, y, node) {
|
|
2532
|
+
const scale = Math.max(
|
|
2533
|
+
node.width / image.bounds.width,
|
|
2534
|
+
node.height / image.bounds.height,
|
|
2535
|
+
);
|
|
2536
|
+
const sourceX = image.bounds.x +
|
|
2537
|
+
(x - (node.width - image.bounds.width * scale) / 2) / scale;
|
|
2538
|
+
const sourceY = image.bounds.y +
|
|
2539
|
+
(y - (node.height - image.bounds.height * scale) / 2) / scale;
|
|
2540
|
+
let color = [0, 0, 0, 0];
|
|
2541
|
+
for (const shape of image.shapes) {
|
|
2542
|
+
let inside = false;
|
|
2543
|
+
let border = false;
|
|
2544
|
+
if (shape.type === "rect") {
|
|
2545
|
+
const localX = sourceX - shape.x;
|
|
2546
|
+
const localY = sourceY - shape.y;
|
|
2547
|
+
const rect = {
|
|
2548
|
+
cornerRadius: [shape.rx, shape.rx, shape.ry, shape.ry],
|
|
2549
|
+
height: shape.height,
|
|
2550
|
+
width: shape.width,
|
|
2551
|
+
};
|
|
2552
|
+
inside =
|
|
2553
|
+
localX >= 0 &&
|
|
2554
|
+
localY >= 0 &&
|
|
2555
|
+
localX <= shape.width &&
|
|
2556
|
+
localY <= shape.height &&
|
|
2557
|
+
cornerContains(rect, localX, localY);
|
|
2558
|
+
if (inside && shape.stroke) {
|
|
2559
|
+
const inset = shape.strokeWidth;
|
|
2560
|
+
border =
|
|
2561
|
+
localX <= inset ||
|
|
2562
|
+
localY <= inset ||
|
|
2563
|
+
localX >= shape.width - inset ||
|
|
2564
|
+
localY >= shape.height - inset;
|
|
2565
|
+
}
|
|
2566
|
+
} else if (shape.type === "circle" || shape.type === "ellipse") {
|
|
2567
|
+
const distance =
|
|
2568
|
+
((sourceX - shape.cx) / Math.max(shape.rx, 1e-9)) ** 2 +
|
|
2569
|
+
((sourceY - shape.cy) / Math.max(shape.ry, 1e-9)) ** 2;
|
|
2570
|
+
inside = distance <= 1;
|
|
2571
|
+
border = inside && shape.stroke && distance >=
|
|
2572
|
+
(1 - shape.strokeWidth / Math.max(shape.rx, shape.ry, 1)) ** 2;
|
|
2573
|
+
} else if (shape.type === "path") {
|
|
2574
|
+
if (shape.fill && shape.geometry) {
|
|
2575
|
+
inside = pathFillContains(shape.geometry, sourceX, sourceY, shape.evenOdd);
|
|
2576
|
+
}
|
|
2577
|
+
if (
|
|
2578
|
+
shape.stroke &&
|
|
2579
|
+
shape.geometry &&
|
|
2580
|
+
pathStrokeContains(shape.geometry, sourceX, sourceY, shape.strokeWidth)
|
|
2581
|
+
) {
|
|
2582
|
+
border = true;
|
|
2583
|
+
}
|
|
2584
|
+
} else if (shape.type === "line") {
|
|
2585
|
+
border =
|
|
2586
|
+
shape.stroke &&
|
|
2587
|
+
shape.segments.some(
|
|
2588
|
+
({ start, end }) =>
|
|
2589
|
+
distanceToSegment(sourceX, sourceY, start, end) <=
|
|
2590
|
+
shape.strokeWidth / 2,
|
|
2591
|
+
);
|
|
2592
|
+
}
|
|
2593
|
+
if (inside && shape.fill) color = compositeColor(color, shape.fill);
|
|
2594
|
+
if (border && shape.stroke) color = compositeColor(color, shape.stroke);
|
|
2595
|
+
}
|
|
2596
|
+
return color;
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
function rasterSample(image, x, y, node) {
|
|
2600
|
+
const scale = Math.max(
|
|
2601
|
+
node.width / image.width,
|
|
2602
|
+
node.height / image.height,
|
|
2603
|
+
);
|
|
2604
|
+
const sourceX = Math.floor(
|
|
2605
|
+
(x - (node.width - image.width * scale) / 2) / scale,
|
|
2606
|
+
);
|
|
2607
|
+
const sourceY = Math.floor(
|
|
2608
|
+
(y - (node.height - image.height * scale) / 2) / scale,
|
|
2609
|
+
);
|
|
2610
|
+
if (
|
|
2611
|
+
sourceX < 0 ||
|
|
2612
|
+
sourceY < 0 ||
|
|
2613
|
+
sourceX >= image.width ||
|
|
2614
|
+
sourceY >= image.height
|
|
2615
|
+
) {
|
|
2616
|
+
return [0, 0, 0, 0];
|
|
2617
|
+
}
|
|
2618
|
+
const index = (sourceY * image.width + sourceX) * 4;
|
|
2619
|
+
return [
|
|
2620
|
+
image.pixels[index],
|
|
2621
|
+
image.pixels[index + 1],
|
|
2622
|
+
image.pixels[index + 2],
|
|
2623
|
+
image.pixels[index + 3],
|
|
2624
|
+
];
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
function decodeMediaBytes(media, bytes, diagnostics, path) {
|
|
2628
|
+
switch (media.mimeType) {
|
|
2629
|
+
case "image/svg+xml":
|
|
2630
|
+
return {
|
|
2631
|
+
kind: "svg",
|
|
2632
|
+
value: parseSvgMedia(bytes, media, diagnostics, path),
|
|
2633
|
+
};
|
|
2634
|
+
case "image/png":
|
|
2635
|
+
return {
|
|
2636
|
+
kind: "raster",
|
|
2637
|
+
value: decodePng(bytes, diagnostics, path, media.name),
|
|
2638
|
+
};
|
|
2639
|
+
case "image/gif":
|
|
2640
|
+
return {
|
|
2641
|
+
kind: "raster",
|
|
2642
|
+
value: decodeGif(bytes, diagnostics, path, media.name),
|
|
2643
|
+
};
|
|
2644
|
+
case "image/jpeg":
|
|
2645
|
+
return {
|
|
2646
|
+
kind: "raster",
|
|
2647
|
+
value: decodeJpeg(bytes, diagnostics, path, media.name),
|
|
2648
|
+
};
|
|
2649
|
+
case "image/webp":
|
|
2650
|
+
return {
|
|
2651
|
+
kind: "raster",
|
|
2652
|
+
value: decodeWebpSync(bytes, diagnostics, path, media.name),
|
|
2653
|
+
};
|
|
2654
|
+
default:
|
|
2655
|
+
diagnostics.push({
|
|
2656
|
+
code: "media_decode_unsupported",
|
|
2657
|
+
message: `Renderer cannot decode ${media.mimeType} media ${media.name}; using the deterministic placeholder`,
|
|
2658
|
+
path,
|
|
2659
|
+
});
|
|
2660
|
+
return undefined;
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
function* renderSnapshotMedia(snapshot) {
|
|
2665
|
+
if (!snapshot) return;
|
|
2666
|
+
for (const entry of snapshot.manifest.entries.assets) {
|
|
2667
|
+
for (const media of snapshot.entries[entry].media ?? []) yield media;
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
function state_media_refs(projection) {
|
|
2672
|
+
void projection;
|
|
2673
|
+
return [];
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
function createMediaLibrary(product, foundation, libraries, diagnostics) {
|
|
2677
|
+
const result = new Map();
|
|
2678
|
+
for (const snapshot of [foundation, ...libraries, product]) {
|
|
2679
|
+
if (!snapshot) continue;
|
|
2680
|
+
for (const entry of snapshot.manifest.entries.assets) {
|
|
2681
|
+
for (const media of snapshot.entries[entry].media ?? []) {
|
|
2682
|
+
const bytes = snapshot.blobs?.get(media.blob);
|
|
2683
|
+
if (!bytes) continue;
|
|
2684
|
+
const path = `${entry}.media.${media.id}`;
|
|
2685
|
+
const decoded = decodeMediaBytes(media, bytes, diagnostics, path);
|
|
2686
|
+
result.set(assetReferenceKey(media.id, snapshot.manifest.packageId), {
|
|
2687
|
+
descriptor: media,
|
|
2688
|
+
image: decoded?.kind === "svg" ? decoded.value : undefined,
|
|
2689
|
+
raster: decoded?.kind === "raster" ? decoded.value : undefined,
|
|
2690
|
+
});
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
return result;
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
function cornerContains(node, x, y) {
|
|
2698
|
+
const values = Array.isArray(node.cornerRadius)
|
|
2699
|
+
? node.cornerRadius
|
|
2700
|
+
: [node.cornerRadius ?? 0];
|
|
2701
|
+
const requested = (values.length === 4 ? values : Array(4).fill(values[0]))
|
|
2702
|
+
.map((value) => Math.max(0, Number(value) || 0));
|
|
2703
|
+
const [topLeft, topRight, bottomRight, bottomLeft] = requested;
|
|
2704
|
+
const scale = Math.min(
|
|
2705
|
+
1,
|
|
2706
|
+
topLeft + topRight > 0 ? node.width / (topLeft + topRight) : 1,
|
|
2707
|
+
bottomLeft + bottomRight > 0
|
|
2708
|
+
? node.width / (bottomLeft + bottomRight)
|
|
2709
|
+
: 1,
|
|
2710
|
+
topLeft + bottomLeft > 0 ? node.height / (topLeft + bottomLeft) : 1,
|
|
2711
|
+
topRight + bottomRight > 0
|
|
2712
|
+
? node.height / (topRight + bottomRight)
|
|
2713
|
+
: 1,
|
|
2714
|
+
);
|
|
2715
|
+
const radii = requested.map((radius) => radius * scale);
|
|
2716
|
+
const tests = [
|
|
2717
|
+
[radii[0], radii[0], radii[0]],
|
|
2718
|
+
[node.width - radii[1], radii[1], radii[1]],
|
|
2719
|
+
[node.width - radii[2], node.height - radii[2], radii[2]],
|
|
2720
|
+
[radii[3], node.height - radii[3], radii[3]],
|
|
2721
|
+
];
|
|
2722
|
+
if (x < radii[0] && y < radii[0]) {
|
|
2723
|
+
return (x - tests[0][0]) ** 2 + (y - tests[0][1]) ** 2 <= tests[0][2] ** 2;
|
|
2724
|
+
}
|
|
2725
|
+
if (x > node.width - radii[1] && y < radii[1]) {
|
|
2726
|
+
return (x - tests[1][0]) ** 2 + (y - tests[1][1]) ** 2 <= tests[1][2] ** 2;
|
|
2727
|
+
}
|
|
2728
|
+
if (x > node.width - radii[2] && y > node.height - radii[2]) {
|
|
2729
|
+
return (x - tests[2][0]) ** 2 + (y - tests[2][1]) ** 2 <= tests[2][2] ** 2;
|
|
2730
|
+
}
|
|
2731
|
+
if (x < radii[3] && y > node.height - radii[3]) {
|
|
2732
|
+
return (x - tests[3][0]) ** 2 + (y - tests[3][1]) ** 2 <= tests[3][2] ** 2;
|
|
2733
|
+
}
|
|
2734
|
+
return true;
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
function nodeContains(node, x, y) {
|
|
2738
|
+
if (x < 0 || y < 0 || x >= node.width || y >= node.height) return false;
|
|
2739
|
+
if (node.type === "ELLIPSE") {
|
|
2740
|
+
const radiusX = node.width / 2;
|
|
2741
|
+
const radiusY = node.height / 2;
|
|
2742
|
+
if (radiusX <= 0 || radiusY <= 0) return false;
|
|
2743
|
+
return (
|
|
2744
|
+
((x - radiusX) / radiusX) ** 2 +
|
|
2745
|
+
((y - radiusY) / radiusY) ** 2 <=
|
|
2746
|
+
1
|
|
2747
|
+
);
|
|
2748
|
+
}
|
|
2749
|
+
return cornerContains(node, x, y);
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
function nodeContainsGeometry(node, geometry, x, y) {
|
|
2753
|
+
if (geometry) return pathFillContains(geometry, x, y);
|
|
2754
|
+
return nodeContains(node, x, y);
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
function insetContains(node, x, y, inset) {
|
|
2758
|
+
const width = node.width - inset * 2;
|
|
2759
|
+
const height = node.height - inset * 2;
|
|
2760
|
+
if (width <= 0 || height <= 0) return false;
|
|
2761
|
+
const reduceRadius = (value) => Math.max(0, Number(value) - inset);
|
|
2762
|
+
const cornerRadius = Array.isArray(node.cornerRadius)
|
|
2763
|
+
? node.cornerRadius.map(reduceRadius)
|
|
2764
|
+
: reduceRadius(node.cornerRadius ?? 0);
|
|
2765
|
+
return nodeContains(
|
|
2766
|
+
{ ...node, cornerRadius, height, width },
|
|
2767
|
+
x - inset,
|
|
2768
|
+
y - inset,
|
|
2769
|
+
);
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
function makeGeometry(segments) {
|
|
2773
|
+
const cumulative = [];
|
|
2774
|
+
let totalLength = 0;
|
|
2775
|
+
for (const segment of segments) {
|
|
2776
|
+
totalLength += Math.hypot(
|
|
2777
|
+
segment.end.x - segment.start.x,
|
|
2778
|
+
segment.end.y - segment.start.y,
|
|
2779
|
+
);
|
|
2780
|
+
cumulative.push(totalLength);
|
|
2781
|
+
}
|
|
2782
|
+
return { contours: [], cumulative, segments, totalLength };
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
function clipsAllow(clips, x, y) {
|
|
2786
|
+
if (!clips || clips.length === 0) return true;
|
|
2787
|
+
return clips.every(({ inverse, shape }) => {
|
|
2788
|
+
if (!inverse) return true;
|
|
2789
|
+
const local = point(inverse, x, y);
|
|
2790
|
+
return nodeContains(shape, local.x, local.y);
|
|
2791
|
+
});
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
function effectsOf(value) {
|
|
2795
|
+
if (Array.isArray(value)) return value.filter(isRecordLike);
|
|
2796
|
+
return value ? [value] : [];
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
function isRecordLike(value) {
|
|
2800
|
+
return typeof value === "object" && value !== null;
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
function blurRadiusOf(value) {
|
|
2804
|
+
const effect = effectsOf(value)[0];
|
|
2805
|
+
if (!effect) return undefined;
|
|
2806
|
+
const radius = Number(effect.value ?? effect.radius ?? effect.blur);
|
|
2807
|
+
return Number.isFinite(radius) && radius > 0 ? radius : undefined;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
function blurAlphaRegion(buffer, width, height, radius) {
|
|
2811
|
+
const boxRadius = Math.round(radius);
|
|
2812
|
+
if (boxRadius < 1) return;
|
|
2813
|
+
const temporary = new Float32Array(buffer.length);
|
|
2814
|
+
const window = boxRadius * 2 + 1;
|
|
2815
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
2816
|
+
for (let row = 0; row < height; row += 1) {
|
|
2817
|
+
let sum = 0;
|
|
2818
|
+
for (let offset = -boxRadius; offset <= boxRadius; offset += 1) {
|
|
2819
|
+
sum += buffer[row * width + clamp(offset, 0, width - 1)];
|
|
2820
|
+
}
|
|
2821
|
+
for (let column = 0; column < width; column += 1) {
|
|
2822
|
+
temporary[row * width + column] = sum / window;
|
|
2823
|
+
sum += buffer[row * width + clamp(column + boxRadius + 1, 0, width - 1)] -
|
|
2824
|
+
buffer[row * width + clamp(column - boxRadius, 0, width - 1)];
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
for (let column = 0; column < width; column += 1) {
|
|
2828
|
+
let sum = 0;
|
|
2829
|
+
for (let offset = -boxRadius; offset <= boxRadius; offset += 1) {
|
|
2830
|
+
sum += temporary[clamp(offset, 0, height - 1) * width + column];
|
|
2831
|
+
}
|
|
2832
|
+
for (let row = 0; row < height; row += 1) {
|
|
2833
|
+
buffer[row * width + column] = sum / window;
|
|
2834
|
+
sum += temporary[clamp(row + boxRadius + 1, 0, height - 1) * width + column] -
|
|
2835
|
+
temporary[clamp(row - boxRadius, 0, height - 1) * width + column];
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
function blurRgbaRegion(pixels, width, height, left, top, right, bottom, radius) {
|
|
2842
|
+
const boxRadius = Math.round(radius);
|
|
2843
|
+
if (boxRadius < 1 || right <= left || bottom <= top) return;
|
|
2844
|
+
const regionWidth = right - left;
|
|
2845
|
+
const regionHeight = bottom - top;
|
|
2846
|
+
const buffer = new Float32Array(regionWidth * regionHeight * 4);
|
|
2847
|
+
for (let row = 0; row < regionHeight; row += 1) {
|
|
2848
|
+
for (let column = 0; column < regionWidth; column += 1) {
|
|
2849
|
+
const source = ((top + row) * width + (left + column)) * 4;
|
|
2850
|
+
const alpha = pixels[source + 3] / 255;
|
|
2851
|
+
const target = (row * regionWidth + column) * 4;
|
|
2852
|
+
buffer[target] = pixels[source] * alpha;
|
|
2853
|
+
buffer[target + 1] = pixels[source + 1] * alpha;
|
|
2854
|
+
buffer[target + 2] = pixels[source + 2] * alpha;
|
|
2855
|
+
buffer[target + 3] = pixels[source + 3];
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
const temporary = new Float32Array(buffer.length);
|
|
2859
|
+
const window = boxRadius * 2 + 1;
|
|
2860
|
+
const at = (buffer_, row, column, channel) =>
|
|
2861
|
+
buffer_[(clamp(row, 0, regionHeight - 1) * regionWidth + clamp(column, 0, regionWidth - 1)) * 4 + channel];
|
|
2862
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
2863
|
+
for (let row = 0; row < regionHeight; row += 1) {
|
|
2864
|
+
for (let channel = 0; channel < 4; channel += 1) {
|
|
2865
|
+
let sum = 0;
|
|
2866
|
+
for (let offset = -boxRadius; offset <= boxRadius; offset += 1) {
|
|
2867
|
+
sum += at(buffer, row, offset, channel);
|
|
2868
|
+
}
|
|
2869
|
+
for (let column = 0; column < regionWidth; column += 1) {
|
|
2870
|
+
temporary[(row * regionWidth + column) * 4 + channel] = sum / window;
|
|
2871
|
+
sum += at(buffer, row, column + boxRadius + 1, channel) -
|
|
2872
|
+
at(buffer, row, column - boxRadius, channel);
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
for (let column = 0; column < regionWidth; column += 1) {
|
|
2877
|
+
for (let channel = 0; channel < 4; channel += 1) {
|
|
2878
|
+
let sum = 0;
|
|
2879
|
+
for (let offset = -boxRadius; offset <= boxRadius; offset += 1) {
|
|
2880
|
+
sum += at(temporary, offset, column, channel);
|
|
2881
|
+
}
|
|
2882
|
+
for (let row = 0; row < regionHeight; row += 1) {
|
|
2883
|
+
buffer[(row * regionWidth + column) * 4 + channel] = sum / window;
|
|
2884
|
+
sum += at(temporary, row + boxRadius + 1, column, channel) -
|
|
2885
|
+
at(temporary, row - boxRadius, column, channel);
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
for (let row = 0; row < regionHeight; row += 1) {
|
|
2891
|
+
for (let column = 0; column < regionWidth; column += 1) {
|
|
2892
|
+
const source = (row * regionWidth + column) * 4;
|
|
2893
|
+
const alpha = buffer[source + 3];
|
|
2894
|
+
const target = ((top + row) * width + (left + column)) * 4;
|
|
2895
|
+
const coverage = alpha / 255;
|
|
2896
|
+
pixels[target] = coverage > 0
|
|
2897
|
+
? clamp(Math.round(buffer[source] / coverage), 0, 255)
|
|
2898
|
+
: 0;
|
|
2899
|
+
pixels[target + 1] = coverage > 0
|
|
2900
|
+
? clamp(Math.round(buffer[source + 1] / coverage), 0, 255)
|
|
2901
|
+
: 0;
|
|
2902
|
+
pixels[target + 2] = coverage > 0
|
|
2903
|
+
? clamp(Math.round(buffer[source + 2] / coverage), 0, 255)
|
|
2904
|
+
: 0;
|
|
2905
|
+
pixels[target + 3] = clamp(Math.round(alpha), 0, 255);
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
function pixelBounds(state, matrix, node) {
|
|
2911
|
+
const bounds = transformedBounds(matrix, node.width, node.height);
|
|
2912
|
+
return {
|
|
2913
|
+
bottom: clamp(Math.ceil(bounds.bottom * state.scale), 0, state.height),
|
|
2914
|
+
left: clamp(Math.floor(bounds.left * state.scale), 0, state.width),
|
|
2915
|
+
right: clamp(Math.ceil(bounds.right * state.scale), 0, state.width),
|
|
2916
|
+
top: clamp(Math.floor(bounds.top * state.scale), 0, state.height),
|
|
2917
|
+
};
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
function renderNodeShadow(state, node, matrix, opacity, path, clips, geometry) {
|
|
2921
|
+
const shadows = effectsOf(node.shadow);
|
|
2922
|
+
for (const shadow of shadows) {
|
|
2923
|
+
if (shadow.hidden === true) continue;
|
|
2924
|
+
if (shadow.style !== undefined && shadow.style !== "drop-shadow") {
|
|
2925
|
+
state.diagnostics.push({
|
|
2926
|
+
code: "unsupported_render_shadow",
|
|
2927
|
+
message: `Renderer ignored shadow style ${shadow.style}`,
|
|
2928
|
+
nodeId: node.id,
|
|
2929
|
+
path,
|
|
2930
|
+
});
|
|
2931
|
+
continue;
|
|
2932
|
+
}
|
|
2933
|
+
const offsetX = Number(shadow.offsetX ?? 0);
|
|
2934
|
+
const offsetY = Number(shadow.offsetY ?? 0);
|
|
2935
|
+
const blur = Number(shadow.blur ?? 0);
|
|
2936
|
+
const color = parseColor(
|
|
2937
|
+
typeof shadow.color === "string" ? shadow.color : "#000000",
|
|
2938
|
+
state.diagnostics,
|
|
2939
|
+
`${path}.shadow`,
|
|
2940
|
+
);
|
|
2941
|
+
const shadowOpacity = Number.isFinite(Number(shadow.opacity))
|
|
2942
|
+
? clamp(Number(shadow.opacity), 0, 1)
|
|
2943
|
+
: 1;
|
|
2944
|
+
const offsetDeviceX =
|
|
2945
|
+
(matrix[0] * offsetX + matrix[2] * offsetY) * state.scale;
|
|
2946
|
+
const offsetDeviceY =
|
|
2947
|
+
(matrix[1] * offsetX + matrix[3] * offsetY) * state.scale;
|
|
2948
|
+
const bounds = pixelBounds(state, matrix, node);
|
|
2949
|
+
const expand = Math.ceil(blur * state.scale * 3) + 2;
|
|
2950
|
+
const left = clamp(bounds.left + Math.floor(offsetDeviceX - expand), 0, state.width);
|
|
2951
|
+
const right = clamp(bounds.right + Math.ceil(offsetDeviceX + expand), 0, state.width);
|
|
2952
|
+
const top = clamp(bounds.top + Math.floor(offsetDeviceY - expand), 0, state.height);
|
|
2953
|
+
const bottom = clamp(bounds.bottom + Math.ceil(offsetDeviceY + expand), 0, state.height);
|
|
2954
|
+
if (right <= left || bottom <= top) continue;
|
|
2955
|
+
const regionWidth = right - left;
|
|
2956
|
+
const alphaBuffer = new Float32Array(regionWidth * (bottom - top));
|
|
2957
|
+
const inverseMatrix = inverse(matrix);
|
|
2958
|
+
if (!inverseMatrix) continue;
|
|
2959
|
+
for (let pixelY = top; pixelY < bottom; pixelY += 1) {
|
|
2960
|
+
for (let pixelX = left; pixelX < right; pixelX += 1) {
|
|
2961
|
+
const canvasX = (pixelX + 0.5 - offsetDeviceX) / state.scale;
|
|
2962
|
+
const canvasY = (pixelY + 0.5 - offsetDeviceY) / state.scale;
|
|
2963
|
+
const local = point(inverseMatrix, canvasX, canvasY);
|
|
2964
|
+
if (
|
|
2965
|
+
local.x < 0 ||
|
|
2966
|
+
local.y < 0 ||
|
|
2967
|
+
local.x >= node.width ||
|
|
2968
|
+
local.y >= node.height ||
|
|
2969
|
+
!nodeContainsGeometry(node, geometry, local.x, local.y) ||
|
|
2970
|
+
!clipsAllow(clips, canvasX, canvasY)
|
|
2971
|
+
) {
|
|
2972
|
+
continue;
|
|
2973
|
+
}
|
|
2974
|
+
alphaBuffer[(pixelY - top) * regionWidth + (pixelX - left)] = 1;
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
blurAlphaRegion(alphaBuffer, regionWidth, bottom - top, blur * state.scale);
|
|
2978
|
+
for (let pixelY = top; pixelY < bottom; pixelY += 1) {
|
|
2979
|
+
for (let pixelX = left; pixelX < right; pixelX += 1) {
|
|
2980
|
+
const coverage = alphaBuffer[(pixelY - top) * regionWidth + (pixelX - left)];
|
|
2981
|
+
if (coverage <= 0.004) continue;
|
|
2982
|
+
blend(
|
|
2983
|
+
state.pixels,
|
|
2984
|
+
(pixelY * state.width + pixelX) * 4,
|
|
2985
|
+
color,
|
|
2986
|
+
opacity * shadowOpacity * coverage,
|
|
2987
|
+
);
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
function renderBackgroundBlur(state, node, matrix, clips) {
|
|
2994
|
+
const radius = blurRadiusOf(node.backgroundBlur);
|
|
2995
|
+
if (!radius) return;
|
|
2996
|
+
const inverseMatrix = inverse(matrix);
|
|
2997
|
+
if (!inverseMatrix) return;
|
|
2998
|
+
const bounds = pixelBounds(state, matrix, node);
|
|
2999
|
+
if (bounds.right <= bounds.left || bounds.bottom <= bounds.top) return;
|
|
3000
|
+
const regionWidth = bounds.right - bounds.left;
|
|
3001
|
+
const regionHeight = bounds.bottom - bounds.top;
|
|
3002
|
+
const original = new Uint8ClampedArray(regionWidth * regionHeight * 4);
|
|
3003
|
+
for (let row = 0; row < regionHeight; row += 1) {
|
|
3004
|
+
for (let column = 0; column < regionWidth; column += 1) {
|
|
3005
|
+
const source = ((bounds.top + row) * state.width + (bounds.left + column)) * 4;
|
|
3006
|
+
const target = (row * regionWidth + column) * 4;
|
|
3007
|
+
original[target] = state.pixels[source];
|
|
3008
|
+
original[target + 1] = state.pixels[source + 1];
|
|
3009
|
+
original[target + 2] = state.pixels[source + 2];
|
|
3010
|
+
original[target + 3] = state.pixels[source + 3];
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
blurRgbaRegion(
|
|
3014
|
+
state.pixels,
|
|
3015
|
+
state.width,
|
|
3016
|
+
state.height,
|
|
3017
|
+
bounds.left,
|
|
3018
|
+
bounds.top,
|
|
3019
|
+
bounds.right,
|
|
3020
|
+
bounds.bottom,
|
|
3021
|
+
radius * state.scale,
|
|
3022
|
+
);
|
|
3023
|
+
for (let pixelY = bounds.top; pixelY < bounds.bottom; pixelY += 1) {
|
|
3024
|
+
for (let pixelX = bounds.left; pixelX < bounds.right; pixelX += 1) {
|
|
3025
|
+
const canvasX = (pixelX + 0.5) / state.scale;
|
|
3026
|
+
const canvasY = (pixelY + 0.5) / state.scale;
|
|
3027
|
+
const local = point(inverseMatrix, canvasX, canvasY);
|
|
3028
|
+
if (
|
|
3029
|
+
local.x < 0 ||
|
|
3030
|
+
local.y < 0 ||
|
|
3031
|
+
local.x >= node.width ||
|
|
3032
|
+
local.y >= node.height ||
|
|
3033
|
+
(nodeContains(node, local.x, local.y) && clipsAllow(clips, canvasX, canvasY))
|
|
3034
|
+
) {
|
|
3035
|
+
continue;
|
|
3036
|
+
}
|
|
3037
|
+
const target = (pixelY * state.width + pixelX) * 4;
|
|
3038
|
+
const source =
|
|
3039
|
+
((pixelY - bounds.top) * regionWidth + (pixelX - bounds.left)) * 4;
|
|
3040
|
+
state.pixels[target] = original[source];
|
|
3041
|
+
state.pixels[target + 1] = original[source + 1];
|
|
3042
|
+
state.pixels[target + 2] = original[source + 2];
|
|
3043
|
+
state.pixels[target + 3] = original[source + 3];
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
function boundaryGeometry(node) {
|
|
3049
|
+
// Boundary polyline of rect/ellipse shapes, used for dashed strokes.
|
|
3050
|
+
if (node.type === "ELLIPSE") {
|
|
3051
|
+
const sides = 96;
|
|
3052
|
+
const segments = [];
|
|
3053
|
+
const radiusX = node.width / 2;
|
|
3054
|
+
const radiusY = node.height / 2;
|
|
3055
|
+
let previous = { x: radiusX, y: 0 };
|
|
3056
|
+
for (let index = 1; index <= sides; index += 1) {
|
|
3057
|
+
const angle = (index / sides) * Math.PI * 2;
|
|
3058
|
+
const next = {
|
|
3059
|
+
x: radiusX + Math.cos(angle) * radiusX,
|
|
3060
|
+
y: radiusY + Math.sin(angle) * radiusY,
|
|
3061
|
+
};
|
|
3062
|
+
segments.push({ end: next, start: previous });
|
|
3063
|
+
previous = next;
|
|
3064
|
+
}
|
|
3065
|
+
return { contours: [], segments };
|
|
3066
|
+
}
|
|
3067
|
+
const radii = (Array.isArray(node.cornerRadius)
|
|
3068
|
+
? node.cornerRadius
|
|
3069
|
+
: Array(4).fill(node.cornerRadius ?? 0)
|
|
3070
|
+
).map((value) => clamp(Number(value) || 0, 0, Math.min(node.width, node.height) / 2));
|
|
3071
|
+
const segments = [];
|
|
3072
|
+
const addArc = (centerX, centerY, radius, startAngle, endAngle) => {
|
|
3073
|
+
if (radius <= 0) return;
|
|
3074
|
+
const steps = Math.max(2, Math.ceil((Math.abs(endAngle - startAngle) / (Math.PI / 2)) * 8));
|
|
3075
|
+
let previous = {
|
|
3076
|
+
x: centerX + Math.cos(startAngle) * radius,
|
|
3077
|
+
y: centerY + Math.sin(startAngle) * radius,
|
|
3078
|
+
};
|
|
3079
|
+
for (let step = 1; step <= steps; step += 1) {
|
|
3080
|
+
const angle = startAngle + ((endAngle - startAngle) * step) / steps;
|
|
3081
|
+
const next = {
|
|
3082
|
+
x: centerX + Math.cos(angle) * radius,
|
|
3083
|
+
y: centerY + Math.sin(angle) * radius,
|
|
3084
|
+
};
|
|
3085
|
+
segments.push({ end: next, start: previous });
|
|
3086
|
+
previous = next;
|
|
3087
|
+
}
|
|
3088
|
+
};
|
|
3089
|
+
const [topLeft, topRight, bottomRight, bottomLeft] = radii;
|
|
3090
|
+
const addLine = (start, end) => {
|
|
3091
|
+
if (Math.hypot(end.x - start.x, end.y - start.y) > 0) segments.push({ end, start });
|
|
3092
|
+
};
|
|
3093
|
+
const top = ({ x: topLeft, y: 0 });
|
|
3094
|
+
addLine(top, { x: node.width - topRight, y: 0 });
|
|
3095
|
+
addArc(node.width - topRight, topRight, topRight, -Math.PI / 2, 0);
|
|
3096
|
+
addLine({ x: node.width, y: topRight }, { x: node.width, y: node.height - bottomRight });
|
|
3097
|
+
addArc(node.width - bottomRight, node.height - bottomRight, bottomRight, 0, Math.PI / 2);
|
|
3098
|
+
addLine(
|
|
3099
|
+
{ x: node.width - bottomRight, y: node.height },
|
|
3100
|
+
{ x: bottomLeft, y: node.height },
|
|
3101
|
+
);
|
|
3102
|
+
addArc(bottomLeft, node.height - bottomLeft, bottomLeft, Math.PI / 2, Math.PI);
|
|
3103
|
+
addLine({ x: 0, y: node.height - bottomLeft }, { x: 0, y: topLeft });
|
|
3104
|
+
addArc(topLeft, topLeft, topLeft, Math.PI, Math.PI * 1.5);
|
|
3105
|
+
return { contours: [], segments };
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
function strokeSegmentsFor(node, geometry, stroke, width, pattern) {
|
|
3109
|
+
if (geometry) return [];
|
|
3110
|
+
if (pattern) return boundaryGeometry(node).segments;
|
|
3111
|
+
return [];
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
function renderStrokeColor(state, node, stroke, path) {
|
|
3115
|
+
return paintColor(
|
|
3116
|
+
stroke,
|
|
3117
|
+
0,
|
|
3118
|
+
0,
|
|
3119
|
+
node,
|
|
3120
|
+
state.colors,
|
|
3121
|
+
state.diagnostics,
|
|
3122
|
+
`${path}.strokes`,
|
|
3123
|
+
);
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
function mergeMissingGlyphs(state, missing) {
|
|
3127
|
+
for (const [nodeId, characters] of missing) {
|
|
3128
|
+
const existing = state.missingGlyphs.get(nodeId) ?? new Set();
|
|
3129
|
+
for (const character of characters) existing.add(character);
|
|
3130
|
+
state.missingGlyphs.set(nodeId, existing);
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
function createScratchState(state) {
|
|
3135
|
+
return {
|
|
3136
|
+
...state,
|
|
3137
|
+
colors: state.colors,
|
|
3138
|
+
diagnostics: [],
|
|
3139
|
+
fontFallbacks: state.fontFallbacks,
|
|
3140
|
+
fonts: state.fonts,
|
|
3141
|
+
media: state.media,
|
|
3142
|
+
pixels: new Uint8ClampedArray(state.width * state.height * 4),
|
|
3143
|
+
missingGlyphs: new Map(),
|
|
3144
|
+
};
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
function childClips(state, nodes, node, matrix, clips) {
|
|
3148
|
+
const inverseMatrix = inverse(matrix);
|
|
3149
|
+
if (!inverseMatrix) return clips;
|
|
3150
|
+
if (node.type === "FRAME" && node["show-content"] === false) {
|
|
3151
|
+
return [...(clips ?? []), { inverse: inverseMatrix, shape: node }];
|
|
3152
|
+
}
|
|
3153
|
+
if (
|
|
3154
|
+
node["masked-group"] === true &&
|
|
3155
|
+
(node.children?.length ?? 0) > 1 &&
|
|
3156
|
+
node["show-content"] !== true
|
|
3157
|
+
) {
|
|
3158
|
+
const maskNode = nodes[node.children[0]];
|
|
3159
|
+
if (maskNode) {
|
|
3160
|
+
const maskMatrix = multiply(matrix, nodeTransform(maskNode));
|
|
3161
|
+
const maskInverse = inverse(maskMatrix);
|
|
3162
|
+
if (maskInverse) {
|
|
3163
|
+
return [...(clips ?? []), { inverse: maskInverse, shape: maskNode }];
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
return clips;
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
function renderTree(state, nodes, nodeId, parentMatrix, parentOpacity, stack, clips) {
|
|
3171
|
+
if (stack.has(nodeId)) throw new SmallPenError("node_cycle", `Render cycle includes ${nodeId}`);
|
|
3172
|
+
const node = nodes[nodeId];
|
|
3173
|
+
if (!node) throw new SmallPenError("missing_node", `Render Node is missing: ${nodeId}`);
|
|
3174
|
+
if (node.visible === false) return;
|
|
3175
|
+
const matrix = multiply(parentMatrix, nodeTransform(node));
|
|
3176
|
+
const opacity = parentOpacity * (node.opacity ?? 1);
|
|
3177
|
+
const path = `projection.nodes.${nodeId}`;
|
|
3178
|
+
const isMaskSource =
|
|
3179
|
+
clips?.length > 0 && clips[clips.length - 1].shape === node;
|
|
3180
|
+
const layerBlurRadius = isMaskSource ? undefined : blurRadiusOf(node.blur);
|
|
3181
|
+
const children = node.children ?? [];
|
|
3182
|
+
const masked =
|
|
3183
|
+
node["masked-group"] === true &&
|
|
3184
|
+
children.length > 1 &&
|
|
3185
|
+
node["show-content"] !== true;
|
|
3186
|
+
if (layerBlurRadius) {
|
|
3187
|
+
// Layer blur isolates the node and its subtree in an offscreen buffer.
|
|
3188
|
+
const scratch = createScratchState(state);
|
|
3189
|
+
renderNodeShape(scratch, node, matrix, opacity, path, clips);
|
|
3190
|
+
stack.add(nodeId);
|
|
3191
|
+
for (let index = masked ? 1 : 0; index < children.length; index += 1) {
|
|
3192
|
+
renderTree(
|
|
3193
|
+
scratch,
|
|
3194
|
+
nodes,
|
|
3195
|
+
children[index],
|
|
3196
|
+
matrix,
|
|
3197
|
+
opacity,
|
|
3198
|
+
stack,
|
|
3199
|
+
masked || (node.type === "FRAME" && node["show-content"] === false)
|
|
3200
|
+
? childClips(state, nodes, node, matrix, clips)
|
|
3201
|
+
: clips,
|
|
3202
|
+
);
|
|
3203
|
+
}
|
|
3204
|
+
stack.delete(nodeId);
|
|
3205
|
+
for (const entry of scratch.diagnostics) state.diagnostics.push(entry);
|
|
3206
|
+
if (scratch.missingGlyphs) mergeMissingGlyphs(state, scratch.missingGlyphs);
|
|
3207
|
+
blurRgbaRegion(
|
|
3208
|
+
scratch.pixels,
|
|
3209
|
+
state.width,
|
|
3210
|
+
state.height,
|
|
3211
|
+
0,
|
|
3212
|
+
0,
|
|
3213
|
+
state.width,
|
|
3214
|
+
state.height,
|
|
3215
|
+
layerBlurRadius * state.scale,
|
|
3216
|
+
);
|
|
3217
|
+
for (let index = 0; index < state.pixels.length; index += 4) {
|
|
3218
|
+
blend(
|
|
3219
|
+
state.pixels,
|
|
3220
|
+
index,
|
|
3221
|
+
[
|
|
3222
|
+
scratch.pixels[index],
|
|
3223
|
+
scratch.pixels[index + 1],
|
|
3224
|
+
scratch.pixels[index + 2],
|
|
3225
|
+
scratch.pixels[index + 3],
|
|
3226
|
+
],
|
|
3227
|
+
1,
|
|
3228
|
+
);
|
|
3229
|
+
}
|
|
3230
|
+
return;
|
|
3231
|
+
}
|
|
3232
|
+
if (!isMaskSource) {
|
|
3233
|
+
renderNodeShape(state, node, matrix, opacity, path, clips);
|
|
3234
|
+
}
|
|
3235
|
+
stack.add(nodeId);
|
|
3236
|
+
const clipsChildren =
|
|
3237
|
+
masked || (node.type === "FRAME" && node["show-content"] === false);
|
|
3238
|
+
const childClipsHere = clipsChildren
|
|
3239
|
+
? childClips(state, nodes, node, matrix, clips)
|
|
3240
|
+
: clips;
|
|
3241
|
+
for (let index = 0; index < children.length; index += 1) {
|
|
3242
|
+
if (masked && index === 0) {
|
|
3243
|
+
// The mask source is used only as the clip shape (Penpot semantics) and
|
|
3244
|
+
// is not rendered as content.
|
|
3245
|
+
continue;
|
|
3246
|
+
}
|
|
3247
|
+
renderTree(state, nodes, children[index], matrix, opacity, stack, childClipsHere);
|
|
3248
|
+
}
|
|
3249
|
+
stack.delete(nodeId);
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
function renderNodeShape(state, node, matrix, opacity, path, clips) {
|
|
3253
|
+
const inverseMatrix = inverse(matrix);
|
|
3254
|
+
if (!inverseMatrix || node.width <= 0 || node.height <= 0) return;
|
|
3255
|
+
const geometry = node.type === "PATH" && typeof node.pathData === "string"
|
|
3256
|
+
? parsePathData(node.pathData)
|
|
3257
|
+
: undefined;
|
|
3258
|
+
if (node.type === "PATH" && typeof node.pathData === "string" && !geometry) {
|
|
3259
|
+
state.diagnostics.push({
|
|
3260
|
+
code: "unsupported_render_path",
|
|
3261
|
+
message: `Renderer could not parse pathData and used the bounding box`,
|
|
3262
|
+
nodeId: node.id,
|
|
3263
|
+
path,
|
|
3264
|
+
});
|
|
3265
|
+
}
|
|
3266
|
+
renderNodeShadow(state, node, matrix, opacity, path, clips, geometry);
|
|
3267
|
+
renderBackgroundBlur(state, node, matrix, clips);
|
|
3268
|
+
const bounds = pixelBounds(state, matrix, node);
|
|
3269
|
+
// hide-fill-on-export suppresses fills in exported raster output while the
|
|
3270
|
+
// editing surface keeps showing them; the CLI render is the export surface.
|
|
3271
|
+
const suppressFill = node["hide-fill-on-export"] === true;
|
|
3272
|
+
const fills =
|
|
3273
|
+
suppressFill || node.type === "TEXT"
|
|
3274
|
+
? []
|
|
3275
|
+
: node.fills?.length
|
|
3276
|
+
? node.fills
|
|
3277
|
+
: node.type === "IMAGE" && node.mediaRef
|
|
3278
|
+
? [{ mediaRef: node.mediaRef, type: "image" }]
|
|
3279
|
+
: node.type === "FRAME" && path.endsWith(`.${state.rootId}`)
|
|
3280
|
+
? [{ color: "#ffffff", type: "solid" }]
|
|
3281
|
+
: [];
|
|
3282
|
+
for (const fill of fills) {
|
|
3283
|
+
const media = fill.type === "image"
|
|
3284
|
+
? state.media.get(
|
|
3285
|
+
assetReferenceKey(fill.mediaRef, state.productPackageId),
|
|
3286
|
+
)
|
|
3287
|
+
: undefined;
|
|
3288
|
+
const raster = media?.raster;
|
|
3289
|
+
if (fill.type === "image" && !media?.image && !raster) {
|
|
3290
|
+
state.diagnostics.push({
|
|
3291
|
+
code: "image_render_placeholder",
|
|
3292
|
+
message: `Image ${JSON.stringify(fill.mediaRef)} uses a deterministic placeholder`,
|
|
3293
|
+
nodeId: node.id,
|
|
3294
|
+
path,
|
|
3295
|
+
});
|
|
3296
|
+
}
|
|
3297
|
+
for (let pixelY = bounds.top; pixelY < bounds.bottom; pixelY += 1) {
|
|
3298
|
+
for (let pixelX = bounds.left; pixelX < bounds.right; pixelX += 1) {
|
|
3299
|
+
const canvasX = (pixelX + 0.5) / state.scale;
|
|
3300
|
+
const canvasY = (pixelY + 0.5) / state.scale;
|
|
3301
|
+
const local = point(inverseMatrix, canvasX, canvasY);
|
|
3302
|
+
if (
|
|
3303
|
+
local.x < 0 ||
|
|
3304
|
+
local.y < 0 ||
|
|
3305
|
+
local.x >= node.width ||
|
|
3306
|
+
local.y >= node.height ||
|
|
3307
|
+
!nodeContainsGeometry(node, geometry, local.x, local.y) ||
|
|
3308
|
+
!clipsAllow(clips, canvasX, canvasY)
|
|
3309
|
+
) {
|
|
3310
|
+
continue;
|
|
3311
|
+
}
|
|
3312
|
+
const color = raster
|
|
3313
|
+
? rasterSample(raster, local.x, local.y, node)
|
|
3314
|
+
: media?.image
|
|
3315
|
+
? svgSample(media.image, local.x, local.y, node)
|
|
3316
|
+
: paintColor(
|
|
3317
|
+
fill,
|
|
3318
|
+
local.x,
|
|
3319
|
+
local.y,
|
|
3320
|
+
node,
|
|
3321
|
+
state.colors,
|
|
3322
|
+
state.diagnostics,
|
|
3323
|
+
path,
|
|
3324
|
+
state.productPackageId,
|
|
3325
|
+
);
|
|
3326
|
+
if (fill.type === "image" && !media?.image && !raster) {
|
|
3327
|
+
const band = (Math.floor(local.x / 8) + Math.floor(local.y / 8)) % 2;
|
|
3328
|
+
color[0] -= band * 18;
|
|
3329
|
+
color[1] -= band * 18;
|
|
3330
|
+
color[2] -= band * 18;
|
|
3331
|
+
}
|
|
3332
|
+
blend(
|
|
3333
|
+
state.pixels,
|
|
3334
|
+
(pixelY * state.width + pixelX) * 4,
|
|
3335
|
+
color,
|
|
3336
|
+
opacity * (fill.opacity ?? 1),
|
|
3337
|
+
);
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
for (const stroke of node.type === "TEXT" ? [] : (node.strokes ?? [])) {
|
|
3342
|
+
if (stroke.hidden === true) continue;
|
|
3343
|
+
const width = Math.max(1, stroke.width ?? 1);
|
|
3344
|
+
const color = renderStrokeColor(state, node, stroke, path);
|
|
3345
|
+
const pattern = dashPattern(stroke, width);
|
|
3346
|
+
const boundary = geometry || (pattern ? makeGeometry(boundaryGeometry(node).segments) : undefined);
|
|
3347
|
+
const markerStart = stroke.capStart && geometry
|
|
3348
|
+
? markerGeometry(
|
|
3349
|
+
stroke.capStart,
|
|
3350
|
+
geometry.start,
|
|
3351
|
+
directionAt(geometry, { segmentIndex: 0 }),
|
|
3352
|
+
width,
|
|
3353
|
+
)
|
|
3354
|
+
: undefined;
|
|
3355
|
+
const markerEnd = stroke.capEnd && geometry
|
|
3356
|
+
? markerGeometry(
|
|
3357
|
+
stroke.capEnd,
|
|
3358
|
+
geometry.end,
|
|
3359
|
+
directionAt(geometry, { segmentIndex: geometry.segments.length - 1 }),
|
|
3360
|
+
width,
|
|
3361
|
+
)
|
|
3362
|
+
: undefined;
|
|
3363
|
+
for (let pixelY = bounds.top; pixelY < bounds.bottom; pixelY += 1) {
|
|
3364
|
+
for (let pixelX = bounds.left; pixelX < bounds.right; pixelX += 1) {
|
|
3365
|
+
const canvasX = (pixelX + 0.5) / state.scale;
|
|
3366
|
+
const canvasY = (pixelY + 0.5) / state.scale;
|
|
3367
|
+
const local = point(inverseMatrix, canvasX, canvasY);
|
|
3368
|
+
if (
|
|
3369
|
+
local.x < 0 ||
|
|
3370
|
+
local.y < 0 ||
|
|
3371
|
+
local.x >= node.width ||
|
|
3372
|
+
local.y >= node.height ||
|
|
3373
|
+
!clipsAllow(clips, canvasX, canvasY)
|
|
3374
|
+
) {
|
|
3375
|
+
continue;
|
|
3376
|
+
}
|
|
3377
|
+
let covered;
|
|
3378
|
+
if (boundary) {
|
|
3379
|
+
const fillInside = nodeContainsGeometry(node, geometry, local.x, local.y);
|
|
3380
|
+
covered = pathStrokeContains(
|
|
3381
|
+
boundary,
|
|
3382
|
+
local.x,
|
|
3383
|
+
local.y,
|
|
3384
|
+
width,
|
|
3385
|
+
pattern,
|
|
3386
|
+
stroke.alignment,
|
|
3387
|
+
fillInside,
|
|
3388
|
+
);
|
|
3389
|
+
} else {
|
|
3390
|
+
covered =
|
|
3391
|
+
nodeContains(node, local.x, local.y) &&
|
|
3392
|
+
!insetContains(node, local.x, local.y, width);
|
|
3393
|
+
}
|
|
3394
|
+
if (!covered && markerStart) {
|
|
3395
|
+
covered =
|
|
3396
|
+
windingNumber(markerStart.contours, local.x, local.y) !== 0 ||
|
|
3397
|
+
(markerStart.segments.length > 0 &&
|
|
3398
|
+
pathStrokeContains(
|
|
3399
|
+
makeGeometry(markerStart.segments),
|
|
3400
|
+
local.x,
|
|
3401
|
+
local.y,
|
|
3402
|
+
width,
|
|
3403
|
+
));
|
|
3404
|
+
}
|
|
3405
|
+
if (!covered && markerEnd) {
|
|
3406
|
+
covered =
|
|
3407
|
+
windingNumber(markerEnd.contours, local.x, local.y) !== 0 ||
|
|
3408
|
+
(markerEnd.segments.length > 0 &&
|
|
3409
|
+
pathStrokeContains(
|
|
3410
|
+
makeGeometry(markerEnd.segments),
|
|
3411
|
+
local.x,
|
|
3412
|
+
local.y,
|
|
3413
|
+
width,
|
|
3414
|
+
));
|
|
3415
|
+
}
|
|
3416
|
+
if (!covered) continue;
|
|
3417
|
+
blend(
|
|
3418
|
+
state.pixels,
|
|
3419
|
+
(pixelY * state.width + pixelX) * 4,
|
|
3420
|
+
color,
|
|
3421
|
+
opacity * (stroke.opacity ?? 1),
|
|
3422
|
+
);
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
if (node.type === "TEXT") renderText(state, node, matrix, opacity, path, clips);
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3429
|
+
function flattenRegions(
|
|
3430
|
+
node,
|
|
3431
|
+
scale,
|
|
3432
|
+
width,
|
|
3433
|
+
height,
|
|
3434
|
+
originX,
|
|
3435
|
+
originY,
|
|
3436
|
+
result = [],
|
|
3437
|
+
) {
|
|
3438
|
+
const rawLeft = (node.bounds.x - originX) * scale;
|
|
3439
|
+
const rawTop = (node.bounds.y - originY) * scale;
|
|
3440
|
+
const left = clamp(rawLeft, 0, width);
|
|
3441
|
+
const top = clamp(rawTop, 0, height);
|
|
3442
|
+
const right = clamp(rawLeft + node.bounds.width * scale, 0, width);
|
|
3443
|
+
const bottom = clamp(rawTop + node.bounds.height * scale, 0, height);
|
|
3444
|
+
result.push({
|
|
3445
|
+
height: Math.max(0, bottom - top),
|
|
3446
|
+
nodeId: node.id,
|
|
3447
|
+
width: Math.max(0, right - left),
|
|
3448
|
+
x: left,
|
|
3449
|
+
y: top,
|
|
3450
|
+
});
|
|
3451
|
+
for (const child of node.children) {
|
|
3452
|
+
flattenRegions(
|
|
3453
|
+
child,
|
|
3454
|
+
scale,
|
|
3455
|
+
width,
|
|
3456
|
+
height,
|
|
3457
|
+
originX,
|
|
3458
|
+
originY,
|
|
3459
|
+
result,
|
|
3460
|
+
);
|
|
3461
|
+
}
|
|
3462
|
+
return result;
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
export async function renderProjection(product, projection, options = {}) {
|
|
3466
|
+
if (projection.fallbackUsed) {
|
|
3467
|
+
throw new SmallPenError(
|
|
3468
|
+
"preview_fallback_not_renderable",
|
|
3469
|
+
"Preview fallback cannot be used for strict render or evidence",
|
|
3470
|
+
);
|
|
3471
|
+
}
|
|
3472
|
+
const scale = options.scale ?? 1;
|
|
3473
|
+
if (!Number.isFinite(scale) || scale <= 0 || scale > 8) {
|
|
3474
|
+
throw new SmallPenError("invalid_render_scale", "Render scale must be within (0, 8]", {
|
|
3475
|
+
scale,
|
|
3476
|
+
});
|
|
3477
|
+
}
|
|
3478
|
+
const root = projection.nodes[projection.rootId];
|
|
3479
|
+
if (!root) throw new SmallPenError("missing_root_node", "Projection root is unavailable");
|
|
3480
|
+
const width = Math.ceil(root.width * scale);
|
|
3481
|
+
const height = Math.ceil(root.height * scale);
|
|
3482
|
+
if (
|
|
3483
|
+
width < 1 ||
|
|
3484
|
+
height < 1 ||
|
|
3485
|
+
width > MAX_DIMENSION ||
|
|
3486
|
+
height > MAX_DIMENSION ||
|
|
3487
|
+
width * height > MAX_PIXELS
|
|
3488
|
+
) {
|
|
3489
|
+
throw new SmallPenError("render_size_exceeded", "Rendered image exceeds safety limits", {
|
|
3490
|
+
height,
|
|
3491
|
+
maximumDimension: MAX_DIMENSION,
|
|
3492
|
+
maximumPixels: MAX_PIXELS,
|
|
3493
|
+
width,
|
|
3494
|
+
});
|
|
3495
|
+
}
|
|
3496
|
+
const diagnostics = [...(projection.diagnostics ?? [])];
|
|
3497
|
+
const hasText = Object.values(projection.nodes).some(
|
|
3498
|
+
(node) => node.type === "TEXT" && node.text,
|
|
3499
|
+
);
|
|
3500
|
+
// WebP media requires the libwebp wasm decoder; initialize it lazily once.
|
|
3501
|
+
const hasWebp = [product, options.foundation, ...(options.libraries ?? [])]
|
|
3502
|
+
.some((snapshot) =>
|
|
3503
|
+
snapshot?.manifest?.entries?.assets?.some((entry) =>
|
|
3504
|
+
(snapshot.entries[entry]?.media ?? []).some(
|
|
3505
|
+
(media) => media.mimeType === "image/webp",
|
|
3506
|
+
),
|
|
3507
|
+
) ?? false,
|
|
3508
|
+
);
|
|
3509
|
+
if (hasWebp) await initWebpDecoder();
|
|
3510
|
+
const state = {
|
|
3511
|
+
colors: colorLibrary(
|
|
3512
|
+
product,
|
|
3513
|
+
options.foundation,
|
|
3514
|
+
options.libraries ?? [],
|
|
3515
|
+
diagnostics,
|
|
3516
|
+
),
|
|
3517
|
+
diagnostics,
|
|
3518
|
+
fontFallbacks: new Set(),
|
|
3519
|
+
fonts: hasText
|
|
3520
|
+
? await createFontLibrary(
|
|
3521
|
+
product,
|
|
3522
|
+
options.foundation,
|
|
3523
|
+
options.libraries ?? [],
|
|
3524
|
+
diagnostics,
|
|
3525
|
+
)
|
|
3526
|
+
: undefined,
|
|
3527
|
+
height,
|
|
3528
|
+
media: createMediaLibrary(
|
|
3529
|
+
product,
|
|
3530
|
+
options.foundation,
|
|
3531
|
+
options.libraries ?? [],
|
|
3532
|
+
diagnostics,
|
|
3533
|
+
),
|
|
3534
|
+
missingGlyphs: new Map(),
|
|
3535
|
+
pixels: new Uint8ClampedArray(width * height * 4),
|
|
3536
|
+
productPackageId: product.manifest.packageId,
|
|
3537
|
+
rootId: projection.rootId,
|
|
3538
|
+
scale,
|
|
3539
|
+
width,
|
|
3540
|
+
};
|
|
3541
|
+
renderTree(
|
|
3542
|
+
state,
|
|
3543
|
+
projection.nodes,
|
|
3544
|
+
projection.rootId,
|
|
3545
|
+
translate(-root.x, -root.y),
|
|
3546
|
+
1,
|
|
3547
|
+
new Set(),
|
|
3548
|
+
);
|
|
3549
|
+
const bytes = encodePng(width, height, state.pixels);
|
|
3550
|
+
return {
|
|
3551
|
+
bytes,
|
|
3552
|
+
diagnostics,
|
|
3553
|
+
height,
|
|
3554
|
+
mimeType: "image/png",
|
|
3555
|
+
renderHash: await sha256Hex(bytes),
|
|
3556
|
+
width,
|
|
3557
|
+
};
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
export async function createEvidence(product, options = {}) {
|
|
3561
|
+
const resolved = resolveDesignView(product, options);
|
|
3562
|
+
const projection = projectDesignView(product, resolved, options);
|
|
3563
|
+
const render = await renderProjection(product, projection, options);
|
|
3564
|
+
const semanticTree = createSemanticTree(product, projection, {
|
|
3565
|
+
foundationRevision: options.foundation?.revision,
|
|
3566
|
+
scenarioId: resolved.scenarioId,
|
|
3567
|
+
selection: resolved.selection,
|
|
3568
|
+
});
|
|
3569
|
+
const packageHash = options.foundation
|
|
3570
|
+
? await sha256Hex(`${product.revision}\0${options.foundation.revision}`)
|
|
3571
|
+
: product.revision;
|
|
3572
|
+
const rootX = semanticTree.root.bounds.x;
|
|
3573
|
+
const rootY = semanticTree.root.bounds.y;
|
|
3574
|
+
const regions = flattenRegions(
|
|
3575
|
+
semanticTree.root,
|
|
3576
|
+
options.scale ?? 1,
|
|
3577
|
+
render.width,
|
|
3578
|
+
render.height,
|
|
3579
|
+
rootX,
|
|
3580
|
+
rootY,
|
|
3581
|
+
);
|
|
3582
|
+
return {
|
|
3583
|
+
evidence: {
|
|
3584
|
+
diagnostics: render.diagnostics,
|
|
3585
|
+
nodeRegions: regions,
|
|
3586
|
+
packageHash,
|
|
3587
|
+
packageId: product.manifest.packageId,
|
|
3588
|
+
...(options.foundation
|
|
3589
|
+
? { foundationRevision: options.foundation.revision }
|
|
3590
|
+
: {}),
|
|
3591
|
+
productRevision: product.revision,
|
|
3592
|
+
renderHash: render.renderHash,
|
|
3593
|
+
revision: product.revision,
|
|
3594
|
+
selector: resolved.selection,
|
|
3595
|
+
semanticTree,
|
|
3596
|
+
viewport: {
|
|
3597
|
+
height: render.height,
|
|
3598
|
+
scale: options.scale ?? 1,
|
|
3599
|
+
width: render.width,
|
|
3600
|
+
},
|
|
3601
|
+
},
|
|
3602
|
+
projection,
|
|
3603
|
+
render,
|
|
3604
|
+
};
|
|
3605
|
+
}
|