@arraypress/covers 0.0.0-stage → 1.0.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/CHANGELOG.md +50 -0
- package/LICENSE +29 -0
- package/README.md +561 -2
- package/fonts/OFL.txt +93 -0
- package/fonts/inter-latin-400.woff +0 -0
- package/fonts/inter-latin-500.woff +0 -0
- package/fonts/inter-latin-600.woff +0 -0
- package/fonts/inter-latin-700.woff +0 -0
- package/package.json +96 -4
- package/recipes/aurora.json +132 -0
- package/recipes/bauhaus.json +113 -0
- package/recipes/bigType.json +103 -0
- package/recipes/contour.json +109 -0
- package/recipes/cutout.json +191 -0
- package/recipes/diagram.json +92 -0
- package/recipes/dither.json +170 -0
- package/recipes/duotone.json +75 -0
- package/recipes/gridTitle.json +136 -0
- package/recipes/iconTile.json +214 -0
- package/recipes/issue.json +221 -0
- package/recipes/mark.json +170 -0
- package/recipes/photo.json +156 -0
- package/recipes/riso.json +387 -0
- package/recipes/stripes.json +206 -0
- package/recipes/waveform.json +154 -0
- package/schema/recipe.schema.json +2292 -0
- package/src/astro.d.ts +31 -0
- package/src/astro.js +86 -0
- package/src/color.js +246 -0
- package/src/diagram.js +248 -0
- package/src/extract.js +90 -0
- package/src/flatten.js +234 -0
- package/src/fonts.js +200 -0
- package/src/geometry.js +187 -0
- package/src/grid.js +41 -0
- package/src/handmade.js +253 -0
- package/src/icons.js +157 -0
- package/src/index.d.ts +897 -0
- package/src/index.js +19 -0
- package/src/json-recipe.js +458 -0
- package/src/layer-options.js +37 -0
- package/src/layers.js +972 -0
- package/src/palette.js +185 -0
- package/src/png.js +77 -0
- package/src/print.js +658 -0
- package/src/recipes.js +78 -0
- package/src/render.js +462 -0
- package/src/rng.js +95 -0
- package/src/svg.js +69 -0
- package/src/text.js +230 -0
package/src/extract.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A palette from a photo: k-means in OKLab over a small thumbnail.
|
|
3
|
+
*
|
|
4
|
+
* @module extract
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { toOklch, fromOklch, toHex } from './color.js';
|
|
9
|
+
import { createRng } from './rng.js';
|
|
10
|
+
|
|
11
|
+
/** @type {Map<string, Promise<string[]>>} */
|
|
12
|
+
const cache = new Map();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The dominant colours of an image, most common first.
|
|
16
|
+
*
|
|
17
|
+
* Decodes with sharp (an optional peer dependency) to a 64×64 thumbnail,
|
|
18
|
+
* clusters its pixels in OKLab (k-means, seeded so the result is stable),
|
|
19
|
+
* drops clusters too small to matter, prefers chromatic colours (a nearly
|
|
20
|
+
* grey cluster is kept only when there aren't enough others) and skips
|
|
21
|
+
* near-duplicate shades while distinct colours remain.
|
|
22
|
+
*
|
|
23
|
+
* @param {string | Uint8Array} src - A file path or image bytes.
|
|
24
|
+
* @param {{ count?: number }} [options] - How many colours (default 4).
|
|
25
|
+
* @returns {Promise<string[]>} `#rrggbb` colours.
|
|
26
|
+
* @throws {Error} Without sharp, or on an unreadable image.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* await extractPalette('cover.jpg'); // ['#2f5d8a', '#e0b25a', …]
|
|
30
|
+
*/
|
|
31
|
+
export function extractPalette(src, options = {}) {
|
|
32
|
+
const count = options.count ?? 4;
|
|
33
|
+
const key = typeof src === 'string' ? `${src}:${count}` : null;
|
|
34
|
+
if (key && cache.has(key)) return /** @type {Promise<string[]>} */ (cache.get(key));
|
|
35
|
+
const job = (async () => {
|
|
36
|
+
let sharp;
|
|
37
|
+
try {
|
|
38
|
+
sharp = (await import('sharp')).default;
|
|
39
|
+
} catch {
|
|
40
|
+
throw new Error('Extracting a palette from a photo needs sharp: npm install sharp');
|
|
41
|
+
}
|
|
42
|
+
const bytes = typeof src === 'string' ? await readFile(src) : src;
|
|
43
|
+
const { data } = await sharp(bytes).rotate().resize(64, 64, { fit: 'cover' }).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
44
|
+
/** @type {[number, number, number][]} */
|
|
45
|
+
const pts = [];
|
|
46
|
+
for (let i = 0; i < data.length; i += 3) {
|
|
47
|
+
const hex = '#' + [data[i], data[i + 1], data[i + 2]].map((v) => v.toString(16).padStart(2, '0')).join('');
|
|
48
|
+
const { l, c, h } = toOklch(hex);
|
|
49
|
+
pts.push([l, c * Math.cos((h * Math.PI) / 180), c * Math.sin((h * Math.PI) / 180)]);
|
|
50
|
+
}
|
|
51
|
+
const k = Math.min(8, Math.max(count + 2, 5));
|
|
52
|
+
const rng = createRng('extract');
|
|
53
|
+
let centres = rng.shuffle(pts).slice(0, k).map((p) => p.slice());
|
|
54
|
+
const assign = new Int32Array(pts.length);
|
|
55
|
+
for (let it = 0; it < 18; it++) {
|
|
56
|
+
for (let i = 0; i < pts.length; i++) {
|
|
57
|
+
let best = 0;
|
|
58
|
+
let bd = Infinity;
|
|
59
|
+
for (let j = 0; j < centres.length; j++) {
|
|
60
|
+
const d = (pts[i][0] - centres[j][0]) ** 2 + (pts[i][1] - centres[j][1]) ** 2 + (pts[i][2] - centres[j][2]) ** 2;
|
|
61
|
+
if (d < bd) { bd = d; best = j; }
|
|
62
|
+
}
|
|
63
|
+
assign[i] = best;
|
|
64
|
+
}
|
|
65
|
+
centres = centres.map((c, j) => {
|
|
66
|
+
let n = 0;
|
|
67
|
+
const s = [0, 0, 0];
|
|
68
|
+
for (let i = 0; i < pts.length; i++) if (assign[i] === j) { n++; s[0] += pts[i][0]; s[1] += pts[i][1]; s[2] += pts[i][2]; }
|
|
69
|
+
return n ? s.map((v) => v / n) : c;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const sizes = centres.map((_, j) => assign.filter((a) => a === j).length);
|
|
73
|
+
const clusters = centres
|
|
74
|
+
.map((c, j) => ({ c, n: sizes[j], chroma: Math.hypot(c[1], c[2]) }))
|
|
75
|
+
.filter((x) => x.n > pts.length * 0.03)
|
|
76
|
+
.sort((a, b) => b.n - a.n);
|
|
77
|
+
const vivid = clusters.filter((x) => x.chroma > 0.035);
|
|
78
|
+
// Skip near-duplicates (shades of one colour) while others remain.
|
|
79
|
+
const ordered = [...vivid, ...clusters.filter((x) => x.chroma <= 0.035)];
|
|
80
|
+
const chosen = [];
|
|
81
|
+
for (const x of ordered) {
|
|
82
|
+
if (chosen.every((y) => Math.hypot(x.c[0] - y.c[0], x.c[1] - y.c[1], x.c[2] - y.c[2]) > 0.09)) chosen.push(x);
|
|
83
|
+
}
|
|
84
|
+
for (const x of ordered) if (chosen.length < count && !chosen.includes(x)) chosen.push(x);
|
|
85
|
+
chosen.length = Math.min(chosen.length, count);
|
|
86
|
+
return chosen.map(({ c }) => toHex(fromOklch({ l: c[0], c: Math.hypot(c[1], c[2]), h: ((Math.atan2(c[2], c[1]) * 180) / Math.PI + 360) % 360 })));
|
|
87
|
+
})();
|
|
88
|
+
if (key) cache.set(key, job);
|
|
89
|
+
return job;
|
|
90
|
+
}
|
package/src/flatten.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flatten SVG shapes to polylines, so they can be redrawn by hand (jittered
|
|
3
|
+
* and stroked unevenly) by the `scribble` layer.
|
|
4
|
+
*
|
|
5
|
+
* Reads the elements an icon set uses: `path` (every command, including
|
|
6
|
+
* arcs, absolute and relative), `circle`, `ellipse`, `rect` (with `rx`),
|
|
7
|
+
* `line`, `polyline` and `polygon`.
|
|
8
|
+
*
|
|
9
|
+
* @module flatten
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @typedef {[number, number][]} Polyline */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Flatten an icon body to polylines, in its own units.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} body - SVG markup.
|
|
18
|
+
* @param {number} [step=0.6] - Target segment length, in the icon's units.
|
|
19
|
+
* @returns {{ lines: Polyline[], closed: boolean[] }}
|
|
20
|
+
*/
|
|
21
|
+
export function flattenBody(body, step = 0.6) {
|
|
22
|
+
/** @type {Polyline[]} */
|
|
23
|
+
const lines = [];
|
|
24
|
+
/** @type {boolean[]} */
|
|
25
|
+
const closed = [];
|
|
26
|
+
const attr = (/** @type {string} */ tag, /** @type {string} */ name) => {
|
|
27
|
+
const m = tag.match(new RegExp(`\\s${name}\\s*=\\s*["']([^"']*)["']`));
|
|
28
|
+
return m ? m[1] : undefined;
|
|
29
|
+
};
|
|
30
|
+
const numAttr = (/** @type {string} */ tag, /** @type {string} */ name, d = 0) => parseFloat(attr(tag, name) ?? `${d}`) || d;
|
|
31
|
+
for (const m of body.matchAll(/<(path|circle|ellipse|rect|line|polyline|polygon)\b[^>]*>/g)) {
|
|
32
|
+
const tag = m[0];
|
|
33
|
+
const kind = m[1];
|
|
34
|
+
if (kind === 'path') {
|
|
35
|
+
for (const sub of flattenPath(attr(tag, 'd') ?? '', step)) {
|
|
36
|
+
lines.push(sub.points);
|
|
37
|
+
closed.push(sub.closed);
|
|
38
|
+
}
|
|
39
|
+
} else if (kind === 'circle' || kind === 'ellipse') {
|
|
40
|
+
const cx = numAttr(tag, 'cx');
|
|
41
|
+
const cy = numAttr(tag, 'cy');
|
|
42
|
+
const rx = kind === 'circle' ? numAttr(tag, 'r') : numAttr(tag, 'rx');
|
|
43
|
+
const ry = kind === 'circle' ? rx : numAttr(tag, 'ry');
|
|
44
|
+
const n = Math.max(12, Math.ceil((2 * Math.PI * Math.max(rx, ry)) / step));
|
|
45
|
+
lines.push(Array.from({ length: n + 1 }, (_, i) => [cx + rx * Math.cos((i / n) * 2 * Math.PI), cy + ry * Math.sin((i / n) * 2 * Math.PI)]));
|
|
46
|
+
closed.push(true);
|
|
47
|
+
} else if (kind === 'rect') {
|
|
48
|
+
const x = numAttr(tag, 'x');
|
|
49
|
+
const y = numAttr(tag, 'y');
|
|
50
|
+
const w = numAttr(tag, 'width');
|
|
51
|
+
const h = numAttr(tag, 'height');
|
|
52
|
+
const r = Math.min(numAttr(tag, 'rx', numAttr(tag, 'ry')), w / 2, h / 2);
|
|
53
|
+
const d = r > 0
|
|
54
|
+
? `M${x + r} ${y}H${x + w - r}A${r} ${r} 0 0 1 ${x + w} ${y + r}V${y + h - r}A${r} ${r} 0 0 1 ${x + w - r} ${y + h}H${x + r}A${r} ${r} 0 0 1 ${x} ${y + h - r}V${y + r}A${r} ${r} 0 0 1 ${x + r} ${y}Z`
|
|
55
|
+
: `M${x} ${y}H${x + w}V${y + h}H${x}Z`;
|
|
56
|
+
for (const sub of flattenPath(d, step)) {
|
|
57
|
+
lines.push(sub.points);
|
|
58
|
+
closed.push(true);
|
|
59
|
+
}
|
|
60
|
+
} else if (kind === 'line') {
|
|
61
|
+
lines.push(densify([[numAttr(tag, 'x1'), numAttr(tag, 'y1')], [numAttr(tag, 'x2'), numAttr(tag, 'y2')]], step));
|
|
62
|
+
closed.push(false);
|
|
63
|
+
} else {
|
|
64
|
+
const nums = (attr(tag, 'points') ?? '').split(/[\s,]+/).filter(Boolean).map(Number);
|
|
65
|
+
/** @type {Polyline} */
|
|
66
|
+
const pts = [];
|
|
67
|
+
for (let i = 0; i + 1 < nums.length; i += 2) pts.push([nums[i], nums[i + 1]]);
|
|
68
|
+
if (kind === 'polygon' && pts.length) pts.push(pts[0]);
|
|
69
|
+
lines.push(densify(pts, step));
|
|
70
|
+
closed.push(kind === 'polygon');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { lines: lines.filter((l) => l.length > 1), closed };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Add points along straight segments so no segment is longer than `step`.
|
|
78
|
+
*
|
|
79
|
+
* @param {Polyline} pts
|
|
80
|
+
* @param {number} step
|
|
81
|
+
* @returns {Polyline}
|
|
82
|
+
*/
|
|
83
|
+
export function densify(pts, step) {
|
|
84
|
+
/** @type {Polyline} */
|
|
85
|
+
const out = [];
|
|
86
|
+
for (let i = 0; i < pts.length; i++) {
|
|
87
|
+
if (i === 0) { out.push(pts[0]); continue; }
|
|
88
|
+
const [ax, ay] = pts[i - 1];
|
|
89
|
+
const [bx, by] = pts[i];
|
|
90
|
+
const n = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay) / step));
|
|
91
|
+
for (let k = 1; k <= n; k++) out.push([ax + ((bx - ax) * k) / n, ay + ((by - ay) * k) / n]);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Flatten path data into sub-paths.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} d - Path data.
|
|
100
|
+
* @param {number} step - Target segment length.
|
|
101
|
+
* @returns {{ points: Polyline, closed: boolean }[]}
|
|
102
|
+
*/
|
|
103
|
+
export function flattenPath(d, step) {
|
|
104
|
+
const tokens = d.match(/[a-zA-Z]|-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/g) ?? [];
|
|
105
|
+
/** @type {{ points: Polyline, closed: boolean }[]} */
|
|
106
|
+
const subs = [];
|
|
107
|
+
/** @type {Polyline} */
|
|
108
|
+
let cur = [];
|
|
109
|
+
let x = 0;
|
|
110
|
+
let y = 0;
|
|
111
|
+
let sx = 0;
|
|
112
|
+
let sy = 0;
|
|
113
|
+
let cmd = '';
|
|
114
|
+
let lastCtrl = /** @type {[number, number] | null} */ (null);
|
|
115
|
+
let lastQ = /** @type {[number, number] | null} */ (null);
|
|
116
|
+
let i = 0;
|
|
117
|
+
const next = () => parseFloat(tokens[i++]);
|
|
118
|
+
const isNum = () => i < tokens.length && !/^[a-zA-Z]$/.test(tokens[i]);
|
|
119
|
+
const flush = (closedPath = false) => {
|
|
120
|
+
if (cur.length > 1) subs.push({ points: cur, closed: closedPath });
|
|
121
|
+
cur = [];
|
|
122
|
+
};
|
|
123
|
+
const lineTo = (nx, ny) => {
|
|
124
|
+
const seg = densify([[x, y], [nx, ny]], step);
|
|
125
|
+
cur.push(...seg.slice(1));
|
|
126
|
+
x = nx; y = ny;
|
|
127
|
+
};
|
|
128
|
+
const cubic = (x1, y1, x2, y2, nx, ny) => {
|
|
129
|
+
const n = Math.max(4, Math.ceil((Math.hypot(x1 - x, y1 - y) + Math.hypot(x2 - x1, y2 - y1) + Math.hypot(nx - x2, ny - y2)) / step));
|
|
130
|
+
for (let k = 1; k <= n; k++) {
|
|
131
|
+
const t = k / n;
|
|
132
|
+
const u = 1 - t;
|
|
133
|
+
cur.push([u * u * u * x + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * nx, u * u * u * y + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * ny]);
|
|
134
|
+
}
|
|
135
|
+
lastCtrl = [x2, y2];
|
|
136
|
+
x = nx; y = ny;
|
|
137
|
+
};
|
|
138
|
+
const quad = (x1, y1, nx, ny) => {
|
|
139
|
+
const n = Math.max(4, Math.ceil((Math.hypot(x1 - x, y1 - y) + Math.hypot(nx - x1, ny - y1)) / step));
|
|
140
|
+
for (let k = 1; k <= n; k++) {
|
|
141
|
+
const t = k / n;
|
|
142
|
+
const u = 1 - t;
|
|
143
|
+
cur.push([u * u * x + 2 * u * t * x1 + t * t * nx, u * u * y + 2 * u * t * y1 + t * t * ny]);
|
|
144
|
+
}
|
|
145
|
+
lastQ = [x1, y1];
|
|
146
|
+
x = nx; y = ny;
|
|
147
|
+
};
|
|
148
|
+
const arc = (rx, ry, rot, large, sweep, nx, ny) => {
|
|
149
|
+
// Endpoint to centre parameterisation (SVG implementation notes, F.6.5).
|
|
150
|
+
if (rx === 0 || ry === 0) return lineTo(nx, ny);
|
|
151
|
+
rx = Math.abs(rx); ry = Math.abs(ry);
|
|
152
|
+
const phi = (rot * Math.PI) / 180;
|
|
153
|
+
const cos = Math.cos(phi);
|
|
154
|
+
const sin = Math.sin(phi);
|
|
155
|
+
const dx = (x - nx) / 2;
|
|
156
|
+
const dy = (y - ny) / 2;
|
|
157
|
+
const x1p = cos * dx + sin * dy;
|
|
158
|
+
const y1p = -sin * dx + cos * dy;
|
|
159
|
+
const lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
|
|
160
|
+
if (lambda > 1) { rx *= Math.sqrt(lambda); ry *= Math.sqrt(lambda); }
|
|
161
|
+
const sign = large === sweep ? -1 : 1;
|
|
162
|
+
const num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
|
|
163
|
+
const coef = sign * Math.sqrt(Math.max(0, num / (rx * rx * y1p * y1p + ry * ry * x1p * x1p)));
|
|
164
|
+
const cxp = (coef * rx * y1p) / ry;
|
|
165
|
+
const cyp = (-coef * ry * x1p) / rx;
|
|
166
|
+
const cx = cos * cxp - sin * cyp + (x + nx) / 2;
|
|
167
|
+
const cy = sin * cxp + cos * cyp + (y + ny) / 2;
|
|
168
|
+
const angle = (ux, uy, vx, vy) => {
|
|
169
|
+
const a = Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
|
|
170
|
+
return a;
|
|
171
|
+
};
|
|
172
|
+
const t1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
|
|
173
|
+
let dt = angle((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry);
|
|
174
|
+
if (!sweep && dt > 0) dt -= 2 * Math.PI;
|
|
175
|
+
if (sweep && dt < 0) dt += 2 * Math.PI;
|
|
176
|
+
const n = Math.max(4, Math.ceil((Math.abs(dt) * Math.max(rx, ry)) / step));
|
|
177
|
+
for (let k = 1; k <= n; k++) {
|
|
178
|
+
const t = t1 + (dt * k) / n;
|
|
179
|
+
cur.push([cx + rx * Math.cos(t) * cos - ry * Math.sin(t) * sin, cy + rx * Math.cos(t) * sin + ry * Math.sin(t) * cos]);
|
|
180
|
+
}
|
|
181
|
+
x = nx; y = ny;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
while (i < tokens.length) {
|
|
185
|
+
if (!isNum()) cmd = tokens[i++];
|
|
186
|
+
const rel = cmd === cmd.toLowerCase();
|
|
187
|
+
const C = cmd.toUpperCase();
|
|
188
|
+
const ox = rel ? x : 0;
|
|
189
|
+
const oy = rel ? y : 0;
|
|
190
|
+
if (C !== 'C' && C !== 'S') lastCtrl = null;
|
|
191
|
+
if (C !== 'Q' && C !== 'T') lastQ = null;
|
|
192
|
+
switch (C) {
|
|
193
|
+
case 'M': {
|
|
194
|
+
flush();
|
|
195
|
+
x = ox + next(); y = oy + next();
|
|
196
|
+
sx = x; sy = y;
|
|
197
|
+
cur = [[x, y]];
|
|
198
|
+
cmd = rel ? 'l' : 'L';
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case 'L': lineTo(ox + next(), oy + next()); break;
|
|
202
|
+
case 'H': lineTo(ox + next(), y); break;
|
|
203
|
+
case 'V': lineTo(x, (rel ? y : 0) + next()); break;
|
|
204
|
+
case 'C': { const a = [ox + next(), oy + next(), ox + next(), oy + next(), ox + next(), oy + next()]; cubic(a[0], a[1], a[2], a[3], a[4], a[5]); break; }
|
|
205
|
+
case 'S': {
|
|
206
|
+
const [rx1, ry1] = lastCtrl ? [2 * x - lastCtrl[0], 2 * y - lastCtrl[1]] : [x, y];
|
|
207
|
+
const a = [ox + next(), oy + next(), ox + next(), oy + next()];
|
|
208
|
+
cubic(rx1, ry1, a[0], a[1], a[2], a[3]);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case 'Q': { const a = [ox + next(), oy + next(), ox + next(), oy + next()]; quad(a[0], a[1], a[2], a[3]); break; }
|
|
212
|
+
case 'T': {
|
|
213
|
+
const [qx, qy] = lastQ ? [2 * x - lastQ[0], 2 * y - lastQ[1]] : [x, y];
|
|
214
|
+
quad(qx, qy, ox + next(), oy + next());
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case 'A': {
|
|
218
|
+
const a = [next(), next(), next(), next(), next()];
|
|
219
|
+
arc(a[0], a[1], a[2], a[3] ? 1 : 0, a[4] ? 1 : 0, ox + next(), oy + next());
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case 'Z': {
|
|
223
|
+
lineTo(sx, sy);
|
|
224
|
+
flush(true);
|
|
225
|
+
cur = [[sx, sy]];
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
default:
|
|
229
|
+
i++;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
flush();
|
|
233
|
+
return subs;
|
|
234
|
+
}
|
package/src/fonts.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fonts: loading, decoding and picking a face for a text layer.
|
|
3
|
+
*
|
|
4
|
+
* Text is shaped by HarfBuzz (`harfbuzzjs`, WebAssembly) and drawn as
|
|
5
|
+
* outlines, so the rendered cover never depends on fonts installed on the
|
|
6
|
+
* build machine. Inter (Latin, 400/500/600/700, about 120 KB of WOFF) ships
|
|
7
|
+
* in `fonts/` and is always available as the family `Inter`.
|
|
8
|
+
*
|
|
9
|
+
* Accepted formats: TrueType/OpenType (`.ttf`/`.otf`) and WOFF (`.woff`).
|
|
10
|
+
* WOFF2 is rejected with a clear error: it needs Brotli plus glyph-table
|
|
11
|
+
* reconstruction, which isn't worth carrying for a build tool.
|
|
12
|
+
*
|
|
13
|
+
* @module fonts
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { inflateSync } from 'node:zlib';
|
|
18
|
+
|
|
19
|
+
/** @type {Promise<typeof import('harfbuzzjs')> | undefined} */
|
|
20
|
+
let hbPromise;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load HarfBuzz once (its WebAssembly initialises on import, so it's
|
|
24
|
+
* imported lazily: covers with no text never pay for it).
|
|
25
|
+
*
|
|
26
|
+
* @returns {Promise<typeof import('harfbuzzjs')>} The harfbuzzjs module.
|
|
27
|
+
*/
|
|
28
|
+
export function harfbuzz() {
|
|
29
|
+
hbPromise ??= import('harfbuzzjs');
|
|
30
|
+
return hbPromise;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Bundled weights of Inter. */
|
|
34
|
+
const BUNDLED = [400, 500, 600, 700];
|
|
35
|
+
|
|
36
|
+
/** @type {import('./index.js').FontSource[] | undefined} */
|
|
37
|
+
let bundled;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The bundled Inter faces, read from disk once per process.
|
|
41
|
+
*
|
|
42
|
+
* @returns {import('./index.js').FontSource[]} Inter 400/500/600/700.
|
|
43
|
+
*/
|
|
44
|
+
export function bundledFonts() {
|
|
45
|
+
bundled ??= BUNDLED.map((weight) => ({
|
|
46
|
+
name: 'Inter',
|
|
47
|
+
weight,
|
|
48
|
+
style: 'normal',
|
|
49
|
+
// A URL, not `.pathname`: a path with a space would be percent-encoded.
|
|
50
|
+
data: readFileSync(new URL(`../fonts/inter-latin-${weight}.woff`, import.meta.url)),
|
|
51
|
+
}));
|
|
52
|
+
return bundled;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Turn font bytes into a bare sfnt (TrueType/OpenType) HarfBuzz can read.
|
|
57
|
+
*
|
|
58
|
+
* @param {Uint8Array | ArrayBuffer} input - `.ttf`, `.otf` or `.woff` bytes.
|
|
59
|
+
* @returns {Uint8Array} sfnt bytes.
|
|
60
|
+
* @throws {TypeError} On WOFF2 or bytes that aren't a font.
|
|
61
|
+
*/
|
|
62
|
+
export function decodeFont(input) {
|
|
63
|
+
const buf = input instanceof Uint8Array ? input : new Uint8Array(input);
|
|
64
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
65
|
+
if (buf.byteLength < 12) throw new TypeError('Font data is too short to be a font');
|
|
66
|
+
const tag = view.getUint32(0);
|
|
67
|
+
if (tag === 0x774f4632) throw new TypeError('WOFF2 fonts are not supported: pass a .woff, .ttf or .otf');
|
|
68
|
+
if (tag !== 0x774f4646) {
|
|
69
|
+
// 0x00010000 TrueType, 'OTTO' CFF, 'true' old Apple TrueType.
|
|
70
|
+
if (tag === 0x00010000 || tag === 0x4f54544f || tag === 0x74727565) return buf;
|
|
71
|
+
throw new TypeError('Font data is not TrueType, OpenType or WOFF');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const flavor = view.getUint32(4);
|
|
75
|
+
const count = view.getUint16(12);
|
|
76
|
+
const tables = [];
|
|
77
|
+
for (let i = 0; i < count; i++) {
|
|
78
|
+
const o = 44 + i * 20;
|
|
79
|
+
tables.push({
|
|
80
|
+
tag: view.getUint32(o),
|
|
81
|
+
offset: view.getUint32(o + 4),
|
|
82
|
+
compLength: view.getUint32(o + 8),
|
|
83
|
+
origLength: view.getUint32(o + 12),
|
|
84
|
+
checksum: view.getUint32(o + 16),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
let pow = 1;
|
|
88
|
+
let log = 0;
|
|
89
|
+
while (pow * 2 <= count) { pow *= 2; log++; }
|
|
90
|
+
|
|
91
|
+
const headerSize = 12 + 16 * count;
|
|
92
|
+
const pad = (n) => (n + 3) & ~3;
|
|
93
|
+
const total = tables.reduce((sum, t) => sum + pad(t.origLength), headerSize);
|
|
94
|
+
const out = new Uint8Array(total);
|
|
95
|
+
const ov = new DataView(out.buffer);
|
|
96
|
+
ov.setUint32(0, flavor);
|
|
97
|
+
ov.setUint16(4, count);
|
|
98
|
+
ov.setUint16(6, pow * 16);
|
|
99
|
+
ov.setUint16(8, log);
|
|
100
|
+
ov.setUint16(10, count * 16 - pow * 16);
|
|
101
|
+
|
|
102
|
+
let offset = headerSize;
|
|
103
|
+
tables.forEach((t, i) => {
|
|
104
|
+
const src = buf.subarray(t.offset, t.offset + t.compLength);
|
|
105
|
+
const data = t.compLength < t.origLength ? inflateSync(src) : src;
|
|
106
|
+
out.set(data, offset);
|
|
107
|
+
const rec = 12 + i * 16;
|
|
108
|
+
ov.setUint32(rec, t.tag);
|
|
109
|
+
ov.setUint32(rec + 4, t.checksum);
|
|
110
|
+
ov.setUint32(rec + 8, offset);
|
|
111
|
+
ov.setUint32(rec + 12, t.origLength);
|
|
112
|
+
offset += pad(t.origLength);
|
|
113
|
+
});
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A loaded face, ready to shape with.
|
|
119
|
+
*
|
|
120
|
+
* @typedef {object} LoadedFont
|
|
121
|
+
* @property {string} name - Family name.
|
|
122
|
+
* @property {number} weight - Weight.
|
|
123
|
+
* @property {string} style - `'normal'` or `'italic'`.
|
|
124
|
+
* @property {any} font - The harfbuzzjs `Font`.
|
|
125
|
+
* @property {number} upem - Units per em.
|
|
126
|
+
* @property {number} ascender - In font units.
|
|
127
|
+
* @property {number} descender - In font units (negative).
|
|
128
|
+
* @property {number} capHeight - In font units.
|
|
129
|
+
* @property {Map<number, (string | number)[]>} paths - Glyph outline cache.
|
|
130
|
+
*/
|
|
131
|
+
|
|
132
|
+
/** @type {WeakMap<object, Promise<LoadedFont>>} */
|
|
133
|
+
const loaded = new WeakMap();
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Load one font source (cached per data buffer, so the bundled faces and a
|
|
137
|
+
* caller's reused buffers are decoded once per process).
|
|
138
|
+
*
|
|
139
|
+
* @param {import('./index.js').FontSource} source - The font.
|
|
140
|
+
* @returns {Promise<LoadedFont>} The loaded face.
|
|
141
|
+
*/
|
|
142
|
+
export function loadFont(source) {
|
|
143
|
+
const key = /** @type {object} */ (source.data);
|
|
144
|
+
let p = loaded.get(key);
|
|
145
|
+
if (!p) {
|
|
146
|
+
p = (async () => {
|
|
147
|
+
const hb = await harfbuzz();
|
|
148
|
+
const face = new hb.Face(new hb.Blob(decodeFont(source.data)));
|
|
149
|
+
const font = new hb.Font(face);
|
|
150
|
+
const ext = font.hExtents();
|
|
151
|
+
const cap = font.getMetricPosition?.(hb.MetricsTag.CAP_HEIGHT);
|
|
152
|
+
const upem = face.upem;
|
|
153
|
+
return {
|
|
154
|
+
name: source.name,
|
|
155
|
+
weight: source.weight ?? 400,
|
|
156
|
+
style: source.style ?? 'normal',
|
|
157
|
+
font,
|
|
158
|
+
upem,
|
|
159
|
+
ascender: ext.ascender,
|
|
160
|
+
descender: ext.descender,
|
|
161
|
+
capHeight: cap || capFromGlyph(font) || upem * 0.7,
|
|
162
|
+
paths: new Map(),
|
|
163
|
+
};
|
|
164
|
+
})();
|
|
165
|
+
loaded.set(key, p);
|
|
166
|
+
}
|
|
167
|
+
// The same bytes can be registered under another name or weight.
|
|
168
|
+
return p.then((f) => ({ ...f, name: source.name, weight: source.weight ?? 400, style: source.style ?? 'normal' }));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The height of `H` when the font has no OS/2 cap height.
|
|
173
|
+
*
|
|
174
|
+
* @param {any} font - A harfbuzzjs Font.
|
|
175
|
+
* @returns {number | undefined} Cap height in font units.
|
|
176
|
+
*/
|
|
177
|
+
function capFromGlyph(font) {
|
|
178
|
+
const gid = font.nominalGlyph?.(0x48);
|
|
179
|
+
const ext = gid != null ? font.glyphExtents?.(gid) : undefined;
|
|
180
|
+
return ext ? ext.yBearing : undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Pick the face for a family, weight and style: the closest weight within
|
|
185
|
+
* the family (CSS's matching rule, simplified), falling back to Inter.
|
|
186
|
+
*
|
|
187
|
+
* @param {import('./index.js').FontSource[]} sources - The available fonts.
|
|
188
|
+
* @param {string} family - Wanted family.
|
|
189
|
+
* @param {number} weight - Wanted weight.
|
|
190
|
+
* @param {string} [style='normal'] - Wanted style.
|
|
191
|
+
* @returns {import('./index.js').FontSource} The chosen source.
|
|
192
|
+
*/
|
|
193
|
+
export function pickFont(sources, family, weight, style = 'normal') {
|
|
194
|
+
let pool = sources.filter((s) => s.name.toLowerCase() === family.toLowerCase());
|
|
195
|
+
if (!pool.length) pool = sources.filter((s) => s.name === 'Inter');
|
|
196
|
+
if (!pool.length) pool = sources;
|
|
197
|
+
const styled = pool.filter((s) => (s.style ?? 'normal') === style);
|
|
198
|
+
if (styled.length) pool = styled;
|
|
199
|
+
return pool.reduce((best, s) => (Math.abs((s.weight ?? 400) - weight) < Math.abs((best.weight ?? 400) - weight) ? s : best));
|
|
200
|
+
}
|