freshcoat 0.1.0

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.
Files changed (62) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +17 -0
  3. package/README.md +110 -0
  4. package/package.json +53 -0
  5. package/src/adjust.d.ts +18 -0
  6. package/src/adjust.js +153 -0
  7. package/src/approx-layout.d.ts +2 -0
  8. package/src/approx-layout.js +91 -0
  9. package/src/bake-text.d.ts +12 -0
  10. package/src/bake-text.js +321 -0
  11. package/src/browser.d.ts +7 -0
  12. package/src/browser.js +79 -0
  13. package/src/canvaskit.d.ts +4 -0
  14. package/src/canvaskit.js +1674 -0
  15. package/src/compile-scene.d.ts +24 -0
  16. package/src/compile-scene.js +325 -0
  17. package/src/css.d.ts +5 -0
  18. package/src/css.js +153 -0
  19. package/src/decode.d.ts +22 -0
  20. package/src/decode.js +112 -0
  21. package/src/export-scale.d.ts +25 -0
  22. package/src/export-scale.js +74 -0
  23. package/src/font-bytes.d.ts +4 -0
  24. package/src/font-bytes.js +165 -0
  25. package/src/font-metrics.d.ts +5 -0
  26. package/src/font-metrics.js +62 -0
  27. package/src/headless.d.ts +24 -0
  28. package/src/headless.js +58 -0
  29. package/src/index.d.ts +25 -0
  30. package/src/index.js +29 -0
  31. package/src/jpeg.d.ts +5 -0
  32. package/src/jpeg.js +20 -0
  33. package/src/line-height.d.ts +7 -0
  34. package/src/line-height.js +80 -0
  35. package/src/node.d.ts +139 -0
  36. package/src/node.js +47 -0
  37. package/src/paint-cache.d.ts +49 -0
  38. package/src/paint-cache.js +109 -0
  39. package/src/paint-helpers.d.ts +21 -0
  40. package/src/paint-helpers.js +71 -0
  41. package/src/paragraph-layout.d.ts +6 -0
  42. package/src/paragraph-layout.js +283 -0
  43. package/src/path-data.d.ts +5 -0
  44. package/src/path-data.js +101 -0
  45. package/src/png.d.ts +23 -0
  46. package/src/png.js +189 -0
  47. package/src/resolve-layout.d.ts +9 -0
  48. package/src/resolve-layout.js +657 -0
  49. package/src/runtime.d.ts +6 -0
  50. package/src/runtime.js +44 -0
  51. package/src/squircle.d.ts +1 -0
  52. package/src/squircle.js +60 -0
  53. package/src/text-cache.d.ts +14 -0
  54. package/src/text-cache.js +46 -0
  55. package/src/text-engine.d.ts +26 -0
  56. package/src/text-engine.js +1 -0
  57. package/src/text-types.d.ts +30 -0
  58. package/src/text-types.js +1 -0
  59. package/src/types.d.ts +406 -0
  60. package/src/types.js +13 -0
  61. package/src/validate-commands.d.ts +8 -0
  62. package/src/validate-commands.js +206 -0
@@ -0,0 +1,1674 @@
1
+ // CanvasKit (WASM Skia) render backend. Consumes the backend-agnostic Command
2
+ // IR (one scene's `Command[]` from compileScene) and paints via the full Skia
3
+ // API: Canvas/Paint/Shader for fills, ParagraphBuilder (HarfBuzz shaping →
4
+ // Figma-matching kerning) for text, the ImageFilter graph for blur/shadow, and
5
+ // drawImageRect/image shaders for images.
6
+ //
7
+ // The caller supplies an initialized CanvasKit instance (this module does not
8
+ // import `canvaskit-wasm`, so it stays runtime-agnostic), the font bytes, and
9
+ // any image bytes keyed by src. Text is drawn at the compile-baked baseline
10
+ // (`line.baseline`).
11
+ import { exportPixelSize, resolveSupersample } from "./export-scale.js";
12
+ import { fontBytes } from "./font-bytes.js";
13
+ import { cachedFontProvider, cachedSurface, evictUnusedImages, paintCacheState, } from "./paint-cache.js";
14
+ import { decorationLine, fitRect, fontVariationList, insetCorner, strokeInset, } from "./paint-helpers.js";
15
+ import { flattenOverWhite } from "./jpeg.js";
16
+ import { DEFAULT_JPEG_QUALITY, DEFAULT_WEBP_QUALITY, encodePng, } from "./png.js";
17
+ import { squircleSvg } from "./squircle.js";
18
+ // Canvas2D shadowBlur ≈ 2·sigma. Figma's layer-blur value is ~2.27× the
19
+ // Gaussian sigma (bjango blur-radius comparison), so a value of N → sigma N/2.27.
20
+ const SHADOW_SIGMA = (blur) => blur / 2;
21
+ const LAYER_BLUR_SIGMA = (blur) => blur / 2.2727;
22
+ const WEIGHTS = {
23
+ 100: "Thin",
24
+ 200: "ExtraLight",
25
+ 300: "Light",
26
+ 400: "Normal",
27
+ 500: "Medium",
28
+ 600: "SemiBold",
29
+ 700: "Bold",
30
+ 800: "ExtraBold",
31
+ 900: "Black",
32
+ };
33
+ function makeBin() {
34
+ const items = [];
35
+ return {
36
+ track: (o) => {
37
+ if (o && typeof o.delete === "function")
38
+ items.push(o);
39
+ return o;
40
+ },
41
+ free: () => {
42
+ for (const o of items) {
43
+ try {
44
+ o.delete();
45
+ }
46
+ catch { }
47
+ }
48
+ },
49
+ lutImages: new Map(),
50
+ lut3dImages: new Map(),
51
+ };
52
+ }
53
+ function toColor(ck, input) {
54
+ const s = input.trim();
55
+ // CanvasKit's parseColorString("transparent") wrongly returns opaque black,
56
+ // which would fill e.g. a QR's transparent background solid black.
57
+ if (s.toLowerCase() === "transparent")
58
+ return ck.TRANSPARENT;
59
+ if (s.startsWith("#")) {
60
+ let h = s.slice(1);
61
+ if (h.length === 3)
62
+ h = h
63
+ .split("")
64
+ .map((c) => c + c)
65
+ .join("");
66
+ const r = parseInt(h.slice(0, 2), 16);
67
+ const g = parseInt(h.slice(2, 4), 16);
68
+ const b = parseInt(h.slice(4, 6), 16);
69
+ const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
70
+ return ck.Color(r, g, b, a);
71
+ }
72
+ const m = /rgba?\(([^)]+)\)/i.exec(s);
73
+ if (m) {
74
+ const [r, g, b, a] = m[1].split(",").map((v) => parseFloat(v.trim()));
75
+ return ck.Color(r, g, b, a ?? 1);
76
+ }
77
+ // Named colors / hsl() etc. — CanvasKit parses these; fall back to black.
78
+ try {
79
+ const c = ck.parseColorString(s);
80
+ if (c)
81
+ return c;
82
+ }
83
+ catch { }
84
+ return ck.BLACK;
85
+ }
86
+ function shaderFor(ck, bin, fill, x, y, w, h) {
87
+ if (fill.kind === "linear") {
88
+ return bin.track(ck.Shader.MakeLinearGradient([x + fill.from.x * w, y + fill.from.y * h], [x + fill.to.x * w, y + fill.to.y * h], fill.stops.map((s) => toColor(ck, s.color)), fill.stops.map((s) => s.offset), ck.TileMode.Clamp));
89
+ }
90
+ if (fill.kind === "angular") {
91
+ const cx = x + fill.center.x * w;
92
+ const cy = y + fill.center.y * h;
93
+ // rotation 0 = top; a sweep's 0 is at 3 o'clock, so it turns by
94
+ // rotation - 90. Skia measures sweep angles from 0 to 360, so the turn
95
+ // is a local matrix about the centre: a start angle would leave part
96
+ // of the circle outside the swept window, clamped to an end stop.
97
+ const turn = (((fill.rotation - 90) % 360) + 360) % 360;
98
+ return bin.track(ck.Shader.MakeSweepGradient(cx, cy, fill.stops.map((s) => toColor(ck, s.color)), fill.stops.map((s) => s.offset), ck.TileMode.Clamp, turn === 0 ? null : ck.Matrix.rotated((turn * Math.PI) / 180, cx, cy), 0, 0, 360));
99
+ }
100
+ const cx = x + fill.center.x * w;
101
+ const cy = y + fill.center.y * h;
102
+ const longest = Math.max(w, h);
103
+ const rx = fill.radius * longest;
104
+ const ry = (fill.radiusY ?? fill.radius) * longest;
105
+ const rotation = fill.rotation ?? 0;
106
+ const colors = fill.stops.map((s) => toColor(ck, s.color));
107
+ const offsets = fill.stops.map((s) => s.offset);
108
+ // Skia only draws circles, so an elliptical gradient is that circle under a
109
+ // local matrix: squash the secondary axis to ry/rx, then turn the whole thing
110
+ // to where the primary axis points. Both operate about the center so the
111
+ // gradient stays put. A circular gradient skips the matrix entirely.
112
+ if (rx <= 0 || (ry === rx && rotation % 180 === 0)) {
113
+ return bin.track(ck.Shader.MakeRadialGradient([cx, cy], rx, colors, offsets, ck.TileMode.Clamp));
114
+ }
115
+ const localMatrix = ck.Matrix.multiply(ck.Matrix.rotated((rotation * Math.PI) / 180, cx, cy), ck.Matrix.scaled(1, ry / rx, cx, cy));
116
+ return bin.track(ck.Shader.MakeRadialGradient([cx, cy], rx, colors, offsets, ck.TileMode.Clamp, localMatrix));
117
+ }
118
+ // A TypefaceFontProvider registered from explicit font bytes (no global font
119
+ // registry, no cache poisoning) — Paragraph resolves families against it.
120
+ function makeFontProvider(ck, fonts) {
121
+ const provider = ck.TypefaceFontProvider.Make();
122
+ for (const f of fonts) {
123
+ const buf = f.bytes.buffer.slice(f.bytes.byteOffset, f.bytes.byteOffset + f.bytes.byteLength);
124
+ provider.registerFont(buf, f.family);
125
+ }
126
+ // Stash the registered families on the provider so text styles can append them
127
+ // as a per-glyph fallback chain (drawText → textStyleOf). Without listing them
128
+ // in fontFamilies, CanvasKit renders any glyph the span's font lacks (emoji,
129
+ // CJK, …) as tofu even though a covering font is registered here.
130
+ provider.__families = [
131
+ ...new Set(fonts.map((f) => f.family)),
132
+ ];
133
+ return provider;
134
+ }
135
+ const STROKE_CAP = {
136
+ butt: "Butt",
137
+ round: "Round",
138
+ square: "Square",
139
+ };
140
+ const STROKE_JOIN = {
141
+ miter: "Miter",
142
+ round: "Round",
143
+ bevel: "Bevel",
144
+ };
145
+ // Build a Skia RRect from a uniform or per-corner radius. CanvasKit's RRect is
146
+ // [l,t,r,b, ulX,ulY, urX,urY, lrX,lrY, llX,llY]; our CornerRadius is
147
+ // [topLeft, topRight, bottomRight, bottomLeft] = ul, ur, lr, ll.
148
+ function rrectFor(ck, rect, cr) {
149
+ if (typeof cr === "number")
150
+ return ck.RRectXY(rect, cr, cr);
151
+ const [tl, tr, br, bl] = cr;
152
+ return Float32Array.of(rect[0], rect[1], rect[2], rect[3], tl, tl, tr, tr, br, br, bl, bl);
153
+ }
154
+ function strokePaint(ck, bin, stroke) {
155
+ const p = bin.track(new ck.Paint());
156
+ p.setAntiAlias(true);
157
+ p.setStyle(ck.PaintStyle.Stroke);
158
+ p.setColor(toColor(ck, stroke.color));
159
+ p.setStrokeWidth(stroke.width);
160
+ p.setStrokeCap(ck.StrokeCap[STROKE_CAP[stroke.cap ?? "butt"]]);
161
+ p.setStrokeJoin(ck.StrokeJoin[STROKE_JOIN[stroke.join ?? "miter"]]);
162
+ if (stroke.dash && stroke.dash.length > 0)
163
+ p.setPathEffect(bin.track(ck.PathEffect.MakeDash(stroke.dash)));
164
+ return p;
165
+ }
166
+ // SVG path string for a clip/stroke shape.
167
+ // This build of CanvasKit exposes no imperative Path builders — only
168
+ // Path.MakeFromSVGString — so we describe shapes as SVG.
169
+ function maskSvg(clip, x, y, w, h) {
170
+ switch (clip.kind) {
171
+ case "rect": {
172
+ // Outset lets text fit:"clip" keep glyph overshoot whole (see ClipOutset).
173
+ const top = clip.outset?.top ?? 0;
174
+ const bottom = clip.outset?.bottom ?? 0;
175
+ const y0 = y - top;
176
+ const y1 = y + h + bottom;
177
+ return `M ${x} ${y0} H ${x + w} V ${y1} H ${x} Z`;
178
+ }
179
+ case "rounded-rect": {
180
+ const r = Math.min(clip.radius, Math.min(w, h) / 2);
181
+ return `M ${x + r} ${y} H ${x + w - r} A ${r} ${r} 0 0 1 ${x + w} ${y + r} V ${y + h - r} A ${r} ${r} 0 0 1 ${x + w - r} ${y + h} H ${x + r} A ${r} ${r} 0 0 1 ${x} ${y + h - r} V ${y + r} A ${r} ${r} 0 0 1 ${x + r} ${y} Z`;
182
+ }
183
+ case "circle": {
184
+ const rr = Math.min(w, h) / 2;
185
+ const cx = x + w / 2;
186
+ const cy = y + h / 2;
187
+ return `M ${cx - rr} ${cy} A ${rr} ${rr} 0 1 0 ${cx + rr} ${cy} A ${rr} ${rr} 0 1 0 ${cx - rr} ${cy} Z`;
188
+ }
189
+ case "ellipse": {
190
+ const rx = w / 2;
191
+ const ry = h / 2;
192
+ const cy = y + h / 2;
193
+ return `M ${x} ${cy} A ${rx} ${ry} 0 1 0 ${x + w} ${cy} A ${rx} ${ry} 0 1 0 ${x} ${cy} Z`;
194
+ }
195
+ case "polygon": {
196
+ const cx = x + w / 2;
197
+ const cy = y + h / 2;
198
+ const rx = w / 2;
199
+ const ry = h / 2;
200
+ const rot = ((clip.rotation ?? 0) * Math.PI) / 180;
201
+ let d = "";
202
+ for (let i = 0; i < clip.sides; i++) {
203
+ const a = -Math.PI / 2 + (i * 2 * Math.PI) / clip.sides + rot;
204
+ d += `${i === 0 ? "M" : "L"} ${cx + rx * Math.cos(a)} ${cy + ry * Math.sin(a)} `;
205
+ }
206
+ return `${d}Z`;
207
+ }
208
+ case "squircle": {
209
+ const max = Math.min(w, h) / 2;
210
+ const r = Math.min(clip.radius, max);
211
+ const p = Math.min(r * 1.5, max);
212
+ const k = p * 0.4;
213
+ return `M ${x + p} ${y} L ${x + w - p} ${y} C ${x + w - k} ${y} ${x + w} ${y + k} ${x + w} ${y + p} L ${x + w} ${y + h - p} C ${x + w} ${y + h - k} ${x + w - k} ${y + h} ${x + w - p} ${y + h} L ${x + p} ${y + h} C ${x + k} ${y + h} ${x} ${y + h - k} ${x} ${y + h - p} L ${x} ${y + p} C ${x} ${y + k} ${x + k} ${y} ${x + p} ${y} Z`;
214
+ }
215
+ default:
216
+ return `M ${x} ${y} H ${x + w} V ${y + h} H ${x} Z`;
217
+ }
218
+ }
219
+ function maskPath(ck, bin, clip, x, y, w, h) {
220
+ return bin.track(ck.Path.MakeFromSVGString(maskSvg(clip, x, y, w, h)));
221
+ }
222
+ function textStyleOf(ck, span, cmd, fallbackFamilies = []) {
223
+ const weight = WEIGHTS[Math.round((span.font.weight || 400) / 100) * 100] ?? "Normal";
224
+ return {
225
+ color: toColor(ck, span.color ?? cmd.color ?? "#000000"),
226
+ fontFamilies: [
227
+ span.font.family,
228
+ ...fallbackFamilies.filter((f) => f !== span.font.family),
229
+ ],
230
+ fontSize: span.font.size,
231
+ fontStyle: { weight: ck.FontWeight[weight] },
232
+ // Instantiate a variable face at the span's weight and axes instead of
233
+ // drawing its default instance under synthetic bold — see paragraph-layout's
234
+ // spanTextStyle, which measures with the identical style.
235
+ fontVariations: fontVariationList(span.font.weight, span.font.variations),
236
+ ...(span.font.letterSpacing
237
+ ? { letterSpacing: span.font.letterSpacing }
238
+ : {}),
239
+ };
240
+ }
241
+ // Render each baked line with a ParagraphBuilder — HarfBuzz shaping applies the
242
+ // kerning Figma uses — aligning the paragraph's baseline to the baked
243
+ // baseline via getLineMetrics.
244
+ function drawText(ck, canvas, provider, bin, cmd) {
245
+ // A gradient fill spans the whole text box, applied to every glyph (via a
246
+ // foreground paint) and its decoration, overriding per-span colors.
247
+ const fillShader = cmd.fill && cmd.fill.kind !== "solid"
248
+ ? shaderFor(ck, bin, cmd.fill, cmd.pos.x, cmd.pos.y, cmd.size.width, cmd.size.height)
249
+ : null;
250
+ let fgPaint = null;
251
+ let bgPaint = null;
252
+ if (fillShader) {
253
+ fgPaint = bin.track(new ck.Paint());
254
+ fgPaint.setAntiAlias(true);
255
+ fgPaint.setShader(fillShader);
256
+ bgPaint = bin.track(new ck.Paint());
257
+ bgPaint.setColor(ck.TRANSPARENT);
258
+ }
259
+ const fallback = provider.__families ?? [];
260
+ for (const line of cmd.layout.lines) {
261
+ const first = line.spans[0];
262
+ if (!first)
263
+ continue;
264
+ const style = new ck.ParagraphStyle({
265
+ textStyle: textStyleOf(ck, first, cmd, fallback),
266
+ });
267
+ const builder = bin.track(ck.ParagraphBuilder.MakeFromFontProvider(style, provider));
268
+ for (const span of line.spans) {
269
+ const ts = ck.TextStyle(textStyleOf(ck, span, cmd, fallback));
270
+ if (fgPaint)
271
+ builder.pushPaintStyle(ts, fgPaint, bgPaint);
272
+ else
273
+ builder.pushStyle(ts);
274
+ builder.addText(span.text);
275
+ builder.pop();
276
+ }
277
+ const para = bin.track(builder.build());
278
+ para.layout(1e6); // single pre-wrapped line; no re-wrapping
279
+ const lm = para.getLineMetrics();
280
+ const ascent = lm.length ? lm[0].ascent : 0;
281
+ canvas.drawParagraph(para, first.x, (line.baseline ?? line.y) - ascent);
282
+ // Decoration lines are drawn as rects
283
+ // rather than via Paragraph decoration, so both backends agree.
284
+ const baseline = line.baseline ?? line.y;
285
+ for (const span of line.spans) {
286
+ if (!span.font.decoration)
287
+ continue;
288
+ const { top, thickness } = decorationLine(span.font.size, span.font.decoration, baseline);
289
+ const p = bin.track(new ck.Paint());
290
+ p.setAntiAlias(true);
291
+ if (fillShader)
292
+ p.setShader(fillShader);
293
+ else
294
+ p.setColor(toColor(ck, span.color));
295
+ canvas.drawRect(ck.XYWHRect(span.x, top, span.width, thickness), p);
296
+ }
297
+ }
298
+ }
299
+ // Mitchell–Netravali cubic (B = C = 1/3): Skia's canonical "high quality"
300
+ // resampler — sharp, ring-free magnification and clean mild minification.
301
+ const MITCHELL = 1 / 3;
302
+ // Draw a scaled image slice with quality sampling chosen by scale direction.
303
+ // drawImageRect's bare overload falls back to SkSamplingOptions()'s default —
304
+ // nearest-neighbour, no mipmaps — so any up- or down-scaled placement comes out
305
+ // jagged and soft (visible on the OG share card, whose art is downscaled into
306
+ // its frame). Card art is routinely dropped into a slot larger or smaller than
307
+ // itself, so pick per draw:
308
+ // • Heavy minification (a big photo shrunk into a small slot): sample a
309
+ // mipmapped copy trilinearly, so shrinking doesn't alias or shimmer.
310
+ // • Otherwise (upscale, or a mild shrink like the OG card): Mitchell cubic.
311
+ function drawImageRectHQ(ck, canvas, bin, img, src, dest, paint, scale) {
312
+ // Below this ratio, cubic sampling of the full-res image starts to alias;
313
+ // mipmaps (each level pre-filtered) keep the shrink clean. Above it, cubic
314
+ // is both sharper and cheaper (no mip pyramid to build).
315
+ if (scale > 0 && scale < 0.5) {
316
+ const mipped = bin.track(img.makeCopyWithDefaultMipmaps());
317
+ canvas.drawImageRectOptions(mipped, src, dest, ck.FilterMode.Linear, ck.MipmapMode.Linear, paint);
318
+ }
319
+ else {
320
+ canvas.drawImageRectCubic(img, src, dest, MITCHELL, MITCHELL, paint);
321
+ }
322
+ }
323
+ // A light-gray field + a centered photo glyph (frame + sun + mountains) for an unresolved image.
324
+ function drawImagePlaceholder(ck, canvas, bin, pos, size) {
325
+ const field = bin.track(new ck.Paint());
326
+ field.setColor(toColor(ck, "#e5e7eb"));
327
+ canvas.drawRect(ck.XYWHRect(pos.x, pos.y, size.width, size.height), field);
328
+ const icon = Math.min(size.width, size.height) * 0.38;
329
+ if (icon < 6)
330
+ return;
331
+ const x = pos.x + (size.width - icon) / 2;
332
+ const y = pos.y + (size.height - icon) / 2;
333
+ const gray = toColor(ck, "#9ca3af");
334
+ const stroke = bin.track(new ck.Paint());
335
+ stroke.setColor(gray);
336
+ stroke.setStyle(ck.PaintStyle.Stroke);
337
+ stroke.setStrokeWidth(Math.max(1, icon * 0.07));
338
+ stroke.setAntiAlias(true);
339
+ canvas.drawRect(ck.XYWHRect(x, y, icon, icon), stroke);
340
+ const fill = bin.track(new ck.Paint());
341
+ fill.setColor(gray);
342
+ fill.setAntiAlias(true);
343
+ // This CanvasKit build only exposes Path.MakeFromSVGString (see maskSvg), so
344
+ // draw the sun as an SVG circle path rather than canvas.drawCircle.
345
+ const sr = icon * 0.1;
346
+ const scx = x + icon * 0.32;
347
+ const scy = y + icon * 0.3;
348
+ const sun = `M ${scx - sr} ${scy} A ${sr} ${sr} 0 1 0 ${scx + sr} ${scy} A ${sr} ${sr} 0 1 0 ${scx - sr} ${scy} Z`;
349
+ canvas.drawPath(bin.track(ck.Path.MakeFromSVGString(sun)), fill);
350
+ const mtn = `M ${x + icon * 0.08} ${y + icon * 0.85} L ${x + icon * 0.42} ${y + icon * 0.5} L ${x + icon * 0.62} ${y + icon * 0.68} L ${x + icon * 0.8} ${y + icon * 0.45} L ${x + icon * 0.92} ${y + icon * 0.85} Z`;
351
+ canvas.drawPath(bin.track(ck.Path.MakeFromSVGString(mtn)), fill);
352
+ }
353
+ // The node's outline stroke, drawn along its mask (or its box when unmasked).
354
+ // Shared by the painted-image and placeholder paths so both get the same border.
355
+ function drawImageStroke(ck, canvas, bin, cmd) {
356
+ if (!cmd.stroke)
357
+ return;
358
+ const { pos, size } = cmd;
359
+ const path = maskPath(ck, bin, cmd.clip ?? { kind: "rect" }, pos.x, pos.y, size.width, size.height);
360
+ canvas.drawPath(path, strokePaint(ck, bin, cmd.stroke));
361
+ }
362
+ function drawImage(ck, canvas, bin, images, cmd, issues) {
363
+ const { pos, size } = cmd;
364
+ const img = images.get(cmd.src);
365
+ if (!img) {
366
+ // No image (unfilled {{token}} in a preview, or a failed load) → draw an
367
+ // image placeholder (gray field + photo glyph) rather than a bare rect,
368
+ // clipped to the node's mask so it matches the shape a real image fills
369
+ // (e.g. a circular avatar), then stroked like the painted path. Reported so
370
+ // callers that expected a real image can tell — previews that intend the
371
+ // placeholder simply ignore the warning.
372
+ issues.missingImages.push(cmd.src);
373
+ canvas.save();
374
+ if (cmd.clip)
375
+ canvas.clipPath(maskPath(ck, bin, cmd.clip, pos.x, pos.y, size.width, size.height), ck.ClipOp.Intersect, true);
376
+ drawImagePlaceholder(ck, canvas, bin, pos, size);
377
+ canvas.restore();
378
+ drawImageStroke(ck, canvas, bin, cmd);
379
+ return;
380
+ }
381
+ canvas.save();
382
+ if (cmd.clip)
383
+ canvas.clipPath(maskPath(ck, bin, cmd.clip, pos.x, pos.y, size.width, size.height), ck.ClipOp.Intersect, true);
384
+ const paint = bin.track(new ck.Paint());
385
+ paint.setAntiAlias(true);
386
+ if (cmd.fit === "tile") {
387
+ const shader = bin.track(img.makeShaderOptions(ck.TileMode.Repeat, ck.TileMode.Repeat, ck.FilterMode.Linear, ck.MipmapMode.None));
388
+ paint.setShader(shader);
389
+ canvas.drawRect(ck.XYWHRect(pos.x, pos.y, size.width, size.height), paint);
390
+ }
391
+ else {
392
+ const r = fitRect(img.width(), img.height(), pos.x, pos.y, size.width, size.height, cmd.fit);
393
+ drawImageRectHQ(ck, canvas, bin, img, ck.XYWHRect(r.sx, r.sy, r.sw, r.sh), ck.XYWHRect(r.dx, r.dy, r.dw, r.dh), paint, Math.min(r.dw / r.sw, r.dh / r.sh));
394
+ }
395
+ canvas.restore();
396
+ drawImageStroke(ck, canvas, bin, cmd);
397
+ }
398
+ // A barcode reads by the widths of its bars, so an edge that lands between two
399
+ // pixels (a grey column either side of a bar) is a misread waiting to happen.
400
+ // When the bitmap is drawn upright, its module size in output pixels is floored
401
+ // to a whole number and the code centred in its box on whole pixels. Returns the
402
+ // rect in device space, or null to draw as placed.
403
+ function snapBarcode(canvas, cmd, grid) {
404
+ const m = canvas.getTotalMatrix();
405
+ const [a, b, c, d, e, f, g, h] = m;
406
+ const eps = 1e-9;
407
+ if (Math.abs(b) > eps ||
408
+ Math.abs(d) > eps ||
409
+ Math.abs(g) > eps ||
410
+ Math.abs(h) > eps ||
411
+ a <= 0 ||
412
+ e <= 0)
413
+ return null;
414
+ const { pos, size, pixelWidth, pixelHeight } = cmd;
415
+ const dx = (a * pos.x + c) / grid;
416
+ const dy = (e * pos.y + f) / grid;
417
+ const dw = (a * size.width) / grid;
418
+ const dh = (e * size.height) / grid;
419
+ const along = (start, extent, module, n) => {
420
+ if (module < 1)
421
+ return { start, extent };
422
+ const snapped = Math.floor(module + 1e-6) * n;
423
+ return {
424
+ start: Math.round(start + (extent - snapped) / 2),
425
+ extent: snapped,
426
+ };
427
+ };
428
+ // A 1D code is one row stretched to the bar height, so only its columns
429
+ // carry modules; its top and bottom just land on whole pixels. A 2D code
430
+ // keeps square modules, so both axes take the smaller whole size.
431
+ let x;
432
+ let y;
433
+ if (pixelHeight === 1) {
434
+ x = along(dx, dw, dw / pixelWidth, pixelWidth);
435
+ const top = Math.round(dy);
436
+ y = { start: top, extent: Math.max(1, Math.round(dy + dh) - top) };
437
+ }
438
+ else {
439
+ const module = Math.min(dw / pixelWidth, dh / pixelHeight);
440
+ x = along(dx, dw, module, pixelWidth);
441
+ y = along(dy, dh, module, pixelHeight);
442
+ }
443
+ return {
444
+ x: x.start * grid,
445
+ y: y.start * grid,
446
+ width: x.extent * grid,
447
+ height: y.extent * grid,
448
+ };
449
+ }
450
+ function drawBitmap(ck, canvas, bin, cmd, frame) {
451
+ const { pos, size, pixels, pixelWidth, pixelHeight } = cmd;
452
+ if (pixelWidth <= 0 || pixelHeight <= 0)
453
+ return;
454
+ const img = ck.MakeImage({
455
+ width: pixelWidth,
456
+ height: pixelHeight,
457
+ colorType: ck.ColorType.RGBA_8888,
458
+ alphaType: ck.AlphaType.Unpremul,
459
+ colorSpace: ck.ColorSpace.SRGB,
460
+ }, pixels, pixelWidth * 4);
461
+ if (!img)
462
+ return;
463
+ canvas.save();
464
+ if (cmd.clip)
465
+ canvas.clipPath(maskPath(ck, bin, cmd.clip, pos.x, pos.y, size.width, size.height), ck.ClipOp.Intersect, true);
466
+ const paint = bin.track(new ck.Paint());
467
+ const snapped = cmd.role === "barcode" ? snapBarcode(canvas, cmd, frame.grid ?? 1) : null;
468
+ // Nearest-neighbor keeps QR module / pixel-art edges crisp when scaled up.
469
+ const src = ck.XYWHRect(0, 0, pixelWidth, pixelHeight);
470
+ if (snapped) {
471
+ // Drawn in device space, where the snapped rect was worked out.
472
+ const inverse = ck.Matrix.invert(canvas.getTotalMatrix());
473
+ if (inverse)
474
+ canvas.concat(inverse);
475
+ canvas.drawImageRectOptions(img, src, ck.XYWHRect(snapped.x, snapped.y, snapped.width, snapped.height), ck.FilterMode.Nearest, ck.MipmapMode.None, paint);
476
+ }
477
+ else {
478
+ canvas.drawImageRectOptions(img, src, ck.XYWHRect(pos.x, pos.y, size.width, size.height), ck.FilterMode.Nearest, ck.MipmapMode.None, paint);
479
+ }
480
+ img.delete();
481
+ canvas.restore();
482
+ }
483
+ function drawPath(ck, canvas, bin, cmd) {
484
+ const path = bin.track(ck.Path.MakeFromSVGString(cmd.d));
485
+ if (!path)
486
+ return;
487
+ if (cmd.fillRule === "evenodd")
488
+ path.setFillType(ck.FillType.EvenOdd);
489
+ canvas.save();
490
+ canvas.translate(cmd.pos.x, cmd.pos.y); // path coords are origin-relative
491
+ // A viewBox scales the authored path into the node's size box (SVG viewBox →
492
+ // viewport). Fills then span the viewBox, so they map onto the full box.
493
+ const vb = cmd.viewBox;
494
+ let boxW = cmd.size.width;
495
+ let boxH = cmd.size.height;
496
+ if (vb && vb.width > 0 && vb.height > 0) {
497
+ canvas.scale(cmd.size.width / vb.width, cmd.size.height / vb.height);
498
+ if (vb.x || vb.y)
499
+ canvas.translate(-(vb.x ?? 0), -(vb.y ?? 0));
500
+ boxW = vb.width;
501
+ boxH = vb.height;
502
+ }
503
+ for (const fill of cmd.fills ?? []) {
504
+ const paint = bin.track(new ck.Paint());
505
+ paint.setAntiAlias(true);
506
+ if (fill.kind === "solid")
507
+ paint.setColor(toColor(ck, fill.color));
508
+ else
509
+ paint.setShader(shaderFor(ck, bin, fill, 0, 0, boxW, boxH));
510
+ canvas.drawPath(path, paint);
511
+ }
512
+ if (cmd.stroke)
513
+ canvas.drawPath(path, strokePaint(ck, bin, cmd.stroke));
514
+ canvas.restore();
515
+ }
516
+ function reportAdjustUnsupported(issues, cmd, component) {
517
+ const layer = cmd.id ?? (cmd.op === "drawImage" ? cmd.src : undefined);
518
+ issues.adjustUnsupported.set(`${component}\u0000${layer ?? ""}`, {
519
+ component,
520
+ layer,
521
+ });
522
+ }
523
+ function drawShape(ck, canvas, provider, images, bin, cmd, issues, frame) {
524
+ if (cmd.op === "drawRect") {
525
+ const { x, y } = cmd.pos;
526
+ const { width: w, height: h } = cmd.size;
527
+ const rect = ck.XYWHRect(x, y, w, h);
528
+ const cr = cmd.cornerRadius;
529
+ // Corner smoothing (superellipse) applies to a uniform radius; otherwise
530
+ // fall back to a plain (possibly per-corner) rounded rect.
531
+ const smoothing = cmd.cornerSmoothing ?? 0;
532
+ const smoothR = smoothing > 0 && typeof cr === "number" && cr > 0 ? cr : null;
533
+ const rr = smoothR === null &&
534
+ cr !== undefined &&
535
+ (typeof cr === "number" ? cr > 0 : cr.some((r) => r > 0))
536
+ ? rrectFor(ck, rect, cr)
537
+ : null;
538
+ const fillPath = smoothR !== null
539
+ ? bin.track(ck.Path.MakeFromSVGString(squircleSvg(x, y, w, h, smoothR, smoothing)))
540
+ : null;
541
+ for (const fill of cmd.fills ?? []) {
542
+ const paint = bin.track(new ck.Paint());
543
+ paint.setAntiAlias(true);
544
+ if (fill.kind === "solid")
545
+ paint.setColor(toColor(ck, fill.color));
546
+ else
547
+ paint.setShader(shaderFor(ck, bin, fill, x, y, w, h));
548
+ if (fillPath)
549
+ canvas.drawPath(fillPath, paint);
550
+ else if (rr)
551
+ canvas.drawRRect(rr, paint);
552
+ else
553
+ canvas.drawRect(rect, paint);
554
+ }
555
+ if (cmd.stroke) {
556
+ const sp = strokePaint(ck, bin, cmd.stroke);
557
+ // Offset the stroked rect for inside/outside alignment (center = 0).
558
+ const inset = strokeInset(cmd.stroke);
559
+ if (smoothR !== null) {
560
+ const path = bin.track(ck.Path.MakeFromSVGString(squircleSvg(x + inset, y + inset, w - 2 * inset, h - 2 * inset, Math.max(0, smoothR - inset), smoothing)));
561
+ canvas.drawPath(path, sp);
562
+ }
563
+ else if (inset === 0) {
564
+ if (rr)
565
+ canvas.drawRRect(rr, sp);
566
+ else
567
+ canvas.drawRect(rect, sp);
568
+ }
569
+ else {
570
+ const srect = ck.XYWHRect(x + inset, y + inset, w - 2 * inset, h - 2 * inset);
571
+ if (rr)
572
+ canvas.drawRRect(rrectFor(ck, srect, insetCorner(cr, inset)), sp);
573
+ else
574
+ canvas.drawRect(srect, sp);
575
+ }
576
+ }
577
+ }
578
+ else if (cmd.op === "drawText") {
579
+ drawText(ck, canvas, provider, bin, cmd);
580
+ }
581
+ else if (cmd.op === "drawImage") {
582
+ drawImage(ck, canvas, bin, images, cmd, issues);
583
+ }
584
+ else if (cmd.op === "drawBitmap") {
585
+ drawBitmap(ck, canvas, bin, cmd, frame);
586
+ }
587
+ else if (cmd.op === "drawPath") {
588
+ drawPath(ck, canvas, bin, cmd);
589
+ }
590
+ else if (cmd.op === "drawQr") {
591
+ const { pos, size, modules, margin = 0, foreground, background } = cmd;
592
+ const m = (Math.min(size.width, size.height) - margin * 2) / modules.length;
593
+ const bg = bin.track(new ck.Paint());
594
+ bg.setColor(toColor(ck, background ?? "#ffffff"));
595
+ canvas.drawRect(ck.XYWHRect(pos.x, pos.y, size.width, size.height), bg);
596
+ const fg = bin.track(new ck.Paint());
597
+ fg.setColor(toColor(ck, foreground));
598
+ for (let y = 0; y < modules.length; y++)
599
+ for (let x = 0; x < modules.length; x++)
600
+ if (modules[y][x])
601
+ canvas.drawRect(ck.XYWHRect(pos.x + margin + x * m, pos.y + margin + y * m, m, m), fg);
602
+ }
603
+ else if (cmd.op === "drawGroup") {
604
+ for (const child of cmd.children)
605
+ paintDrawable(ck, canvas, provider, images, bin, child, issues, frame);
606
+ }
607
+ else if (cmd.op === "drawMasked") {
608
+ drawMasked(ck, canvas, provider, images, bin, cmd, issues, frame);
609
+ }
610
+ else {
611
+ issues.unhandled.push(cmd.op);
612
+ }
613
+ }
614
+ // The general mask: draw children to an offscreen content layer, then composite
615
+ // the mask's coverage onto it. DstIn keeps content where the mask is opaque
616
+ // (DstOut where it's transparent, for invert); a luminance channel first maps
617
+ // the mask's brightness to alpha via a color matrix.
618
+ function drawMasked(ck, canvas, provider, images, bin, cmd, issues, frame) {
619
+ canvas.saveLayer();
620
+ for (const child of cmd.children)
621
+ paintDrawable(ck, canvas, provider, images, bin, child, issues, frame);
622
+ const maskPaint = bin.track(new ck.Paint());
623
+ maskPaint.setBlendMode(cmd.invert ? ck.BlendMode.DstOut : ck.BlendMode.DstIn);
624
+ if (cmd.channel === "luminance")
625
+ maskPaint.setColorFilter(bin.track(ck.ColorFilter.MakeMatrix([
626
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.2126, 0.7152, 0.0722,
627
+ 0, 0,
628
+ ])));
629
+ canvas.saveLayer(maskPaint);
630
+ paintDrawable(ck, canvas, provider, images, bin, cmd.mask, issues, frame);
631
+ canvas.restore();
632
+ canvas.restore();
633
+ }
634
+ // The color-matrix component of an `adjust` as a Skia ColorFilter — the cheap
635
+ // per-pixel path, folded into the layer paint. The nonlinear `lut` and spatial
636
+ // `sharpen` components can't be a color filter, so they take the offscreen SkSL
637
+ // path (see paintAdjustedOffscreen); here we handle only the matrix.
638
+ function adjustColorFilter(ck, bin, cmd) {
639
+ const m = cmd.adjust?.colorMatrix;
640
+ if (!m)
641
+ return null;
642
+ return shaderSideMatrix(cmd.adjust)
643
+ ? null
644
+ : bin.track(ck.ColorFilter.MakeMatrix(m));
645
+ }
646
+ // Whether a 4×5 matrix touches only RGB: alpha is neither an input to the color
647
+ // rows (column 3) nor transformed itself (row 3 is identity). The shader carries
648
+ // alpha alongside unpremultiplied RGB, so it stands in for the color filter only
649
+ // for these. Every matrix ./adjust builds qualifies.
650
+ function matrixIsRgbOnly(m) {
651
+ if (m.length !== 20)
652
+ return false;
653
+ const zero = (v) => Math.abs(v) < 1e-6;
654
+ return (zero(m[3]) &&
655
+ zero(m[8]) &&
656
+ zero(m[13]) &&
657
+ zero(m[15]) &&
658
+ zero(m[16]) &&
659
+ zero(m[17]) &&
660
+ Math.abs(m[18] - 1) < 1e-6 &&
661
+ zero(m[19]));
662
+ }
663
+ // "preserve-hue" needs the matrix's unclamped output, which a Skia color filter
664
+ // never exposes — so the matrix moves onto the shader pass with it. A matrix with
665
+ // alpha terms stays on the color filter and the mode reports unsupported.
666
+ function shaderSideMatrix(a) {
667
+ return (!!a &&
668
+ a.gamut === "preserve-hue" &&
669
+ !!a.colorMatrix &&
670
+ matrixIsRgbOnly(a.colorMatrix));
671
+ }
672
+ // True when an adjust carries components the layer-paint color filter can't do —
673
+ // the per-channel LUT (nonlinear), sharpen (spatial), or a matrix that has to be
674
+ // gamut-mapped per pixel. These take the offscreen shader path; a matrix that does
675
+ // not need mapping rides along inside that offscreen render as a color filter.
676
+ function needsShaderAdjust(cmd) {
677
+ const a = cmd.adjust;
678
+ if (!a)
679
+ return false;
680
+ if (a.lut || a.lut3d)
681
+ return true;
682
+ if (typeof a.sharpen === "number" && a.sharpen > 0)
683
+ return true;
684
+ return shaderSideMatrix(a);
685
+ }
686
+ // A 256×1 lookup image encoding a per-channel LUT (x = input 0..255, texel =
687
+ // output). Sampled Nearest/Clamp by the adjust shader.
688
+ function lutKey(lut) {
689
+ if (!lut)
690
+ throw new Error("lutKey: no lut");
691
+ // The bytes are the value of a curve; callers often construct separate typed
692
+ // arrays for the same gamma, so object identity would miss the useful cache.
693
+ return `${lut.r.join(",")}|${lut.g.join(",")}|${lut.b.join(",")}`;
694
+ }
695
+ function lutImage(ck, bin, lut) {
696
+ if (!lut)
697
+ throw new Error("lutImage: no lut");
698
+ const key = lutKey(lut);
699
+ const cached = bin.lutImages.get(key);
700
+ if (cached)
701
+ return cached;
702
+ const px = new Uint8Array(256 * 4);
703
+ for (let i = 0; i < 256; i++) {
704
+ px[i * 4] = lut.r[i];
705
+ px[i * 4 + 1] = lut.g[i];
706
+ px[i * 4 + 2] = lut.b[i];
707
+ px[i * 4 + 3] = 255;
708
+ }
709
+ const image = bin.track(ck.MakeImage({
710
+ width: 256,
711
+ height: 1,
712
+ colorType: ck.ColorType.RGBA_8888,
713
+ alphaType: ck.AlphaType.Unpremul,
714
+ colorSpace: ck.ColorSpace.SRGB,
715
+ }, px, 256 * 4));
716
+ bin.lutImages.set(key, image);
717
+ return image;
718
+ }
719
+ function lut3dKey(lut) {
720
+ if (!lut)
721
+ throw new Error("lut3dKey: no LUT");
722
+ return `${lut.size}:${lut.data.join(",")}`;
723
+ }
724
+ // A type guard, not a bare boolean, so `lut3dImage` can read `lut.size`/`lut.data`
725
+ // without re-checking after the throw.
726
+ function validLut3d(lut) {
727
+ return (!!lut &&
728
+ Number.isInteger(lut.size) &&
729
+ lut.size >= 2 &&
730
+ lut.data.length === lut.size ** 3 * 3);
731
+ }
732
+ // A 3D cube packed into a 2D atlas. Columns are red within blue slices and rows
733
+ // are green: atlas(x = b * size + r, y = g) = cube(r, g, b).
734
+ function lut3dImage(ck, bin, lut) {
735
+ if (!validLut3d(lut))
736
+ throw new Error("lut3dImage: invalid LUT");
737
+ const key = lut3dKey(lut);
738
+ const cached = bin.lut3dImages.get(key);
739
+ if (cached)
740
+ return cached;
741
+ const width = lut.size * lut.size;
742
+ const px = new Uint8Array(width * lut.size * 4);
743
+ for (let b = 0; b < lut.size; b++) {
744
+ for (let g = 0; g < lut.size; g++) {
745
+ for (let r = 0; r < lut.size; r++) {
746
+ const source = ((b * lut.size + g) * lut.size + r) * 3;
747
+ const target = (g * width + b * lut.size + r) * 4;
748
+ px[target] = lut.data[source];
749
+ px[target + 1] = lut.data[source + 1];
750
+ px[target + 2] = lut.data[source + 2];
751
+ px[target + 3] = 255;
752
+ }
753
+ }
754
+ }
755
+ const image = bin.track(ck.MakeImage({
756
+ width,
757
+ height: lut.size,
758
+ colorType: ck.ColorType.RGBA_8888,
759
+ alphaType: ck.AlphaType.Unpremul,
760
+ colorSpace: ck.ColorSpace.SRGB,
761
+ }, px, width * 4));
762
+ bin.lut3dImages.set(key, image);
763
+ return image;
764
+ }
765
+ // SkSL for the adjust post-pass, generated per feature combination. The source
766
+ // layer arrives as `src`; 1D curves and 3D cubes are child shaders when present.
767
+ // Colors are unpremultiplied before the matrix / LUT / cube / unsharp and
768
+ // re-premultiplied on the way out. Sharpen is a 3×3 unsharp mask
769
+ // (box-blur radius 1), matching the straight-alpha unsharp used elsewhere.
770
+ //
771
+ // Per-pixel order is matrix → gamut → 1D curve → 3D cube → sharpen. The gamut step
772
+ // sits after the matrix: it can leave [0,1], while both LUTs need in-range input.
773
+ function adjustShaderSksl(hasMatrix, preserveHue, hasLut, hasLut3d, hasSharpen) {
774
+ // Rows of the 4×5 color matrix as float4(r, g, b, bias). Declared first, so the
775
+ // flat uniform array the caller builds starts with them.
776
+ const matrixFn = hasMatrix
777
+ ? `uniform float4 cmR; uniform float4 cmG; uniform float4 cmB;
778
+ half3 mtx(half3 c){ return half3(
779
+ dot(c, half3(cmR.rgb)) + half(cmR.w),
780
+ dot(c, half3(cmG.rgb)) + half(cmG.w),
781
+ dot(c, half3(cmB.rgb)) + half(cmB.w)); }`
782
+ : `half3 mtx(half3 c){ return c; }`;
783
+ // Scale chroma toward the color's own luma until every channel fits [0,1] — the
784
+ // largest t in [0,1] with l + t·(c−l) in range, applied to all three channels.
785
+ // Hue direction and luma survive; saturation absorbs the difference. The
786
+ // divisions stay under 1 (a channel above 1 has v−l > 1−l), so half precision
787
+ // holds.
788
+ const gamutFn = preserveHue
789
+ ? `half fitScale(half v, half l){
790
+ half d = v - l;
791
+ if (v > 1.0 && d > 0.0) return (1.0 - l) / d;
792
+ if (v < 0.0 && d < 0.0) return -l / d;
793
+ return 1.0; }
794
+ half3 gamut(half3 c){
795
+ half l = clamp(dot(c, half3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
796
+ half t = min(min(fitScale(c.r, l), fitScale(c.g, l)), fitScale(c.b, l));
797
+ return clamp(l + (c - l) * t, 0.0, 1.0); }`
798
+ : `half3 gamut(half3 c){ return clamp(c, 0.0, 1.0); }`;
799
+ const lutFn = hasLut
800
+ ? `uniform shader lut;
801
+ half3 curve(half3 c){ return half3(
802
+ lut.eval(float2(c.r*255.0+0.5, 0.5)).r,
803
+ lut.eval(float2(c.g*255.0+0.5, 0.5)).r,
804
+ lut.eval(float2(c.b*255.0+0.5, 0.5)).r); }`
805
+ : `half3 curve(half3 c){ return c; }`;
806
+ const lut3dFn = hasLut3d
807
+ ? `uniform float cubeSize;
808
+ uniform shader cube;
809
+ half3 cubeAt(float r, float g, float b){
810
+ return cube.eval(float2(b*cubeSize+r+0.5, g+0.5)).rgb; }
811
+ half3 cubeCurve(half3 c){
812
+ float top = cubeSize - 1.0;
813
+ float rf = clamp(float(c.r), 0.0, 1.0) * top;
814
+ float gf = clamp(float(c.g), 0.0, 1.0) * top;
815
+ float bf = clamp(float(c.b), 0.0, 1.0) * top;
816
+ float r0 = floor(rf); float g0 = floor(gf); float b0 = floor(bf);
817
+ float r1 = min(r0 + 1.0, top); float g1 = min(g0 + 1.0, top); float b1 = min(b0 + 1.0, top);
818
+ half fr = half(rf-r0); half fg = half(gf-g0); half fb = half(bf-b0);
819
+ half3 c00 = mix(cubeAt(r0,g0,b0), cubeAt(r1,g0,b0), fr);
820
+ half3 c10 = mix(cubeAt(r0,g1,b0), cubeAt(r1,g1,b0), fr);
821
+ half3 c01 = mix(cubeAt(r0,g0,b1), cubeAt(r1,g0,b1), fr);
822
+ half3 c11 = mix(cubeAt(r0,g1,b1), cubeAt(r1,g1,b1), fr);
823
+ return mix(mix(c00,c10,fg), mix(c01,c11,fg), fb); }`
824
+ : `half3 cubeCurve(half3 c){ return c; }`;
825
+ const sharpenUniform = hasSharpen ? "uniform float amount;" : "";
826
+ // samp() returns the corrected, unpremultiplied colour at a pixel.
827
+ const common = `uniform shader src;
828
+ ${matrixFn}
829
+ ${gamutFn}
830
+ ${lutFn}
831
+ ${lut3dFn}
832
+ ${sharpenUniform}
833
+ half3 samp(float2 p){ half4 s = src.eval(p); half3 c = s.a > 0.0 ? s.rgb/s.a : s.rgb; return cubeCurve(curve(gamut(mtx(c)))); }`;
834
+ const body = hasSharpen
835
+ ? `half4 s0 = src.eval(xy);
836
+ half3 c = samp(xy);
837
+ half3 sum = samp(xy+float2(-1,-1))+samp(xy+float2(0,-1))+samp(xy+float2(1,-1))
838
+ + samp(xy+float2(-1, 0))+c +samp(xy+float2(1, 0))
839
+ + samp(xy+float2(-1, 1))+samp(xy+float2(0, 1))+samp(xy+float2(1, 1));
840
+ half3 outc = clamp(c + amount*(c - sum/9.0), 0.0, 1.0);
841
+ return half4(outc*s0.a, s0.a);`
842
+ : `half4 s0 = src.eval(xy);
843
+ half3 outc = clamp(samp(xy), 0.0, 1.0);
844
+ return half4(outc*s0.a, s0.a);`;
845
+ return `${common}
846
+ half4 main(float2 xy){ ${body} }`;
847
+ }
848
+ // RuntimeEffect is compiled per (ck, variant) and reused — Make() parses SkSL, so
849
+ // caching keeps the hot path free of recompiles. Keyed by ck so distinct CanvasKit
850
+ // instances (e.g. across tests) never share an effect.
851
+ const effectCache = new WeakMap();
852
+ function adjustEffect(ck, hasMatrix, preserveHue, hasLut, hasLut3d, hasSharpen) {
853
+ let byVariant = effectCache.get(ck);
854
+ if (!byVariant) {
855
+ byVariant = new Map();
856
+ effectCache.set(ck, byVariant);
857
+ }
858
+ const bit = (b) => (b ? 1 : 0);
859
+ const key = `${bit(hasMatrix)}${bit(preserveHue)}${bit(hasLut)}${bit(hasLut3d)}${bit(hasSharpen)}`;
860
+ let eff = byVariant.get(key);
861
+ if (eff === undefined) {
862
+ eff =
863
+ ck.RuntimeEffect.Make(adjustShaderSksl(hasMatrix, preserveHue, hasLut, hasLut3d, hasSharpen)) ?? null;
864
+ byVariant.set(key, eff);
865
+ }
866
+ return eff;
867
+ }
868
+ // Apply an adjust's LUTs/sharpen via an offscreen SkSL pass: render the drawable
869
+ // (with only its color matrix) to a frame-sized offscreen surface, then draw it
870
+ // back through the adjust shader. The color matrix rides the inner render, so
871
+ // ordering is matrix → curve → cube → sharpen — color first, spatial last. The
872
+ // offscreen inherits the main canvas's full CTM and is composited back in
873
+ // device coordinates, so an adjusted descendant follows every parent transform
874
+ // exactly once. Falls back to a matrix-only render (+ an adjust_unsupported warning)
875
+ // if the surface or effect can't be created.
876
+ function paintAdjustedOffscreen(ck, canvas, provider, images, bin, cmd, issues, frame) {
877
+ const adjust = cmd.adjust;
878
+ const hasLut = !!adjust.lut;
879
+ const hasLut3d = !!adjust.lut3d;
880
+ const amount = adjust.sharpen ?? 0;
881
+ const hasSharpen = amount > 0;
882
+ // "preserve-hue" pulls the matrix into this pass; otherwise it rides the inner
883
+ // render as a color filter, as before.
884
+ const matrixInShader = shaderSideMatrix(adjust);
885
+ if (adjust.gamut === "preserve-hue" && !matrixInShader) {
886
+ reportAdjustUnsupported(issues, cmd, "gamut");
887
+ }
888
+ // The inner drawable: same node, but only the (cheap) color matrix survives —
889
+ // lut/sharpen are this pass's job, so dropping them avoids re-entering here.
890
+ const inner = {
891
+ ...cmd,
892
+ adjust: adjust.colorMatrix && !matrixInShader
893
+ ? { colorMatrix: adjust.colorMatrix }
894
+ : undefined,
895
+ };
896
+ if (hasLut3d && !validLut3d(adjust.lut3d)) {
897
+ reportAdjustUnsupported(issues, cmd, "lut3d");
898
+ const fallback = matrixInShader
899
+ ? { ...cmd, adjust: { colorMatrix: adjust.colorMatrix } }
900
+ : inner;
901
+ paintDrawable(ck, canvas, provider, images, bin, fallback, issues, frame);
902
+ return;
903
+ }
904
+ // The offscreen matches the SURFACE, not the design box: at a 2× export the
905
+ // layer must be rendered at 2× too, or it would be blitted back upscaled from
906
+ // half-resolution pixels. The inner render draws in design units under the same
907
+ // scaled matrix the main canvas carries.
908
+ const device = exportPixelSize(frame, frame.scale);
909
+ const info = {
910
+ width: device.width,
911
+ height: device.height,
912
+ colorType: ck.ColorType.RGBA_8888,
913
+ alphaType: ck.AlphaType.Premul,
914
+ colorSpace: ck.ColorSpace.SRGB,
915
+ };
916
+ const surface = canvas.makeSurface(info);
917
+ const effect = surface
918
+ ? adjustEffect(ck, matrixInShader, matrixInShader, hasLut, hasLut3d, hasSharpen)
919
+ : null;
920
+ if (!surface || !effect) {
921
+ // Couldn't build the offscreen path — render matrix-only and flag what was
922
+ // dropped rather than pretending it applied. The matrix goes back on the
923
+ // color filter, so a "preserve-hue" layer still paints, clipped.
924
+ if (hasLut)
925
+ reportAdjustUnsupported(issues, cmd, "lut");
926
+ if (hasLut3d)
927
+ reportAdjustUnsupported(issues, cmd, "lut3d");
928
+ if (hasSharpen)
929
+ reportAdjustUnsupported(issues, cmd, "sharpen");
930
+ if (matrixInShader)
931
+ reportAdjustUnsupported(issues, cmd, "gamut");
932
+ surface?.delete();
933
+ const fallback = matrixInShader
934
+ ? { ...cmd, adjust: { colorMatrix: adjust.colorMatrix } }
935
+ : inner;
936
+ paintDrawable(ck, canvas, provider, images, bin, fallback, issues, frame);
937
+ return;
938
+ }
939
+ const off = surface.getCanvas();
940
+ off.clear(ck.TRANSPARENT);
941
+ // The main canvas may already carry an export scale and arbitrary ancestor
942
+ // transforms. Render the inner layer through that exact local→device matrix so
943
+ // the snapshot occupies the same texels it would on the main surface.
944
+ const matrix = canvas.getTotalMatrix();
945
+ off.concat(matrix);
946
+ paintDrawable(ck, off, provider, images, bin, inner, issues, frame);
947
+ // The snapshot is copy-on-write off this surface, and the shader samples it when
948
+ // the MAIN surface flushes (later, on the GPU path) — so keep the offscreen alive
949
+ // until bin.free() runs (after that flush), disposing (not delete()ing, which
950
+ // would leak the pixel buffer) it then.
951
+ const img = bin.track(surface.makeImageSnapshot());
952
+ bin.track({ delete: () => surface.dispose() });
953
+ const srcSh = bin.track(img.makeShaderOptions(ck.TileMode.Clamp, ck.TileMode.Clamp, ck.FilterMode.Nearest, ck.MipmapMode.None));
954
+ const children = [srcSh];
955
+ if (hasLut) {
956
+ const lutImg = lutImage(ck, bin, adjust.lut);
957
+ children.push(bin.track(lutImg.makeShaderOptions(ck.TileMode.Clamp, ck.TileMode.Clamp, ck.FilterMode.Nearest, ck.MipmapMode.None)));
958
+ }
959
+ if (hasLut3d) {
960
+ const cubeImg = lut3dImage(ck, bin, adjust.lut3d);
961
+ children.push(bin.track(cubeImg.makeShaderOptions(ck.TileMode.Clamp, ck.TileMode.Clamp, ck.FilterMode.Nearest, ck.MipmapMode.None)));
962
+ }
963
+ // Uniforms go in declaration order (see adjustShaderSksl): the three matrix rows
964
+ // as float4(r, g, b, bias), then cube size, then the sharpen amount.
965
+ const uniforms = [];
966
+ if (matrixInShader) {
967
+ const m = adjust.colorMatrix;
968
+ uniforms.push(m[0], m[1], m[2], m[4]);
969
+ uniforms.push(m[5], m[6], m[7], m[9]);
970
+ uniforms.push(m[10], m[11], m[12], m[14]);
971
+ }
972
+ if (hasLut3d)
973
+ uniforms.push(adjust.lut3d?.size ?? 0);
974
+ if (hasSharpen)
975
+ uniforms.push(amount);
976
+ const shader = bin.track(effect.makeShaderWithChildren(uniforms, children));
977
+ const paint = bin.track(new ck.Paint());
978
+ paint.setShader(shader);
979
+ // Blit in DEVICE pixels — undo the main canvas's complete CTM, not merely the
980
+ // export scale. The snapshot already includes parent rotations/transforms; a
981
+ // second application here would move it. The main clip remains in device space,
982
+ // so clipping semantics are unchanged.
983
+ // Transparent offscreen pixels stay transparent, so a full-frame draw only
984
+ // lays down the drawable.
985
+ const inverse = ck.Matrix.invert(matrix);
986
+ if (!inverse)
987
+ throw new Error("adjust: non-invertible canvas transform");
988
+ canvas.save();
989
+ canvas.concat(inverse);
990
+ canvas.drawRect(ck.XYWHRect(0, 0, device.width, device.height), paint);
991
+ canvas.restore();
992
+ }
993
+ // Collapse the supersampled render into the output surface, so each output pixel
994
+ // carries the coverage of the supersample² samples under it — an edge that a
995
+ // same-size render can only approximate (two abutting shapes each blending their
996
+ // own coverage against the background, never summing back to the solid they
997
+ // tile) comes out with the coverage it actually had.
998
+ //
999
+ // Done as a chain of exact 2:1 halvings, NOT one resample down to the output
1000
+ // size, because only at 2:1 does every sample reach the average. A bilinear tap
1001
+ // halving an axis lands exactly on the boundary between two source pixels, so a
1002
+ // step is precisely a 2×2 box average; a cubic filter run straight from 4× to 1×
1003
+ // keeps its narrow support and reads a fraction of the samples instead —
1004
+ // measurably no better than 2× — and mipmapped trilinear (drawImageRectHQ's
1005
+ // choice below 0.5) samples between pyramid levels rather than the render.
1006
+ //
1007
+ // Every level is kept alive in `bin` until the OUTPUT surface flushes: on the GPU
1008
+ // path the draws only sample them then. Surfaces are disposed rather than
1009
+ // delete()d, which would strand their pixel buffers.
1010
+ function reduceSupersampled(ck, out, src, bin, design, exportScale, supersample) {
1011
+ const rect = (s) => ck.XYWHRect(0, 0, s.width, s.height);
1012
+ const retire = (s) => bin.track({ delete: () => s.dispose() });
1013
+ let level = src;
1014
+ let levelSize = exportPixelSize(design, exportScale * supersample);
1015
+ retire(src);
1016
+ for (let factor = supersample; factor > 1; factor /= 2) {
1017
+ const target = exportPixelSize(design, (exportScale * factor) / 2);
1018
+ const last = factor === 2;
1019
+ const dst = last
1020
+ ? out
1021
+ : level.getCanvas().makeSurface({
1022
+ width: target.width,
1023
+ height: target.height,
1024
+ colorType: ck.ColorType.RGBA_8888,
1025
+ alphaType: ck.AlphaType.Premul,
1026
+ colorSpace: ck.ColorSpace.SRGB,
1027
+ });
1028
+ const img = bin.track(level.makeImageSnapshot());
1029
+ const paint = bin.track(new ck.Paint());
1030
+ if (!dst) {
1031
+ // An intermediate the backend wouldn't give: finish what is left in one
1032
+ // cubic step rather than leaving the output blank. Softer than the halving
1033
+ // chain, still sharper than no supersampling.
1034
+ const canvas = out.getCanvas();
1035
+ canvas.clear(ck.TRANSPARENT);
1036
+ const final = exportPixelSize(design, exportScale);
1037
+ canvas.drawImageRectCubic(img, rect(levelSize), rect(final), MITCHELL, MITCHELL, paint);
1038
+ return;
1039
+ }
1040
+ const canvas = dst.getCanvas();
1041
+ canvas.clear(ck.TRANSPARENT);
1042
+ canvas.drawImageRectOptions(img, rect(levelSize), rect(target), ck.FilterMode.Linear, ck.MipmapMode.None, paint);
1043
+ if (!last)
1044
+ retire(dst);
1045
+ level = dst;
1046
+ levelSize = target;
1047
+ }
1048
+ }
1049
+ // SkSL for the whole-frame finishing pass. Order mirrors the classic output
1050
+ // pipeline: dither first (break banding), then black-extract, then white-clamp.
1051
+ // Colors are unpremultiplied for the threshold tests and re-premultiplied out.
1052
+ // Disabled ops are signalled by a sentinel threshold of -1 (dither 0).
1053
+ const FINISH_SKSL = `uniform shader src;
1054
+ uniform float whiteT;
1055
+ uniform float blackT;
1056
+ uniform float dither;
1057
+ uniform float ditherSeed;
1058
+ uniform float monochromeDither;
1059
+ half hash(float2 p){
1060
+ p += float2(ditherSeed * 0.1031, ditherSeed * 0.11369);
1061
+ return half(fract(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453) * 2.0 - 1.0);
1062
+ }
1063
+ half3 hash3(float2 p){
1064
+ return half3(hash(p + float2(19.19, 7.13)),
1065
+ hash(p + float2(43.31, 31.71)),
1066
+ hash(p + float2(67.67, 59.59)));
1067
+ }
1068
+ half4 main(float2 xy){
1069
+ half4 s = src.eval(xy);
1070
+ half a = s.a;
1071
+ half3 c = a > 0.0 ? s.rgb/a : s.rgb;
1072
+ if (dither > 0.0) {
1073
+ half n = hash(xy);
1074
+ half3 noise = monochromeDither > 0.5 ? half3(n) : hash3(xy);
1075
+ c = clamp(c + noise * dither, 0.0, 1.0);
1076
+ }
1077
+ if (blackT >= 0.0 && c.r < blackT && c.g < blackT && c.b < blackT) c = half3(0.0);
1078
+ if (whiteT >= 0.0 && c.r > whiteT && c.g > whiteT && c.b > whiteT) c = half3(1.0);
1079
+ return half4(c*a, a);
1080
+ }`;
1081
+ function finishEffect(ck) {
1082
+ let byVariant = effectCache.get(ck);
1083
+ if (!byVariant) {
1084
+ byVariant = new Map();
1085
+ effectCache.set(ck, byVariant);
1086
+ }
1087
+ let eff = byVariant.get("finish");
1088
+ if (eff === undefined) {
1089
+ eff = ck.RuntimeEffect.Make(FINISH_SKSL) ?? null;
1090
+ byVariant.set("finish", eff);
1091
+ }
1092
+ return eff;
1093
+ }
1094
+ function normalizeDither(dither) {
1095
+ if (typeof dither === "number") {
1096
+ return { amount: dither, seed: 0, monochrome: false };
1097
+ }
1098
+ const amount = dither?.amount ?? 0;
1099
+ const rawSeed = dither?.seed ?? 0;
1100
+ // RuntimeEffect uniforms are floats: bound the integer so every admissible seed
1101
+ // remains distinguishable in float precision and non-finite input is harmless.
1102
+ const seed = Number.isFinite(rawSeed) ? Math.trunc(rawSeed) % 1_000_000 : 0;
1103
+ return { amount, seed, monochrome: dither?.mode === "monochrome" };
1104
+ }
1105
+ // Run the finishing pass on the composited surface: snapshot it, clear it, then
1106
+ // redraw the snapshot through the finish shader. A no-op finish never reaches
1107
+ // here (compileScene only emits the command when something is set).
1108
+ function applyFrameFinish(ck, surface, bin, finish,
1109
+ // The surface's own pixel size — this pass reads and rewrites the composited
1110
+ // pixels, so it works in device pixels whatever density the scene exported at.
1111
+ device) {
1112
+ const effect = finishEffect(ck);
1113
+ if (!effect)
1114
+ return; // rt_effect unavailable — leave the frame as-is.
1115
+ const snap = bin.track(surface.makeImageSnapshot());
1116
+ const canvas = surface.getCanvas();
1117
+ canvas.clear(ck.TRANSPARENT);
1118
+ const srcSh = bin.track(snap.makeShaderOptions(ck.TileMode.Clamp, ck.TileMode.Clamp, ck.FilterMode.Nearest, ck.MipmapMode.None));
1119
+ // Thresholds normalized to [0,1]; -1 disables. Dither gets an amplitude, stable
1120
+ // seed, and a scalar-vs-per-channel mode flag.
1121
+ // The comparisons are integer strict >/< on 8-bit values (matching a 0–255
1122
+ // pipeline), so the cutoff sits half a level past the threshold — robust to the
1123
+ // float round-trip and exact at the boundary (a pixel == threshold stays put).
1124
+ const whiteT = finish.whiteClamp !== undefined ? (finish.whiteClamp + 0.5) / 255 : -1;
1125
+ const blackT = finish.blackExtract !== undefined ? (finish.blackExtract - 0.5) / 255 : -1;
1126
+ const ditherConfig = normalizeDither(finish.dither);
1127
+ const dither = ditherConfig.amount > 0 ? ditherConfig.amount / 255 : 0;
1128
+ const shader = bin.track(effect.makeShaderWithChildren([
1129
+ whiteT,
1130
+ blackT,
1131
+ dither,
1132
+ ditherConfig.seed,
1133
+ ditherConfig.monochrome ? 1 : 0,
1134
+ ], [srcSh]));
1135
+ const paint = bin.track(new ck.Paint());
1136
+ paint.setShader(shader);
1137
+ canvas.drawRect(ck.XYWHRect(0, 0, device.width, device.height), paint);
1138
+ }
1139
+ // Skia's name for each layer blend mode. Figma's linear dodge is Skia's Plus.
1140
+ const SKIA_BLEND_MODE = {
1141
+ normal: "SrcOver",
1142
+ multiply: "Multiply",
1143
+ screen: "Screen",
1144
+ overlay: "Overlay",
1145
+ darken: "Darken",
1146
+ lighten: "Lighten",
1147
+ "color-dodge": "ColorDodge",
1148
+ "color-burn": "ColorBurn",
1149
+ "hard-light": "HardLight",
1150
+ "soft-light": "SoftLight",
1151
+ difference: "Difference",
1152
+ exclusion: "Exclusion",
1153
+ hue: "Hue",
1154
+ saturation: "Saturation",
1155
+ color: "Color",
1156
+ luminosity: "Luminosity",
1157
+ plus: "Plus",
1158
+ };
1159
+ // A layer paint carrying opacity + blend + blur/shadow + adjust, so the element
1160
+ // composites onto everything below it exactly like a Figma layer.
1161
+ function layerPaint(ck, bin, cmd) {
1162
+ const { blendMode, opacity, blur, shadow } = cmd;
1163
+ const hasBlend = blendMode && blendMode !== "normal";
1164
+ const hasOpacity = opacity !== undefined && opacity < 1;
1165
+ const hasBlur = typeof blur === "number" && blur > 0;
1166
+ const colorFilter = adjustColorFilter(ck, bin, cmd);
1167
+ if (!hasBlend && !hasOpacity && !hasBlur && !shadow && !colorFilter)
1168
+ return null;
1169
+ const paint = bin.track(new ck.Paint());
1170
+ // The color filter runs on the layer's contents (before blur/shadow, which are
1171
+ // image filters on the layer result): color-correct first, spatial effects
1172
+ // after.
1173
+ if (colorFilter)
1174
+ paint.setColorFilter(colorFilter);
1175
+ if (hasOpacity)
1176
+ paint.setAlphaf(opacity);
1177
+ if (blendMode && blendMode !== "normal") {
1178
+ paint.setBlendMode(ck.BlendMode[SKIA_BLEND_MODE[blendMode]] ?? ck.BlendMode.SrcOver);
1179
+ }
1180
+ let filter = null;
1181
+ if (typeof blur === "number" && blur > 0)
1182
+ filter = bin.track(ck.ImageFilter.MakeBlur(LAYER_BLUR_SIGMA(blur), LAYER_BLUR_SIGMA(blur), ck.TileMode.Decal, null));
1183
+ const sh = shadowFilter(ck, bin, shadow);
1184
+ if (sh)
1185
+ filter = filter ? bin.track(ck.ImageFilter.MakeCompose(sh, filter)) : sh;
1186
+ if (filter)
1187
+ paint.setImageFilter(filter);
1188
+ return paint;
1189
+ }
1190
+ // Grow (or shrink) the silhouette a shadow is cast from. Figma's spread, and the
1191
+ // third length of a CSS box-shadow.
1192
+ function spreadSource(ck, bin, spread, input) {
1193
+ if (!spread)
1194
+ return input;
1195
+ const r = Math.abs(spread);
1196
+ return bin.track(spread > 0
1197
+ ? ck.ImageFilter.MakeDilate(r, r, input)
1198
+ : ck.ImageFilter.MakeErode(r, r, input));
1199
+ }
1200
+ // A colour filter that replaces every pixel with `color` at the INVERSE of the
1201
+ // source's alpha: opaque where the drawable is empty, empty where it is solid.
1202
+ // That inverted silhouette, offset and blurred, is what an inner shadow casts —
1203
+ // the shape's own edges lighting inward.
1204
+ function invertedSilhouette(ck, bin, color) {
1205
+ const [r, g, b, a] = toColor(ck, color);
1206
+ // Skia colour matrix, row-major RGBA rows with a bias column: constant colour,
1207
+ // alpha = a·(1 − srcAlpha).
1208
+ const cf = bin.track(ck.ColorFilter.MakeMatrix([
1209
+ 0,
1210
+ 0,
1211
+ 0,
1212
+ 0,
1213
+ r,
1214
+ 0,
1215
+ 0,
1216
+ 0,
1217
+ 0,
1218
+ g,
1219
+ 0,
1220
+ 0,
1221
+ 0,
1222
+ 0,
1223
+ b,
1224
+ 0,
1225
+ 0,
1226
+ 0,
1227
+ -a,
1228
+ a,
1229
+ ]));
1230
+ return bin.track(ck.ImageFilter.MakeColorFilter(cf, null));
1231
+ }
1232
+ // The image filter for a drawable's whole shadow stack, or null when it has
1233
+ // none. A `null` input anywhere in the graph is the layer's own contents.
1234
+ //
1235
+ // Every shadow is cast from the ORIGINAL silhouette, not from the result of the
1236
+ // one below it — stacking MakeDropShadow would blur each shadow into the next.
1237
+ // So each is built on its own and blended: drop shadows under the contents,
1238
+ // inner shadows over them, each list painted bottom-up.
1239
+ function shadowFilter(ck, bin, shadow) {
1240
+ const list = (Array.isArray(shadow) ? shadow : shadow ? [shadow] : []).filter((s) => s.color !== "transparent");
1241
+ if (list.length === 0)
1242
+ return null;
1243
+ let under = null;
1244
+ let over = null;
1245
+ for (const s of list) {
1246
+ const sigma = SHADOW_SIGMA(s.blur);
1247
+ if (s.inset) {
1248
+ // Offset + blur the inverted silhouette, then keep only the part that
1249
+ // lands on the drawable — an inner shadow never spills outside it.
1250
+ let f = invertedSilhouette(ck, bin, s.color);
1251
+ f = spreadSource(ck, bin, s.spread ?? 0, f);
1252
+ if (s.dx || s.dy)
1253
+ f = bin.track(ck.ImageFilter.MakeOffset(s.dx, s.dy, f));
1254
+ if (sigma > 0)
1255
+ f = bin.track(ck.ImageFilter.MakeBlur(sigma, sigma, ck.TileMode.Decal, f));
1256
+ const clipped = bin.track(ck.ImageFilter.MakeBlend(ck.BlendMode.SrcIn, null, f));
1257
+ over = over
1258
+ ? bin.track(ck.ImageFilter.MakeBlend(ck.BlendMode.SrcOver, over, clipped))
1259
+ : clipped;
1260
+ continue;
1261
+ }
1262
+ const cast = bin.track(ck.ImageFilter.MakeDropShadowOnly(s.dx, s.dy, sigma, sigma, toColor(ck, s.color), spreadSource(ck, bin, s.spread ?? 0, null)));
1263
+ under = under
1264
+ ? bin.track(ck.ImageFilter.MakeBlend(ck.BlendMode.SrcOver, under, cast))
1265
+ : cast;
1266
+ }
1267
+ // contents over the cast shadows, then the inner ones over that.
1268
+ let out = under
1269
+ ? bin.track(ck.ImageFilter.MakeBlend(ck.BlendMode.SrcOver, under, null))
1270
+ : null;
1271
+ if (over)
1272
+ out = bin.track(ck.ImageFilter.MakeBlend(ck.BlendMode.SrcOver, out, over));
1273
+ return out;
1274
+ }
1275
+ function paintDrawable(ck, canvas, provider, images, bin, cmd, issues, frame) {
1276
+ // lut/sharpen can't be a color filter — route the whole drawable through an
1277
+ // offscreen SkSL pass (which re-enters here with a matrix-only adjust).
1278
+ if (needsShaderAdjust(cmd)) {
1279
+ paintAdjustedOffscreen(ck, canvas, provider, images, bin, cmd, issues, frame);
1280
+ return;
1281
+ }
1282
+ // Past the offscreen path with "preserve-hue" still asked for: the matrix has
1283
+ // alpha terms the shader can't carry, so it runs through the clipping color
1284
+ // filter below and the mode is reported dropped rather than silently ignored.
1285
+ if (cmd.adjust?.gamut === "preserve-hue" && cmd.adjust.colorMatrix) {
1286
+ reportAdjustUnsupported(issues, cmd, "gamut");
1287
+ }
1288
+ canvas.save();
1289
+ if (cmd.rotation) {
1290
+ const cx = cmd.pos.x + cmd.size.width / 2;
1291
+ const cy = cmd.pos.y + cmd.size.height / 2;
1292
+ canvas.translate(cx, cy);
1293
+ canvas.rotate(cmd.rotation, 0, 0);
1294
+ canvas.translate(-cx, -cy);
1295
+ }
1296
+ const lp = layerPaint(ck, bin, cmd);
1297
+ if (lp)
1298
+ canvas.saveLayer(lp);
1299
+ // drawImage clips/strokes itself so its stroke isn't clipped.
1300
+ if (cmd.clip && cmd.op !== "drawImage")
1301
+ canvas.clipPath(maskPath(ck, bin, cmd.clip, cmd.pos.x, cmd.pos.y, cmd.size.width, cmd.size.height), ck.ClipOp.Intersect, true);
1302
+ drawShape(ck, canvas, provider, images, bin, cmd, issues, frame);
1303
+ if (lp)
1304
+ canvas.restore();
1305
+ canvas.restore();
1306
+ }
1307
+ // Collect the unique font requests + image srcs a scene's commands declare.
1308
+ function collectAssets(commands) {
1309
+ const fonts = new Map();
1310
+ const images = new Set();
1311
+ for (const cmd of commands) {
1312
+ if (cmd.op === "loadFonts")
1313
+ for (const r of cmd.requests)
1314
+ fonts.set(r.family, r);
1315
+ if (cmd.op === "loadImages")
1316
+ for (const s of cmd.srcs)
1317
+ images.add(s);
1318
+ }
1319
+ return { fonts: [...fonts.values()], images: [...images] };
1320
+ }
1321
+ // The CanvasKit paint routine env.paint runs (CanvasKit is the only backend, so
1322
+ // there is no painter-strategy indirection): one compiled scene -> a live surface
1323
+ // + a PaintOutput. freshcoat paints a single scene here — a card's multiple sides
1324
+ // are coatfile's concern, which calls paint() once per side. The font provider
1325
+ // + decoded images are built from the runtime's bytes and freed before returning;
1326
+ // the surface is bound to rt.canvas when present (displayable) else offscreen, and
1327
+ // returned live (the env disposes). encode/dispose defer to the caller/env. With
1328
+ // rt.cache, all three are kept by the cache instead and reused by the next paint.
1329
+ export async function paintScene(ck, commands, rt) {
1330
+ const cache = rt.cache ? paintCacheState(rt.cache) : null;
1331
+ if (cache)
1332
+ cache.stats.paints++;
1333
+ const { fonts, images } = collectAssets(commands);
1334
+ const warnings = [];
1335
+ const loaded = [];
1336
+ for (const req of fonts) {
1337
+ try {
1338
+ // CanvasKit has no native font system, so it always materializes bytes
1339
+ // (from the env's pre-supplied resolution or the shared fetch).
1340
+ for (const bytes of await fontBytes(rt.resolveFont(req)))
1341
+ loaded.push({ family: req.family, bytes });
1342
+ }
1343
+ catch (e) {
1344
+ warnings.push({
1345
+ kind: "font_load_failed",
1346
+ family: req.family,
1347
+ error: String(e),
1348
+ });
1349
+ }
1350
+ }
1351
+ const provider = cache
1352
+ ? cachedFontProvider(cache, loaded, () => makeFontProvider(ck, loaded))
1353
+ : makeFontProvider(ck, loaded);
1354
+ const imageMap = new Map();
1355
+ // Images the runtime lent through loadImage: painted, never freed here.
1356
+ const borrowed = new Set();
1357
+ for (const src of images) {
1358
+ if (rt.loadImage) {
1359
+ try {
1360
+ const img = await rt.loadImage(src, ck);
1361
+ if (img) {
1362
+ imageMap.set(src, img);
1363
+ borrowed.add(src);
1364
+ }
1365
+ else
1366
+ warnings.push({
1367
+ kind: "image_load_failed",
1368
+ src,
1369
+ error: "decode failed",
1370
+ });
1371
+ }
1372
+ catch (e) {
1373
+ warnings.push({ kind: "image_load_failed", src, error: String(e) });
1374
+ }
1375
+ continue;
1376
+ }
1377
+ const hit = cache?.images.get(src);
1378
+ if (hit) {
1379
+ imageMap.set(src, hit);
1380
+ continue;
1381
+ }
1382
+ try {
1383
+ const bytes = await rt.loadImageBytes(src);
1384
+ if (cache)
1385
+ cache.stats.imageDecodes++;
1386
+ const img = ck.MakeImageFromEncoded(bytes);
1387
+ if (img) {
1388
+ imageMap.set(src, img);
1389
+ cache?.images.set(src, img);
1390
+ }
1391
+ else
1392
+ warnings.push({
1393
+ kind: "image_load_failed",
1394
+ src,
1395
+ error: "decode failed",
1396
+ });
1397
+ }
1398
+ catch (e) {
1399
+ warnings.push({
1400
+ kind: "image_load_failed",
1401
+ src,
1402
+ error: String(e),
1403
+ });
1404
+ }
1405
+ }
1406
+ const bin = makeBin();
1407
+ const create = commands.find((c) => c.op === "createCanvas");
1408
+ if (!create)
1409
+ throw new Error("canvaskit: scene has no createCanvas command");
1410
+ const design = { width: create.width, height: create.height };
1411
+ const exportScale = create.scale ?? 1;
1412
+ // The size the caller asked for. Everything the outside world sees — the
1413
+ // returned canvas, the encoded PNG, the finish pass — is this size.
1414
+ const device = exportPixelSize(design, exportScale);
1415
+ const { surface, canvas, loseContext } = cache
1416
+ ? cachedSurface(cache, { width: device.width, height: device.height, hosted: !!rt.canvas }, () => makeSurface(ck, rt, device.width, device.height))
1417
+ : makeSurface(ck, rt, device.width, device.height);
1418
+ // Supersampling renders denser than the export and reduces (see
1419
+ // ./export-scale). Only the RENDER density belongs on `frame`: every pass that
1420
+ // steps outside the scaled matrix — the adjust offscreen, a layer blur — has to
1421
+ // size itself to the surface being drawn on, which is the render surface.
1422
+ const supersample = resolveSupersample(create.supersample, design, exportScale);
1423
+ const renderScale = exportScale * supersample;
1424
+ const render = exportPixelSize(design, renderScale);
1425
+ const frame = { ...design, scale: renderScale, grid: supersample };
1426
+ // Derived from the output surface rather than ck.MakeSurface so it stays on the
1427
+ // same backend (GPU under WebGL). A backend that won't give one degrades to
1428
+ // rendering at the export size — softer than asked for, but a render.
1429
+ const superSurface = supersample > 1
1430
+ ? surface.getCanvas().makeSurface({
1431
+ width: render.width,
1432
+ height: render.height,
1433
+ colorType: ck.ColorType.RGBA_8888,
1434
+ alphaType: ck.AlphaType.Premul,
1435
+ colorSpace: ck.ColorSpace.SRGB,
1436
+ })
1437
+ : null;
1438
+ if (supersample > 1 && !superSurface) {
1439
+ frame.scale = exportScale;
1440
+ frame.grid = 1;
1441
+ }
1442
+ // Where the drawables actually land: the supersampled offscreen when there is
1443
+ // one, else the output surface directly (the pre-supersampling path, untouched).
1444
+ const target = superSurface ?? surface;
1445
+ const skCanvas = target.getCanvas();
1446
+ skCanvas.clear(ck.TRANSPARENT);
1447
+ try {
1448
+ // Scoped to the drawable loop: the finishing pass below reads back the
1449
+ // composited pixels and belongs in device space.
1450
+ if (frame.scale !== 1) {
1451
+ skCanvas.save();
1452
+ skCanvas.scale(frame.scale, frame.scale);
1453
+ }
1454
+ for (const cmd of commands) {
1455
+ if (cmd.op === "createCanvas" ||
1456
+ cmd.op === "loadFonts" ||
1457
+ cmd.op === "loadImages" ||
1458
+ cmd.op === "finishFrame" // handled as a post-pass, after the loop
1459
+ )
1460
+ continue;
1461
+ const issues = {
1462
+ unhandled: [],
1463
+ missingImages: [],
1464
+ adjustUnsupported: new Map(),
1465
+ };
1466
+ paintDrawable(ck, skCanvas, provider, imageMap, bin, cmd, issues, frame);
1467
+ for (const op of issues.unhandled)
1468
+ warnings.push({ kind: "unhandled_op", op });
1469
+ for (const warning of issues.adjustUnsupported.values())
1470
+ warnings.push({ kind: "adjust_unsupported", ...warning });
1471
+ // Only srcs the loader never even attempted: a src it tried and failed
1472
+ // already pushed its own image_load_failed above, with the real error.
1473
+ for (const src of issues.missingImages) {
1474
+ if (!images.includes(src)) {
1475
+ warnings.push({
1476
+ kind: "image_load_failed",
1477
+ src,
1478
+ error: "no image was loaded for this src",
1479
+ });
1480
+ }
1481
+ }
1482
+ }
1483
+ // Whole-frame finishing runs on the composited result, so after every
1484
+ // drawable and before the flush — and in device pixels, off the scaled
1485
+ // matrix the drawables used.
1486
+ if (frame.scale !== 1)
1487
+ skCanvas.restore();
1488
+ // The reduction comes BEFORE the finish: dither, black-extract and
1489
+ // white-clamp are output-resolution ops (see applyFrameFinish), and running
1490
+ // them on the dense render would average the dither back out and re-admit
1491
+ // values either threshold had just pushed off.
1492
+ if (superSurface)
1493
+ reduceSupersampled(ck, surface, superSurface, bin, design, exportScale, supersample);
1494
+ const finishCmd = commands.find((c) => c.op === "finishFrame");
1495
+ if (finishCmd)
1496
+ applyFrameFinish(ck, surface, bin, finishCmd.finish, device);
1497
+ // Flush before freeing the provider/images below: on the WebGL path the GPU
1498
+ // still references the decoded images until the surface is flushed.
1499
+ surface.flush();
1500
+ }
1501
+ finally {
1502
+ if (cache)
1503
+ evictUnusedImages(cache, images);
1504
+ else {
1505
+ provider.delete();
1506
+ for (const [src, img] of imageMap)
1507
+ if (!borrowed.has(src))
1508
+ img.delete();
1509
+ }
1510
+ }
1511
+ bin.free();
1512
+ return {
1513
+ canvas,
1514
+ warnings,
1515
+ // Raw RGBA at the device size, unpremultiplied — the surface's pixels with
1516
+ // no encoder in the way. Conformance compares backends here rather than on
1517
+ // encoded bytes, which would be comparing PNG writers.
1518
+ readPixels: () => {
1519
+ const snap = surface.makeImageSnapshot();
1520
+ try {
1521
+ const w = snap.width();
1522
+ const h = snap.height();
1523
+ const data = snap.readPixels(0, 0, {
1524
+ width: w,
1525
+ height: h,
1526
+ colorType: ck.ColorType.RGBA_8888,
1527
+ alphaType: ck.AlphaType.Unpremul,
1528
+ colorSpace: ck.ColorSpace.SRGB,
1529
+ });
1530
+ return data
1531
+ ? { data: new Uint8Array(data), width: w, height: h }
1532
+ : null;
1533
+ }
1534
+ finally {
1535
+ snap.delete();
1536
+ }
1537
+ },
1538
+ encode: async (encodeOpts) => {
1539
+ // Skia's own PNG writer is ~25-40% larger than the same pixels through
1540
+ // ./png (it keeps an alpha channel an opaque frame doesn't need, and
1541
+ // filters rows that compress better unfiltered), so read the pixels back
1542
+ // and encode them here. makeImageSnapshot() returns a WASM-backed Image
1543
+ // that must be freed; readPixels copies out, so delete right after.
1544
+ const snap = surface.makeImageSnapshot();
1545
+ const skiaPng = () => ({
1546
+ bytes: snap.encodeToBytes(),
1547
+ format: "png",
1548
+ });
1549
+ try {
1550
+ // WebP is Skia's own encoder and there is no hand-rolled alternative
1551
+ // the way there is for PNG: lossy WebP is a codec, not a deflate with
1552
+ // choices to make. A build compiled without it answers null, and PNG
1553
+ // — which every build has — is what comes back instead, said so in
1554
+ // `format` rather than passed off as the WebP that was asked for.
1555
+ if (encodeOpts?.format === "webp") {
1556
+ const webp = snap.encodeToBytes(ck.ImageFormat.WEBP, encodeOpts.quality ?? DEFAULT_WEBP_QUALITY);
1557
+ if (webp)
1558
+ return { bytes: webp, format: "webp" };
1559
+ }
1560
+ // JPEG is Skia's too, and falls back to PNG the same way.
1561
+ if (encodeOpts?.format === "jpeg") {
1562
+ const jpeg = encodeJpeg(ck, snap, encodeOpts.quality ?? DEFAULT_JPEG_QUALITY);
1563
+ if (jpeg)
1564
+ return { bytes: jpeg, format: "jpeg" };
1565
+ }
1566
+ const w = snap.width();
1567
+ const h = snap.height();
1568
+ const pixels = snap.readPixels(0, 0, {
1569
+ width: w,
1570
+ height: h,
1571
+ colorType: ck.ColorType.RGBA_8888,
1572
+ alphaType: ck.AlphaType.Unpremul,
1573
+ colorSpace: ck.ColorSpace.SRGB,
1574
+ });
1575
+ // A surface that won't read back (a lost context) still has Skia's
1576
+ // encoder, which works off the snapshot rather than a pixel buffer —
1577
+ // as does a runtime without CompressionStream. Bigger bytes beat no
1578
+ // bytes, so both fall back to it rather than failing the render.
1579
+ if (!pixels)
1580
+ return skiaPng();
1581
+ try {
1582
+ return {
1583
+ bytes: await encodePng(new Uint8Array(pixels), w, h, encodeOpts),
1584
+ format: "png",
1585
+ };
1586
+ }
1587
+ catch {
1588
+ return skiaPng();
1589
+ }
1590
+ }
1591
+ finally {
1592
+ snap.delete();
1593
+ }
1594
+ },
1595
+ // Surface is freed with dispose(), not delete() — delete() leaves the
1596
+ // backing pixel buffer allocated (a ~w·h·4 leak per frame). On the WebGL
1597
+ // path this also drops the DOM canvas's GL context (releaseGL) so a full
1598
+ // release means dispose(); no consumer has to reach into result.canvas.
1599
+ // A cached surface is the cache's to free (clear/dispose), not the result's.
1600
+ dispose: cache
1601
+ ? () => { }
1602
+ : () => {
1603
+ surface.dispose();
1604
+ loseContext();
1605
+ },
1606
+ };
1607
+ }
1608
+ // Bind to rt.canvas when present (WebGL, SW fallback) -> displayable; otherwise an
1609
+ // offscreen raster surface. MakeWebGLCanvasSurface -> null IS the capability probe
1610
+ // (a host canvas that can't back WebGL), so this needs no environment flag.
1611
+ // `loseContext` drops the DOM canvas's WebGL context on dispose (see releaseGL); it
1612
+ // is a no-op for the SW/offscreen paths, which hold no such context.
1613
+ function makeSurface(ck, rt, w, h) {
1614
+ const noop = () => { };
1615
+ if (rt.canvas) {
1616
+ try {
1617
+ const el = rt.canvas.createCanvas(w, h);
1618
+ const gl = ck.MakeWebGLCanvasSurface(el);
1619
+ if (gl)
1620
+ return { surface: gl, canvas: el, loseContext: () => releaseGL(el) };
1621
+ const sw = ck.MakeSWCanvasSurface(el);
1622
+ if (sw)
1623
+ return { surface: sw, canvas: el, loseContext: noop };
1624
+ }
1625
+ catch {
1626
+ // Fall through to an offscreen surface.
1627
+ }
1628
+ }
1629
+ const surface = ck.MakeSurface(w, h);
1630
+ return {
1631
+ surface,
1632
+ canvas: { width: w, height: h, getContext: () => null },
1633
+ loseContext: noop,
1634
+ };
1635
+ }
1636
+ // Free the WebGL context MakeWebGLCanvasSurface grabbed on this canvas so it stops
1637
+ // counting against the browser's live-context cap (~16 in Chrome). surface.dispose()
1638
+ // frees Skia's GPU resources but leaves the DOM canvas's context alive until GC —
1639
+ // under repeated repaints that races the cap and the browser force-drops the oldest
1640
+ // live context. getContext returns the SAME context Skia bound (a canvas is locked
1641
+ // to one context type), so this loses exactly that one and never allocates a new one.
1642
+ function releaseGL(canvas) {
1643
+ const c = canvas;
1644
+ const gl = c.getContext?.("webgl2") ?? c.getContext?.("webgl");
1645
+ gl?.getExtension("WEBGL_lose_context")?.loseContext();
1646
+ }
1647
+ // The snapshot as a JPEG, flattened over white first because JPEG keeps no
1648
+ // alpha. Reads the pixels back rather than drawing the snapshot onto a second
1649
+ // surface, which works the same whether the frame is on the GPU or in memory.
1650
+ // null when the pixels will not read back or the build has no JPEG encoder.
1651
+ function encodeJpeg(ck, snap, quality) {
1652
+ const width = snap.width();
1653
+ const height = snap.height();
1654
+ const info = {
1655
+ width,
1656
+ height,
1657
+ colorType: ck.ColorType.RGBA_8888,
1658
+ alphaType: ck.AlphaType.Unpremul,
1659
+ colorSpace: ck.ColorSpace.SRGB,
1660
+ };
1661
+ const pixels = snap.readPixels(0, 0, info);
1662
+ if (!pixels)
1663
+ return null;
1664
+ const flat = ck.MakeImage({ ...info, alphaType: ck.AlphaType.Opaque }, flattenOverWhite(new Uint8Array(pixels)), width * 4);
1665
+ if (!flat)
1666
+ return null;
1667
+ try {
1668
+ return (flat.encodeToBytes(ck.ImageFormat.JPEG, quality) ??
1669
+ null);
1670
+ }
1671
+ finally {
1672
+ flat.delete();
1673
+ }
1674
+ }