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,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>;
|
package/src/headless.js
ADDED
|
@@ -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
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
|
+
}
|