@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
package/src/svg.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Small helpers for writing SVG markup deterministically.
3
+ *
4
+ * Numbers are always written through {@link num}, so the same input gives
5
+ * byte-identical output on every machine (no `0.30000000000000004`, no
6
+ * locale formatting, no `-0`).
7
+ *
8
+ * @module svg
9
+ */
10
+
11
+ /**
12
+ * Format a number for SVG: at most `digits` decimals, no trailing zeros.
13
+ *
14
+ * @param {number} v - The number.
15
+ * @param {number} [digits=2] - Maximum decimals.
16
+ * @returns {string} The formatted number.
17
+ *
18
+ * @example
19
+ * num(1 / 3); // '0.33'
20
+ * num(12.5); // '12.5'
21
+ * num(-0.001); // '0'
22
+ */
23
+ export function num(v, digits = 2) {
24
+ if (!Number.isFinite(v)) return '0';
25
+ const s = v.toFixed(digits).replace(/\.?0+$/, '');
26
+ return s === '-0' || s === '' ? '0' : s;
27
+ }
28
+
29
+ /**
30
+ * Escape text for an XML attribute or text node.
31
+ *
32
+ * @param {string} s - Raw text.
33
+ * @returns {string} Escaped text.
34
+ */
35
+ export function esc(s) {
36
+ return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
37
+ }
38
+
39
+ /**
40
+ * Serialise an attribute object. `undefined`, `null` and `false` values are
41
+ * skipped; numbers go through {@link num}. Keys keep their insertion order.
42
+ *
43
+ * @param {Record<string, string | number | boolean | null | undefined>} obj - Attributes.
44
+ * @returns {string} ` key="value"` pairs (with a leading space), or `''`.
45
+ *
46
+ * @example
47
+ * attrs({ x: 1.005, fill: '#fff', opacity: undefined }); // ' x="1" fill="#fff"'
48
+ */
49
+ export function attrs(obj) {
50
+ let out = '';
51
+ for (const [k, v] of Object.entries(obj)) {
52
+ if (v === undefined || v === null || v === false) continue;
53
+ out += ` ${k}="${typeof v === 'number' ? num(v) : esc(String(v))}"`;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * Opacity and blend-mode attributes shared by every layer.
60
+ *
61
+ * @param {{ opacity?: number, blend?: string }} layer - The layer.
62
+ * @returns {Record<string, string | number | undefined>} Attributes for the layer's group.
63
+ */
64
+ export function layerAttrs(layer) {
65
+ return {
66
+ opacity: layer.opacity != null && layer.opacity < 1 ? Math.max(0, layer.opacity) : undefined,
67
+ style: layer.blend && layer.blend !== 'normal' ? `mix-blend-mode:${layer.blend}` : undefined,
68
+ };
69
+ }
package/src/text.js ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Text: shaping, line breaking and outline drawing.
3
+ *
4
+ * A line is shaped by HarfBuzz as a whole (so kerning across spaces and
5
+ * ligatures are right), then every glyph's outline is transformed into
6
+ * canvas pixels and written as one `<path>` per line. The output never
7
+ * references a font, so it renders the same in librsvg, a browser or a
8
+ * design tool.
9
+ *
10
+ * Line breaking is greedy on spaces, then optionally balanced (like CSS
11
+ * `text-wrap: balance`: the narrowest width that keeps the same number of
12
+ * lines, so a headline doesn't end on a lonely word). A word wider than
13
+ * the line is broken between characters. Past `maxLines` the text is cut
14
+ * at a word and given an ellipsis; with `minSize`, the size first steps
15
+ * down until the text fits.
16
+ *
17
+ * Vertical metrics are trimmed to the cap height above the first line and
18
+ * the baseline below the last (CSS `text-box: trim-both cap alphabetic`),
19
+ * so a margin measured to a headline is the margin you see.
20
+ *
21
+ * @module text
22
+ */
23
+
24
+ import { num } from './svg.js';
25
+
26
+ /**
27
+ * A shaped run of text.
28
+ *
29
+ * @typedef {object} Shaped
30
+ * @property {{ gid: number, x: number, y: number }[]} glyphs - Glyph ids and pen positions in px (y is the baseline offset).
31
+ * @property {number} width - Advance width in px (no trailing letter-spacing).
32
+ */
33
+
34
+ /**
35
+ * Shape a run of text.
36
+ *
37
+ * @param {import('./fonts.js').LoadedFont} face - The face.
38
+ * @param {string} text - One line of text.
39
+ * @param {number} size - Font size in px.
40
+ * @param {number} tracking - Letter-spacing in px, added between clusters (0 for none).
41
+ * @param {typeof import('harfbuzzjs')} hb - harfbuzzjs.
42
+ * @returns {Shaped} Positioned glyphs.
43
+ */
44
+ export function shape(face, text, size, tracking, hb) {
45
+ const buffer = new hb.Buffer();
46
+ buffer.addText(text);
47
+ buffer.guessSegmentProperties();
48
+ hb.shape(face.font, buffer);
49
+ const infos = buffer.getGlyphInfos();
50
+ const positions = buffer.getGlyphPositions();
51
+ const scale = size / face.upem;
52
+ const glyphs = [];
53
+ let pen = 0;
54
+ for (let i = 0; i < infos.length; i++) {
55
+ const p = positions[i];
56
+ glyphs.push({ gid: infos[i].codepoint, x: pen + p.xOffset * scale, y: -p.yOffset * scale });
57
+ pen += p.xAdvance * scale;
58
+ const nextCluster = infos[i + 1]?.cluster;
59
+ if (tracking && nextCluster !== undefined && nextCluster !== infos[i].cluster) pen += tracking;
60
+ }
61
+ return { glyphs, width: pen };
62
+ }
63
+
64
+ /**
65
+ * A glyph's outline as parsed commands, in font units (y up). Cached on
66
+ * the face.
67
+ *
68
+ * @param {import('./fonts.js').LoadedFont} face
69
+ * @param {number} gid
70
+ * @returns {(string | number)[]} Flat list: command letter followed by its numbers.
71
+ */
72
+ function outline(face, gid) {
73
+ let cmds = face.paths.get(gid);
74
+ if (!cmds) {
75
+ cmds = [];
76
+ const d = face.font.glyphToPath(gid);
77
+ for (const m of d.matchAll(/([MLQCZ])([^MLQCZ]*)/gi)) {
78
+ cmds.push(m[1].toUpperCase());
79
+ for (const n of m[2].split(/[\s,]+/).filter(Boolean)) cmds.push(parseFloat(n));
80
+ }
81
+ face.paths.set(gid, cmds);
82
+ }
83
+ return cmds;
84
+ }
85
+
86
+ /**
87
+ * Write shaped glyphs as SVG path data in canvas pixels.
88
+ *
89
+ * @param {import('./fonts.js').LoadedFont} face
90
+ * @param {Shaped} run - Shaped glyphs.
91
+ * @param {number} size - Font size in px.
92
+ * @param {number} x - Left edge of the run.
93
+ * @param {number} baseline - Baseline y.
94
+ * @returns {string} Path data.
95
+ */
96
+ export function runPath(face, run, size, x, baseline) {
97
+ const s = size / face.upem;
98
+ const parts = [];
99
+ for (const g of run.glyphs) {
100
+ const ox = x + g.x;
101
+ const oy = baseline + g.y;
102
+ let coords = [];
103
+ for (const c of outline(face, g.gid)) {
104
+ if (typeof c === 'string') {
105
+ if (coords.length) parts.push(coords.join(' '));
106
+ parts.push(c);
107
+ coords = [];
108
+ } else {
109
+ // Coordinates alternate x, y; y flips (font units point up).
110
+ coords.push(num(coords.length % 2 === 0 ? ox + c * s : oy - c * s));
111
+ }
112
+ }
113
+ if (coords.length) parts.push(coords.join(' '));
114
+ }
115
+ return parts.join('');
116
+ }
117
+
118
+ /**
119
+ * Laid-out text.
120
+ *
121
+ * @typedef {object} Layout
122
+ * @property {{ text: string, run: Shaped }[]} lines - Each line and its shaping.
123
+ * @property {number} size - The size used (may be below the asked size with `minSize`).
124
+ * @property {boolean} truncated - Whether an ellipsis was added.
125
+ * @property {number} width - The widest line.
126
+ */
127
+
128
+ /**
129
+ * Break text into lines.
130
+ *
131
+ * @param {object} o
132
+ * @param {import('./fonts.js').LoadedFont} o.face - The face.
133
+ * @param {typeof import('harfbuzzjs')} o.hb - harfbuzzjs.
134
+ * @param {string} o.text - The text (`\n` starts a new line).
135
+ * @param {number} o.size - Font size in px.
136
+ * @param {number} [o.minSize] - Smallest size to shrink to before truncating.
137
+ * @param {number} [o.letterSpacing=0] - Tracking in em.
138
+ * @param {number} [o.maxWidth=Infinity] - Line width in px.
139
+ * @param {number} [o.maxLines=Infinity] - Most lines before truncating.
140
+ * @param {string} [o.ellipsis='…'] - Appended to a cut line.
141
+ * @param {boolean} [o.balance=false] - Balance line lengths.
142
+ * @returns {Layout} The layout.
143
+ */
144
+ export function layout(o) {
145
+ const maxLines = Math.max(1, o.maxLines ?? Infinity);
146
+ const maxWidth = o.maxWidth ?? Infinity;
147
+ let size = o.size;
148
+ const floor = Math.min(o.minSize ?? size, size);
149
+
150
+ for (;;) {
151
+ const attempt = breakLines({ ...o, size, maxWidth, maxLines });
152
+ if ((!attempt.truncated && !attempt.forced) || size <= floor) return attempt;
153
+ size = Math.max(floor, size * 0.94);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * One layout pass at a fixed size.
159
+ *
160
+ * @param {Parameters<typeof layout>[0] & { maxWidth: number, maxLines: number }} o
161
+ * @returns {Layout & { forced: boolean }} The layout; `forced` is true when a word had to be split.
162
+ */
163
+ function breakLines(o) {
164
+ const tracking = (o.letterSpacing ?? 0) * o.size;
165
+ const measure = (/** @type {string} */ t) => shape(o.face, t, o.size, tracking, o.hb);
166
+ const ellipsis = o.ellipsis ?? '…';
167
+
168
+ /** @param {number} width */
169
+ const wrap = (width) => {
170
+ /** @type {string[]} */
171
+ const lines = [];
172
+ let forced = false;
173
+ for (const para of o.text.split('\n')) {
174
+ const words = para.split(/ +/).filter(Boolean);
175
+ if (!words.length) { lines.push(''); continue; }
176
+ let line = '';
177
+ for (const word of words) {
178
+ const candidate = line ? `${line} ${word}` : word;
179
+ if (!line || measure(candidate).width <= width) {
180
+ line = candidate;
181
+ } else {
182
+ lines.push(line);
183
+ line = word;
184
+ }
185
+ // A single word wider than the line: split it between characters.
186
+ while (measure(line).width > width && Array.from(line).length > 1) {
187
+ forced = true;
188
+ const chars = Array.from(line);
189
+ let cut = chars.length - 1;
190
+ while (cut > 1 && measure(chars.slice(0, cut).join('')).width > width) cut--;
191
+ lines.push(chars.slice(0, cut).join(''));
192
+ line = chars.slice(cut).join('');
193
+ }
194
+ }
195
+ lines.push(line);
196
+ }
197
+ return { lines, forced };
198
+ };
199
+
200
+ let { lines, forced } = wrap(o.maxWidth);
201
+
202
+ if (o.balance && lines.length > 1 && lines.length <= o.maxLines && Number.isFinite(o.maxWidth)) {
203
+ const target = lines.length;
204
+ let lo = o.maxWidth * 0.4;
205
+ let hi = o.maxWidth;
206
+ for (let i = 0; i < 12; i++) {
207
+ const mid = (lo + hi) / 2;
208
+ const w = wrap(mid);
209
+ if (w.lines.length <= target && !w.forced) hi = mid; else lo = mid;
210
+ }
211
+ lines = wrap(hi).lines;
212
+ }
213
+
214
+ let truncated = false;
215
+ if (lines.length > o.maxLines) {
216
+ truncated = true;
217
+ const kept = lines.slice(0, o.maxLines);
218
+ let last = kept[o.maxLines - 1];
219
+ const fits = (/** @type {string} */ t) => measure(t + ellipsis).width <= o.maxWidth;
220
+ while (!fits(last) && last.length) {
221
+ const cut = last.lastIndexOf(' ');
222
+ last = cut > 0 ? last.slice(0, cut) : Array.from(last).slice(0, -1).join('');
223
+ }
224
+ kept[o.maxLines - 1] = last.replace(/[\s.,;:!?·—–-]+$/u, '') + ellipsis;
225
+ lines = kept;
226
+ }
227
+
228
+ const shaped = lines.map((text) => ({ text, run: measure(text) }));
229
+ return { lines: shaped, size: o.size, truncated, forced, width: Math.max(0, ...shaped.map((l) => l.run.width)) };
230
+ }