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,24 @@
1
+ import type { Node } from "./node.js";
2
+ import type { TextEngine } from "./text-engine.js";
3
+ import type { MeasureText } from "./text-types.js";
4
+ import type { Command, FontRequest, FontVMetrics, FrameFinish } from "./types.js";
5
+ export type CompileSceneOptions = {
6
+ width: number;
7
+ height: number;
8
+ textEngine?: TextEngine;
9
+ measure?: MeasureText;
10
+ leadingTrim?: boolean;
11
+ fontMetrics?: Record<string, FontVMetrics>;
12
+ fonts?: FontRequest[];
13
+ images?: string[];
14
+ finish?: FrameFinish;
15
+ prepared?: boolean;
16
+ scale?: number;
17
+ supersample?: number;
18
+ };
19
+ export declare function prepareScene(root: Node, opts: Pick<CompileSceneOptions, "textEngine" | "measure" | "fontMetrics">): Node;
20
+ export declare function compileScene(root: Node, opts: CompileSceneOptions): Command[];
21
+ export declare function sceneAssets(node: Node): {
22
+ fonts: FontRequest[];
23
+ images: string[];
24
+ };
@@ -0,0 +1,325 @@
1
+ // compileScene — the PURE compile: a Node tree → the flat Command IR the painter
2
+ // walks. No runtime, no painter, no pixels — painting is the separate
3
+ // env.paint(commands, ck) step.
4
+ //
5
+ // Geometric lowering (rect / ellipse→path / path / image / bitmap / group) plus
6
+ // text: a text node's glyphs are baked here via the injected TextEngine
7
+ // (bakeText), or taken verbatim from a pre-baked `layout`.
8
+ // When a `measure`/`textEngine` is supplied, `layout` (auto-layout) groups are
9
+ // resolved to absolute geometry first; otherwise nodes must carry absolute
10
+ // pos/size. freshcoat owns text *shaping* (the TextEngine) but knows nothing
11
+ // about templates — the caller delivers final resolved values on the nodes.
12
+ import { bakeText, resolveLeadingTrim, textClipOutset } from "./bake-text.js";
13
+ import { metricsLookup, resolveAutoLineHeights } from "./line-height.js";
14
+ import { resolveLayout } from "./resolve-layout.js";
15
+ // Everything that has to happen to a tree before it can be lowered: AUTO line
16
+ // heights become the font's own line box, then `layout` groups resolve to
17
+ // absolute geometry. compileScene runs this itself; a caller that needs the
18
+ // absolute tree first (to measure a layer as rendered, or to rewrite it) runs it
19
+ // here and compiles with `prepared: true`.
20
+ //
21
+ // AUTO line heights resolve BEFORE layout: they change how tall a wrapped
22
+ // paragraph is, so resolving them after measuring would size every hug box to
23
+ // the wrong height.
24
+ export function prepareScene(root, opts) {
25
+ const measure = opts.measure ?? opts.textEngine?.measureText;
26
+ const authored = resolveAutoLineHeights(root, metricsLookup(opts.fontMetrics, opts.textEngine?.metricsFor));
27
+ return measure ? resolveLayout(authored, { measure }) : authored;
28
+ }
29
+ export function compileScene(root, opts) {
30
+ const scene = opts.prepared ? root : prepareScene(root, opts);
31
+ // A 1× export leaves `scale` off the command entirely — the density is an
32
+ // opt-in, and an absent field keeps the stream identical to what it was before
33
+ // export scaling existed.
34
+ const scale = opts.scale ?? 1;
35
+ const supersample = opts.supersample ?? 1;
36
+ const commands = [
37
+ {
38
+ op: "createCanvas",
39
+ width: opts.width,
40
+ height: opts.height,
41
+ ...(scale !== 1 ? { scale } : {}),
42
+ ...(supersample !== 1 ? { supersample } : {}),
43
+ },
44
+ ];
45
+ // `fonts` and `images` override INDEPENDENTLY: whichever is omitted is still
46
+ // derived from the walk. Treating them as one all-or-nothing switch silently
47
+ // blanked the other list, so a caller declaring only extra font families lost
48
+ // every image src and the painter drew placeholders instead.
49
+ const images = new Set();
50
+ const fonts = new Set();
51
+ collectAssets(scene, images, fonts);
52
+ const fontRequests = opts.fonts ?? [...fonts].map((family) => ({ family }));
53
+ const imageSrcs = opts.images ?? [...images];
54
+ if (fontRequests.length > 0) {
55
+ commands.push({ op: "loadFonts", requests: fontRequests });
56
+ }
57
+ if (imageSrcs.length > 0) {
58
+ commands.push({ op: "loadImages", srcs: imageSrcs });
59
+ }
60
+ const ctx = {
61
+ textEngine: opts.textEngine,
62
+ leadingTrim: opts.leadingTrim,
63
+ fontMetrics: opts.fontMetrics,
64
+ // Text is baked in design units, but its line advances snap to whole
65
+ // pixels — of the surface it will be painted on, not of the design box.
66
+ deviceScale: scale,
67
+ };
68
+ commands.push(lower(scene, ctx));
69
+ // The finish runs on the composited frame, so it goes last.
70
+ if (opts.finish && hasFinishOp(opts.finish)) {
71
+ commands.push({ op: "finishFrame", finish: opts.finish });
72
+ }
73
+ return commands;
74
+ }
75
+ // True when a finish actually asks for something — an all-undefined finish is a
76
+ // no-op and shouldn't emit a command (or spin up the post-pass).
77
+ function hasFinishOp(f) {
78
+ return (f.whiteClamp !== undefined ||
79
+ f.blackExtract !== undefined ||
80
+ (typeof f.dither === "number" ? f.dither > 0 : (f.dither?.amount ?? 0) > 0));
81
+ }
82
+ // The fonts and images a scene references — the same walk compileScene runs for
83
+ // its own setup commands, exposed for callers that must declare a frame's assets
84
+ // up front (coatfile's CompiledFrame, the worker's raw render source). Those
85
+ // callers hand the result back as `fonts`/`images`, which are OVERRIDES: an empty
86
+ // list means "load nothing", not "nothing found", so a second walk that drifted
87
+ // would silently render text in no font at all.
88
+ export function sceneAssets(node) {
89
+ const images = new Set();
90
+ const fonts = new Set();
91
+ collectAssets(node, images, fonts);
92
+ return {
93
+ fonts: [...fonts].map((family) => ({ family })),
94
+ images: [...images],
95
+ };
96
+ }
97
+ function collectAssets(node, images, fonts) {
98
+ switch (node.kind) {
99
+ case "image":
100
+ images.add(node.src);
101
+ break;
102
+ case "text":
103
+ fonts.add(node.font.family);
104
+ break;
105
+ case "group":
106
+ for (const child of node.children)
107
+ collectAssets(child, images, fonts);
108
+ break;
109
+ case "mask":
110
+ collectAssets(node.mask, images, fonts);
111
+ for (const child of node.children)
112
+ collectAssets(child, images, fonts);
113
+ break;
114
+ }
115
+ }
116
+ function lower(node, ctx) {
117
+ const base = {
118
+ id: node.id,
119
+ pos: node.pos ?? { x: 0, y: 0 },
120
+ size: node.size ?? { width: 0, height: 0 },
121
+ rotation: node.rotation,
122
+ opacity: node.opacity,
123
+ blendMode: node.blendMode,
124
+ shadow: node.shadow,
125
+ blur: node.blur,
126
+ adjust: node.adjust,
127
+ };
128
+ switch (node.kind) {
129
+ case "rect":
130
+ return {
131
+ ...base,
132
+ op: "drawRect",
133
+ fills: node.fills,
134
+ stroke: node.stroke,
135
+ cornerRadius: node.cornerRadius,
136
+ cornerSmoothing: node.cornerSmoothing,
137
+ };
138
+ case "ellipse":
139
+ return {
140
+ ...base,
141
+ op: "drawPath",
142
+ d: ellipseSvg(base.size),
143
+ fills: node.fills,
144
+ stroke: node.stroke,
145
+ };
146
+ case "path":
147
+ return {
148
+ ...base,
149
+ op: "drawPath",
150
+ d: node.d,
151
+ fills: node.fills,
152
+ stroke: node.stroke,
153
+ ...(node.viewBox ? { viewBox: node.viewBox } : {}),
154
+ ...(node.fillRule ? { fillRule: node.fillRule } : {}),
155
+ };
156
+ case "image":
157
+ return {
158
+ ...base,
159
+ op: "drawImage",
160
+ src: node.src,
161
+ fit: node.fit,
162
+ stroke: node.stroke,
163
+ clip: node.mask,
164
+ };
165
+ case "group":
166
+ return {
167
+ ...base,
168
+ op: "drawGroup",
169
+ clip: groupClip(node),
170
+ children: [
171
+ ...groupBackground(node, base),
172
+ ...node.children.map((c) => lower(c, ctx)),
173
+ ],
174
+ };
175
+ case "mask":
176
+ return lowerMask(node, base, ctx);
177
+ case "text":
178
+ return lowerText(node, base, ctx);
179
+ case "bitmap":
180
+ return {
181
+ ...base,
182
+ op: "drawBitmap",
183
+ pixels: node.pixels,
184
+ pixelWidth: node.pixelWidth,
185
+ pixelHeight: node.pixelHeight,
186
+ ...(node.role ? { role: node.role } : {}),
187
+ };
188
+ }
189
+ }
190
+ function lowerText(node, base, ctx) {
191
+ // Pre-baked layout wins (the caller already shaped it); otherwise bake now
192
+ // via the injected engine. Only when neither is present is it an error.
193
+ const layout = node.layout ??
194
+ (ctx.textEngine
195
+ ? bakeText(node, {
196
+ textEngine: ctx.textEngine,
197
+ leadingTrim: ctx.leadingTrim,
198
+ fontMetrics: ctx.fontMetrics,
199
+ deviceScale: ctx.deviceScale,
200
+ })
201
+ : undefined);
202
+ if (!layout) {
203
+ throw new Error("compileScene: text node has no pre-baked `layout` and no `textEngine` was supplied to bake it");
204
+ }
205
+ // fit:"clip" clips overflow to the box, but glyphs legitimately overshoot it
206
+ // (descenders/ascenders); outset the clip so visible glyphs stay whole.
207
+ const clip = node.fit === "clip"
208
+ ? {
209
+ kind: "rect",
210
+ outset: textClipOutset(node.font, resolveLeadingTrim(node, ctx.leadingTrim), ctx.fontMetrics),
211
+ }
212
+ : undefined;
213
+ return {
214
+ ...base,
215
+ op: "drawText",
216
+ clip,
217
+ layout,
218
+ color: node.color ?? "#000000",
219
+ fill: node.fill,
220
+ };
221
+ }
222
+ // A mask node lowers by what its mask IS: a single opaque shape → a drawGroup
223
+ // whose clip is the shape (one cheap clipPath, at the mask shape's box); any
224
+ // other mask → a drawMasked command the painter composites via an offscreen
225
+ // coverage layer.
226
+ //
227
+ // `invert` and `channel` disqualify the fast path whatever the shape is. A
228
+ // clipPath keeps what the geometry covers, which is the alpha channel,
229
+ // uninverted: there is no inverse clip, and an OPAQUE shape's luminance coverage
230
+ // is its colour rather than its geometry (a black rect masks everything out
231
+ // under `luminance` and nothing out under a clip).
232
+ function lowerMask(node, base, ctx) {
233
+ const children = node.children.map((c) => lower(c, ctx));
234
+ const shape = node.invert || node.channel === "luminance" ? null : fastClip(node.mask);
235
+ if (shape) {
236
+ // The clip is built at the mask SHAPE's box; the mask node's own transform
237
+ // (opacity/blend/blur/shadow/rotation) still wraps the masked content.
238
+ return {
239
+ ...base,
240
+ pos: node.mask.pos ?? { x: 0, y: 0 },
241
+ size: node.mask.size ?? { width: 0, height: 0 },
242
+ op: "drawGroup",
243
+ clip: shape,
244
+ children,
245
+ };
246
+ }
247
+ return {
248
+ ...base,
249
+ op: "drawMasked",
250
+ mask: lower(node.mask, ctx),
251
+ children,
252
+ channel: node.channel,
253
+ invert: node.invert,
254
+ };
255
+ }
256
+ // The conservative fast-path predicate: a mask that is a single opaque vector
257
+ // shape (rect/ellipse, no transform effects, no gradient/partial-alpha fill,
258
+ // uniform corner radius) maps to a ShapeMask clipPath. Everything else (paths,
259
+ // per-corner radii, images, text, groups, any opacity/blur/blend/rotation on
260
+ // the mask) returns null → the offscreen path. It reads the MASK only; the mask
261
+ // node's own `invert`/`channel` are the caller's to check.
262
+ function fastClip(mask) {
263
+ if (mask.rotation ||
264
+ (mask.opacity !== undefined && mask.opacity < 1) ||
265
+ mask.blur ||
266
+ (mask.blendMode && mask.blendMode !== "normal") ||
267
+ mask.shadow)
268
+ return null;
269
+ if (mask.kind === "ellipse")
270
+ return solidOrNone(mask.fills) ? { kind: "ellipse" } : null;
271
+ if (mask.kind === "rect") {
272
+ if (!solidOrNone(mask.fills))
273
+ return null;
274
+ const cr = mask.cornerRadius;
275
+ if (Array.isArray(cr))
276
+ return null; // per-corner → offscreen
277
+ if (mask.cornerSmoothing && typeof cr === "number" && cr > 0)
278
+ return { kind: "squircle", radius: cr };
279
+ if (typeof cr === "number" && cr > 0)
280
+ return { kind: "rounded-rect", radius: cr };
281
+ return { kind: "rect" };
282
+ }
283
+ return null;
284
+ }
285
+ function solidOrNone(fills) {
286
+ return !fills || (fills.length === 1 && fills[0].kind === "solid");
287
+ }
288
+ // A group's self-clip: clip children to its box, honoring its corner radius.
289
+ // Per-corner radii and corner smoothing on the clip are a later refinement.
290
+ // A filled group's background, as an ordinary rect drawn first.
291
+ //
292
+ // Lowering it rather than teaching the painter about group fills keeps one
293
+ // implementation of fills, gradients, corner radii and smoothing — the rect's,
294
+ // which the parity suites already cover. The group's own composite effects
295
+ // (shadow, opacity, blur, adjust) stay on the group, so the fill is inside the
296
+ // layer they apply to and the shadow is cast by the filled box.
297
+ function groupBackground(node, base) {
298
+ if (!node.fills?.length)
299
+ return [];
300
+ return [
301
+ {
302
+ op: "drawRect",
303
+ pos: base.pos,
304
+ size: base.size,
305
+ fills: node.fills,
306
+ cornerRadius: node.cornerRadius,
307
+ cornerSmoothing: node.cornerSmoothing,
308
+ },
309
+ ];
310
+ }
311
+ function groupClip(node) {
312
+ if (!node.clip)
313
+ return undefined;
314
+ const r = typeof node.cornerRadius === "number" ? node.cornerRadius : 0;
315
+ return r > 0 ? { kind: "rounded-rect", radius: r } : { kind: "rect" };
316
+ }
317
+ // Box-relative ellipse as an SVG path (two half-arcs), positioned at the node's
318
+ // pos by the painter. CanvasKit's Path.MakeFromSVGString handles arcs — the same
319
+ // path shape the painter already uses for circle/ellipse clips.
320
+ function ellipseSvg(size) {
321
+ const rx = size.width / 2;
322
+ const ry = size.height / 2;
323
+ const cy = ry;
324
+ return `M 0 ${cy} A ${rx} ${ry} 0 1 0 ${size.width} ${cy} A ${rx} ${ry} 0 1 0 0 ${cy} Z`;
325
+ }
package/src/css.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { ResolvedFill } from "./types.js";
2
+ export type ColorResolver = (raw: string) => string;
3
+ export declare function oklchToHex(l: number, c: number, h: number): string;
4
+ export declare function parseCssColor(raw: string, fallback?: string): string;
5
+ export declare function parseLinearGradient(value: string, resolveColor?: ColorResolver): ResolvedFill | null;
package/src/css.js ADDED
@@ -0,0 +1,153 @@
1
+ const CSS_NAMED = {
2
+ transparent: "#00000000",
3
+ white: "#ffffff",
4
+ black: "#000000",
5
+ };
6
+ const clamp01 = (x) => Math.min(1, Math.max(0, x));
7
+ function toHexByte(x) {
8
+ return Math.round(clamp01(x) * 255)
9
+ .toString(16)
10
+ .padStart(2, "0");
11
+ }
12
+ // oklch(L C H) → sRGB hex. L is 0..1 (or a %), C is chroma, H is degrees.
13
+ // OKLCh → OKLab → linear sRGB → gamma-encoded sRGB.
14
+ export function oklchToHex(l, c, h) {
15
+ const hr = (h * Math.PI) / 180;
16
+ const a = c * Math.cos(hr);
17
+ const b = c * Math.sin(hr);
18
+ const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
19
+ const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
20
+ const s_ = l - 0.0894841775 * a - 1.291485548 * b;
21
+ const lc = l_ * l_ * l_;
22
+ const mc = m_ * m_ * m_;
23
+ const sc = s_ * s_ * s_;
24
+ const lr = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
25
+ const lg = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
26
+ const lb = -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc;
27
+ const enc = (x) => x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055;
28
+ return `#${toHexByte(enc(lr))}${toHexByte(enc(lg))}${toHexByte(enc(lb))}`;
29
+ }
30
+ function parseOklch(value) {
31
+ const inner = value.slice(value.indexOf("(") + 1, value.lastIndexOf(")"));
32
+ const parts = inner
33
+ .replace(/\//g, " ") // drop any `/ alpha`
34
+ .split(/[\s,]+/)
35
+ .filter(Boolean);
36
+ if (parts.length < 3)
37
+ return null;
38
+ const l = parts[0].endsWith("%")
39
+ ? parseFloat(parts[0]) / 100
40
+ : parseFloat(parts[0]);
41
+ const c = parseFloat(parts[1]);
42
+ const h = parseFloat(parts[2]);
43
+ if ([l, c, h].some(Number.isNaN))
44
+ return null;
45
+ return oklchToHex(l, c, h);
46
+ }
47
+ function expandHex(value) {
48
+ const hex = value.slice(1);
49
+ if (hex.length === 3 || hex.length === 4) {
50
+ return `#${[...hex].map((ch) => ch + ch).join("")}`;
51
+ }
52
+ return value;
53
+ }
54
+ function parseRgb(value) {
55
+ const inner = value.slice(value.indexOf("(") + 1, value.lastIndexOf(")"));
56
+ const parts = inner.split(/[\s,/]+/).filter(Boolean);
57
+ if (parts.length < 3)
58
+ return null;
59
+ const toByte = (p) => p.endsWith("%") ? (parseFloat(p) / 100) * 255 : parseFloat(p);
60
+ const [r, g, b] = parts.map(toByte);
61
+ if ([r, g, b].some(Number.isNaN))
62
+ return null;
63
+ return `#${toHexByte(r / 255)}${toHexByte(g / 255)}${toHexByte(b / 255)}`;
64
+ }
65
+ // A CSS color string (hex / rgb()/rgba() / oklch() / a named color) → `#rrggbb`.
66
+ // Unknown/unparseable values (including `var(...)` — resolve those before calling,
67
+ // or use parseLinearGradient's resolver hook) yield `fallback`.
68
+ export function parseCssColor(raw, fallback = "#000000") {
69
+ const value = raw.trim();
70
+ if (!value)
71
+ return fallback;
72
+ if (value.startsWith("#"))
73
+ return expandHex(value).toLowerCase();
74
+ if (value.startsWith("oklch"))
75
+ return parseOklch(value) ?? fallback;
76
+ if (value.startsWith("rgb"))
77
+ return parseRgb(value) ?? fallback;
78
+ return CSS_NAMED[value.toLowerCase()] ?? fallback;
79
+ }
80
+ // Split on top-level commas only (colors like rgb(…) carry their own commas).
81
+ function splitTopLevel(input) {
82
+ const out = [];
83
+ let depth = 0;
84
+ let start = 0;
85
+ for (let i = 0; i < input.length; i++) {
86
+ const ch = input[i];
87
+ if (ch === "(")
88
+ depth++;
89
+ else if (ch === ")")
90
+ depth--;
91
+ else if (ch === "," && depth === 0) {
92
+ out.push(input.slice(start, i));
93
+ start = i + 1;
94
+ }
95
+ }
96
+ out.push(input.slice(start));
97
+ return out.map((s) => s.trim()).filter(Boolean);
98
+ }
99
+ const ANGLE_KEYWORDS = {
100
+ "to top": 0,
101
+ "to right": 90,
102
+ "to bottom": 180,
103
+ "to left": 270,
104
+ };
105
+ // CSS gradient angle → normalized endpoints in the [0,1] bbox. 0deg = to top,
106
+ // 90deg = to right, 180deg = to bottom.
107
+ function angleToEndpoints(angle) {
108
+ const rad = (angle * Math.PI) / 180;
109
+ const dx = Math.sin(rad) / 2;
110
+ const dy = -Math.cos(rad) / 2;
111
+ return {
112
+ from: { x: 0.5 - dx, y: 0.5 - dy },
113
+ to: { x: 0.5 + dx, y: 0.5 + dy },
114
+ };
115
+ }
116
+ // Parse a CSS `linear-gradient(...)` into a freshcoat linear fill. Angles (`Ndeg`
117
+ // or `to <side>`, default 180) and per-stop positions (`<color> <pos>%`, else
118
+ // evenly spaced) are honored; stop colors resolve through `resolveColor` (default
119
+ // parseCssColor), so callers can inject `var(--token)` resolution. Returns null
120
+ // for anything that isn't a linear-gradient (radial/conic → caller's fallback).
121
+ export function parseLinearGradient(value, resolveColor = (c) => parseCssColor(c)) {
122
+ const trimmed = value.trim();
123
+ const open = trimmed.indexOf("(");
124
+ if (!trimmed.startsWith("linear-gradient") || open < 0)
125
+ return null;
126
+ const inner = trimmed.slice(open + 1, trimmed.lastIndexOf(")"));
127
+ const parts = splitTopLevel(inner);
128
+ if (parts.length < 2)
129
+ return null;
130
+ let angle = 180;
131
+ let stopParts = parts;
132
+ const first = parts[0].toLowerCase();
133
+ if (/deg\s*$/.test(first)) {
134
+ angle = parseFloat(first);
135
+ stopParts = parts.slice(1);
136
+ }
137
+ else if (first in ANGLE_KEYWORDS) {
138
+ angle = ANGLE_KEYWORDS[first];
139
+ stopParts = parts.slice(1);
140
+ }
141
+ if (stopParts.length < 2)
142
+ return null;
143
+ const stops = stopParts.map((part, i) => {
144
+ const m = part.match(/^(.*?)(?:\s+(-?[\d.]+)%)?$/);
145
+ const colorRaw = (m?.[1] ?? part).trim();
146
+ const posRaw = m?.[2];
147
+ const offset = posRaw !== undefined
148
+ ? parseFloat(posRaw) / 100
149
+ : i / (stopParts.length - 1);
150
+ return { offset, color: resolveColor(colorRaw) };
151
+ });
152
+ return { kind: "linear", stops, ...angleToEndpoints(angle) };
153
+ }
@@ -0,0 +1,22 @@
1
+ import type { ImageNode } from "./node.js";
2
+ export type DecodedPixels = {
3
+ data: Uint8Array;
4
+ width: number;
5
+ height: number;
6
+ };
7
+ export type DecodeOptions = {
8
+ maxDim?: number;
9
+ };
10
+ export type ImageSampleOptions = {
11
+ maxDim?: number;
12
+ };
13
+ export type ImageBytesLoader = (src: string) => Promise<Uint8Array>;
14
+ export type PixelRect = {
15
+ x: number;
16
+ y: number;
17
+ w: number;
18
+ h: number;
19
+ };
20
+ export declare function drawImageToPixels(ck: unknown, img: any, outW: number, outH: number, src: PixelRect, dest: PixelRect): DecodedPixels | null;
21
+ export declare function sampleImageNode(ck: unknown, node: ImageNode, loadImageBytes: ImageBytesLoader, opts?: ImageSampleOptions): Promise<DecodedPixels | null>;
22
+ export declare function decodePixels(ck: unknown, bytes: Uint8Array, opts?: DecodeOptions): DecodedPixels | null;
package/src/decode.js ADDED
@@ -0,0 +1,112 @@
1
+ // decodePixels — decode encoded image bytes to a raw RGBA buffer via CanvasKit.
2
+ // freshcoat owns image decoding (it already does it to paint), so this is the
3
+ // primitive a consumer that only needs PIXELS (e.g. image analysis) calls, instead
4
+ // of standing up its own rasterizer. Takes the caller's CanvasKit instance; imports
5
+ // no WASM itself, so it stays in the light barrel.
6
+ import { fitRect } from "./paint-helpers.js";
7
+ // Read RGBA out of an Image or Canvas. Returns null when CanvasKit can't read back
8
+ // (an unsupported ImageInfo, a lost context) — readPixels can return null, so the
9
+ // caller must not assume a buffer.
10
+ function readRGBA(
11
+ // biome-ignore lint/suspicious/noExplicitAny: ck is the untyped WASM instance
12
+ c,
13
+ // biome-ignore lint/suspicious/noExplicitAny: an Image or Canvas handle from it
14
+ source, width, height) {
15
+ const info = {
16
+ width,
17
+ height,
18
+ colorType: c.ColorType.RGBA_8888,
19
+ alphaType: c.AlphaType.Unpremul,
20
+ colorSpace: c.ColorSpace.SRGB,
21
+ };
22
+ const px = source.readPixels(0, 0, info, undefined, width * 4);
23
+ // Copy out of the WASM heap so the result owns its buffer.
24
+ return px ? px.slice() : null;
25
+ }
26
+ // Draw an image's `src` rect into an `outW`×`outH` surface at `dest` (Mitchell
27
+ // cubic) and read the result back as RGBA. Owns the surface + paint lifecycle
28
+ // (freed even on throw) and returns null if the surface or readback fails. Shared
29
+ // by the downscale path here and by consumers that render a cropped/fitted image
30
+ // for analysis.
31
+ export function drawImageToPixels(ck,
32
+ // biome-ignore lint/suspicious/noExplicitAny: CanvasKit Image, untyped
33
+ img, outW, outH, src, dest) {
34
+ // biome-ignore lint/suspicious/noExplicitAny: ck is the untyped WASM instance
35
+ const c = ck;
36
+ const surface = c.MakeSurface(outW, outH);
37
+ if (!surface)
38
+ return null;
39
+ const paint = new c.Paint();
40
+ try {
41
+ const canvas = surface.getCanvas();
42
+ canvas.clear(c.TRANSPARENT);
43
+ canvas.drawImageRectCubic(img, c.XYWHRect(src.x, src.y, src.w, src.h), c.XYWHRect(dest.x, dest.y, dest.w, dest.h), 1 / 3, 1 / 3, paint);
44
+ surface.flush();
45
+ const data = readRGBA(c, canvas, outW, outH);
46
+ return data ? { data, width: outW, height: outH } : null;
47
+ }
48
+ finally {
49
+ paint.delete();
50
+ surface.delete();
51
+ }
52
+ }
53
+ // Decode and rasterize an ImageNode exactly as the painter draws it: its resolved
54
+ // box and object-fit crop determine the pixels returned, rather than the source
55
+ // file alone. This is the reusable bridge for consumers such as print analysis;
56
+ // it keeps CanvasKit decoding and fit math in freshcoat instead of duplicating
57
+ // them in every caller.
58
+ export async function sampleImageNode(ck, node, loadImageBytes, opts) {
59
+ // biome-ignore lint/suspicious/noExplicitAny: ck is the untyped WASM instance
60
+ const c = ck;
61
+ const img = c.MakeImageFromEncoded(await loadImageBytes(node.src));
62
+ if (!img)
63
+ return null;
64
+ try {
65
+ const boxW = node.size?.width ?? img.width();
66
+ const boxH = node.size?.height ?? img.height();
67
+ const maxDim = opts?.maxDim;
68
+ const scale = maxDim ? Math.min(1, maxDim / Math.max(boxW, boxH)) : 1;
69
+ const width = Math.max(1, Math.round(boxW * scale));
70
+ const height = Math.max(1, Math.round(boxH * scale));
71
+ // Tile repeats the full source; its distribution is therefore the source's,
72
+ // and rendering it as fill gives analysis the same pixels without allocating
73
+ // a potentially enormous repeated surface.
74
+ const fit = node.fit === "tile" ? "fill" : node.fit;
75
+ const r = fitRect(img.width(), img.height(), 0, 0, width, height, fit);
76
+ return drawImageToPixels(c, img, width, height, { x: r.sx, y: r.sy, w: r.sw, h: r.sh }, { x: r.dx, y: r.dy, w: r.dw, h: r.dh });
77
+ }
78
+ finally {
79
+ img.delete();
80
+ }
81
+ }
82
+ // Decode `bytes` (PNG/JPEG/WebP/GIF — whatever CanvasKit's codecs handle) to RGBA.
83
+ // Returns null if the bytes can't be decoded (or read back). With `maxDim`, the
84
+ // image is downscaled (Mitchell cubic) so the longest side is at most that many.
85
+ export function decodePixels(ck, bytes, opts) {
86
+ // biome-ignore lint/suspicious/noExplicitAny: ck is the untyped WASM instance
87
+ const c = ck;
88
+ const img = c.MakeImageFromEncoded(bytes);
89
+ if (!img)
90
+ return null;
91
+ try {
92
+ const iw = img.width();
93
+ const ih = img.height();
94
+ const maxDim = opts?.maxDim;
95
+ const scale = maxDim ? Math.min(1, maxDim / Math.max(iw, ih)) : 1;
96
+ if (scale >= 1) {
97
+ const data = readRGBA(c, img, iw, ih);
98
+ return data ? { data, width: iw, height: ih } : null;
99
+ }
100
+ const w = Math.max(1, Math.round(iw * scale));
101
+ const h = Math.max(1, Math.round(ih * scale));
102
+ const out = drawImageToPixels(c, img, w, h, { x: 0, y: 0, w: iw, h: ih }, { x: 0, y: 0, w, h });
103
+ if (out)
104
+ return out;
105
+ // No surface / failed readback — fall back to a full-res read.
106
+ const data = readRGBA(c, img, iw, ih);
107
+ return data ? { data, width: iw, height: ih } : null;
108
+ }
109
+ finally {
110
+ img.delete();
111
+ }
112
+ }
@@ -0,0 +1,25 @@
1
+ import type { Size } from "./types.js";
2
+ export type ExportConstraint = {
3
+ kind: "scale";
4
+ value: number;
5
+ } | {
6
+ kind: "width";
7
+ value: number;
8
+ } | {
9
+ kind: "height";
10
+ value: number;
11
+ };
12
+ export type ExportSetting = {
13
+ constraint?: ExportConstraint;
14
+ suffix?: string;
15
+ supersample?: number;
16
+ };
17
+ export declare const MAX_EXPORT_DIMENSION = 8192;
18
+ export declare function resolveExportScale(constraint: ExportConstraint | undefined, size: Size, opts?: {
19
+ maxDimension?: number;
20
+ }): number;
21
+ export declare const MAX_SUPERSAMPLE = 4;
22
+ export declare function resolveSupersample(supersample: number | undefined, size: Size, scale: number, opts?: {
23
+ maxDimension?: number;
24
+ }): number;
25
+ export declare function exportPixelSize(size: Size, scale: number): Size;