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.
- package/LICENSE +202 -0
- package/NOTICE +17 -0
- package/README.md +110 -0
- package/package.json +53 -0
- package/src/adjust.d.ts +18 -0
- package/src/adjust.js +153 -0
- package/src/approx-layout.d.ts +2 -0
- package/src/approx-layout.js +91 -0
- package/src/bake-text.d.ts +12 -0
- package/src/bake-text.js +321 -0
- package/src/browser.d.ts +7 -0
- package/src/browser.js +79 -0
- package/src/canvaskit.d.ts +4 -0
- package/src/canvaskit.js +1674 -0
- package/src/compile-scene.d.ts +24 -0
- package/src/compile-scene.js +325 -0
- package/src/css.d.ts +5 -0
- package/src/css.js +153 -0
- package/src/decode.d.ts +22 -0
- package/src/decode.js +112 -0
- package/src/export-scale.d.ts +25 -0
- package/src/export-scale.js +74 -0
- package/src/font-bytes.d.ts +4 -0
- package/src/font-bytes.js +165 -0
- package/src/font-metrics.d.ts +5 -0
- package/src/font-metrics.js +62 -0
- package/src/headless.d.ts +24 -0
- package/src/headless.js +58 -0
- package/src/index.d.ts +25 -0
- package/src/index.js +29 -0
- package/src/jpeg.d.ts +5 -0
- package/src/jpeg.js +20 -0
- package/src/line-height.d.ts +7 -0
- package/src/line-height.js +80 -0
- package/src/node.d.ts +139 -0
- package/src/node.js +47 -0
- package/src/paint-cache.d.ts +49 -0
- package/src/paint-cache.js +109 -0
- package/src/paint-helpers.d.ts +21 -0
- package/src/paint-helpers.js +71 -0
- package/src/paragraph-layout.d.ts +6 -0
- package/src/paragraph-layout.js +283 -0
- package/src/path-data.d.ts +5 -0
- package/src/path-data.js +101 -0
- package/src/png.d.ts +23 -0
- package/src/png.js +189 -0
- package/src/resolve-layout.d.ts +9 -0
- package/src/resolve-layout.js +657 -0
- package/src/runtime.d.ts +6 -0
- package/src/runtime.js +44 -0
- package/src/squircle.d.ts +1 -0
- package/src/squircle.js +60 -0
- package/src/text-cache.d.ts +14 -0
- package/src/text-cache.js +46 -0
- package/src/text-engine.d.ts +26 -0
- package/src/text-engine.js +1 -0
- package/src/text-types.d.ts +30 -0
- package/src/text-types.js +1 -0
- package/src/types.d.ts +406 -0
- package/src/types.js +13 -0
- package/src/validate-commands.d.ts +8 -0
- 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
|
+
}
|
package/src/decode.d.ts
ADDED
|
@@ -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;
|