@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/geometry.js
ADDED
|
@@ -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
|
+
|
package/src/handmade.js
ADDED
|
@@ -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
|
+
}
|