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,74 @@
1
+ // Longest axis an export may reach. A raster surface is width·height·4 bytes, so
2
+ // an unbounded scale on a large design is an out-of-memory, not a slow render.
3
+ // Past this the scale is lowered to fit rather than throwing — a softer export
4
+ // beats no export, and the effective scale comes back from resolveExportScale for
5
+ // a caller that wants to notice.
6
+ export const MAX_EXPORT_DIMENSION = 8192;
7
+ // The device-pixel multiplier an export constraint asks for on a design of `size`,
8
+ // lowered so neither axis crosses `maxDimension`. A missing/degenerate constraint
9
+ // is 1×.
10
+ export function resolveExportScale(constraint, size, opts) {
11
+ const requested = requestedScale(constraint, size);
12
+ if (!Number.isFinite(requested) || requested <= 0)
13
+ return 1;
14
+ const longest = Math.max(size.width, size.height);
15
+ if (longest <= 0)
16
+ return requested;
17
+ const ceiling = (opts?.maxDimension ?? MAX_EXPORT_DIMENSION) / longest;
18
+ return Math.min(requested, ceiling);
19
+ }
20
+ function requestedScale(constraint, size) {
21
+ if (!constraint)
22
+ return 1;
23
+ switch (constraint.kind) {
24
+ case "scale":
25
+ return constraint.value;
26
+ case "width":
27
+ return size.width > 0 ? constraint.value / size.width : 1;
28
+ case "height":
29
+ return size.height > 0 ? constraint.value / size.height : 1;
30
+ }
31
+ }
32
+ // How many times denser than the export a scene may be RENDERED before being
33
+ // reduced to the export size. Past this the extra samples stop buying visible
34
+ // quality and only cost fill rate — 4× the density is already 16× the pixels.
35
+ export const MAX_SUPERSAMPLE = 4;
36
+ // The supersample factor an export may actually use: the scene is rendered at
37
+ // `scale × supersample` and reduced to `scale` before it is finished or encoded,
38
+ // so the output keeps its 1× pixel count while every edge is resolved from
39
+ // several samples instead of one. This is the knob for a FIXED output size — a
40
+ // caller that can afford more pixels should raise `scale` instead and get the
41
+ // same detail at the larger size for a quarter of the fill.
42
+ //
43
+ // Bounded twice: by MAX_SUPERSAMPLE, and by the same `maxDimension` ceiling the
44
+ // export scale answers to — the render surface is the one that has to be
45
+ // allocated, so it is the one that must fit. A request that cannot be honoured
46
+ // is lowered rather than refused (1 = render at the export size, no reduction).
47
+ //
48
+ // Always a power of two, rounded down. The reduction is a chain of exact 2:1
49
+ // halvings (see the painter's reduceSupersampled) because that is the only ratio
50
+ // a resampler averages every sample at: a cubic filter's support does not widen
51
+ // with the minification, so a 4:1 cubic reduction quietly reads a fraction of the
52
+ // samples that were paid for and lands no better than 2:1. A request of 3 is
53
+ // therefore honoured as 2, and anything under 2 as no supersampling.
54
+ export function resolveSupersample(supersample, size, scale, opts) {
55
+ const requested = Math.min(supersample ?? 1, MAX_SUPERSAMPLE);
56
+ if (!Number.isFinite(requested) || requested < 2)
57
+ return 1;
58
+ const longest = Math.max(size.width, size.height) * scale;
59
+ const ceiling = longest > 0
60
+ ? (opts?.maxDimension ?? MAX_EXPORT_DIMENSION) / longest
61
+ : requested;
62
+ const fitted = Math.min(requested, ceiling);
63
+ return fitted >= 2 ? 2 ** Math.floor(Math.log2(fitted)) : 1;
64
+ }
65
+ // The surface a design of `size` occupies at `scale`, in whole pixels. Rounding
66
+ // (not flooring) is what makes a `width: 1024` constraint land on exactly 1024.
67
+ // The painter sizes its surface with this, so a caller naming or laying out the
68
+ // result reads the same numbers the pixels have.
69
+ export function exportPixelSize(size, scale) {
70
+ return {
71
+ width: Math.max(1, Math.round(size.width * scale)),
72
+ height: Math.max(1, Math.round(size.height * scale)),
73
+ };
74
+ }
@@ -0,0 +1,4 @@
1
+ import type { FontRequest, FontResolution } from "./types.js";
2
+ export declare function resolveFontRequest(req: FontRequest | string, bytesByFamily?: Map<string, Uint8Array[]>): FontResolution;
3
+ export declare function fontBytes(res: FontResolution): Promise<Uint8Array[]>;
4
+ export declare function dataUrlToBytes(src: string): Uint8Array;
@@ -0,0 +1,165 @@
1
+ // Resolve a request against the env's pre-supplied bytes, else its descriptor.
2
+ // Shared by every runtime — the only per-env input is the pre-supplied map.
3
+ export function resolveFontRequest(req, bytesByFamily) {
4
+ const r = typeof req === "string" ? { family: req } : req;
5
+ const pre = bytesByFamily?.get(r.family);
6
+ if (pre)
7
+ return { kind: "bytes", bytes: pre };
8
+ if ("descriptor" in r)
9
+ return { kind: "descriptor", descriptor: r.descriptor };
10
+ return { kind: "none" };
11
+ }
12
+ // Old UA nudges Google Fonts / Fontsource to serve one unsubsetted TTF, which the
13
+ // sfnt metrics reader (readFontMetrics) can parse; it cannot parse WOFF2.
14
+ // Browsers treat user-agent as a forbidden header and ignore it, falling back to
15
+ // WOFF2, which CanvasKit decodes fine.
16
+ const TTF_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/534.30 (KHTML, like Gecko)";
17
+ const FONT_FETCH_TIMEOUT_MS = 8000;
18
+ // Turn a resolution into raw font-file bytes: stylesheet parsing + fetch. Purely
19
+ // async I/O over data:/http (universal) — local filesystem reads are the env's
20
+ // job, done up-front in its resolveFont (which returns { kind: "bytes" }).
21
+ export async function fontBytes(res) {
22
+ if (res.kind === "bytes")
23
+ return res.bytes;
24
+ if (res.kind === "none")
25
+ return [];
26
+ return descriptorBytes(res.descriptor);
27
+ }
28
+ async function descriptorBytes(d) {
29
+ if (d.kind === "local") {
30
+ return Promise.all(d.files.map((f) => fileBytes(f.src)));
31
+ }
32
+ // google / fontsource carry a CSS stylesheet, not a font file.
33
+ return stylesheetFontBytes(d.url);
34
+ }
35
+ async function fileBytes(src) {
36
+ if (src.startsWith("data:"))
37
+ return dataUrlToBytes(src);
38
+ if (/^https?:/i.test(src))
39
+ return fetchBytes(src);
40
+ throw new Error(`cannot fetch local font path "${src}"; a runtime must materialize local files in resolveFont`);
41
+ }
42
+ /**
43
+ * Every font file a Google / Fontsource stylesheet points at, Latin-covering
44
+ * faces first.
45
+ *
46
+ * Taking the stylesheet's *first* url is wrong, and silently so. When the
47
+ * request carries a modern user-agent — which is every browser, since UA is a
48
+ * forbidden header there and TTF_UA below is dropped — Google answers with one
49
+ * `@font-face` per (weight × unicode subset), and it lists `latin-ext` before
50
+ * `latin`. That first face's range starts at U+0100, so it has no A–Z at all:
51
+ * every ASCII glyph renders as .notdef, with correct metrics, which reads as a
52
+ * broken template rather than a wrong font file. Servers never saw it, because
53
+ * the old UA gets them a single unsubsetted TTF.
54
+ *
55
+ * So: parse the faces, sort the ones that cover Basic Latin to the front —
56
+ * `deriveFontMetrics` reads `faces[0]`, and a subset without Latin would give
57
+ * the wrong cap height — and load them all, which also picks up the accented
58
+ * ranges and the other weights the single-face fetch was dropping.
59
+ */
60
+ async function stylesheetFontBytes(cssUrl) {
61
+ const css = await fetchText(cssUrl);
62
+ const faces = parseStylesheetFaces(css);
63
+ if (faces.length === 0) {
64
+ throw new Error(`no font file found in stylesheet: ${cssUrl}`);
65
+ }
66
+ const ordered = [
67
+ ...faces.filter((f) => f.coversLatin),
68
+ ...faces.filter((f) => !f.coversLatin),
69
+ ];
70
+ // One URL, one face. A VARIABLE family answers every requested weight with the
71
+ // same file per subset — `wght@400;600;700` is three @font-face rows pointing
72
+ // at one woff2 — so without this the same bytes are fetched and registered
73
+ // three times. The duplicates are not a second weight (the axis is set at
74
+ // draw time, see paragraph-layout's spanTextStyle); they are only cost, and
75
+ // they make the family's face list read as if the weights were there.
76
+ const seen = new Set();
77
+ const unique = [];
78
+ for (const face of ordered) {
79
+ if (seen.has(face.url))
80
+ continue;
81
+ seen.add(face.url);
82
+ unique.push(face);
83
+ }
84
+ return Promise.all(unique.map((f) => fetchBytes(f.url)));
85
+ }
86
+ const FONT_FILE = /\.(ttf|otf|woff2?)(\?|#|$)/i;
87
+ const LATIN_A = 0x41;
88
+ function parseStylesheetFaces(css) {
89
+ const faces = [];
90
+ // A CSS @font-face body has no nested braces, so the first `}` ends it.
91
+ for (const chunk of css.split(/@font-face/i).slice(1)) {
92
+ const end = chunk.indexOf("}");
93
+ const body = end === -1 ? chunk : chunk.slice(0, end);
94
+ const urls = [...body.matchAll(/url\((['"]?)([^'")]+)\1\)/g)].map((m) => m[2]);
95
+ const url = urls.find((u) => FONT_FILE.test(u)) ?? urls[0];
96
+ if (!url)
97
+ continue;
98
+ const range = body.match(/unicode-range:\s*([^;}]+)/i)?.[1];
99
+ // No declared range means the face carries everything it has.
100
+ faces.push({
101
+ url,
102
+ coversLatin: range === undefined || rangeCovers(range, LATIN_A),
103
+ });
104
+ }
105
+ if (faces.length > 0)
106
+ return faces;
107
+ // A stylesheet with no @font-face at all (or one shaped unexpectedly): fall
108
+ // back to the old whole-file scan rather than claim the font is unavailable.
109
+ const urls = [...css.matchAll(/url\((['"]?)([^'")]+)\1\)/g)].map((m) => m[2]);
110
+ const url = urls.find((u) => FONT_FILE.test(u)) ?? urls[0];
111
+ return url ? [{ url, coversLatin: true }] : [];
112
+ }
113
+ /** Whether a CSS `unicode-range` value covers a code point. */
114
+ function rangeCovers(spec, code) {
115
+ for (const part of spec.split(",")) {
116
+ const token = part.trim().replace(/^u\+/i, "");
117
+ if (!token)
118
+ continue;
119
+ if (token.includes("?")) {
120
+ // Wildcard form, e.g. `U+00??` — every code point matching the pattern.
121
+ const lo = Number.parseInt(token.replace(/\?/g, "0"), 16);
122
+ const hi = Number.parseInt(token.replace(/\?/g, "F"), 16);
123
+ if (code >= lo && code <= hi)
124
+ return true;
125
+ continue;
126
+ }
127
+ const [from, to] = token.split("-");
128
+ const lo = Number.parseInt(from, 16);
129
+ const hi = to === undefined ? lo : Number.parseInt(to, 16);
130
+ if (Number.isFinite(lo) && code >= lo && code <= hi)
131
+ return true;
132
+ }
133
+ return false;
134
+ }
135
+ async function fetchText(url) {
136
+ const res = await fetch(url, {
137
+ headers: { "user-agent": TTF_UA },
138
+ signal: AbortSignal.timeout(FONT_FETCH_TIMEOUT_MS),
139
+ });
140
+ if (!res.ok)
141
+ throw new Error(`fetch ${url} -> ${res.status}`);
142
+ return res.text();
143
+ }
144
+ async function fetchBytes(url) {
145
+ const res = await fetch(url, {
146
+ headers: { "user-agent": TTF_UA },
147
+ signal: AbortSignal.timeout(FONT_FETCH_TIMEOUT_MS),
148
+ });
149
+ if (!res.ok)
150
+ throw new Error(`fetch ${url} -> ${res.status}`);
151
+ return new Uint8Array(await res.arrayBuffer());
152
+ }
153
+ export function dataUrlToBytes(src) {
154
+ const comma = src.indexOf(",");
155
+ const meta = src.slice(0, comma);
156
+ const data = src.slice(comma + 1);
157
+ if (meta.includes(";base64")) {
158
+ const bin = atob(data);
159
+ const out = new Uint8Array(bin.length);
160
+ for (let i = 0; i < bin.length; i++)
161
+ out[i] = bin.charCodeAt(i);
162
+ return out;
163
+ }
164
+ return new TextEncoder().encode(decodeURIComponent(data));
165
+ }
@@ -0,0 +1,5 @@
1
+ import type { FontVMetrics } from "./types.js";
2
+ export declare function registerFontMetrics(family: string, data: Uint8Array | ArrayBuffer): void;
3
+ export declare function getFontMetrics(family: string): FontVMetrics | undefined;
4
+ export declare function deriveFontMetrics(fonts: Map<string, Uint8Array[]>): Record<string, FontVMetrics>;
5
+ export declare function readFontMetrics(data: Uint8Array | ArrayBuffer): FontVMetrics | null;
@@ -0,0 +1,62 @@
1
+ const registry = new Map();
2
+ export function registerFontMetrics(family, data) {
3
+ const m = readFontMetrics(data);
4
+ if (m)
5
+ registry.set(family, m);
6
+ }
7
+ export function getFontMetrics(family) {
8
+ return registry.get(family);
9
+ }
10
+ // Build the { family: FontVMetrics } map compileScene wants from a family→bytes
11
+ // map (the first face per family), skipping any the reader can't parse. The
12
+ // standard way to derive CompileSceneOptions.fontMetrics when rendering with a
13
+ // bytes-backed font set (headless / CanvasKit paths).
14
+ export function deriveFontMetrics(fonts) {
15
+ const out = {};
16
+ for (const [family, faces] of fonts) {
17
+ const bytes = faces[0];
18
+ if (!bytes)
19
+ continue;
20
+ const m = readFontMetrics(bytes);
21
+ if (m)
22
+ out[family] = m;
23
+ }
24
+ return out;
25
+ }
26
+ // Read OS/2 sTypoAscender/Descender/LineGap + sCapHeight and head.unitsPerEm
27
+ // from an sfnt (ttf/otf) buffer, normalized per em. Returns null on anything
28
+ // unexpected so callers fall back.
29
+ export function readFontMetrics(data) {
30
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
31
+ try {
32
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
33
+ const numTables = dv.getUint16(4);
34
+ let os2 = 0;
35
+ let head = 0;
36
+ for (let i = 0; i < numTables; i++) {
37
+ const o = 12 + i * 16;
38
+ const tag = String.fromCharCode(bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]);
39
+ const off = dv.getUint32(o + 8);
40
+ if (tag === "OS/2")
41
+ os2 = off;
42
+ else if (tag === "head")
43
+ head = off;
44
+ }
45
+ if (!os2 || !head)
46
+ return null;
47
+ const upem = dv.getUint16(head + 18);
48
+ if (!upem)
49
+ return null;
50
+ const version = dv.getUint16(os2);
51
+ return {
52
+ ascent: dv.getInt16(os2 + 68) / upem,
53
+ descent: -dv.getInt16(os2 + 70) / upem,
54
+ lineGap: dv.getInt16(os2 + 72) / upem,
55
+ // sCapHeight exists from OS/2 v2 onward (offset 88).
56
+ capHeight: version >= 2 ? dv.getInt16(os2 + 88) / upem : 0,
57
+ };
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
@@ -0,0 +1,24 @@
1
+ import type { Node } from "./node.js";
2
+ import type { PaintCache } from "./paint-cache.js";
3
+ import type { EncodeOptions } from "./png.js";
4
+ import type { EncodedPaintResult, FontVMetrics, FrameFinish, PaintRuntime } from "./types.js";
5
+ export declare function createHeadlessEnv(opts?: {
6
+ fonts?: Map<string, Uint8Array[]>;
7
+ images?: Map<string, Uint8Array>;
8
+ encode?: EncodeOptions;
9
+ cache?: PaintCache;
10
+ loadImage?: PaintRuntime["loadImage"];
11
+ }): PaintRuntime;
12
+ export declare function renderSceneToPng(node: Node, opts: {
13
+ width: number;
14
+ height: number;
15
+ ck: unknown;
16
+ fonts?: Map<string, Uint8Array[]>;
17
+ images?: Map<string, Uint8Array>;
18
+ fontMetrics?: Record<string, FontVMetrics>;
19
+ leadingTrim?: boolean;
20
+ finish?: FrameFinish;
21
+ encode?: EncodeOptions;
22
+ scale?: number;
23
+ supersample?: number;
24
+ }): Promise<EncodedPaintResult>;
@@ -0,0 +1,58 @@
1
+ import { compileScene } from "./compile-scene.js";
2
+ import { dataUrlToBytes, resolveFontRequest } from "./font-bytes.js";
3
+ import { deriveFontMetrics } from "./font-metrics.js";
4
+ import { createParagraphEngine } from "./paragraph-layout.js";
5
+ import { makeRuntime } from "./runtime.js";
6
+ async function fetchBytes(url) {
7
+ if (url.startsWith("data:"))
8
+ return dataUrlToBytes(url);
9
+ const res = await fetch(url);
10
+ if (!res.ok)
11
+ throw new Error(`fetch ${url} -> ${res.status}`);
12
+ return new Uint8Array(await res.arrayBuffer());
13
+ }
14
+ // A headless runtime: font resolution + image byte I/O, NO canvas host, encode
15
+ // policy. With no host, the paint step renders offscreen via ck.MakeSurface and
16
+ // PaintOutput.encode() reads those pixels back as a PNG (see ./png).
17
+ export function createHeadlessEnv(opts) {
18
+ return makeRuntime({
19
+ cache: opts?.cache,
20
+ ...(opts?.loadImage ? { loadImage: opts.loadImage } : {}),
21
+ resolveFont(req) {
22
+ return resolveFontRequest(req, opts?.fonts);
23
+ },
24
+ async loadImageBytes(src) {
25
+ return opts?.images?.get(src) ?? fetchBytes(src);
26
+ },
27
+ }, "encode", opts?.encode);
28
+ }
29
+ // Compile a Node scene and rasterize it to PNG bytes offscreen — the whole
30
+ // headless path in one call. Derives font metrics from `fonts` (override with
31
+ // `fontMetrics`), builds the CanvasKit Paragraph text engine, compiles + paints,
32
+ // and disposes the engine. `ck` is the caller's CanvasKit-WASM instance.
33
+ export async function renderSceneToPng(node, opts) {
34
+ const fonts = opts.fonts ?? new Map();
35
+ const fontMetrics = opts.fontMetrics ?? deriveFontMetrics(fonts);
36
+ const textEngine = createParagraphEngine(opts.ck, fonts);
37
+ try {
38
+ const commands = compileScene(node, {
39
+ width: opts.width,
40
+ height: opts.height,
41
+ textEngine,
42
+ fontMetrics,
43
+ leadingTrim: opts.leadingTrim,
44
+ finish: opts.finish,
45
+ scale: opts.scale,
46
+ supersample: opts.supersample,
47
+ });
48
+ const env = createHeadlessEnv({
49
+ fonts,
50
+ images: opts.images,
51
+ encode: opts.encode,
52
+ });
53
+ return (await env.paint(commands, opts.ck));
54
+ }
55
+ finally {
56
+ textEngine.dispose();
57
+ }
58
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ export { type AdjustOptions, brightnessMatrix, buildAdjust, type ColorMatrix, concatColorMatrix, contrastMatrix, gammaLut, identityColorMatrix, identityLut3d, saturationMatrix, } from "./adjust.js";
2
+ export { approxEngine } from "./approx-layout.js";
3
+ export { type BakeTextOptions, bakeText, resolveLeadingTrim, textClipOutset, } from "./bake-text.js";
4
+ export { type CompileSceneOptions, compileScene, prepareScene, sceneAssets, } from "./compile-scene.js";
5
+ export { type ColorResolver, oklchToHex, parseCssColor, parseLinearGradient, } from "./css.js";
6
+ export { type DecodedPixels, type DecodeOptions, decodePixels, drawImageToPixels, type ImageBytesLoader, type ImageSampleOptions, type PixelRect, sampleImageNode, } from "./decode.js";
7
+ export { type ExportConstraint, type ExportSetting, exportPixelSize, MAX_EXPORT_DIMENSION, MAX_SUPERSAMPLE, resolveExportScale, resolveSupersample, } from "./export-scale.js";
8
+ export { dataUrlToBytes, fontBytes, resolveFontRequest, } from "./font-bytes.js";
9
+ export { deriveFontMetrics, getFontMetrics, readFontMetrics, registerFontMetrics, } from "./font-metrics.js";
10
+ export { autoLineHeight, FALLBACK_LINE_HEIGHT, type MetricsLookup, metricsLookup, resolveAutoLineHeights, } from "./line-height.js";
11
+ export type { Align, BitmapNode, ChildLayout, EllipseNode, FlexLayout, GridLayout, GroupNode, ImageNode, Justify, Layout, MaskNode, Node, Padding, PathNode, RectNode, TextNode, TrackSize, Transform, } from "./node.js";
12
+ export { autoLayout, createBitmap, createCircle, createEllipse, createFrame, createGroup, createImage, createMask, createPath, createRect, createText, gridLayout, } from "./node.js";
13
+ export { createPaintCache, type PaintCache, type PaintCacheStats, } from "./paint-cache.js";
14
+ export { decorationLine, fitRect, insetCorner, strokeInset, } from "./paint-helpers.js";
15
+ export { createParagraphEngine } from "./paragraph-layout.js";
16
+ export { scalePathData } from "./path-data.js";
17
+ export { flattenOverWhite } from "./jpeg.js";
18
+ export { DEFAULT_JPEG_QUALITY, DEFAULT_WEBP_QUALITY, type EncodeFormat, type EncodeOptions, type EncodePngOptions, encodeDecodedPng, encodePng, type PngEffort, } from "./png.js";
19
+ export { resolveLayout } from "./resolve-layout.js";
20
+ export { squircleSvg } from "./squircle.js";
21
+ export { type CachedTextEngine, memoizeTextEngine, type TextEngineCacheStats, } from "./text-cache.js";
22
+ export type { SpanFont, TextEngine } from "./text-engine.js";
23
+ export type { MeasuredLine, MeasureText, TextLayout, TextLayoutInput, } from "./text-types.js";
24
+ export type * from "./types.js";
25
+ export { type IrIssue, validateCommands } from "./validate-commands.js";
package/src/index.js ADDED
@@ -0,0 +1,29 @@
1
+ // freshcoat — the rendering foundation. Compiles the composable Node IR
2
+ // (via compileScene) to a flat Command list and paints it to pixels via CanvasKit
3
+ // (WASM Skia) — no native canvas anywhere. The paint TARGET (offscreen vs live
4
+ // canvas) lives on its own subpaths — freshcoat/browser | /headless — and
5
+ // each env's `paint(frames, ck)` defaults to the CanvasKit painter, so the barrel
6
+ // stays free of DOM / WASM weight. The backend seam itself is
7
+ // freshcoat/runtime (Painter + makeRuntime); the `Painter` TYPE is
8
+ // re-exported here, since a type costs nothing.
9
+ export { brightnessMatrix, buildAdjust, concatColorMatrix, contrastMatrix, gammaLut, identityColorMatrix, identityLut3d, saturationMatrix, } from "./adjust.js";
10
+ export { approxEngine } from "./approx-layout.js";
11
+ export { bakeText, resolveLeadingTrim, textClipOutset, } from "./bake-text.js";
12
+ export { compileScene, prepareScene, sceneAssets, } from "./compile-scene.js";
13
+ export { oklchToHex, parseCssColor, parseLinearGradient, } from "./css.js";
14
+ export { decodePixels, drawImageToPixels, sampleImageNode, } from "./decode.js";
15
+ export { exportPixelSize, MAX_EXPORT_DIMENSION, MAX_SUPERSAMPLE, resolveExportScale, resolveSupersample, } from "./export-scale.js";
16
+ export { dataUrlToBytes, fontBytes, resolveFontRequest, } from "./font-bytes.js";
17
+ export { deriveFontMetrics, getFontMetrics, readFontMetrics, registerFontMetrics, } from "./font-metrics.js";
18
+ export { autoLineHeight, FALLBACK_LINE_HEIGHT, metricsLookup, resolveAutoLineHeights, } from "./line-height.js";
19
+ export { autoLayout, createBitmap, createCircle, createEllipse, createFrame, createGroup, createImage, createMask, createPath, createRect, createText, gridLayout, } from "./node.js";
20
+ export { createPaintCache, } from "./paint-cache.js";
21
+ export { decorationLine, fitRect, insetCorner, strokeInset, } from "./paint-helpers.js";
22
+ export { createParagraphEngine } from "./paragraph-layout.js";
23
+ export { scalePathData } from "./path-data.js";
24
+ export { flattenOverWhite } from "./jpeg.js";
25
+ export { DEFAULT_JPEG_QUALITY, DEFAULT_WEBP_QUALITY, encodeDecodedPng, encodePng, } from "./png.js";
26
+ export { resolveLayout } from "./resolve-layout.js";
27
+ export { squircleSvg } from "./squircle.js";
28
+ export { memoizeTextEngine, } from "./text-cache.js";
29
+ export { validateCommands } from "./validate-commands.js";
package/src/jpeg.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Composites unpremultiplied RGBA8888 pixels over opaque white, in place, and
3
+ * returns the same buffer with every alpha at 255.
4
+ */
5
+ export declare function flattenOverWhite(pixels: Uint8Array): Uint8Array;
package/src/jpeg.js ADDED
@@ -0,0 +1,20 @@
1
+ // JPEG has no alpha channel. Skia's encoder drops it from premultiplied
2
+ // pixels, so a transparent corner comes out black; a photo export wants what
3
+ // a viewer shows on a page instead, which is white.
4
+ /**
5
+ * Composites unpremultiplied RGBA8888 pixels over opaque white, in place, and
6
+ * returns the same buffer with every alpha at 255.
7
+ */
8
+ export function flattenOverWhite(pixels) {
9
+ for (let i = 0; i < pixels.length; i += 4) {
10
+ const a = pixels[i + 3];
11
+ if (a === 255)
12
+ continue;
13
+ const rest = 255 * (255 - a);
14
+ pixels[i] = ((pixels[i] * a + rest + 127) / 255) | 0;
15
+ pixels[i + 1] = ((pixels[i + 1] * a + rest + 127) / 255) | 0;
16
+ pixels[i + 2] = ((pixels[i + 2] * a + rest + 127) / 255) | 0;
17
+ pixels[i + 3] = 255;
18
+ }
19
+ return pixels;
20
+ }
@@ -0,0 +1,7 @@
1
+ import type { Node } from "./node.js";
2
+ import type { FontVMetrics } from "./types.js";
3
+ export declare const FALLBACK_LINE_HEIGHT = 1.2;
4
+ export declare function autoLineHeight(metrics: FontVMetrics | undefined): number;
5
+ export type MetricsLookup = (family: string) => FontVMetrics | undefined;
6
+ export declare function metricsLookup(explicit?: Record<string, FontVMetrics>, probe?: MetricsLookup): MetricsLookup;
7
+ export declare function resolveAutoLineHeights(root: Node, lookup: MetricsLookup): Node;
@@ -0,0 +1,80 @@
1
+ // Automatic line height — the "AUTO" a design tool shows when nobody typed a
2
+ // number. It is not a constant: it is the FONT's own line box, its typo ascent +
3
+ // descent + line gap. Vend Sans is 1.48; Geist is not; a template that hard-codes
4
+ // 1.2 for both renders neither the way its source did.
5
+ //
6
+ // A font carries `autoLineHeight` when the authored value was AUTO. `lineHeight`
7
+ // still holds a usable number (the fallback below), so every consumer that never
8
+ // learned about this — a bare resolveLayout, the approx engine, an older
9
+ // painter — keeps laying text out instead of producing NaN. compileScene
10
+ // substitutes the real value once, before layout and baking, for the families it
11
+ // has metrics for.
12
+ import { getFontMetrics } from "./font-metrics.js";
13
+ // What an unresolvable AUTO falls back to: the value coatfile has always
14
+ // written for it, so a family with no metrics renders exactly as it did before.
15
+ export const FALLBACK_LINE_HEIGHT = 1.2;
16
+ // A font's own line box, per em. The sum a design tool uses for AUTO.
17
+ export function autoLineHeight(metrics) {
18
+ if (!metrics)
19
+ return FALLBACK_LINE_HEIGHT;
20
+ const height = metrics.ascent + metrics.descent + metrics.lineGap;
21
+ return height > 0 ? height : FALLBACK_LINE_HEIGHT;
22
+ }
23
+ export function metricsLookup(explicit, probe) {
24
+ return (family) => explicit?.[family] ?? getFontMetrics(family) ?? probe?.(family);
25
+ }
26
+ // Replace every AUTO line height in the tree with the font's own. Returns the
27
+ // same node when nothing is marked AUTO, so a tree that never used it is not
28
+ // copied. Idempotent: a resolved font is no longer marked.
29
+ export function resolveAutoLineHeights(root, lookup) {
30
+ return hasAuto(root) ? rewrite(root, lookup) : root;
31
+ }
32
+ function hasAuto(node) {
33
+ switch (node.kind) {
34
+ case "text":
35
+ return (!!node.font.autoLineHeight ||
36
+ !!node.spans?.some((s) => s.font?.autoLineHeight));
37
+ case "group":
38
+ return node.children.some(hasAuto);
39
+ case "mask":
40
+ return hasAuto(node.mask) || node.children.some(hasAuto);
41
+ default:
42
+ return false;
43
+ }
44
+ }
45
+ function rewrite(node, lookup) {
46
+ switch (node.kind) {
47
+ case "text":
48
+ return rewriteText(node, lookup);
49
+ case "group":
50
+ return {
51
+ ...node,
52
+ children: node.children.map((c) => rewrite(c, lookup)),
53
+ };
54
+ case "mask":
55
+ return {
56
+ ...node,
57
+ mask: rewrite(node.mask, lookup),
58
+ children: node.children.map((c) => rewrite(c, lookup)),
59
+ };
60
+ default:
61
+ return node;
62
+ }
63
+ }
64
+ function rewriteText(node, lookup) {
65
+ const font = resolveFont(node.font, node.font.family, lookup);
66
+ const spans = node.spans?.map((span) => span.font
67
+ ? {
68
+ ...span,
69
+ // A span inherits the element's family unless it names its own.
70
+ font: resolveFont(span.font, span.font.family ?? node.font.family, lookup),
71
+ }
72
+ : span);
73
+ return { ...node, font, ...(spans ? { spans } : {}) };
74
+ }
75
+ function resolveFont(font, family, lookup) {
76
+ if (!font.autoLineHeight)
77
+ return font;
78
+ const { autoLineHeight: _, ...rest } = font;
79
+ return { ...rest, lineHeight: autoLineHeight(lookup(family)) };
80
+ }