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,321 @@
1
+ // bakeText — lay out a TextNode's already-resolved text into a BakedTextLayout
2
+ // (per-line spans with absolute x, baseline, and vertical placement) the painter
3
+ // draws verbatim. Operates purely on the Node IR — concrete strings +
4
+ // ResolvedFont — through the injected TextEngine (the seam compile measures
5
+ // through).
6
+ //
7
+ // Two paths, chosen by content:
8
+ // • wrappable — one span, no per-span overrides → full wrap + shrink-to-fit
9
+ // via engine.layoutText.
10
+ // • inline — multiple spans (or a single span overriding font/color) → one
11
+ // shaped, wrapped paragraph via engine.layoutInline when available, else an
12
+ // independent-span single-line fallback.
13
+ import { getFontMetrics } from "./font-metrics.js";
14
+ // A node's leadingTrim wins over the compile default; both default to true.
15
+ export function resolveLeadingTrim(node, fallback) {
16
+ return (node.leadingTrim ?? fallback) !== false;
17
+ }
18
+ // Vertical slack a fit:"clip" rect needs so glyph overshoot isn't sliced.
19
+ // - leadingTrim: the cap line tucks against the box top, so the ascent rises
20
+ // (ascent − capHeight) above it and descenders drop a full descent below the
21
+ // last baseline (bottom-align seats it at the box bottom).
22
+ // - line-box model: glyphs are already centered via half-leading, so nothing
23
+ // spills unless the line height is tighter than ascent + descent.
24
+ export function textClipOutset(font, leadingTrim, metricsMap) {
25
+ const m = fontMetrics(font, metricsMap);
26
+ if (leadingTrim) {
27
+ return { top: Math.max(0, m.ascent - m.capHeight), bottom: m.descent };
28
+ }
29
+ const halfLeading = (font.size * font.lineHeight - (m.ascent + m.descent)) / 2;
30
+ const spill = Math.max(0, -halfLeading);
31
+ return { top: spill, bottom: spill };
32
+ }
33
+ export function bakeText(node, opts) {
34
+ const engine = opts.textEngine;
35
+ const leadingTrim = resolveLeadingTrim(node, opts.leadingTrim);
36
+ const metricsMap = opts.fontMetrics;
37
+ const pos = node.pos ?? { x: 0, y: 0 };
38
+ const size = node.size ?? { width: 0, height: 0 };
39
+ const color = node.color ?? "#000";
40
+ const align = node.align ?? "left";
41
+ const verticalAlign = node.verticalAlign ?? "top";
42
+ const fit = node.fit;
43
+ const maxLines = node.maxLines;
44
+ const defaultFont = node.font;
45
+ const lineHeight = defaultFont.lineHeight;
46
+ const deviceScale = opts.deviceScale;
47
+ const spans = normalizeSpans(node);
48
+ // Wrappable: a single span with no font/color override keeps the full wrap +
49
+ // shrink path. Anything richer goes through the inline (adjacent) path.
50
+ const wrappable = spans.length === 1 && !hasOverrides(spans[0]);
51
+ return wrappable
52
+ ? layoutWrappable(spans[0].text, defaultFont, color, pos, size, align, verticalAlign, lineHeight, fit, leadingTrim, maxLines, engine, metricsMap, deviceScale)
53
+ : layoutInline(spans, defaultFont, color, pos, size, align, verticalAlign, leadingTrim, engine, metricsMap, deviceScale);
54
+ }
55
+ // ─────────────── span normalization ───────────────
56
+ function normalizeSpans(node) {
57
+ if (node.spans && node.spans.length > 0)
58
+ return node.spans;
59
+ return [{ text: node.text ?? "" }];
60
+ }
61
+ function hasOverrides(span) {
62
+ if (span.color !== undefined)
63
+ return true;
64
+ const f = span.font;
65
+ if (!f)
66
+ return false;
67
+ return (f.family !== undefined ||
68
+ f.size !== undefined ||
69
+ f.weight !== undefined ||
70
+ f.style !== undefined ||
71
+ f.letterSpacing !== undefined ||
72
+ f.lineHeight !== undefined ||
73
+ f.variations !== undefined);
74
+ }
75
+ // ─────────────── metrics + baseline ───────────────
76
+ // Font ascent/descent (+ line gap, cap height) in target pixels. Prefers the
77
+ // OS/2 sTypo metrics Figma uses (registered from font bytes); falls back to
78
+ // typical ratios when a family isn't registered.
79
+ function fontMetrics(font, metricsMap) {
80
+ const reg = metricsMap?.[font.family] ?? getFontMetrics(font.family);
81
+ if (reg) {
82
+ return {
83
+ ascent: reg.ascent * font.size,
84
+ descent: reg.descent * font.size,
85
+ lineGap: reg.lineGap * font.size,
86
+ capHeight: reg.capHeight * font.size,
87
+ };
88
+ }
89
+ return {
90
+ ascent: font.size * 0.8,
91
+ descent: font.size * 0.2,
92
+ lineGap: 0,
93
+ capHeight: font.size * 0.7,
94
+ };
95
+ }
96
+ // Distance from a line's top to its alphabetic baseline.
97
+ // - default: Figma centers (ascent + descent) in the line height, so the
98
+ // baseline sits half a leading below the top, plus the ascent.
99
+ // - leadingTrim: cap height is anchored to the box top, so baseline = capHeight.
100
+ function baselineOffset(font, lineHeightPx, leadingTrim, metricsMap) {
101
+ const m = fontMetrics(font, metricsMap);
102
+ if (leadingTrim && m.capHeight > 0)
103
+ return m.capHeight;
104
+ const halfLeading = (lineHeightPx - (m.ascent + m.descent)) / 2;
105
+ return halfLeading + m.ascent;
106
+ }
107
+ // ─────────────── wrappable (single-style) path ───────────────
108
+ function layoutWrappable(text, defaultFont, color, pos, size, align, verticalAlign, lineHeight, fit, leadingTrim, maxLines, engine, metricsMap, deviceScale) {
109
+ const measured = engine.layoutText({
110
+ value: text,
111
+ font: {
112
+ family: defaultFont.family,
113
+ size: defaultFont.size,
114
+ weight: defaultFont.weight,
115
+ style: defaultFont.style,
116
+ letterSpacing: defaultFont.letterSpacing,
117
+ variations: defaultFont.variations,
118
+ },
119
+ maxWidth: size.width,
120
+ maxHeight: size.height,
121
+ lineHeight,
122
+ fit,
123
+ });
124
+ const effFont = {
125
+ ...defaultFont,
126
+ size: measured.effectiveFontSize,
127
+ };
128
+ // Figma "Truncate text": keep the first maxLines, ellipsize the last on overflow.
129
+ let mlines = measured.lines;
130
+ if (maxLines !== undefined && mlines.length > maxLines) {
131
+ const kept = mlines.slice(0, maxLines);
132
+ const last = kept[maxLines - 1];
133
+ const truncated = ellipsize(last.text, effFont, size.width, engine);
134
+ kept[maxLines - 1] = {
135
+ ...last,
136
+ text: truncated,
137
+ width: engine.measureText(truncated, effFont, null).width,
138
+ };
139
+ mlines = kept;
140
+ }
141
+ const lineHeightPx = measured.effectiveFontSize * lineHeight;
142
+ // Per-line advance, pixel-rounded like Figma to avoid sub-px drift down a
143
+ // paragraph — on the grid the export will be painted on. (No authoring ratio
144
+ // here — the Node IR is already in target px.)
145
+ const lineAdvance = snapToDevice(lineHeightPx, deviceScale);
146
+ const baseOffset = baselineOffset(effFont, lineHeightPx, leadingTrim, metricsMap);
147
+ const nLines = mlines.length;
148
+ const contentHeight = leadingTrim
149
+ ? baseOffset + (nLines - 1) * lineAdvance
150
+ : lineAdvance * nLines;
151
+ const startY = startYForVAlign(verticalAlign, pos.y, size.height, contentHeight);
152
+ const lines = mlines.map((line, i) => {
153
+ const x = xForAlign(align, pos.x, size.width, line.width);
154
+ const y = startY + i * lineAdvance;
155
+ return {
156
+ text: line.text,
157
+ y,
158
+ baseline: y + baseOffset,
159
+ spans: [{ text: line.text, x, width: line.width, font: effFont, color }],
160
+ };
161
+ });
162
+ return {
163
+ font: effFont,
164
+ lines,
165
+ totalHeight: nLines * lineHeightPx,
166
+ shrinkApplied: measured.shrinkApplied,
167
+ };
168
+ }
169
+ // Trim `text` so `text + "…"` fits within maxWidth (binary search on length).
170
+ function ellipsize(text, font, maxWidth, engine) {
171
+ const ell = "…";
172
+ if (engine.measureText(`${text}${ell}`, font, null).width <= maxWidth)
173
+ return `${text}${ell}`;
174
+ let lo = 0;
175
+ let hi = text.length;
176
+ while (lo < hi) {
177
+ const mid = Math.ceil((lo + hi) / 2);
178
+ const candidate = `${text.slice(0, mid).trimEnd()}${ell}`;
179
+ if (engine.measureText(candidate, font, null).width <= maxWidth)
180
+ lo = mid;
181
+ else
182
+ hi = mid - 1;
183
+ }
184
+ return `${text.slice(0, lo).trimEnd()}${ell}`;
185
+ }
186
+ const sum = (ns) => ns.reduce((a, b) => a + b, 0);
187
+ // Snap to the pixel grid the scene will actually be painted on.
188
+ function snapToDevice(v, deviceScale) {
189
+ const s = deviceScale && deviceScale > 0 ? deviceScale : 1;
190
+ return Math.round(v * s) / s;
191
+ }
192
+ // ─────────────── inline (multi-style) path ───────────────
193
+ function layoutInline(spans, defaultFont, defaultColor, pos, size, align, verticalAlign, leadingTrim, engine, metricsMap, deviceScale) {
194
+ const resolved = spans.map((s) => {
195
+ const f = s.font ?? {};
196
+ const font = {
197
+ family: f.family ?? defaultFont.family,
198
+ size: f.size ?? defaultFont.size,
199
+ weight: f.weight ?? defaultFont.weight,
200
+ style: f.style ?? defaultFont.style,
201
+ letterSpacing: f.letterSpacing ?? defaultFont.letterSpacing,
202
+ lineHeight: f.lineHeight ?? defaultFont.lineHeight,
203
+ decoration: f.decoration ?? defaultFont.decoration,
204
+ // A span's axes adjust the element's rather than replacing them.
205
+ variations: f.variations || defaultFont.variations
206
+ ? { ...defaultFont.variations, ...f.variations }
207
+ : undefined,
208
+ };
209
+ return { text: s.text, font, color: s.color ?? defaultColor };
210
+ });
211
+ const dominantFont = resolved.reduce((d, r) => r.font.size > d.font.size ? r : d).font;
212
+ const lineHeightPx = dominantFont.size * dominantFont.lineHeight;
213
+ const baseOffset = baselineOffset(dominantFont, lineHeightPx, leadingTrim, metricsMap);
214
+ // Engine path: one shaped, wrappable paragraph across all spans (cross-span
215
+ // shaping + mixed-style wrapping). This module owns vertical placement (line
216
+ // advance / baseline / vertical-align); the engine returns per-line fragment
217
+ // geometry only.
218
+ if (engine.layoutInline) {
219
+ const shaped = engine.layoutInline(resolved.map((r) => ({ text: r.text, font: r.font })), size.width);
220
+ // Each line gets the box ITS OWN spans ask for, not the node's tallest.
221
+ // Line height varies within a text node as freely as size does — a
222
+ // signature block set to 132% on its first line and Auto on the rest is one
223
+ // node with two different line boxes — and using a single advance stacks
224
+ // every later line where the first line's box would have put it.
225
+ const measuredLines = shaped.lines.map((sl) => {
226
+ const fonts = sl.fragments.map((fr) => resolved[fr.spanIndex].font);
227
+ const tallest = fonts.reduce((best, f) => f.size * f.lineHeight > best.size * best.lineHeight ? f : best, fonts[0] ?? dominantFont);
228
+ const boxPx = tallest.size * tallest.lineHeight;
229
+ return {
230
+ shaped: sl,
231
+ font: tallest,
232
+ boxPx,
233
+ // Pixel-rounded like Figma, so a paragraph doesn't drift sub-pixel.
234
+ advance: snapToDevice(boxPx, deviceScale),
235
+ baseOffset: baselineOffset(tallest, boxPx, leadingTrim, metricsMap),
236
+ };
237
+ });
238
+ const advances = measuredLines.map((l) => l.advance);
239
+ const firstOffset = measuredLines[0]?.baseOffset ?? baseOffset;
240
+ const contentHeight = leadingTrim
241
+ ? firstOffset + sum(advances.slice(1))
242
+ : sum(advances) || snapToDevice(lineHeightPx, deviceScale);
243
+ const startY = startYForVAlign(verticalAlign, pos.y, size.height, contentHeight);
244
+ let cursorY = startY;
245
+ const lines = measuredLines.map((ml) => {
246
+ const sl = ml.shaped;
247
+ const lineX = xForAlign(align, pos.x, size.width, sl.width);
248
+ const y = cursorY;
249
+ cursorY += ml.advance;
250
+ const spansOut = sl.fragments.map((fr) => ({
251
+ text: fr.text,
252
+ x: lineX + fr.x,
253
+ width: fr.width,
254
+ font: resolved[fr.spanIndex].font,
255
+ color: resolved[fr.spanIndex].color,
256
+ }));
257
+ return {
258
+ text: sl.fragments.map((f) => f.text).join(""),
259
+ y,
260
+ baseline: y + ml.baseOffset,
261
+ spans: spansOut,
262
+ };
263
+ });
264
+ return {
265
+ font: dominantFont,
266
+ lines,
267
+ totalHeight: sum(measuredLines.map((l) => l.boxPx)) || lineHeightPx,
268
+ shrinkApplied: false,
269
+ };
270
+ }
271
+ // Fallback: independent per-span widths, single line, no wrap.
272
+ const widths = resolved.map((r) => engine.measureSpanWidth(r.text, r.font));
273
+ const totalWidth = widths.reduce((s, w) => s + w, 0);
274
+ const startY = startYForVAlign(verticalAlign, pos.y, size.height, lineHeightPx);
275
+ const lineX = xForAlign(align, pos.x, size.width, totalWidth);
276
+ let cursor = lineX;
277
+ const spanLayouts = resolved.map((r, i) => {
278
+ const out = {
279
+ text: r.text,
280
+ x: cursor,
281
+ width: widths[i],
282
+ font: r.font,
283
+ color: r.color,
284
+ };
285
+ cursor += widths[i];
286
+ return out;
287
+ });
288
+ return {
289
+ font: dominantFont,
290
+ lines: [
291
+ {
292
+ text: resolved.map((r) => r.text).join(""),
293
+ y: startY,
294
+ baseline: startY + baseOffset,
295
+ spans: spanLayouts,
296
+ },
297
+ ],
298
+ totalHeight: lineHeightPx,
299
+ shrinkApplied: false,
300
+ };
301
+ }
302
+ // ─────────────── alignment ───────────────
303
+ function xForAlign(align, boxX, boxWidth, lineWidth) {
304
+ if (align === "center")
305
+ return boxX + (boxWidth - lineWidth) / 2;
306
+ if (align === "right")
307
+ return boxX + boxWidth - lineWidth;
308
+ return boxX;
309
+ }
310
+ function startYForVAlign(va, boxY, boxHeight, layoutHeight) {
311
+ // When content overflows the box, aligning would push its start above the box
312
+ // top (middle/bottom) so a clip shows only the tail. Pin to the top instead so
313
+ // the START of the text stays visible; alignment applies only when it fits.
314
+ if (layoutHeight > boxHeight)
315
+ return boxY;
316
+ if (va === "middle")
317
+ return boxY + (boxHeight - layoutHeight) / 2;
318
+ if (va === "bottom")
319
+ return boxY + boxHeight - layoutHeight;
320
+ return boxY;
321
+ }
@@ -0,0 +1,7 @@
1
+ import type { PaintCache } from "./paint-cache.js";
2
+ import type { PaintRuntime } from "./types.js";
3
+ export declare function createBrowserEnv(opts?: {
4
+ fonts?: Map<string, Uint8Array[]>;
5
+ images?: Map<string, Uint8Array>;
6
+ cache?: PaintCache;
7
+ }): PaintRuntime;
package/src/browser.js ADDED
@@ -0,0 +1,79 @@
1
+ import { resolveFontRequest } from "./font-bytes.js";
2
+ import { makeRuntime } from "./runtime.js";
3
+ async function fetchBytes(url) {
4
+ const res = await fetch(url);
5
+ if (!res.ok)
6
+ throw new Error(`fetch ${url} -> ${res.status}`);
7
+ return new Uint8Array(await res.arrayBuffer());
8
+ }
9
+ const injectedStylesheets = new Set();
10
+ // The browser runtime: font resolution + fetch image I/O + a DOM Canvas2D host +
11
+ // a native font registry (the DOM's own font loading) under a keep-alive policy
12
+ // (the live <canvas> stays mounted; nothing is auto-disposed).
13
+ export function createBrowserEnv(opts) {
14
+ return makeRuntime({
15
+ cache: opts?.cache,
16
+ resolveFont(req) {
17
+ return resolveFontRequest(req, opts?.fonts);
18
+ },
19
+ async loadImageBytes(src) {
20
+ return opts?.images?.get(src) ?? fetchBytes(src);
21
+ },
22
+ // The browser loads fonts natively — no byte fetch. Google/fontsource
23
+ // inject a stylesheet the DOM fetches; local files + pre-supplied bytes
24
+ // become FontFaces. All land in document.fonts for the 2D ctx to resolve.
25
+ async registerFont(family, res) {
26
+ if (res.kind === "none")
27
+ return;
28
+ if (res.kind === "bytes") {
29
+ await Promise.all(res.bytes.map((bytes) => addFace(family, bytes)));
30
+ return;
31
+ }
32
+ const d = res.descriptor;
33
+ if (d.kind === "local") {
34
+ await Promise.all(d.files.map((f) => addFace(family, `url(${f.src})`)));
35
+ return;
36
+ }
37
+ await injectStylesheet(d.url);
38
+ },
39
+ canvas: {
40
+ createCanvas(w, h) {
41
+ const c = document.createElement("canvas");
42
+ c.width = w;
43
+ c.height = h;
44
+ return c;
45
+ },
46
+ async decodeImage(bytes) {
47
+ const bmp = await createImageBitmap(new Blob([bytes]));
48
+ return bmp;
49
+ },
50
+ encode(canvas) {
51
+ // The keep-alive path never encodes server-side; this is a
52
+ // best-effort sync fallback for callers that want bytes.
53
+ const url = canvas.toDataURL("image/png");
54
+ const b64 = url.slice(url.indexOf(",") + 1);
55
+ return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
56
+ },
57
+ },
58
+ }, "keep");
59
+ }
60
+ async function addFace(family, source) {
61
+ const face = new FontFace(family, source);
62
+ await face.load();
63
+ document.fonts.add(face);
64
+ }
65
+ async function injectStylesheet(url) {
66
+ if (!injectedStylesheets.has(url)) {
67
+ injectedStylesheets.add(url);
68
+ await new Promise((resolve) => {
69
+ const link = document.createElement("link");
70
+ link.rel = "stylesheet";
71
+ link.href = url;
72
+ const done = () => resolve();
73
+ link.onload = done;
74
+ link.onerror = done;
75
+ document.head.appendChild(link);
76
+ });
77
+ }
78
+ await document.fonts.ready;
79
+ }
@@ -0,0 +1,4 @@
1
+ import type { Command, PaintOutput, PaintRuntime } from "./types.js";
2
+ type CK = any;
3
+ export declare function paintScene(ck: CK, commands: Command[], rt: PaintRuntime): Promise<PaintOutput>;
4
+ export {};