@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +29 -0
  3. package/README.md +561 -2
  4. package/fonts/OFL.txt +93 -0
  5. package/fonts/inter-latin-400.woff +0 -0
  6. package/fonts/inter-latin-500.woff +0 -0
  7. package/fonts/inter-latin-600.woff +0 -0
  8. package/fonts/inter-latin-700.woff +0 -0
  9. package/package.json +96 -4
  10. package/recipes/aurora.json +132 -0
  11. package/recipes/bauhaus.json +113 -0
  12. package/recipes/bigType.json +103 -0
  13. package/recipes/contour.json +109 -0
  14. package/recipes/cutout.json +191 -0
  15. package/recipes/diagram.json +92 -0
  16. package/recipes/dither.json +170 -0
  17. package/recipes/duotone.json +75 -0
  18. package/recipes/gridTitle.json +136 -0
  19. package/recipes/iconTile.json +214 -0
  20. package/recipes/issue.json +221 -0
  21. package/recipes/mark.json +170 -0
  22. package/recipes/photo.json +156 -0
  23. package/recipes/riso.json +387 -0
  24. package/recipes/stripes.json +206 -0
  25. package/recipes/waveform.json +154 -0
  26. package/schema/recipe.schema.json +2292 -0
  27. package/src/astro.d.ts +31 -0
  28. package/src/astro.js +86 -0
  29. package/src/color.js +246 -0
  30. package/src/diagram.js +248 -0
  31. package/src/extract.js +90 -0
  32. package/src/flatten.js +234 -0
  33. package/src/fonts.js +200 -0
  34. package/src/geometry.js +187 -0
  35. package/src/grid.js +41 -0
  36. package/src/handmade.js +253 -0
  37. package/src/icons.js +157 -0
  38. package/src/index.d.ts +897 -0
  39. package/src/index.js +19 -0
  40. package/src/json-recipe.js +458 -0
  41. package/src/layer-options.js +37 -0
  42. package/src/layers.js +972 -0
  43. package/src/palette.js +185 -0
  44. package/src/png.js +77 -0
  45. package/src/print.js +658 -0
  46. package/src/recipes.js +78 -0
  47. package/src/render.js +462 -0
  48. package/src/rng.js +95 -0
  49. package/src/svg.js +69 -0
  50. package/src/text.js +230 -0
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Canvas size, lengths and anchoring.
3
+ *
4
+ * Lengths are pixels when given as numbers, or CSS-style viewport units as
5
+ * strings: `'6vmin'` (6% of the shorter side), `'4vw'`, `'4vh'`, `'5vmax'`.
6
+ * Defaults are written in these units, so a cover keeps its proportions at
7
+ * any output size.
8
+ *
9
+ * @module geometry
10
+ */
11
+
12
+ /** Named aspect ratios, as `width / height`. */
13
+ export const ASPECTS = Object.freeze({
14
+ '1:1': 1,
15
+ '16:9': 16 / 9,
16
+ '4:5': 4 / 5,
17
+ '3:2': 3 / 2,
18
+ '4:3': 4 / 3,
19
+ '2:1': 2,
20
+ '9:16': 9 / 16,
21
+ og: 1200 / 630,
22
+ });
23
+
24
+ /**
25
+ * Work out a cover's pixel size from `width`, `height` and `aspect`.
26
+ *
27
+ * - both `width` and `height` → used as given
28
+ * - one side + `aspect` → the other side follows
29
+ * - `aspect` only → 1200 wide (`'og'` is exactly 1200×630)
30
+ * - nothing → 1200×1200
31
+ *
32
+ * @param {{ width?: number, height?: number, aspect?: string | number }} opts
33
+ * @returns {{ width: number, height: number }} Integer pixel size.
34
+ * @throws {RangeError} On a non-positive size or an unreadable aspect.
35
+ *
36
+ * @example
37
+ * resolveSize({ aspect: '16:9' }); // { width: 1200, height: 675 }
38
+ * resolveSize({ height: 800, aspect: '3:2' }); // { width: 1200, height: 800 }
39
+ */
40
+ export function resolveSize({ width, height, aspect } = {}) {
41
+ if (width && height) return checkSize(width, height);
42
+ const ratio = aspectRatio(aspect ?? (width || height ? undefined : '1:1'));
43
+ if (ratio == null) return checkSize(width ?? height ?? 1200, height ?? width ?? 1200);
44
+ if (height && !width) return checkSize(height * ratio, height);
45
+ return checkSize(width ?? 1200, (width ?? 1200) / ratio);
46
+ }
47
+
48
+ /**
49
+ * Parse an aspect: a named one, `'a:b'`, `'a/b'` or a number.
50
+ *
51
+ * @param {string | number | undefined} aspect
52
+ * @returns {number | undefined} `width / height`, or undefined when none was given.
53
+ * @throws {RangeError} On a string that isn't an aspect.
54
+ */
55
+ export function aspectRatio(aspect) {
56
+ if (aspect == null) return undefined;
57
+ if (typeof aspect === 'number') {
58
+ if (aspect > 0) return aspect;
59
+ throw new RangeError(`Invalid aspect ${aspect}`);
60
+ }
61
+ if (Object.hasOwn(ASPECTS, aspect)) return ASPECTS[aspect];
62
+ const m = String(aspect).match(/^\s*(\d+(?:\.\d+)?)\s*[:/x]\s*(\d+(?:\.\d+)?)\s*$/);
63
+ if (m && +m[1] > 0 && +m[2] > 0) return +m[1] / +m[2];
64
+ throw new RangeError(`Invalid aspect "${aspect}". Use one of ${Object.keys(ASPECTS).join(', ')} or "w:h".`);
65
+ }
66
+
67
+ /**
68
+ * @param {number} w @param {number} h
69
+ * @returns {{ width: number, height: number }}
70
+ */
71
+ function checkSize(w, h) {
72
+ const width = Math.round(w);
73
+ const height = Math.round(h);
74
+ if (!(width > 0 && height > 0) || width > 8192 || height > 8192) {
75
+ throw new RangeError(`Cover size must be 1–8192px per side, got ${width}×${height}`);
76
+ }
77
+ return { width, height };
78
+ }
79
+
80
+ /**
81
+ * Resolve a length to pixels.
82
+ *
83
+ * @param {number | string | undefined | null} value - Pixels, or `'Nvmin'` / `'Nvw'` / `'Nvh'` / `'Nvmax'`.
84
+ * @param {{ width: number, height: number }} canvas - The canvas size.
85
+ * @param {number | string} [fallback=0] - Used when `value` is undefined or null.
86
+ * @returns {number} Pixels.
87
+ * @throws {TypeError} On a string with an unknown unit.
88
+ *
89
+ * @example
90
+ * len('5vmin', { width: 1200, height: 630 }); // 31.5
91
+ * len(undefined, canvas, '6vmin'); // the default
92
+ */
93
+ export function len(value, canvas, fallback = 0) {
94
+ const v = value ?? fallback;
95
+ if (typeof v === 'number') return v;
96
+ const m = String(v).match(/^\s*(-?\d*\.?\d+)\s*(vmin|vmax|vw|vh|px)?\s*$/);
97
+ if (!m) throw new TypeError(`Invalid length "${v}" (use px numbers or vmin/vmax/vw/vh)`);
98
+ const n = parseFloat(m[1]);
99
+ switch (m[2]) {
100
+ case 'vmin': return (n / 100) * Math.min(canvas.width, canvas.height);
101
+ case 'vmax': return (n / 100) * Math.max(canvas.width, canvas.height);
102
+ case 'vw': return (n / 100) * canvas.width;
103
+ case 'vh': return (n / 100) * canvas.height;
104
+ default: return n;
105
+ }
106
+ }
107
+
108
+ /** The nine anchors, as fractions of the free space along x and y. */
109
+ const ANCHORS = Object.freeze({
110
+ 'top-left': [0, 0], top: [0.5, 0], 'top-right': [1, 0],
111
+ left: [0, 0.5], center: [0.5, 0.5], right: [1, 0.5],
112
+ 'bottom-left': [0, 1], bottom: [0.5, 1], 'bottom-right': [1, 1],
113
+ });
114
+
115
+ /**
116
+ * Place a box on the canvas.
117
+ *
118
+ * With `position` (fractions 0–1 of the canvas, the box's anchor point
119
+ * goes there), the margin is ignored. Otherwise the box sits at `anchor`
120
+ * inside the safe area (`margin` in from every edge). `offset` nudges it
121
+ * either way.
122
+ *
123
+ * @param {object} o
124
+ * @param {number} o.width - Box width.
125
+ * @param {number} o.height - Box height.
126
+ * @param {{ width: number, height: number }} o.canvas - The canvas.
127
+ * @param {import('./index.js').Anchor} [o.anchor='center'] - Where it sits.
128
+ * @param {number} [o.margin=0] - Safe margin in px.
129
+ * @param {{ x?: number, y?: number }} [o.position] - Explicit placement (fractions).
130
+ * @param {{ x?: number | string, y?: number | string }} [o.offset] - Extra shift (lengths).
131
+ * @returns {{ x: number, y: number }} The box's top-left corner.
132
+ * @throws {TypeError} On an unknown anchor.
133
+ */
134
+ export function place({ width, height, canvas, anchor = 'center', margin = 0, position, offset }) {
135
+ const a = ANCHORS[anchor];
136
+ if (!a) throw new TypeError(`Unknown anchor "${anchor}". Use ${Object.keys(ANCHORS).join(', ')}.`);
137
+ let x;
138
+ let y;
139
+ if (position && (position.x != null || position.y != null)) {
140
+ x = (position.x ?? 0.5) * canvas.width - a[0] * width;
141
+ y = (position.y ?? 0.5) * canvas.height - a[1] * height;
142
+ } else {
143
+ x = margin + a[0] * (canvas.width - 2 * margin - width);
144
+ y = margin + a[1] * (canvas.height - 2 * margin - height);
145
+ }
146
+ return { x: x + len(offset?.x, canvas, 0), y: y + len(offset?.y, canvas, 0) };
147
+ }
148
+
149
+ /**
150
+ * The anchor's horizontal alignment (`'left'`, `'center'`, `'right'`).
151
+ *
152
+ * @param {import('./index.js').Anchor} anchor
153
+ * @returns {'left' | 'center' | 'right'} Alignment.
154
+ */
155
+ export function anchorAlign(anchor = 'center') {
156
+ const fx = ANCHORS[anchor]?.[0] ?? 0.5;
157
+ return fx === 0 ? 'left' : fx === 1 ? 'right' : 'center';
158
+ }
159
+
160
+ /**
161
+ * The shared "design unit" for a canvas: the geometric mean of its sides.
162
+ * Type and margins scale with it, so a 1200×630 card and a 1200×1200
163
+ * square get proportionate (not identical) headlines.
164
+ *
165
+ * @param {{ width: number, height: number }} canvas
166
+ * @returns {number} Pixels.
167
+ */
168
+ export function unit(canvas) {
169
+ return Math.sqrt(canvas.width * canvas.height);
170
+ }
171
+
172
+ /**
173
+ * Resolve a region (fractions of the canvas) to pixels.
174
+ *
175
+ * @param {{ x?: number, y?: number, width?: number, height?: number } | undefined} region
176
+ * @param {{ width: number, height: number }} canvas
177
+ * @returns {{ x: number, y: number, width: number, height: number }} Pixels.
178
+ */
179
+ export function regionPx(region, canvas) {
180
+ const r = region ?? {};
181
+ return {
182
+ x: (r.x ?? 0) * canvas.width,
183
+ y: (r.y ?? 0) * canvas.height,
184
+ width: (r.width ?? 1 - (r.x ?? 0)) * canvas.width,
185
+ height: (r.height ?? 1 - (r.y ?? 0)) * canvas.height,
186
+ };
187
+ }
package/src/grid.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Grid lines that break around content.
3
+ *
4
+ * @module grid
5
+ */
6
+
7
+ import { num } from './svg.js';
8
+
9
+ /**
10
+ * Path data for full-length grid lines at `xs` (columns) and `ys` (rows),
11
+ * each broken where it crosses one of the `gaps` rectangles, so a grid can
12
+ * pass behind content without running through it.
13
+ *
14
+ * @param {number[]} xs @param {number[]} ys
15
+ * @param {number} w @param {number} h
16
+ * @param {{ x: number, y: number, width: number, height: number }[]} gaps - Pixel rects.
17
+ * @returns {string} Path data.
18
+ */
19
+ export function gridPath(xs, ys, w, h, gaps) {
20
+ const cut = (/** @type {number} */ len, /** @type {[number, number][]} */ holes) => {
21
+ const runs = [];
22
+ let at = 0;
23
+ for (const [a, b] of holes.filter(([a, b]) => b > 0 && a < len).sort((p, q) => p[0] - q[0])) {
24
+ if (a > at) runs.push([at, a]);
25
+ at = Math.max(at, b);
26
+ }
27
+ if (at < len) runs.push([at, len]);
28
+ return runs;
29
+ };
30
+ let d = '';
31
+ for (const y of ys) {
32
+ const holes = gaps.filter((g) => y >= g.y && y <= g.y + g.height).map((g) => /** @type {[number, number]} */ ([g.x, g.x + g.width]));
33
+ for (const [a, b] of cut(w, holes)) d += `M${num(a)} ${num(y)}H${num(b)}`;
34
+ }
35
+ for (const x of xs) {
36
+ const holes = gaps.filter((g) => x >= g.x && x <= g.x + g.width).map((g) => /** @type {[number, number]} */ ([g.y, g.y + g.height]));
37
+ for (const [a, b] of cut(h, holes)) d += `M${num(x)} ${num(a)}V${num(b)}`;
38
+ }
39
+ return d;
40
+ }
41
+
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Hand-made layers: cut paper and hand-drawn lines.
3
+ *
4
+ * - `paper` flat shapes with scissor-cut edges: the outline is resampled
5
+ * into short straight cuts and each cut point nudged off the
6
+ * line (seeded), so edges are irregular but never blurry.
7
+ * - `scribble` an icon re-drawn by hand (its outline flattened, wobbled with
8
+ * smooth seeded noise and stroked at a varying width with round
9
+ * caps), or a small node graph: a polyline with filled dots.
10
+ *
11
+ * @module handmade
12
+ */
13
+
14
+ import { attrs, num, layerAttrs } from './svg.js';
15
+ import { len, regionPx } from './geometry.js';
16
+ import { toHex } from './color.js';
17
+ import { resolveIcon, parseSvgIcon } from './icons.js';
18
+ import { flattenBody, densify } from './flatten.js';
19
+
20
+ /** Off-white paper. */
21
+ export const PAPER = '#faf9f5';
22
+ /** Near-black ink. */
23
+ export const INK = '#141413';
24
+
25
+ /**
26
+ * Smooth 1D value noise from a seeded stream: `noise(t)` in [-1, 1].
27
+ *
28
+ * @param {import('./rng.js').Rng} rng
29
+ * @returns {(t: number) => number}
30
+ */
31
+ function noise1(rng) {
32
+ const knots = Array.from({ length: 64 }, () => rng.range(-1, 1));
33
+ return (t) => {
34
+ const i = Math.floor(t);
35
+ const f = t - i;
36
+ const a = knots[((i % 64) + 64) % 64];
37
+ const b = knots[(((i + 1) % 64) + 64) % 64];
38
+ const e = f * f * (3 - 2 * f);
39
+ return a + (b - a) * e;
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Resample a closed outline into scissor cuts: points every `cut` px, each
45
+ * nudged by up to `wobble` px across the edge.
46
+ *
47
+ * @param {[number, number][]} outline - A closed polyline (first point not repeated).
48
+ * @param {number} cut - Cut length in px.
49
+ * @param {number} wobble - Max nudge in px.
50
+ * @param {import('./rng.js').Rng} rng
51
+ * @returns {string} Path data.
52
+ */
53
+ function scissor(outline, cut, wobble, rng) {
54
+ const pts = densify([...outline, outline[0]], cut / 6);
55
+ /** @type {[number, number][]} */
56
+ const out = [];
57
+ let acc = cut;
58
+ for (let i = 1; i < pts.length; i++) {
59
+ acc += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
60
+ if (acc >= cut * rng.range(0.6, 1.4)) {
61
+ acc = 0;
62
+ const [x0, y0] = pts[i - 1];
63
+ const [x1, y1] = pts[i];
64
+ const l = Math.hypot(x1 - x0, y1 - y0) || 1;
65
+ const k = rng.range(-wobble, wobble);
66
+ out.push([x1 + (-(y1 - y0) / l) * k, y1 + ((x1 - x0) / l) * k]);
67
+ }
68
+ }
69
+ return `M${out.map(([x, y]) => `${num(x)} ${num(y)}`).join('L')}Z`;
70
+ }
71
+
72
+ /**
73
+ * A shape's outline, centred on the origin.
74
+ *
75
+ * @param {string} kind - rect | square | circle | blob | arch | pill
76
+ * @param {number} w @param {number} h
77
+ * @param {import('./rng.js').Rng} rng
78
+ * @returns {[number, number][]}
79
+ */
80
+ function outlineOf(kind, w, h, rng) {
81
+ const ellipse = (rx, ry, bump = 0) => {
82
+ const n = 72;
83
+ const wave = noise1(rng);
84
+ return Array.from({ length: n }, (_, i) => {
85
+ const a = (i / n) * Math.PI * 2;
86
+ const r = 1 + bump * wave((i / n) * 5);
87
+ return /** @type {[number, number]} */ ([Math.cos(a) * rx * r, Math.sin(a) * ry * r]);
88
+ });
89
+ };
90
+ if (kind === 'circle') return ellipse(w / 2, h / 2);
91
+ if (kind === 'blob') return ellipse(w / 2, h / 2, 0.16);
92
+ if (kind === 'pill') {
93
+ const r = Math.min(w, h) / 2;
94
+ const pts = [];
95
+ for (let i = 0; i <= 18; i++) { const a = -Math.PI / 2 + (i / 18) * Math.PI; pts.push([w / 2 - r + Math.cos(a) * r, Math.sin(a) * r]); }
96
+ for (let i = 0; i <= 18; i++) { const a = Math.PI / 2 + (i / 18) * Math.PI; pts.push([-w / 2 + r + Math.cos(a) * r, Math.sin(a) * r]); }
97
+ return /** @type {[number, number][]} */ (pts);
98
+ }
99
+ if (kind === 'arch') {
100
+ const r = w / 2;
101
+ const pts = [[w / 2, h / 2], [-w / 2, h / 2]];
102
+ for (let i = 0; i <= 24; i++) { const a = Math.PI + (i / 24) * Math.PI; pts.push([Math.cos(a) * r, -h / 2 + r + Math.sin(a) * r]); }
103
+ return /** @type {[number, number][]} */ (pts);
104
+ }
105
+ // rect / square: corners slightly off-true, as if cut by eye.
106
+ const j = () => rng.range(-0.04, 0.04) * Math.min(w, h);
107
+ return [[-w / 2 + j(), -h / 2 + j()], [w / 2 + j(), -h / 2 + j()], [w / 2 + j(), h / 2 + j()], [-w / 2 + j(), h / 2 + j()]];
108
+ }
109
+
110
+ /**
111
+ * The `paper` layer.
112
+ *
113
+ * @param {import('./index.js').PaperLayer} layer
114
+ * @param {import('./layers.js').RenderContext} ctx
115
+ * @returns {import('./layers.js').Markup}
116
+ */
117
+ export function paper(layer, ctx) {
118
+ const { rng } = ctx;
119
+ const r = regionPx(layer.region ?? { x: 0.2, y: 0.2, width: 0.6, height: 0.6 }, ctx.canvas);
120
+ const fill = layer.color ? toHex(layer.color) : PAPER;
121
+ const kinds = layer.kind && layer.kind !== 'mixed' ? [layer.kind] : ['rect', 'blob', 'arch', 'square', 'circle', 'pill'];
122
+ const wob = layer.wobble ?? 0.5;
123
+
124
+ /** @type {{ kind: string, x: number, y: number, w: number, h: number, rot: number, color: string }[]} */
125
+ let items;
126
+ if (layer.items?.length) {
127
+ items = layer.items.map((it, i) => ({
128
+ kind: it.kind ?? kinds[i % kinds.length],
129
+ x: r.x + (it.x ?? 0.5) * r.width,
130
+ y: r.y + (it.y ?? 0.5) * r.height,
131
+ w: len(it.width ?? it.size, ctx.canvas, r.width * 0.4),
132
+ h: len(it.height ?? it.size, ctx.canvas, r.height * 0.4),
133
+ rot: it.rotate ?? rng.range(-4, 4),
134
+ color: it.color ? toHex(it.color) : fill,
135
+ }));
136
+ } else {
137
+ // A loose collage: cells of a small grid, each shape filling most of its cell.
138
+ const count = layer.count ?? 4;
139
+ const cols = Math.ceil(Math.sqrt(count));
140
+ const rows = Math.ceil(count / cols);
141
+ const cw = r.width / cols;
142
+ const ch = r.height / rows;
143
+ items = [];
144
+ for (let i = 0; i < count; i++) {
145
+ const kind = kinds[rng.int(0, kinds.length - 1)];
146
+ const sq = kind === 'square' || kind === 'circle';
147
+ const fw = cw * rng.range(0.72, 1.02);
148
+ const fh = sq ? Math.min(fw, ch * 1.02) : ch * rng.range(0.72, 1.02);
149
+ items.push({
150
+ kind,
151
+ x: r.x + cw * ((i % cols) + 0.5) + rng.range(-0.08, 0.08) * cw,
152
+ y: r.y + ch * (Math.floor(i / cols) + 0.5) + rng.range(-0.08, 0.08) * ch,
153
+ w: sq ? Math.min(fw, fh) : fw,
154
+ h: sq ? Math.min(fw, fh) : fh,
155
+ rot: rng.range(-5, 5),
156
+ color: fill,
157
+ });
158
+ }
159
+ }
160
+ const body = items.map((it) => {
161
+ const size = Math.min(it.w, it.h);
162
+ const d = scissor(outlineOf(it.kind, it.w, it.h, rng), Math.max(4, size * 0.09), wob * size * 0.018, rng);
163
+ return `<path${attrs({ d, fill: it.color, transform: `translate(${num(it.x)} ${num(it.y)}) rotate(${num(it.rot)})` })}/>`;
164
+ }).join('');
165
+ const a = attrs(layerAttrs(layer));
166
+ return { defs: '', body: a ? `<g${a}>${body}</g>` : body };
167
+ }
168
+
169
+ /**
170
+ * Stroke a polyline by hand: wobbled by smooth noise, drawn in short
171
+ * overlapping runs whose widths drift, with round caps.
172
+ *
173
+ * @param {[number, number][]} pts - Points in px.
174
+ * @param {object} o
175
+ * @param {number} o.width - Base stroke width.
176
+ * @param {number} o.amp - Wobble amplitude in px.
177
+ * @param {number} o.period - Wobble period in px.
178
+ * @param {string} o.color
179
+ * @param {import('./rng.js').Rng} o.rng
180
+ * @returns {string} Markup.
181
+ */
182
+ function handLine(pts, { width, amp, period, color, rng }) {
183
+ const nx = noise1(rng);
184
+ const ny = noise1(rng);
185
+ const nw = noise1(rng);
186
+ const off = rng.range(0, 64);
187
+ let s = 0;
188
+ const moved = pts.map((p, i) => {
189
+ if (i) s += Math.hypot(p[0] - pts[i - 1][0], p[1] - pts[i - 1][1]);
190
+ return /** @type {[number, number, number]} */ ([p[0] + nx(off + s / period) * amp, p[1] + ny(off + s / period) * amp, s]);
191
+ });
192
+ let out = '';
193
+ const run = Math.max(3, Math.round(moved.length / Math.max(3, s / (period * 0.8))));
194
+ for (let i = 0; i < moved.length - 1; i += run) {
195
+ const chunk = moved.slice(i, Math.min(moved.length, i + run + 1));
196
+ const wdt = width * (0.82 + 0.3 * nw(off + chunk[0][2] / (period * 1.5)));
197
+ out += `<path${attrs({ d: `M${chunk.map(([x, y]) => `${num(x)} ${num(y)}`).join('L')}`, 'stroke-width': num(wdt) })}/>`;
198
+ }
199
+ return `<g${attrs({ fill: 'none', stroke: color, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' })}>${out}</g>`;
200
+ }
201
+
202
+ /**
203
+ * The `scribble` layer.
204
+ *
205
+ * @param {import('./index.js').ScribbleLayer} layer
206
+ * @param {import('./layers.js').RenderContext} ctx
207
+ * @returns {import('./layers.js').Markup}
208
+ */
209
+ export function scribble(layer, ctx) {
210
+ const { rng, width: w, height: h } = ctx;
211
+ const size = len(layer.size, ctx.canvas, '46vmin');
212
+ const cx = (layer.position?.x ?? 0.5) * w;
213
+ const cy = (layer.position?.y ?? 0.5) * h;
214
+ const color = layer.color ? toHex(layer.color) : PAPER;
215
+ const stroke = len(layer.stroke, ctx.canvas, '1.3vmin');
216
+ const wob = layer.wobble ?? 0.5;
217
+ let body = '';
218
+
219
+ if (layer.graph) {
220
+ const g = layer.graph === true ? {} : layer.graph;
221
+ const n = Math.max(2, Math.min(9, g.nodes ?? rng.int(4, 6)));
222
+ // Nodes on a jittered lattice so the graph spans the box without clumping.
223
+ const pts = Array.from({ length: n }, (_, i) => {
224
+ const a = (i / n) * Math.PI * 2 + rng.range(-0.5, 0.5);
225
+ const rr = size * 0.5 * rng.range(0.35, 1);
226
+ return /** @type {[number, number]} */ ([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr]);
227
+ });
228
+ pts.sort((p, q) => p[0] - q[0]);
229
+ const path = [pts[0]];
230
+ for (let i = 1; i < n; i++) path.push(pts[i]);
231
+ const lines = [densify(path, size * 0.03)];
232
+ if (n > 3) lines.push(densify([pts[1], pts[n - 1]], size * 0.03));
233
+ for (const l of lines) body += handLine(l, { width: stroke, amp: wob * size * 0.006, period: size * 0.12, color, rng });
234
+ const r = stroke * (g.dot ?? 1.25);
235
+ body += pts.map(([x, y]) => `<circle${attrs({ cx: x, cy: y, r: r * rng.range(0.9, 1.15), fill: color })}/>`).join('');
236
+ } else {
237
+ const icon = layer.icon;
238
+ const data = typeof icon === 'string' && icon.trim().startsWith('<') ? parseSvgIcon(icon) : typeof icon === 'string' && icon ? resolveIcon(icon) : null;
239
+ const src = data ?? { body: `<path d="${SPARK_PATH}"/>`, viewBox: [0, 0, 24, 24] };
240
+ const [vx, vy, vw, vh] = src.viewBox;
241
+ const k = size / Math.max(vw, vh);
242
+ const flat = flattenBody(src.body, Math.max(vw, vh) / 90);
243
+ for (const line of flat.lines) {
244
+ const pts = line.map(([x, y]) => /** @type {[number, number]} */ ([cx + (x - vx - vw / 2) * k, cy + (y - vy - vh / 2) * k]));
245
+ body += handLine(pts, { width: stroke, amp: wob * size * 0.01, period: size * 0.16, color, rng });
246
+ }
247
+ }
248
+ const a = attrs(layerAttrs(layer));
249
+ return { defs: '', body: a ? `<g${a}>${body}</g>` : body };
250
+ }
251
+
252
+ /** The spark, as an outline to trace when no icon is given. */
253
+ const SPARK_PATH = 'M12 2c.4 4.9 2.1 7.6 7 8.6l3 .4-3 .4c-4.9 1-6.6 3.7-7 8.6-.4-4.9-2.1-7.6-7-8.6L2 11l3-.4c4.9-1 6.6-3.7 7-8.6Z';
package/src/icons.js ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Icons: Iconify sets by name, or raw SVG.
3
+ *
4
+ * `'lucide:feather'` and `'simple-icons:github'` resolve through the
5
+ * `@iconify-json/<prefix>` package when it's installed (optional peer
6
+ * dependencies; any Iconify JSON set works the same way). A set can also be
7
+ * handed over directly with {@link registerIcons}, which is how to use
8
+ * icons without installing the packages, or in a bundler that can't
9
+ * resolve them.
10
+ *
11
+ * Raw input works too: an SVG string (its `viewBox` is read) or a bare
12
+ * path `d` with a `viewBox`.
13
+ *
14
+ * @module icons
15
+ */
16
+
17
+ import { readFileSync } from 'node:fs';
18
+ import { createRequire } from 'node:module';
19
+ import { join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const require = createRequire(import.meta.url);
23
+
24
+ /** @type {Map<string, IconSet>} */
25
+ const sets = new Map();
26
+
27
+ /**
28
+ * An Iconify JSON icon set (the shape of `@iconify-json/<prefix>/icons.json`).
29
+ *
30
+ * @typedef {object} IconSet
31
+ * @property {string} prefix
32
+ * @property {Record<string, { body: string, width?: number, height?: number, left?: number, top?: number }>} icons
33
+ * @property {Record<string, { parent: string, width?: number, height?: number }>} [aliases]
34
+ * @property {number} [width]
35
+ * @property {number} [height]
36
+ * @property {number} [left]
37
+ * @property {number} [top]
38
+ */
39
+
40
+ /**
41
+ * A resolved icon.
42
+ *
43
+ * @typedef {object} IconData
44
+ * @property {string} body - Inner SVG markup.
45
+ * @property {[number, number, number, number]} viewBox - `[minX, minY, width, height]`.
46
+ */
47
+
48
+ /**
49
+ * Register an Iconify icon set (for example the parsed
50
+ * `@iconify-json/lucide/icons.json`). Replaces any set with that prefix.
51
+ *
52
+ * @param {IconSet} set - The set.
53
+ * @returns {void}
54
+ *
55
+ * @example
56
+ * import lucide from '@iconify-json/lucide/icons.json' with { type: 'json' };
57
+ * registerIcons(lucide);
58
+ */
59
+ export function registerIcons(set) {
60
+ if (!set?.prefix || typeof set.icons !== 'object') throw new TypeError('registerIcons() expects an Iconify JSON set ({ prefix, icons })');
61
+ sets.set(set.prefix, set);
62
+ }
63
+
64
+ /**
65
+ * Load a set by prefix: a registered one, else `@iconify-json/<prefix>`.
66
+ *
67
+ * @param {string} prefix - e.g. `'lucide'`.
68
+ * @returns {IconSet} The set.
69
+ * @throws {Error} When the set isn't registered or installed.
70
+ */
71
+ function loadSet(prefix) {
72
+ let set = sets.get(prefix);
73
+ if (set) return set;
74
+ const pkg = `@iconify-json/${prefix}`;
75
+ let file;
76
+ try {
77
+ file = join(require.resolve(`${pkg}/package.json`, { paths: [process.cwd(), fileURLToPath(new URL('.', import.meta.url))] }), '..', 'icons.json');
78
+ } catch {
79
+ throw new Error(`Icon set "${prefix}" is not available. Install ${pkg}, or pass the set to registerIcons().`);
80
+ }
81
+ set = /** @type {IconSet} */ (JSON.parse(readFileSync(file, 'utf8')));
82
+ sets.set(prefix, set);
83
+ return set;
84
+ }
85
+
86
+ /**
87
+ * Resolve an Iconify name (`'prefix:name'`) to its markup and viewBox.
88
+ *
89
+ * @param {string} name - e.g. `'lucide:feather'`.
90
+ * @returns {IconData} The icon.
91
+ * @throws {Error} On a malformed name, a missing set or a missing icon.
92
+ *
93
+ * @example
94
+ * resolveIcon('lucide:feather').viewBox; // [0, 0, 24, 24]
95
+ */
96
+ export function resolveIcon(name) {
97
+ const m = String(name).match(/^([a-z0-9-]+):([a-z0-9-]+)$/i);
98
+ if (!m) throw new Error(`Icon names look like "lucide:feather", got "${name}"`);
99
+ const set = loadSet(m[1]);
100
+ let key = m[2];
101
+ /** @type {{ width?: number, height?: number }} */
102
+ let size = {};
103
+ for (let hops = 0; !set.icons[key] && set.aliases?.[key] && hops < 8; hops++) {
104
+ const alias = set.aliases[key];
105
+ size = { width: size.width ?? alias.width, height: size.height ?? alias.height };
106
+ key = alias.parent;
107
+ }
108
+ const icon = set.icons[key];
109
+ if (!icon) throw new Error(`Icon "${name}" was not found in the ${m[1]} set`);
110
+ return {
111
+ body: icon.body,
112
+ viewBox: [
113
+ icon.left ?? set.left ?? 0,
114
+ icon.top ?? set.top ?? 0,
115
+ size.width ?? icon.width ?? set.width ?? 16,
116
+ size.height ?? icon.height ?? set.height ?? 16,
117
+ ],
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Read a raw SVG string: its inner markup and viewBox. Scripts,
123
+ * `foreignObject` and event-handler attributes are dropped.
124
+ *
125
+ * @param {string} svg - An `<svg …>…</svg>` string.
126
+ * @returns {IconData} The icon.
127
+ * @throws {Error} When there's no `<svg>` element or no size.
128
+ */
129
+ export function parseSvgIcon(svg) {
130
+ const open = svg.match(/<svg\b[^>]*>/i);
131
+ if (!open) throw new Error('Icon SVG must contain an <svg> element');
132
+ const tag = open[0];
133
+ const vb = tag.match(/viewBox\s*=\s*["']([^"']+)["']/i);
134
+ let viewBox;
135
+ if (vb) {
136
+ viewBox = vb[1].trim().split(/[\s,]+/).map(Number);
137
+ } else {
138
+ const w = parseFloat(tag.match(/\bwidth\s*=\s*["']([\d.]+)/i)?.[1] ?? '');
139
+ const h = parseFloat(tag.match(/\bheight\s*=\s*["']([\d.]+)/i)?.[1] ?? '');
140
+ if (!(w > 0 && h > 0)) throw new Error('Icon SVG needs a viewBox or width/height');
141
+ viewBox = [0, 0, w, h];
142
+ }
143
+ if (viewBox.length !== 4 || viewBox.some((n) => !Number.isFinite(n))) throw new Error('Icon SVG has an unreadable viewBox');
144
+
145
+ // Presentation attributes on the root (fill="none", stroke=…) apply to the body.
146
+ const inherited = [...tag.matchAll(/\b(fill|stroke|stroke-width|stroke-linecap|stroke-linejoin)\s*=\s*("[^"]*"|'[^']*')/gi)]
147
+ .map((m) => `${m[1]}=${m[2]}`)
148
+ .join(' ');
149
+ const start = svg.indexOf(tag) + tag.length;
150
+ const end = svg.lastIndexOf('</svg>');
151
+ let body = svg.slice(start, end > start ? end : undefined);
152
+ body = body
153
+ .replace(/<script\b[\s\S]*?<\/script>/gi, '')
154
+ .replace(/<foreignObject\b[\s\S]*?<\/foreignObject>/gi, '')
155
+ .replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*')/gi, '');
156
+ return { body: inherited ? `<g ${inherited}>${body}</g>` : body, viewBox: /** @type {any} */ (viewBox) };
157
+ }