@arraypress/covers 1.0.0 → 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 (213) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +296 -11
  3. package/package.json +33 -5
  4. package/recipes/brand.json +1143 -0
  5. package/schema/recipe.schema.json +7300 -770
  6. package/src/animate/index.d.ts +19 -0
  7. package/src/animate/index.js +116 -0
  8. package/src/builtins.js +4 -0
  9. package/src/effects/ascii.d.ts +7 -0
  10. package/src/effects/ascii.js +93 -0
  11. package/src/effects/buffer.d.ts +60 -0
  12. package/src/effects/buffer.js +472 -0
  13. package/src/effects/detail.d.ts +12 -0
  14. package/src/effects/detail.js +124 -0
  15. package/src/effects/digital.d.ts +14 -0
  16. package/src/effects/digital.js +216 -0
  17. package/src/effects/distort.d.ts +20 -0
  18. package/src/effects/distort.js +314 -0
  19. package/src/effects/dither.d.ts +8 -0
  20. package/src/effects/dither.js +147 -0
  21. package/src/effects/drop-shadow.d.ts +4 -0
  22. package/src/effects/drop-shadow.js +45 -0
  23. package/src/effects/extrude.d.ts +5 -0
  24. package/src/effects/extrude.js +55 -0
  25. package/src/effects/film.d.ts +11 -0
  26. package/src/effects/film.js +204 -0
  27. package/src/effects/glow.d.ts +8 -0
  28. package/src/effects/glow.js +56 -0
  29. package/src/effects/grade.d.ts +30 -0
  30. package/src/effects/grade.js +274 -0
  31. package/src/effects/halftone.d.ts +8 -0
  32. package/src/effects/halftone.js +95 -0
  33. package/src/effects/index.d.ts +125 -0
  34. package/src/effects/index.js +277 -0
  35. package/src/effects/ink.d.ts +12 -0
  36. package/src/effects/ink.js +70 -0
  37. package/src/effects/lens.d.ts +14 -0
  38. package/src/effects/lens.js +277 -0
  39. package/src/effects/light.d.ts +10 -0
  40. package/src/effects/light.js +59 -0
  41. package/src/effects/looks.d.ts +9 -0
  42. package/src/effects/looks.js +74 -0
  43. package/src/effects/mask.d.ts +52 -0
  44. package/src/effects/mask.js +314 -0
  45. package/src/effects/motion.d.ts +6 -0
  46. package/src/effects/motion.js +60 -0
  47. package/src/effects/newsprint.d.ts +6 -0
  48. package/src/effects/newsprint.js +89 -0
  49. package/src/effects/optics.d.ts +10 -0
  50. package/src/effects/optics.js +213 -0
  51. package/src/effects/options.d.ts +6 -0
  52. package/src/effects/options.js +106 -0
  53. package/src/effects/paint-presets.d.ts +10 -0
  54. package/src/effects/paint-presets.js +47 -0
  55. package/src/effects/paint.d.ts +49 -0
  56. package/src/effects/paint.js +66 -0
  57. package/src/effects/pixels.d.ts +24 -0
  58. package/src/effects/pixels.js +138 -0
  59. package/src/effects/polar.d.ts +10 -0
  60. package/src/effects/polar.js +265 -0
  61. package/src/effects/region.d.ts +8 -0
  62. package/src/effects/region.js +136 -0
  63. package/src/effects/round-border.d.ts +5 -0
  64. package/src/effects/round-border.js +62 -0
  65. package/src/effects/screen.d.ts +14 -0
  66. package/src/effects/screen.js +212 -0
  67. package/src/effects/sort.d.ts +8 -0
  68. package/src/effects/sort.js +125 -0
  69. package/src/effects/symmetry.d.ts +6 -0
  70. package/src/effects/symmetry.js +74 -0
  71. package/src/effects/tile.d.ts +7 -0
  72. package/src/effects/tile.js +55 -0
  73. package/src/effects/tone.d.ts +16 -0
  74. package/src/effects/tone.js +117 -0
  75. package/src/effects/transform.d.ts +35 -0
  76. package/src/effects/transform.js +127 -0
  77. package/src/effects/util.d.ts +16 -0
  78. package/src/effects/util.js +85 -0
  79. package/src/extract.js +4 -3
  80. package/src/fonts.d.ts +55 -0
  81. package/src/fonts.js +246 -53
  82. package/src/formats/cut.d.ts +10 -0
  83. package/src/formats/cut.js +77 -0
  84. package/src/formats/index.d.ts +6 -0
  85. package/src/formats/index.js +9 -0
  86. package/src/formats/presets.d.ts +41 -0
  87. package/src/formats/presets.js +89 -0
  88. package/src/formats/print.d.ts +25 -0
  89. package/src/formats/print.js +117 -0
  90. package/src/formats/zones.d.ts +10 -0
  91. package/src/formats/zones.js +64 -0
  92. package/src/geometry.js +1 -1
  93. package/src/icons.js +43 -9
  94. package/src/index.d.ts +205 -19
  95. package/src/index.js +12 -4
  96. package/src/inter-data.js +27 -0
  97. package/src/json-recipe.js +181 -23
  98. package/src/kit/index.d.ts +20 -0
  99. package/src/kit/index.js +58 -0
  100. package/src/layer-options.js +23 -4
  101. package/src/layers/barcode.d.ts +19 -0
  102. package/src/layers/barcode.js +63 -0
  103. package/src/layers/code128.d.ts +4 -0
  104. package/src/layers/code128.js +28 -0
  105. package/src/layers/expr.d.ts +6 -0
  106. package/src/layers/expr.js +121 -0
  107. package/src/layers/geometry.d.ts +6 -0
  108. package/src/layers/geometry.js +238 -0
  109. package/src/layers/index.d.ts +18 -0
  110. package/src/layers/index.js +15 -0
  111. package/src/layers/perforation.d.ts +14 -0
  112. package/src/layers/perforation.js +47 -0
  113. package/src/layers/repeat.d.ts +29 -0
  114. package/src/layers/repeat.js +90 -0
  115. package/src/layers/shape.d.ts +46 -0
  116. package/src/layers/shape.js +115 -0
  117. package/src/layers/soft.d.ts +5 -0
  118. package/src/layers/soft.js +43 -0
  119. package/src/layers/svg.d.ts +23 -0
  120. package/src/layers/svg.js +109 -0
  121. package/src/layers/table.d.ts +25 -0
  122. package/src/layers/table.js +80 -0
  123. package/src/layers/tear.d.ts +21 -0
  124. package/src/layers/tear.js +74 -0
  125. package/src/layers/texture.d.ts +17 -0
  126. package/src/layers/texture.js +106 -0
  127. package/src/layers/util.d.ts +8 -0
  128. package/src/layers/util.js +48 -0
  129. package/src/layers.js +125 -41
  130. package/src/lint/image-size.d.ts +4 -0
  131. package/src/lint/image-size.js +34 -0
  132. package/src/lint/index.d.ts +16 -0
  133. package/src/lint/index.js +127 -0
  134. package/src/lint/rules.d.ts +5 -0
  135. package/src/lint/rules.js +19 -0
  136. package/src/node.d.ts +2 -0
  137. package/src/node.js +20 -0
  138. package/src/platform/bytes.d.ts +22 -0
  139. package/src/platform/bytes.js +103 -0
  140. package/src/platform/compress.d.ts +12 -0
  141. package/src/platform/compress.js +81 -0
  142. package/src/platform/harfbuzz.d.ts +9 -0
  143. package/src/platform/harfbuzz.js +80 -0
  144. package/src/platform/index.d.ts +8 -0
  145. package/src/platform/index.js +13 -0
  146. package/src/platform/load.d.ts +6 -0
  147. package/src/platform/load.js +72 -0
  148. package/src/platform/no-harfbuzz.d.ts +2 -0
  149. package/src/platform/no-harfbuzz.js +11 -0
  150. package/src/platform/node.d.ts +26 -0
  151. package/src/platform/node.js +86 -0
  152. package/src/platform/settings.d.ts +28 -0
  153. package/src/platform/settings.js +51 -0
  154. package/src/png.js +77 -28
  155. package/src/print.js +8 -7
  156. package/src/raster/index.d.ts +24 -0
  157. package/src/raster/index.js +171 -0
  158. package/src/recipes.js +4 -3
  159. package/src/render/backdrop.d.ts +6 -0
  160. package/src/render/backdrop.js +55 -0
  161. package/src/render/content.d.ts +5 -0
  162. package/src/render/content.js +58 -0
  163. package/src/render/context.d.ts +82 -0
  164. package/src/render/grain.d.ts +7 -0
  165. package/src/render/grain.js +113 -0
  166. package/src/render/group.d.ts +23 -0
  167. package/src/render/group.js +67 -0
  168. package/src/render/key.d.ts +5 -0
  169. package/src/render/key.js +91 -0
  170. package/src/render/report.d.ts +5 -0
  171. package/src/render/report.js +19 -0
  172. package/src/render/tiles.d.ts +6 -0
  173. package/src/render/tiles.js +77 -0
  174. package/src/render/use.d.ts +27 -0
  175. package/src/render/use.js +73 -0
  176. package/src/render/wrap.d.ts +14 -0
  177. package/src/render/wrap.js +199 -0
  178. package/src/render.js +303 -212
  179. package/src/text.d.ts +29 -0
  180. package/src/text.js +22 -15
  181. package/src/type/auto-color.d.ts +9 -0
  182. package/src/type/auto-color.js +44 -0
  183. package/src/type/fallback.d.ts +11 -0
  184. package/src/type/fallback.js +92 -0
  185. package/src/type/glyphs.d.ts +24 -0
  186. package/src/type/glyphs.js +87 -0
  187. package/src/type/index.d.ts +5 -0
  188. package/src/type/index.js +10 -0
  189. package/src/type/kern.d.ts +4 -0
  190. package/src/type/kern.js +144 -0
  191. package/src/type/layout-ref.d.ts +10 -0
  192. package/src/type/layout-ref.js +74 -0
  193. package/src/type/line.d.ts +11 -0
  194. package/src/type/line.js +105 -0
  195. package/src/type/live.d.ts +6 -0
  196. package/src/type/live.js +39 -0
  197. package/src/type/name.d.ts +4 -0
  198. package/src/type/name.js +32 -0
  199. package/src/type/path.d.ts +18 -0
  200. package/src/type/path.js +89 -0
  201. package/src/type/pixel.d.ts +9 -0
  202. package/src/type/pixel.js +115 -0
  203. package/src/type/sfnt.d.ts +20 -0
  204. package/src/type/sfnt.js +258 -0
  205. package/src/type/spans.d.ts +33 -0
  206. package/src/type/spans.js +138 -0
  207. package/src/type/symbols.d.ts +7 -0
  208. package/src/type/symbols.js +89 -0
  209. package/src/type/typeset.d.ts +10 -0
  210. package/src/type/typeset.js +352 -0
  211. package/src/type/woff2.d.ts +6 -0
  212. package/src/type/woff2.js +301 -0
  213. package/src/version.js +4 -0
package/src/render.js CHANGED
@@ -4,28 +4,42 @@
4
4
  * @module render
5
5
  */
6
6
 
7
- import { createHash } from 'node:crypto';
8
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
9
- import { join, resolve } from 'node:path';
10
- import { readFileSync } from 'node:fs';
7
+ import { node } from './platform/index.js';
11
8
  import { createRng } from './rng.js';
12
9
  import { resolveSize, unit, len } from './geometry.js';
10
+ import { resolveFormat, printMarks, zonesOverlay, safeBox } from './formats/index.js';
11
+ import { animate, isAnimated } from './animate/index.js';
12
+ import { preloadIcons } from './icons.js';
13
+ import { rasterize } from './raster/index.js';
13
14
  import { resolvePalette } from './palette.js';
14
- import { bundledFonts } from './fonts.js';
15
- import { LAYERS, VARY_FLOOR, VARY_PEAK } from './layers.js';
15
+ import { fontPool } from './fonts.js';
16
+ import { LAYERS } from './layers.js';
17
+ import { wrapped, renderWrapped } from './render/wrap.js';
18
+ import { renderGroup } from './render/group.js';
19
+ import { registerDefs, renderUse } from './render/use.js';
20
+ import { expandRepeat } from './layers/repeat.js';
21
+ import { scaledReport } from './render/report.js';
22
+ import { hasLayoutRef, resolveLayoutRefs } from './type/layout-ref.js';
23
+ import { svgPixels } from './render/tiles.js';
24
+ import { IDENTITY, compose } from './effects/transform.js';
25
+ import { needsPixels } from './effects/index.js';
26
+ import { backdropSampler } from './render/backdrop.js';
27
+ import { noise, applyGrain } from './render/grain.js';
16
28
  import { extractPalette } from './extract.js';
17
29
  import { recipes, applyOverrides } from './recipes.js';
18
30
  import { defineRecipe } from './json-recipe.js';
19
31
  import { attrs, esc, num } from './svg.js';
20
32
 
21
- /** The package version, part of every cache key. */
22
- export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
33
+ export { VERSION } from './version.js';
34
+ export { coverKey } from './render/key.js';
35
+ import { coverKey } from './render/key.js';
36
+ export { safeBox } from './formats/index.js';
23
37
 
24
38
  /** Covers built by {@link cover}, so rendering one doesn't resolve it twice. */
25
39
  const built = new WeakSet();
26
40
 
27
41
  /** Options that belong to the cover itself, never to a recipe layer. */
28
- const COVER_KEYS = new Set(['width', 'height', 'aspect', 'seed', 'palette', 'recipe', 'layers', 'fonts', 'alt', 'radius', 'output']);
42
+ const COVER_KEYS = new Set(['width', 'height', 'aspect', 'seed', 'palette', 'recipe', 'layers', 'fonts', 'alt', 'radius', 'output', 'format', 'dpi', 'bleed', 'safe', 'cropMarks', 'cutLine', 'cut', 'textRender', 'overlay', 'loop']);
29
43
 
30
44
  /**
31
45
  * Build a cover: resolve its size and palette and expand its recipe into a
@@ -43,13 +57,19 @@ const COVER_KEYS = new Set(['width', 'height', 'aspect', 'seed', 'palette', 'rec
43
57
  export function cover(options) {
44
58
  if (built.has(/** @type {object} */ (options))) return /** @type {import('./index.js').Cover} */ (options);
45
59
  const o = /** @type {import('./index.js').CoverOptions} */ (options ?? {});
46
- const { width, height } = resolveSize(o);
60
+ const fmt = resolveFormat(o);
61
+ const { width, height } = fmt ?? resolveSize(o);
62
+ const print = fmt?.print;
47
63
  const palette = resolvePalette(o.palette);
48
64
  const seed = String(o.seed ?? (typeof o.title === 'string' ? o.title : 'cover'));
49
65
 
50
66
  let layers;
51
67
  /** @type {{ lossless?: boolean } | undefined} */
52
68
  let output;
69
+ /** @type {string[] | undefined} */
70
+ let pages;
71
+ /** @type {import('./index.js').FontSource[] | undefined} */
72
+ let recipeFonts;
53
73
  if (o.recipe) {
54
74
  const recipe = typeof o.recipe === 'function'
55
75
  ? o.recipe
@@ -57,10 +77,17 @@ export function cover(options) {
57
77
  ? defineRecipe(o.recipe)
58
78
  : Object.hasOwn(recipes, o.recipe) ? recipes[/** @type {keyof typeof recipes} */ (o.recipe)] : undefined;
59
79
  if (!recipe) throw new TypeError(`Unknown recipe "${o.recipe}". Recipes: ${Object.keys(recipes).join(', ')}`);
80
+ /** @type {Record<string, unknown>} */
60
81
  const ctx = { width, height, palette, rng: createRng(`${seed}:recipe`), u: unit({ width, height }), seed };
82
+ if (print) Object.assign(ctx, { bleed: print.bleed, safe: print.safe, trimWidth: print.trimWidth, trimHeight: print.trimHeight, mm: print.mm, dpi: print.dpi });
83
+ const sb = safeBox({ width, height, print, zones: fmt?.zones }) ?? { x: 0, y: 0, width, height };
84
+ Object.assign(ctx, { safeX: sb.x, safeY: sb.y, safeWidth: sb.width, safeHeight: sb.height });
85
+ if (fmt?.format) ctx.format = fmt.format;
61
86
  const recipeOptions = Object.fromEntries(Object.entries(o).filter(([k]) => !COVER_KEYS.has(k)));
62
- layers = [...applyOverrides(recipe(/** @type {any} */ (recipeOptions), ctx), recipeOptions), ...(o.layers ?? [])];
87
+ layers = [...applyOverrides(recipe(/** @type {any} */ (recipeOptions), /** @type {any} */ (ctx)), recipeOptions), ...(o.layers ?? [])];
63
88
  output = /** @type {any} */ (recipe).json?.output;
89
+ recipeFonts = /** @type {any} */ (recipe).json?.fonts;
90
+ pages = /** @type {any} */ (recipe).json?.pages;
64
91
  } else {
65
92
  layers = o.layers ?? [{ type: 'mesh' }, { type: 'grain' }];
66
93
  }
@@ -77,56 +104,182 @@ export function cover(options) {
77
104
  seed,
78
105
  palette,
79
106
  layers,
80
- fonts: o.fonts ?? [],
107
+ // The caller's fonts first, then any the recipe declares.
108
+ fonts: recipeFonts?.length ? [...(o.fonts ?? []), ...recipeFonts] : o.fonts ?? [],
81
109
  alt: o.alt ?? (typeof o.title === 'string' ? o.title : undefined),
82
110
  radius: o.radius,
83
111
  // A recipe's preferred encoding (e.g. dither: lossless). renderImage options win.
84
112
  output: o.output ?? output,
85
113
  };
114
+ // 1.1 fields, set only when used (a 1.0 cover's data is unchanged).
115
+ if (print) /** @type {any} */ (result).print = print;
116
+ if (fmt?.format) /** @type {any} */ (result).format = fmt.format;
117
+ if (o.page) /** @type {any} */ (result).page = o.page;
118
+ if (pages) /** @type {any} */ (result).pages = pages;
119
+ if (o.textRender) /** @type {any} */ (result).textRender = o.textRender;
120
+ if (fmt?.zones) /** @type {any} */ (result).zones = fmt.zones;
121
+ if (o.overlay) /** @type {any} */ (result).overlay = o.overlay;
122
+ if (o.loop != null) /** @type {any} */ (result).loop = o.loop;
86
123
  built.add(result);
87
124
  return result;
88
125
  }
89
126
 
90
127
  /**
91
- * Render every layer to markup, each with its own seeded stream.
128
+ * @typedef {object} Scope
129
+ * @property {import('./index.js').Cover} c
130
+ * @property {any} sharp
131
+ * @property {import('./index.js').FontSource[]} fonts
132
+ * @property {string} prefix - Id prefix ('' at the top).
133
+ * @property {string} seedPrefix - Stream key prefix ('' at the top).
134
+ * @property {number} width
135
+ * @property {number} height
136
+ * @property {((box: { x: number, y: number, width: number, height: number }) => Promise<string | undefined>) | undefined} backdrop
137
+ * @property {((entry: any) => void) | undefined} report
138
+ * @property {object} doc - Identity of the document being rendered.
139
+ * @property {number} [t] - The time being drawn (seconds), for animated layers.
140
+ */
141
+
142
+ /**
143
+ * Render a stack of layers, each with its own seeded stream.
92
144
  *
93
145
  * A layer's stream is keyed by its `seed`, else its `id`, else its type
94
146
  * and how many layers of that type came before it, so adding a layer
95
- * leaves every other layer's random choices alone.
147
+ * leaves every other layer's random choices alone. Nested stacks (groups,
148
+ * masks) key their streams under their parent's.
96
149
  *
97
- * @param {import('./index.js').Cover} c - The cover.
98
- * @param {any} sharp - The sharp module, or undefined.
150
+ * @param {import('./index.js').Layer[]} layers
151
+ * @param {Scope} scope
99
152
  * @returns {Promise<{ layer: import('./index.js').Layer, defs: string, body: string, pad?: number, finest?: number, ctx: import('./layers.js').RenderContext }[]>}
100
153
  */
101
- async function renderLayers(c, sharp) {
102
- const fonts = [...c.fonts, ...bundledFonts()];
154
+ async function renderStack(layers, scope) {
155
+ const { c } = scope;
103
156
  /** @type {Record<string, number>} */
104
157
  const seen = {};
158
+ /** @type {any[]} */
105
159
  const out = [];
106
- for (let i = 0; i < c.layers.length; i++) {
107
- const layer = c.layers[i];
160
+ const top = !scope.prefix;
161
+ const zones = top ? /** @type {any} */ (c).zones : undefined;
162
+ for (let i = 0; i < layers.length; i++) {
163
+ let layer = /** @type {any} */ (layers[i]);
164
+ if (isAnimated(layer)) layer = animate(layer, scope.t ?? 0, /** @type {any} */ (c).loop);
165
+ // `{layout.<id>.bottom}` and the like: where an earlier text block landed.
166
+ if (scope.layout && hasLayoutRef(layer)) layer = resolveLayoutRefs(layer, scope.layout);
167
+ // A format's safe zone: anchored text and icons are laid out inside it.
168
+ if (zones && (layer.type === 'text' || layer.type === 'icon') && layer.x == null && layer.y == null && layer.position == null && layer.area == null && layer.path == null) {
169
+ const [zx, zy, zw, zh] = zones.safe;
170
+ layer = { ...layer, area: { x: zx / c.width, y: zy / c.height, width: zw / c.width, height: zh / c.height } };
171
+ }
172
+ if (!layer || !Object.hasOwn(LAYERS, layer.type)) throw new TypeError(`Unknown layer type "${layer?.type}". Types: ${Object.keys(LAYERS).join(', ')}`);
108
173
  const n = (seen[layer.type] = (seen[layer.type] ?? -1) + 1);
109
174
  const key = layer.seed ?? layer.id ?? `${layer.type}#${n}`;
110
- const ctx = {
111
- width: c.width,
112
- height: c.height,
113
- canvas: { width: c.width, height: c.height },
175
+ const ctx = /** @type {any} */ ({
176
+ width: scope.width,
177
+ height: scope.height,
178
+ canvas: { width: scope.width, height: scope.height },
114
179
  palette: c.palette,
115
- rng: createRng(`${c.seed}:${key}`),
116
- id: `l${i}`,
117
- fonts,
118
- sharp,
180
+ rng: createRng(scope.seedPrefix ? `${c.seed}:${scope.seedPrefix}/${key}` : `${c.seed}:${key}`),
181
+ id: top ? `l${i}` : `${scope.prefix}_${i}`,
182
+ fonts: scope.fonts,
183
+ sharp: scope.sharp,
184
+ });
185
+ // 1.1 context, present only when it applies (1.0 layers never read it).
186
+ const print = /** @type {any} */ (c).print;
187
+ if (print && top) ctx.inset = print.bleed + print.safe;
188
+ if (print) ctx.cut = { x: print.bleed, y: print.bleed, width: print.trimWidth, height: print.trimHeight, cut: print.cut, mm: print.mm };
189
+ if (scope.raster) ctx.raster = true;
190
+ if (scope.reach) ctx.noteReach = (/** @type {number} */ px) => { /** @type {any} */ (scope.reach).px = Math.max(/** @type {any} */ (scope.reach).px, px); };
191
+ if (/** @type {any} */ (c).textRender) ctx.textRender = /** @type {any} */ (c).textRender;
192
+ if (scope.report) ctx.report = scope.report;
193
+ ctx.scale = scope.scale ?? 1;
194
+ ctx.res = scope.res ?? 1;
195
+ ctx.matrix = scope.matrix ?? IDENTITY;
196
+ ctx.doc = scope.doc;
197
+ ctx.defs = scope.defs;
198
+ if (scope.layout) ctx.layout = scope.layout;
199
+ ctx.backdrop = top && scope.backdrop ? (/** @type {any} */ box) => /** @type {any} */ (scope.backdrop)(box, out) : undefined;
200
+ // A nested stack (group, mask) inherits the scale of the context it's
201
+ // called on (a transformed layer's copy carries its own), times its grid's.
202
+ ctx.stack = function stack(/** @type {any[]} */ list, /** @type {any} */ sub) {
203
+ const self = this ?? ctx;
204
+ const k = sub.scale ?? 1;
205
+ return renderStack(list, {
206
+ ...scope,
207
+ report: scaledReport(self.report, k),
208
+ scale: (self.scale ?? 1) * k,
209
+ res: (self.res ?? 1) * (sub.res ?? k),
210
+ matrix: compose(self.matrix ?? IDENTITY, sub.matrix ?? IDENTITY),
211
+ prefix: `${ctx.id}${sub.tag}`,
212
+ seedPrefix: `${scope.seedPrefix ? `${scope.seedPrefix}/` : ''}${key}${sub.tag}`,
213
+ width: sub.width ?? self.width,
214
+ height: sub.height ?? self.height,
215
+ });
119
216
  };
120
- // A mesh can take its colours from a photo (`from`), extracted at render.
121
- const resolved = layer.type === 'mesh' && /** @type {any} */ (layer).from && !/** @type {any} */ (layer).colors
122
- ? { ...layer, colors: await extractPalette(/** @type {any} */ (layer).from, { count: 4 }) }
123
- : layer;
124
- const markup = await LAYERS[layer.type](/** @type {any} */ (resolved), ctx);
217
+ const markup = wrapped(layer) ? await renderWrapped(/** @type {any} */ (layer), ctx, scope, renderOne) : await renderOne(/** @type {any} */ (layer), ctx);
125
218
  out.push({ layer, ...markup, ctx });
126
219
  }
127
220
  return out;
128
221
  }
129
222
 
223
+ /**
224
+ * One layer's own markup.
225
+ *
226
+ * @param {any} layer
227
+ * @param {any} ctx
228
+ * @returns {Promise<{ defs: string, body: string, pad?: number, finest?: number }>}
229
+ */
230
+ async function renderOne(layer, ctx) {
231
+ if (layer.type === 'group') return renderGroup(layer, ctx);
232
+ if (layer.type === 'repeat') return renderGroup(expandRepeat(layer, ctx), ctx);
233
+ if (layer.type === 'defs') return registerDefs(layer, ctx);
234
+ if (layer.type === 'use') return renderUse(layer, ctx);
235
+ // A mesh can take its colours from a photo (`from`), extracted at render.
236
+ const resolved = layer.type === 'mesh' && layer.from && !layer.colors
237
+ ? { ...layer, colors: await extractPalette(layer.from, { count: 4 }) }
238
+ : layer;
239
+ return LAYERS[/** @type {keyof typeof LAYERS} */ (layer.type)](/** @type {any} */ (resolved), ctx);
240
+ }
241
+
242
+ /**
243
+ * Render every layer of a cover.
244
+ *
245
+ * @param {import('./index.js').Cover} c
246
+ * @param {any} sharp
247
+ * @param {{ report?: (entry: any) => void, t?: number, raster?: boolean, reach?: { px: number } }} [extra] - `raster`: drawn for `renderImage` (librsvg), so effects may take its faster forms; `reach` collects how far filters reach, output px.
248
+ */
249
+ async function renderLayers(c, sharp, extra = {}) {
250
+ // Icon sets named anywhere in the stack load first (from node_modules, or configure({ icons }) elsewhere).
251
+ const names = new Set();
252
+ const scan = (/** @type {unknown} */ v) => {
253
+ if (typeof v === 'string') { if (/^[a-z0-9-]+:[a-z0-9-]+$/i.test(v)) names.add(v); } else if (Array.isArray(v)) v.forEach(scan); else if (v && typeof v === 'object' && !(v instanceof Uint8Array)) Object.values(v).forEach(scan);
254
+ };
255
+ scan(c.layers);
256
+ if (names.size) await preloadIcons(names);
257
+ const auto = c.layers.some(usesAuto);
258
+ const t = /** @type {any} */ (extra).t;
259
+ return renderStack(c.layers, {
260
+ c,
261
+ sharp,
262
+ fonts: fontPool(c.fonts),
263
+ prefix: '',
264
+ seedPrefix: '',
265
+ width: c.width,
266
+ height: c.height,
267
+ backdrop: auto ? backdropSampler(c, sharp, documentFor, loadSharp) : undefined,
268
+ report: extra.report,
269
+ doc: {},
270
+ layout: new Map(),
271
+ defs: new Map(),
272
+ t,
273
+ raster: extra.raster,
274
+ reach: extra.reach,
275
+ });
276
+ }
277
+
278
+ /** @param {any} l @returns {boolean} Whether a layer (or its children) picks its colour from the backdrop. */
279
+ function usesAuto(l) {
280
+ return Boolean(l) && (l.color === 'auto' || (Array.isArray(l.layers) && l.layers.some(usesAuto)));
281
+ }
282
+
130
283
  /**
131
284
  * Assemble an SVG document from rendered layers.
132
285
  *
@@ -143,6 +296,18 @@ function documentFor(c, parts) {
143
296
  body = `<g clip-path="url(#r)">${body}</g>`;
144
297
  }
145
298
  const title = c.alt ? `<title>${esc(c.alt)}</title>` : '';
299
+ const zones = /** @type {any} */ (c).overlay ? /** @type {any} */ (c).zones ?? (/** @type {any} */ (c).print ? { safe: Object.values(/** @type {any} */ (safeBox(/** @type {any} */ (c))) ?? {}) } : undefined) : undefined;
300
+ if (zones) body += zonesOverlay(zones, w, h);
301
+ const print = /** @type {import('./formats.js').PrintInfo | undefined} */ (/** @type {any} */ (c).print);
302
+ if (print && (print.cropMarks || print.cutLine)) {
303
+ const { marks, cut } = printMarks(print, w, h);
304
+ const s = print.slug;
305
+ const W = w + 2 * s;
306
+ const H = h + 2 * s;
307
+ defs += `<clipPath id="bleed"><rect${attrs({ width: w, height: h })}/></clipPath>`;
308
+ const art = `<g transform="translate(${num(s)} ${num(s)})"><g clip-path="url(#bleed)">${body}</g>${cut}</g>`;
309
+ return `<svg xmlns="http://www.w3.org/2000/svg"${attrs({ width: W, height: H, viewBox: `0 0 ${num(W)} ${num(H)}`, role: c.alt ? 'img' : undefined })}>${title}${defs ? `<defs>${defs}</defs>` : ''}${s ? `<rect width="${num(W)}" height="${num(H)}" fill="#fff"/>` : ''}${art}${marks}</svg>`;
310
+ }
146
311
  return `<svg xmlns="http://www.w3.org/2000/svg"${attrs({ width: w, height: h, viewBox: `0 0 ${num(w)} ${num(h)}`, role: c.alt ? 'img' : undefined })}>${title}${defs ? `<defs>${defs}</defs>` : ''}${body}</svg>`;
147
312
  }
148
313
 
@@ -152,15 +317,80 @@ function documentFor(c, parts) {
152
317
  * The same cover always gives byte-identical output.
153
318
  *
154
319
  * @param {import('./index.js').CoverOptions | import('./index.js').Cover} input - The cover.
320
+ * @param {{ t?: number }} [options] - `t`: the time to draw, in seconds, for layers with keyframes (static covers ignore it).
155
321
  * @returns {Promise<string>} The SVG document.
156
322
  *
157
323
  * @example
158
324
  * const svg = await renderSvg({ recipe: 'aurora', seed: 'hello', palette: 'paper' });
159
325
  */
160
- export async function renderSvg(input) {
326
+ export async function renderSvg(input, options = {}) {
327
+ const c = cover(input);
328
+ const sharp = c.layers.some(hasImage) ? await loadSharp(false) : undefined;
329
+ return documentFor(c, await renderLayers(c, sharp, { t: options.t }));
330
+ }
331
+
332
+ /**
333
+ * Whether a layer (or one inside it) needs pixels to draw: an image, or an
334
+ * effect computed on pixels (`dither`, a round `border`).
335
+ *
336
+ * @param {any} l
337
+ * @returns {boolean}
338
+ */
339
+ function hasImage(l) {
340
+ if (!l) return false;
341
+ if (l.type === 'image') return true;
342
+ const fx = l.effects == null ? [] : [].concat(l.effects);
343
+ if (fx.some((/** @type {any} */ e) => needsPixels(e) || (e?.mask?.layers ?? []).some(hasImage) || (e?.map?.layers ?? []).some(hasImage))) return true;
344
+ return ['layers', 'mask', 'knockout', 'clip'].some((k) => Array.isArray(l[k]) && l[k].some(hasImage))
345
+ || (l.type === 'repeat' && hasImage(l.layer));
346
+ }
347
+
348
+ /**
349
+ * Render each page of a two-sided piece (a business card, a ticket, a
350
+ * flyer): one SVG per page the recipe declares (`"pages": ["front", "back"]`),
351
+ * or the pages you ask for. A layer is on a page by its `when`
352
+ * (`{ "page": "back" }`); layers without one are on every page.
353
+ *
354
+ * @param {import('./index.js').CoverOptions} input
355
+ * @param {{ pages?: string[] }} [options]
356
+ * @returns {Promise<Record<string, string>>} SVG by page name.
357
+ *
358
+ * @example
359
+ * const { front, back } = await renderPages({ recipe: card, format: 'business-card', name: 'Mira Okafor' });
360
+ */
361
+ export async function renderPages(input, options = {}) {
362
+ const probe = cover({ ...input, page: undefined });
363
+ const pages = options.pages ?? /** @type {any} */ (probe).pages ?? ['front', 'back'];
364
+ /** @type {Record<string, string>} */
365
+ const out = {};
366
+ for (const page of pages) out[page] = await renderSvg({ ...input, page });
367
+ return out;
368
+ }
369
+
370
+ /**
371
+ * Render a cover and note what was drawn (text sizes and boxes), for `lint`.
372
+ *
373
+ * @param {import('./index.js').CoverOptions | import('./index.js').Cover} input
374
+ * @returns {Promise<{ svg: string, drawn: any[] }>}
375
+ */
376
+ export async function renderReport(input) {
161
377
  const c = cover(input);
162
- const sharp = c.layers.some((l) => l.type === 'image') ? await loadSharp(false) : undefined;
163
- return documentFor(c, await renderLayers(c, sharp));
378
+ const sharp = c.layers.some(hasImage) ? await loadSharp(false) : undefined;
379
+ /** @type {any[]} */
380
+ const drawn = [];
381
+ const parts = await renderLayers(c, sharp, { report: (e) => drawn.push(e) });
382
+ return { svg: documentFor(c, parts), drawn };
383
+ }
384
+
385
+ /**
386
+ * Render a cover to an image in the browser (a canvas; no sharp).
387
+ *
388
+ * @param {import('./index.js').CoverOptions | import('./index.js').Cover} input
389
+ * @param {{ format?: 'png' | 'jpeg' | 'webp', quality?: number, scale?: number, width?: number }} [options]
390
+ * @returns {Promise<Blob>}
391
+ */
392
+ export async function renderBlob(input, options = {}) {
393
+ return rasterize(await renderSvg(input), options);
164
394
  }
165
395
 
166
396
  /** @type {Promise<any> | undefined} */
@@ -173,7 +403,9 @@ let sharpPromise;
173
403
  * @returns {Promise<any>} The module's default export, or undefined.
174
404
  */
175
405
  async function loadSharp(required) {
176
- sharpPromise ??= import('sharp').then((m) => m.default ?? m, () => undefined);
406
+ // A computed specifier: bundlers for browsers and Workers leave the optional peer alone.
407
+ const name = 'sharp';
408
+ sharpPromise ??= import(/* @vite-ignore */ name).then((m) => m.default ?? m, () => undefined);
177
409
  const sharp = await sharpPromise;
178
410
  if (!sharp && required) throw new Error('renderImage() needs sharp: npm install sharp');
179
411
  return sharp;
@@ -206,18 +438,22 @@ export async function renderImage(input, options = {}) {
206
438
  if (!Object.hasOwn(MIME, format)) throw new TypeError(`Unknown format "${format}" (webp, png, avif, jpeg)`);
207
439
 
208
440
  let cacheFile;
209
- if (options.cache) {
210
- const dir = options.cache === true ? join(process.cwd(), 'node_modules', '.cache', 'covers') : resolve(String(options.cache));
211
- cacheFile = join(dir, `${await coverKey(c, options)}.${format}`);
441
+ const fsp = node('fs/promises');
442
+ const path = node('path');
443
+ if (options.cache && fsp && path) {
444
+ const dir = options.cache === true ? path.join(/** @type {any} */ (globalThis).process.cwd(), 'node_modules', '.cache', 'covers') : path.resolve(String(options.cache));
445
+ cacheFile = path.join(dir, `${await coverKey(c, options)}.${format}`);
212
446
  try {
213
- return await readFile(cacheFile);
447
+ return await fsp.readFile(cacheFile);
214
448
  } catch {
215
449
  // Not cached yet.
216
450
  }
217
451
  }
218
452
 
219
453
  const sharp = await loadSharp(true);
220
- const parts = await renderLayers(c, sharp);
454
+ /** How far any effect's filter reaches, output px (tiles overlap by it). */
455
+ const reach = { px: 0 };
456
+ const parts = await renderLayers(c, sharp, { raster: true, reach });
221
457
  const { width, height } = c;
222
458
 
223
459
  // Split the stack: [svg] grain [svg] …, with each heavily blurred layer
@@ -238,7 +474,9 @@ export async function renderImage(input, options = {}) {
238
474
  if (run.length) steps.push({ svg: run });
239
475
 
240
476
  const raw = { width, height, channels: /** @type {const} */ (4) };
241
- const svgOf = (/** @type {typeof parts} */ p) => Buffer.from(documentFor({ ...c, radius: undefined, alt: undefined }, p));
477
+ // Every step's document carries every layer's defs (a pattern or a used stack defined in another step).
478
+ const allDefs = parts.map((p) => p.defs).join('');
479
+ const svgOf = (/** @type {typeof parts} */ p) => Buffer.from(documentFor(/** @type {any} */ ({ ...c, radius: undefined, alt: undefined, print: undefined, overlay: undefined }), [{ defs: allDefs, body: '' }, ...p.map((x) => ({ ...x, defs: '' }))]));
242
480
  /** @type {Buffer | undefined} */
243
481
  let pixels;
244
482
  for (const step of steps) {
@@ -263,9 +501,10 @@ export async function renderImage(input, options = {}) {
263
501
  : layerPx;
264
502
  } else if (step.svg) {
265
503
  const svg = svgOf(step.svg);
504
+ const layerPx = await svgPixels(sharp, svg.toString(), width, height, reach.px);
266
505
  pixels = pixels
267
- ? await sharp(pixels, { raw }).composite([{ input: svg }]).raw().toBuffer()
268
- : await sharp(svg).ensureAlpha().raw().toBuffer();
506
+ ? await sharp(pixels, { raw }).composite([{ input: layerPx, raw }]).raw().toBuffer()
507
+ : Buffer.from(layerPx.buffer, layerPx.byteOffset, layerPx.byteLength);
269
508
  } else if (step.grain) {
270
509
  pixels ??= Buffer.alloc(width * height * 4);
271
510
  const g = /** @type {import('./index.js').GrainLayer} */ (step.grain.layer);
@@ -278,6 +517,20 @@ export async function renderImage(input, options = {}) {
278
517
  image = image.composite([{ input: Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="${width}" height="${height}" rx="${num(rx)}"/></svg>`), blend: 'dest-in' }]);
279
518
  }
280
519
 
520
+ // Preview overlay and print furniture (crop marks, die line) go on last.
521
+ const cx = /** @type {any} */ (c);
522
+ if (cx.overlay && (cx.zones || cx.print)) {
523
+ const z = cx.zones ?? { safe: Object.values(/** @type {any} */ (safeBox(cx))) };
524
+ image = sharp(await image.png().toBuffer()).composite([{ input: Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${zonesOverlay(z, width, height)}</svg>`) }]);
525
+ }
526
+ if (cx.print && (cx.print.cropMarks || cx.print.cutLine)) {
527
+ const s = cx.print.slug;
528
+ const { marks, cut } = printMarks(cx.print, width, height);
529
+ image = sharp(await image.png().toBuffer()).extend({ top: s, bottom: s, left: s, right: s, background: '#ffffff' })
530
+ .composite([{ input: Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${width + 2 * s}" height="${height + 2 * s}"><g transform="translate(${s} ${s})">${cut}</g>${marks}</svg>`) }]);
531
+ }
532
+ if (cx.print) image = image.withMetadata({ density: cx.print.dpi });
533
+
281
534
  // Grain is the first thing a lossy encoder throws away. Measured on an
282
535
  // aurora cover: WebP q82 keeps ~40% of the grain (11 KB), q90 ~60% (41 KB),
283
536
  // q94 ~78% (125 KB). 90 is the default; raise it for crisper grain.
@@ -289,174 +542,12 @@ export async function renderImage(input, options = {}) {
289
542
  else image = image.png({ compressionLevel: options.effort ?? 6 });
290
543
  const out = await image.toBuffer();
291
544
 
292
- if (cacheFile) {
293
- await mkdir(join(cacheFile, '..'), { recursive: true });
294
- const tmp = `${cacheFile}.${process.pid}.tmp`;
295
- await writeFile(tmp, out);
296
- await rename(tmp, cacheFile);
545
+ if (cacheFile && fsp && path) {
546
+ await fsp.mkdir(path.join(cacheFile, '..'), { recursive: true });
547
+ const tmp = `${cacheFile}.${/** @type {any} */ (globalThis).process.pid}.tmp`;
548
+ await fsp.writeFile(tmp, out);
549
+ await fsp.rename(tmp, cacheFile);
297
550
  }
298
551
  return out;
299
552
  }
300
553
 
301
- /**
302
- * Seeded film-grain values, one per pixel (three with `monochrome: false`),
303
- * at `size` px per grain (upscaled smoothly). Each value is the mean of two
304
- * uniform draws, a triangular distribution: fewer harsh extremes than flat
305
- * noise, closer to film.
306
- *
307
- * @param {any} sharp - The sharp module.
308
- * @param {number} width @param {number} height
309
- * @param {import('./index.js').GrainLayer} layer
310
- * @param {import('./rng.js').Rng} rng - The layer's stream.
311
- * @returns {Promise<{ data: Buffer, channels: 1 | 3 }>} Full-size noise.
312
- */
313
- async function noise(sharp, width, height, layer, rng) {
314
- const size = Math.max(1, layer.size ?? 1);
315
- const channels = layer.monochrome === false ? 3 : 1;
316
- const nw = Math.ceil(width / size);
317
- const nh = Math.ceil(height / size);
318
- const data = Buffer.alloc(nw * nh * channels);
319
- // xorshift32 seeded from the layer's stream: one 32-bit draw gives two
320
- // values, so 1.4M pixels cost a few milliseconds.
321
- let x = (rng.next() * 4294967296) >>> 0 || 1;
322
- for (let i = 0; i < data.length; i++) {
323
- x ^= x << 13; x ^= x >>> 17; x ^= x << 5;
324
- data[i] = ((x & 255) + ((x >>> 8) & 255)) >>> 1;
325
- }
326
- if (nw === width && nh === height) return { data, channels };
327
- const out = await sharp(data, { raw: { width: nw, height: nh, channels } })
328
- .resize(width, height, { kernel: 'cubic', fit: 'fill' })
329
- .raw()
330
- .toBuffer();
331
- return { data: out, channels };
332
- }
333
-
334
- /** Separable blend modes (W3C Compositing Level 1), channel values 0–1. */
335
- const BLENDS = /** @type {Record<string, (b: number, s: number) => number>} */ ({
336
- normal: (_b, s) => s,
337
- multiply: (b, s) => b * s,
338
- screen: (b, s) => b + s - b * s,
339
- overlay: (b, s) => (b <= 0.5 ? 2 * b * s : 1 - 2 * (1 - b) * (1 - s)),
340
- 'soft-light': (b, s) => {
341
- if (s <= 0.5) return b - (1 - 2 * s) * b * (1 - b);
342
- const d = b <= 0.25 ? ((16 * b - 12) * b + 4) * b : Math.sqrt(b);
343
- return b + (2 * s - 1) * (d - b);
344
- },
345
- });
346
-
347
- /**
348
- * A 256×256 lookup (backdrop × noise → result) for one blend and strength:
349
- * cheaper than the blend maths per pixel.
350
- *
351
- * @param {(b: number, s: number) => number} blend
352
- * @param {number} amount
353
- * @returns {Uint8Array}
354
- */
355
- function grainTable(blend, amount) {
356
- const table = new Uint8Array(65536);
357
- for (let b = 0; b < 256; b++) {
358
- for (let s = 0; s < 256; s++) {
359
- const cb = b / 255;
360
- table[(b << 8) | s] = Math.round((cb + amount * (blend(cb, s / 255) - cb)) * 255);
361
- }
362
- }
363
- return table;
364
- }
365
-
366
- /**
367
- * Blend grain into RGBA pixels in place, at the layer's `amount`.
368
- * Done in JS rather than with sharp's `composite` blend modes, whose
369
- * overlay with a partly transparent layer flattens the noise to almost
370
- * nothing (measured σ 0.5 against the expected 5.9 on mid grey).
371
- *
372
- * @param {Buffer} pixels - RGBA pixels (modified).
373
- * @param {{ data: Buffer, channels: 1 | 3, width: number, height: number }} grain - Noise from {@link noise}, and the canvas size.
374
- * @param {import('./index.js').GrainLayer} layer - The grain layer.
375
- * @returns {void}
376
- */
377
- function applyGrain(pixels, grain, layer) {
378
- const amount = Math.max(0, Math.min(1, (layer.amount ?? 0.08) * (layer.opacity ?? 1)));
379
- const blend = BLENDS[layer.blend ?? 'overlay'];
380
- if (!blend) throw new TypeError(`Unknown grain blend "${layer.blend}" (${Object.keys(BLENDS).join(', ')})`);
381
- const table = grainTable(blend, amount);
382
- const { data, channels } = grain;
383
- if (layer.vary === 'edges') {
384
- // Heavier toward the edges: per-pixel strength from the centre out, as in the SVG mask.
385
- const { width, height } = grain;
386
- const peak = Math.min(1, amount * VARY_PEAK);
387
- const tables = Array.from({ length: 17 }, (_, k) => grainTable(blend, peak * (VARY_FLOOR + (1 - VARY_FLOOR) * (k / 16))));
388
- const half = Math.hypot(width, height) / 2;
389
- for (let y = 0, i = 0, j = 0; y < height; y++) {
390
- for (let x = 0; x < width; x++, i += 4, j += channels) {
391
- const t = tables[Math.min(16, Math.round((Math.hypot(x - width / 2, y - height / 2) / half) * 16))];
392
- pixels[i] = t[(pixels[i] << 8) | data[j]];
393
- pixels[i + 1] = t[(pixels[i + 1] << 8) | data[j + (channels === 3 ? 1 : 0)]];
394
- pixels[i + 2] = t[(pixels[i + 2] << 8) | data[j + (channels === 3 ? 2 : 0)]];
395
- }
396
- }
397
- return;
398
- }
399
- for (let i = 0, j = 0; i < pixels.length; i += 4, j += channels) {
400
- pixels[i] = table[(pixels[i] << 8) | data[j]];
401
- pixels[i + 1] = table[(pixels[i + 1] << 8) | data[j + (channels === 3 ? 1 : 0)]];
402
- pixels[i + 2] = table[(pixels[i + 2] << 8) | data[j + (channels === 3 ? 2 : 0)]];
403
- }
404
- }
405
-
406
- /**
407
- * A stable cache key for a cover and its output options: a SHA-256 of the
408
- * resolved cover (font and image bytes hashed by content), the output
409
- * format and quality, and this package's version.
410
- *
411
- * @param {import('./index.js').CoverOptions | import('./index.js').Cover} input - The cover.
412
- * @param {import('./index.js').RenderImageOptions} [options]
413
- * @returns {Promise<string>} 40 hex characters.
414
- *
415
- * @example
416
- * await coverKey({ recipe: 'aurora', seed: 'a' }, { format: 'webp' }); // 'f3a9…'
417
- */
418
- export async function coverKey(input, options = {}) {
419
- const c = cover(input);
420
- const h = createHash('sha256');
421
- h.update(`covers@${VERSION}\n${options.format ?? 'webp'}:${options.quality ?? ''}:${options.effort ?? ''}:${options.lossless ?? c.output?.lossless ?? ''}\n`);
422
- // Files a layer reads (an image's src, a mesh's photo, a waveform's audio) are hashed by content.
423
- const file = async (/** @type {any} */ v) => (typeof v === 'string' && !v.startsWith('data:') ? digest(await readFile(v)) : v instanceof Uint8Array ? digest(v) : v);
424
- const layers = await Promise.all(c.layers.map(async (l) => {
425
- const x = /** @type {any} */ (l);
426
- if (l.type === 'image') return { ...l, src: await file(x.src) };
427
- if (l.type === 'mesh' && x.from) return { ...l, from: await file(x.from) };
428
- if (l.type === 'waveform' && x.audio) return { ...l, audio: await file(x.audio) };
429
- return l;
430
- }));
431
- h.update(stable({
432
- width: c.width,
433
- height: c.height,
434
- seed: c.seed,
435
- palette: c.palette,
436
- layers,
437
- radius: c.radius,
438
- fonts: c.fonts.map((f) => ({ ...f, data: digest(f.data) })),
439
- }));
440
- return h.digest('hex').slice(0, 40);
441
- }
442
-
443
- /** @param {string | Uint8Array | ArrayBuffer} data @returns {string} */
444
- function digest(data) {
445
- return createHash('sha1').update(data instanceof ArrayBuffer ? new Uint8Array(data) : data).digest('hex');
446
- }
447
-
448
- /**
449
- * JSON with sorted keys and bytes hashed, for hashing.
450
- *
451
- * @param {unknown} v
452
- * @returns {string}
453
- */
454
- function stable(v) {
455
- if (v instanceof Uint8Array || v instanceof ArrayBuffer) return JSON.stringify(digest(v));
456
- if (Array.isArray(v)) return `[${v.map(stable).join(',')}]`;
457
- if (v && typeof v === 'object') {
458
- return `{${Object.keys(v).sort().filter((k) => /** @type {any} */ (v)[k] !== undefined).map((k) => `${JSON.stringify(k)}:${stable(/** @type {any} */ (v)[k])}`).join(',')}}`;
459
- }
460
- if (typeof v === 'function') return JSON.stringify(String(v));
461
- return JSON.stringify(v) ?? 'null';
462
- }