@arraypress/covers 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/LICENSE +29 -0
  3. package/README.md +846 -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 +124 -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/brand.json +1143 -0
  14. package/recipes/contour.json +109 -0
  15. package/recipes/cutout.json +191 -0
  16. package/recipes/diagram.json +92 -0
  17. package/recipes/dither.json +170 -0
  18. package/recipes/duotone.json +75 -0
  19. package/recipes/gridTitle.json +136 -0
  20. package/recipes/iconTile.json +214 -0
  21. package/recipes/issue.json +221 -0
  22. package/recipes/mark.json +170 -0
  23. package/recipes/photo.json +156 -0
  24. package/recipes/riso.json +387 -0
  25. package/recipes/stripes.json +206 -0
  26. package/recipes/waveform.json +154 -0
  27. package/schema/recipe.schema.json +8822 -0
  28. package/src/animate/index.d.ts +19 -0
  29. package/src/animate/index.js +116 -0
  30. package/src/astro.d.ts +31 -0
  31. package/src/astro.js +86 -0
  32. package/src/builtins.js +4 -0
  33. package/src/color.js +246 -0
  34. package/src/diagram.js +248 -0
  35. package/src/effects/ascii.d.ts +7 -0
  36. package/src/effects/ascii.js +93 -0
  37. package/src/effects/buffer.d.ts +60 -0
  38. package/src/effects/buffer.js +472 -0
  39. package/src/effects/detail.d.ts +12 -0
  40. package/src/effects/detail.js +124 -0
  41. package/src/effects/digital.d.ts +14 -0
  42. package/src/effects/digital.js +216 -0
  43. package/src/effects/distort.d.ts +20 -0
  44. package/src/effects/distort.js +314 -0
  45. package/src/effects/dither.d.ts +8 -0
  46. package/src/effects/dither.js +147 -0
  47. package/src/effects/drop-shadow.d.ts +4 -0
  48. package/src/effects/drop-shadow.js +45 -0
  49. package/src/effects/extrude.d.ts +5 -0
  50. package/src/effects/extrude.js +55 -0
  51. package/src/effects/film.d.ts +11 -0
  52. package/src/effects/film.js +204 -0
  53. package/src/effects/glow.d.ts +8 -0
  54. package/src/effects/glow.js +56 -0
  55. package/src/effects/grade.d.ts +30 -0
  56. package/src/effects/grade.js +274 -0
  57. package/src/effects/halftone.d.ts +8 -0
  58. package/src/effects/halftone.js +95 -0
  59. package/src/effects/index.d.ts +125 -0
  60. package/src/effects/index.js +277 -0
  61. package/src/effects/ink.d.ts +12 -0
  62. package/src/effects/ink.js +70 -0
  63. package/src/effects/lens.d.ts +14 -0
  64. package/src/effects/lens.js +277 -0
  65. package/src/effects/light.d.ts +10 -0
  66. package/src/effects/light.js +59 -0
  67. package/src/effects/looks.d.ts +9 -0
  68. package/src/effects/looks.js +74 -0
  69. package/src/effects/mask.d.ts +52 -0
  70. package/src/effects/mask.js +314 -0
  71. package/src/effects/motion.d.ts +6 -0
  72. package/src/effects/motion.js +60 -0
  73. package/src/effects/newsprint.d.ts +6 -0
  74. package/src/effects/newsprint.js +89 -0
  75. package/src/effects/optics.d.ts +10 -0
  76. package/src/effects/optics.js +213 -0
  77. package/src/effects/options.d.ts +6 -0
  78. package/src/effects/options.js +106 -0
  79. package/src/effects/paint-presets.d.ts +10 -0
  80. package/src/effects/paint-presets.js +47 -0
  81. package/src/effects/paint.d.ts +49 -0
  82. package/src/effects/paint.js +66 -0
  83. package/src/effects/pixels.d.ts +24 -0
  84. package/src/effects/pixels.js +138 -0
  85. package/src/effects/polar.d.ts +10 -0
  86. package/src/effects/polar.js +265 -0
  87. package/src/effects/region.d.ts +8 -0
  88. package/src/effects/region.js +136 -0
  89. package/src/effects/round-border.d.ts +5 -0
  90. package/src/effects/round-border.js +62 -0
  91. package/src/effects/screen.d.ts +14 -0
  92. package/src/effects/screen.js +212 -0
  93. package/src/effects/sort.d.ts +8 -0
  94. package/src/effects/sort.js +125 -0
  95. package/src/effects/symmetry.d.ts +6 -0
  96. package/src/effects/symmetry.js +74 -0
  97. package/src/effects/tile.d.ts +7 -0
  98. package/src/effects/tile.js +55 -0
  99. package/src/effects/tone.d.ts +16 -0
  100. package/src/effects/tone.js +117 -0
  101. package/src/effects/transform.d.ts +35 -0
  102. package/src/effects/transform.js +127 -0
  103. package/src/effects/util.d.ts +16 -0
  104. package/src/effects/util.js +85 -0
  105. package/src/extract.js +91 -0
  106. package/src/flatten.js +234 -0
  107. package/src/fonts.d.ts +55 -0
  108. package/src/fonts.js +393 -0
  109. package/src/formats/cut.d.ts +10 -0
  110. package/src/formats/cut.js +77 -0
  111. package/src/formats/index.d.ts +6 -0
  112. package/src/formats/index.js +9 -0
  113. package/src/formats/presets.d.ts +41 -0
  114. package/src/formats/presets.js +89 -0
  115. package/src/formats/print.d.ts +25 -0
  116. package/src/formats/print.js +117 -0
  117. package/src/formats/zones.d.ts +10 -0
  118. package/src/formats/zones.js +64 -0
  119. package/src/geometry.js +187 -0
  120. package/src/grid.js +41 -0
  121. package/src/handmade.js +253 -0
  122. package/src/icons.js +191 -0
  123. package/src/index.d.ts +1083 -0
  124. package/src/index.js +27 -0
  125. package/src/inter-data.js +27 -0
  126. package/src/json-recipe.js +616 -0
  127. package/src/kit/index.d.ts +20 -0
  128. package/src/kit/index.js +58 -0
  129. package/src/layer-options.js +56 -0
  130. package/src/layers/barcode.d.ts +19 -0
  131. package/src/layers/barcode.js +63 -0
  132. package/src/layers/code128.d.ts +4 -0
  133. package/src/layers/code128.js +28 -0
  134. package/src/layers/expr.d.ts +6 -0
  135. package/src/layers/expr.js +121 -0
  136. package/src/layers/geometry.d.ts +6 -0
  137. package/src/layers/geometry.js +238 -0
  138. package/src/layers/index.d.ts +18 -0
  139. package/src/layers/index.js +15 -0
  140. package/src/layers/perforation.d.ts +14 -0
  141. package/src/layers/perforation.js +47 -0
  142. package/src/layers/repeat.d.ts +29 -0
  143. package/src/layers/repeat.js +90 -0
  144. package/src/layers/shape.d.ts +46 -0
  145. package/src/layers/shape.js +115 -0
  146. package/src/layers/soft.d.ts +5 -0
  147. package/src/layers/soft.js +43 -0
  148. package/src/layers/svg.d.ts +23 -0
  149. package/src/layers/svg.js +109 -0
  150. package/src/layers/table.d.ts +25 -0
  151. package/src/layers/table.js +80 -0
  152. package/src/layers/tear.d.ts +21 -0
  153. package/src/layers/tear.js +74 -0
  154. package/src/layers/texture.d.ts +17 -0
  155. package/src/layers/texture.js +106 -0
  156. package/src/layers/util.d.ts +8 -0
  157. package/src/layers/util.js +48 -0
  158. package/src/layers.js +1056 -0
  159. package/src/lint/image-size.d.ts +4 -0
  160. package/src/lint/image-size.js +34 -0
  161. package/src/lint/index.d.ts +16 -0
  162. package/src/lint/index.js +127 -0
  163. package/src/lint/rules.d.ts +5 -0
  164. package/src/lint/rules.js +19 -0
  165. package/src/node.d.ts +2 -0
  166. package/src/node.js +20 -0
  167. package/src/palette.js +185 -0
  168. package/src/platform/bytes.d.ts +22 -0
  169. package/src/platform/bytes.js +103 -0
  170. package/src/platform/compress.d.ts +12 -0
  171. package/src/platform/compress.js +81 -0
  172. package/src/platform/harfbuzz.d.ts +9 -0
  173. package/src/platform/harfbuzz.js +80 -0
  174. package/src/platform/index.d.ts +8 -0
  175. package/src/platform/index.js +13 -0
  176. package/src/platform/load.d.ts +6 -0
  177. package/src/platform/load.js +72 -0
  178. package/src/platform/no-harfbuzz.d.ts +2 -0
  179. package/src/platform/no-harfbuzz.js +11 -0
  180. package/src/platform/node.d.ts +26 -0
  181. package/src/platform/node.js +86 -0
  182. package/src/platform/settings.d.ts +28 -0
  183. package/src/platform/settings.js +51 -0
  184. package/src/png.js +126 -0
  185. package/src/print.js +659 -0
  186. package/src/raster/index.d.ts +24 -0
  187. package/src/raster/index.js +171 -0
  188. package/src/recipes.js +79 -0
  189. package/src/render/backdrop.d.ts +6 -0
  190. package/src/render/backdrop.js +55 -0
  191. package/src/render/content.d.ts +5 -0
  192. package/src/render/content.js +58 -0
  193. package/src/render/context.d.ts +82 -0
  194. package/src/render/grain.d.ts +7 -0
  195. package/src/render/grain.js +113 -0
  196. package/src/render/group.d.ts +23 -0
  197. package/src/render/group.js +67 -0
  198. package/src/render/key.d.ts +5 -0
  199. package/src/render/key.js +91 -0
  200. package/src/render/report.d.ts +5 -0
  201. package/src/render/report.js +19 -0
  202. package/src/render/tiles.d.ts +6 -0
  203. package/src/render/tiles.js +77 -0
  204. package/src/render/use.d.ts +27 -0
  205. package/src/render/use.js +73 -0
  206. package/src/render/wrap.d.ts +14 -0
  207. package/src/render/wrap.js +199 -0
  208. package/src/render.js +553 -0
  209. package/src/rng.js +95 -0
  210. package/src/svg.js +69 -0
  211. package/src/text.d.ts +29 -0
  212. package/src/text.js +237 -0
  213. package/src/type/auto-color.d.ts +9 -0
  214. package/src/type/auto-color.js +44 -0
  215. package/src/type/fallback.d.ts +11 -0
  216. package/src/type/fallback.js +92 -0
  217. package/src/type/glyphs.d.ts +24 -0
  218. package/src/type/glyphs.js +87 -0
  219. package/src/type/index.d.ts +5 -0
  220. package/src/type/index.js +10 -0
  221. package/src/type/kern.d.ts +4 -0
  222. package/src/type/kern.js +144 -0
  223. package/src/type/layout-ref.d.ts +10 -0
  224. package/src/type/layout-ref.js +74 -0
  225. package/src/type/line.d.ts +11 -0
  226. package/src/type/line.js +105 -0
  227. package/src/type/live.d.ts +6 -0
  228. package/src/type/live.js +39 -0
  229. package/src/type/name.d.ts +4 -0
  230. package/src/type/name.js +32 -0
  231. package/src/type/path.d.ts +18 -0
  232. package/src/type/path.js +89 -0
  233. package/src/type/pixel.d.ts +9 -0
  234. package/src/type/pixel.js +115 -0
  235. package/src/type/sfnt.d.ts +20 -0
  236. package/src/type/sfnt.js +258 -0
  237. package/src/type/spans.d.ts +33 -0
  238. package/src/type/spans.js +138 -0
  239. package/src/type/symbols.d.ts +7 -0
  240. package/src/type/symbols.js +89 -0
  241. package/src/type/typeset.d.ts +10 -0
  242. package/src/type/typeset.js +352 -0
  243. package/src/type/woff2.d.ts +6 -0
  244. package/src/type/woff2.js +301 -0
  245. package/src/version.js +4 -0
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Named formats: release covers, social posts, flyers and print stationery,
3
+ * alongside `aspect`.
4
+ *
5
+ * Screen formats are pixel sizes. Print formats are a trim size in
6
+ * millimetres or inches, rendered at `dpi` (300 by default) with a bleed
7
+ * (3 mm by default) and a safe area inside the trim; `cropMarks: true`
8
+ * adds a slug with crop marks round the bleed. The layers are drawn on the
9
+ * bleed box (trim plus bleed on every side), so backgrounds run off the
10
+ * edge, and text and icons keep their default margins inside the safe area.
11
+ *
12
+ * @module formats/print
13
+ */
14
+
15
+ import { FORMATS } from './presets.js';
16
+ import { scaleZones } from './zones.js';
17
+ import { cutPath } from './cut.js';
18
+
19
+ /** @param {number} v @param {string} unit @param {number} dpi @returns {number} px */
20
+ export function toPx(v, unit, dpi) {
21
+ return unit === 'mm' ? (v / 25.4) * dpi : unit === 'in' ? v * dpi : v;
22
+ }
23
+
24
+ /**
25
+ * The print set-up of a cover, in px.
26
+ *
27
+ * @typedef {object} PrintInfo
28
+ * @property {number} dpi
29
+ * @property {'mm' | 'in'} unit - The format's unit.
30
+ * @property {number} trimWidth - px.
31
+ * @property {number} trimHeight - px.
32
+ * @property {number} bleed - px on each side.
33
+ * @property {number} safe - px inside the trim on each side.
34
+ * @property {number} slug - px of slug outside the bleed (crop marks), 0 without.
35
+ * @property {boolean} cropMarks
36
+ * @property {import('./cut.js').Cut} [cut] - Die-cut shape.
37
+ * @property {boolean} cutLine - Draw the die line.
38
+ * @property {number} mm - px per millimetre.
39
+ */
40
+
41
+ /**
42
+ * Resolve a cover's `format` (a name or a custom `{ width, height, unit }`)
43
+ * to its canvas and print set-up.
44
+ *
45
+ * @param {any} o - Cover options: `format`, `dpi`, `bleed` (mm, or `false`), `safe` (mm), `cropMarks`, `cutLine`.
46
+ * @returns {{ width: number, height: number, print?: PrintInfo, format?: string, zones?: import('./index.js').Zones } | undefined}
47
+ */
48
+ export function resolveFormat(o) {
49
+ if (o.format == null) return undefined;
50
+ const f = typeof o.format === 'string' ? FORMATS[o.format] : { unit: 'px', ...o.format };
51
+ if (!f) throw new RangeError(`Unknown format "${o.format}". Formats: ${Object.keys(FORMATS).join(', ')}`);
52
+ const name = typeof o.format === 'string' ? o.format : undefined;
53
+ if (f.unit === 'px') {
54
+ // A screen format: one side given scales the zones and the other side.
55
+ const k = o.width && !o.height ? o.width / f.width : 1;
56
+ const zones = f.zones ? scaleZones(f.zones, k) : undefined;
57
+ return { width: Math.round(f.width * k), height: Math.round(f.height * k), format: name, zones };
58
+ }
59
+ const dpi = o.dpi ?? 300;
60
+ const mm = dpi / 25.4;
61
+ const trimWidth = Math.round(toPx(f.width, f.unit, dpi));
62
+ const trimHeight = Math.round(toPx(f.height, f.unit, dpi));
63
+ const bleed = o.bleed === false ? 0 : Math.round((o.bleed ?? f.bleed ?? 3) * mm);
64
+ const safe = Math.round((o.safe ?? f.safe ?? 3) * mm);
65
+ const cropMarks = Boolean(o.cropMarks);
66
+ const slug = cropMarks ? Math.round(Math.max(6, (o.bleed === false ? 0 : o.bleed ?? f.bleed ?? 3) + 3) * mm) : 0;
67
+ return {
68
+ width: trimWidth + 2 * bleed,
69
+ height: trimHeight + 2 * bleed,
70
+ format: name,
71
+ print: { dpi, unit: /** @type {'mm' | 'in'} */ (f.unit), trimWidth, trimHeight, bleed, safe, slug, cropMarks, cut: o.cut ?? f.cut, cutLine: Boolean(o.cutLine), mm },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Crop marks in the slug, a die line, and the document's outer size.
77
+ *
78
+ * @param {PrintInfo} p
79
+ * @param {number} w - Bleed-box width, px.
80
+ * @param {number} h - Bleed-box height, px.
81
+ * @returns {{ marks: string, cut: string }} Markup: marks in slug coordinates (the bleed box starts at `slug`), and the die line in bleed-box coordinates.
82
+ */
83
+ export function printMarks(p, w, h) {
84
+ const n = (/** @type {number} */ v) => Math.round(v * 100) / 100;
85
+ let marks = '';
86
+ if (p.cropMarks) {
87
+ const s = p.slug;
88
+ const b = p.bleed;
89
+ const len = s - b - p.mm;
90
+ const gap = b + p.mm;
91
+ const stroke = Math.max(0.5, 0.25 * p.mm);
92
+ const xs = [s + b, s + w - b];
93
+ const ys = [s + b, s + h - b];
94
+ let d = '';
95
+ for (const x of xs) for (const y of ys) {
96
+ const dx = x < s + w / 2 ? -1 : 1;
97
+ const dy = y < s + h / 2 ? -1 : 1;
98
+ d += `M${n(x + dx * gap)} ${n(y)}h${n(dx * len)}M${n(x)} ${n(y + dy * gap)}v${n(dy * len)}`;
99
+ }
100
+ marks = `<path d="${d}" fill="none" stroke="#000" stroke-width="${n(stroke)}"/>`;
101
+ }
102
+ let cut = '';
103
+ if (p.cutLine) {
104
+ const x = p.bleed;
105
+ const y = p.bleed;
106
+ const cw = p.trimWidth;
107
+ const ch = p.trimHeight;
108
+ const custom = p.cut && typeof p.cut === 'object';
109
+ const shape = custom
110
+ ? `<path d="${cutPath(p.cut, { x, y, width: cw, height: ch }, p.mm).map((g) => g.cmd + g.pts.map(n).join(' ')).join('')}"`
111
+ : p.cut === 'circle'
112
+ ? `<ellipse cx="${n(x + cw / 2)}" cy="${n(y + ch / 2)}" rx="${n(cw / 2)}" ry="${n(ch / 2)}"`
113
+ : `<rect x="${n(x)}" y="${n(y)}" width="${n(cw)}" height="${n(ch)}"${p.cut === 'rounded' ? ` rx="${n(3 * p.mm)}"` : ''}`;
114
+ cut = `${shape} fill="none" stroke="#ec008c" stroke-width="${n(Math.max(0.5, 0.25 * p.mm))}" id="CutContour"/>`;
115
+ }
116
+ return { marks, cut };
117
+ }
@@ -0,0 +1,10 @@
1
+ // Safe zones.
2
+ import type { Zones } from './presets.js';
3
+ import type { PrintInfo } from './print.js';
4
+
5
+ /** A preview overlay of the zones: safe area dashed green, covered areas red, crops blue. */
6
+ export function zonesOverlay(z: Zones, w: number, h: number): string;
7
+ /** Zones scaled by `k`. */
8
+ export function scaleZones(z: Zones, k: number): Zones;
9
+ /** A cover's safe box, px. */
10
+ export function safeBox(c: { width: number; height: number; print?: PrintInfo; zones?: Zones }): { x: number; y: number; width: number; height: number } | undefined;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Safe zones: the box layouts fit into, scaling, and a preview overlay.
3
+ *
4
+ * @module formats/zones
5
+ */
6
+
7
+ /**
8
+ * A preview overlay of a format's zones: the safe area dashed, covered
9
+ * areas tinted red, device crops outlined.
10
+ *
11
+ * @param {import('./index.js').Zones} z
12
+ * @param {number} w @param {number} h
13
+ * @returns {string} Markup.
14
+ */
15
+ export function zonesOverlay(z, w, h) {
16
+ const n = (/** @type {number} */ v) => Math.round(v * 100) / 100;
17
+ const sw = n(Math.max(1, Math.min(w, h) / 360));
18
+ let out = '';
19
+ for (const c of z.crops ?? []) {
20
+ const [x, y, cw, ch] = c.rect;
21
+ if (c.name === 'circle') {
22
+ out += `<path d="M0 0H${n(w)}V${n(h)}H0ZM${n(x)} ${n(y + ch / 2)}a${n(cw / 2)} ${n(ch / 2)} 0 1 0 ${n(cw)} 0a${n(cw / 2)} ${n(ch / 2)} 0 1 0 ${n(-cw)} 0Z" fill="#000" fill-opacity="0.45" fill-rule="evenodd"/>`;
23
+ } else {
24
+ out += `<rect x="${n(x)}" y="${n(y)}" width="${n(cw)}" height="${n(ch)}" fill="none" stroke="#00b3ff" stroke-width="${sw}" stroke-dasharray="${n(sw * 2)} ${n(sw * 3)}"/>`;
25
+ }
26
+ }
27
+ for (const a of z.avoid ?? []) {
28
+ if (a.circle) out += `<circle cx="${n(a.circle[0])}" cy="${n(a.circle[1])}" r="${n(a.circle[2])}" fill="#ff2d55" fill-opacity="0.35" stroke="#ff2d55" stroke-width="${sw}"/>`;
29
+ if (a.rect) out += `<rect x="${n(a.rect[0])}" y="${n(a.rect[1])}" width="${n(a.rect[2])}" height="${n(a.rect[3])}" fill="#ff2d55" fill-opacity="0.3"/>`;
30
+ }
31
+ const [x, y, sw2, sh] = z.safe;
32
+ out += `<rect x="${n(x)}" y="${n(y)}" width="${n(sw2)}" height="${n(sh)}" fill="none" stroke="#18e05a" stroke-width="${n(sw * 2)}" stroke-dasharray="${n(sw * 8)} ${n(sw * 5)}"/>`;
33
+ return `<g pointer-events="none">${out}</g>`;
34
+ }
35
+
36
+ /**
37
+ * @param {import('./index.js').Zones} z @param {number} k
38
+ * @returns {import('./index.js').Zones}
39
+ */
40
+ export function scaleZones(z, k) {
41
+ if (k === 1) return z;
42
+ const r = (/** @type {number[]} */ a) => /** @type {any} */ (a.map((v) => Math.round(v * k)));
43
+ return { ...z, safe: r(z.safe), avoid: z.avoid?.map((a) => ({ ...a, rect: a.rect && r(a.rect), circle: a.circle && r(a.circle) })), crops: z.crops?.map((c) => ({ ...c, rect: r(c.rect) })) };
44
+ }
45
+
46
+
47
+ /**
48
+ * The safe box of a cover, px: a format's safe zone, or a print piece's
49
+ * trim less its safe margin. Undefined for a plain canvas.
50
+ *
51
+ * @param {{ width: number, height: number, print?: import('./index.js').PrintInfo, zones?: import('./index.js').Zones }} c
52
+ * @returns {{ x: number, y: number, width: number, height: number } | undefined}
53
+ */
54
+ export function safeBox(c) {
55
+ if (c.zones) {
56
+ const [x, y, width, height] = c.zones.safe;
57
+ return { x, y, width, height };
58
+ }
59
+ if (c.print) {
60
+ const i = c.print.bleed + c.print.safe;
61
+ return { x: i, y: i, width: c.width - 2 * i, height: c.height - 2 * i };
62
+ }
63
+ return undefined;
64
+ }
@@ -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
+ export 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';