@arraypress/covers 1.0.0 → 1.3.1

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 (246) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +18 -524
  3. package/package.json +34 -5
  4. package/recipes/brand.json +1143 -0
  5. package/recipes/flyers/afterglow-motion.json +540 -0
  6. package/recipes/flyers/afterglow.json +70 -0
  7. package/recipes/flyers/arcfield-mirror-motion.json +397 -0
  8. package/recipes/flyers/arcfield-mirror.json +364 -0
  9. package/recipes/flyers/bauhaus-oktav.json +319 -0
  10. package/recipes/flyers/bauhaus-ritmo.json +735 -0
  11. package/recipes/flyers/bauhaus-volta.json +394 -0
  12. package/recipes/flyers/bloomfall.json +99 -0
  13. package/recipes/flyers/drag-solenne-motion.json +444 -0
  14. package/recipes/flyers/drag-solenne.json +426 -0
  15. package/recipes/flyers/fennick-patches.json +874 -0
  16. package/recipes/flyers/ferro-pass.json +398 -0
  17. package/recipes/flyers/ferrova.json +812 -0
  18. package/recipes/flyers/grid-rest-rhythm.json +125 -0
  19. package/recipes/flyers/groovy-honeycomb.json +816 -0
  20. package/recipes/flyers/groovy-marigold.json +479 -0
  21. package/recipes/flyers/groovy-peachfuzz.json +974 -0
  22. package/recipes/flyers/halden-orbit-motion.json +381 -0
  23. package/recipes/flyers/halden-orbit.json +356 -0
  24. package/recipes/flyers/halvard.json +120 -0
  25. package/recipes/flyers/kairo-weekender.json +696 -0
  26. package/recipes/flyers/linger-motion.json +518 -0
  27. package/recipes/flyers/linger.json +67 -0
  28. package/recipes/flyers/little-orchard.json +105 -0
  29. package/recipes/flyers/lofi-hexmoth.json +912 -0
  30. package/recipes/flyers/lofi-lowbit-motion.json +888 -0
  31. package/recipes/flyers/lofi-lowbit.json +880 -0
  32. package/recipes/flyers/lofi-nullpoint.json +3013 -0
  33. package/recipes/flyers/lumio-stack-motion.json +648 -0
  34. package/recipes/flyers/lumio-stack.json +438 -0
  35. package/recipes/flyers/magazine-cover.json +168 -0
  36. package/recipes/flyers/magazine-extended.json +269 -0
  37. package/recipes/flyers/magazine-knockout.json +40 -0
  38. package/recipes/flyers/northfold-varsity.json +618 -0
  39. package/recipes/flyers/open-studio.json +69 -0
  40. package/recipes/flyers/orrin-merch.json +715 -0
  41. package/recipes/flyers/oxyde.json +113 -0
  42. package/recipes/flyers/quiet-fields.json +610 -0
  43. package/recipes/flyers/scrapbook-halcyon.json +948 -0
  44. package/recipes/flyers/solenne-ticket.json +730 -0
  45. package/recipes/flyers/solvane-motion.json +576 -0
  46. package/recipes/flyers/solvane.json +98 -0
  47. package/recipes/flyers/subduct.json +99 -0
  48. package/recipes/flyers/sunvale-lineup.json +584 -0
  49. package/recipes/flyers/tidewell.json +91 -0
  50. package/recipes/flyers/tour-kairo.json +535 -0
  51. package/recipes/flyers/velora-stickers.json +885 -0
  52. package/recipes/flyers/wheatpaste-black.json +313 -0
  53. package/recipes/flyers/wheatpaste-mark.json +198 -0
  54. package/recipes/flyers/wheatpaste-torn.json +114 -0
  55. package/recipes/flyers/wheatpaste-wall.json +984 -0
  56. package/recipes/flyers/wheatpaste-words.json +233 -0
  57. package/recipes/release/aura.json +137 -0
  58. package/recipes/release/chrome.json +433 -0
  59. package/recipes/release/collage.json +372 -0
  60. package/recipes/release/duotone.json +113 -0
  61. package/recipes/release/film-still.json +102 -0
  62. package/recipes/release/headline.json +70 -0
  63. package/recipes/release/instant.json +170 -0
  64. package/recipes/release/label.json +364 -0
  65. package/recipes/release/liquid-glass.json +301 -0
  66. package/recipes/release/odd-tile-dark.json +688 -0
  67. package/recipes/release/odd-tile-dense.json +1056 -0
  68. package/recipes/release/odd-tile-shift.json +541 -0
  69. package/recipes/release/odd-tile-split.json +571 -0
  70. package/recipes/release/odd-tile.json +633 -0
  71. package/recipes/release/off-edge.json +96 -0
  72. package/recipes/release/plinth.json +794 -0
  73. package/recipes/release/riso.json +108 -0
  74. package/recipes/release/series/fluid-label.json +561 -0
  75. package/recipes/release/series/fluid-pixel.json +334 -0
  76. package/recipes/release/series/hairline.json +305 -0
  77. package/recipes/release/series/halo.json +282 -0
  78. package/recipes/release/series/mark-crop.json +135 -0
  79. package/recipes/release/series/mark-window.json +150 -0
  80. package/recipes/release/series/photo-label.json +475 -0
  81. package/recipes/release/series/smear-field.json +345 -0
  82. package/recipes/release/series/smear.json +202 -0
  83. package/recipes/release/series/spin.json +172 -0
  84. package/recipes/release/series/split-ghost.json +230 -0
  85. package/recipes/release/series/zoom.json +168 -0
  86. package/recipes/release/small-print.json +114 -0
  87. package/recipes/release/stack.json +156 -0
  88. package/recipes/release/swiss.json +117 -0
  89. package/schema/recipe.schema.json +7624 -827
  90. package/src/animate/index.d.ts +9 -0
  91. package/src/animate/index.js +11 -0
  92. package/src/animate/keyframes.d.ts +21 -0
  93. package/src/animate/keyframes.js +119 -0
  94. package/src/animate/marquee.d.ts +30 -0
  95. package/src/animate/marquee.js +93 -0
  96. package/src/animate/morph.d.ts +8 -0
  97. package/src/animate/morph.js +99 -0
  98. package/src/animate/motion.d.ts +59 -0
  99. package/src/animate/motion.js +63 -0
  100. package/src/animate/sheet.d.ts +19 -0
  101. package/src/animate/sheet.js +61 -0
  102. package/src/animate/timing.d.ts +28 -0
  103. package/src/animate/timing.js +146 -0
  104. package/src/animate/tracks.d.ts +18 -0
  105. package/src/animate/tracks.js +139 -0
  106. package/src/builtins.js +4 -0
  107. package/src/diagram.js +4 -3
  108. package/src/extract.js +6 -5
  109. package/src/fonts.d.ts +55 -0
  110. package/src/fonts.js +245 -53
  111. package/src/formats/cut.d.ts +10 -0
  112. package/src/formats/cut.js +77 -0
  113. package/src/formats/index.d.ts +6 -0
  114. package/src/formats/index.js +9 -0
  115. package/src/formats/presets.d.ts +41 -0
  116. package/src/formats/presets.js +89 -0
  117. package/src/formats/print.d.ts +25 -0
  118. package/src/formats/print.js +117 -0
  119. package/src/formats/zones.d.ts +10 -0
  120. package/src/formats/zones.js +64 -0
  121. package/src/geometry.js +5 -35
  122. package/src/grid.js +1 -1
  123. package/src/handmade.js +9 -7
  124. package/src/icons.js +42 -9
  125. package/src/index.d.ts +232 -55
  126. package/src/index.js +14 -6
  127. package/src/inter-data.js +27 -0
  128. package/src/json-recipe.js +201 -27
  129. package/src/kit/index.d.ts +20 -0
  130. package/src/kit/index.js +58 -0
  131. package/src/layer-options.js +43 -4
  132. package/src/layers/barcode.d.ts +19 -0
  133. package/src/layers/barcode.js +63 -0
  134. package/src/layers/code128.d.ts +4 -0
  135. package/src/layers/code128.js +28 -0
  136. package/src/layers/expr.d.ts +6 -0
  137. package/src/layers/expr.js +121 -0
  138. package/src/layers/geometry.d.ts +6 -0
  139. package/src/layers/geometry.js +238 -0
  140. package/src/layers/index.d.ts +18 -0
  141. package/src/layers/index.js +15 -0
  142. package/src/layers/perforation.d.ts +14 -0
  143. package/src/layers/perforation.js +47 -0
  144. package/src/layers/repeat.d.ts +29 -0
  145. package/src/layers/repeat.js +90 -0
  146. package/src/layers/shape.d.ts +46 -0
  147. package/src/layers/shape.js +115 -0
  148. package/src/layers/soft.d.ts +5 -0
  149. package/src/layers/soft.js +43 -0
  150. package/src/layers/svg.d.ts +23 -0
  151. package/src/layers/svg.js +108 -0
  152. package/src/layers/table.d.ts +25 -0
  153. package/src/layers/table.js +80 -0
  154. package/src/layers/tear.d.ts +21 -0
  155. package/src/layers/tear.js +74 -0
  156. package/src/layers/texture.d.ts +17 -0
  157. package/src/layers/texture.js +106 -0
  158. package/src/layers/util.d.ts +8 -0
  159. package/src/layers/util.js +61 -0
  160. package/src/layers.js +129 -61
  161. package/src/lint/image-size.d.ts +4 -0
  162. package/src/lint/image-size.js +34 -0
  163. package/src/lint/index.d.ts +16 -0
  164. package/src/lint/index.js +127 -0
  165. package/src/lint/rules.d.ts +5 -0
  166. package/src/lint/rules.js +19 -0
  167. package/src/node.d.ts +2 -0
  168. package/src/node.js +20 -0
  169. package/src/paint/index.d.ts +49 -0
  170. package/src/paint/index.js +66 -0
  171. package/src/paint/presets.d.ts +10 -0
  172. package/src/paint/presets.js +47 -0
  173. package/src/paint/tile.d.ts +7 -0
  174. package/src/paint/tile.js +55 -0
  175. package/src/palette.js +1 -1
  176. package/src/platform/files.d.ts +6 -0
  177. package/src/platform/files.js +41 -0
  178. package/src/platform/harfbuzz.d.ts +9 -0
  179. package/src/platform/harfbuzz.js +80 -0
  180. package/src/platform/index.d.ts +8 -0
  181. package/src/platform/index.js +15 -0
  182. package/src/platform/load.d.ts +6 -0
  183. package/src/platform/load.js +71 -0
  184. package/src/platform/no-harfbuzz.d.ts +2 -0
  185. package/src/platform/no-harfbuzz.js +11 -0
  186. package/src/platform/settings.d.ts +28 -0
  187. package/src/platform/settings.js +51 -0
  188. package/src/print.js +16 -13
  189. package/src/recipes.js +5 -4
  190. package/src/render/backdrop.d.ts +6 -0
  191. package/src/render/backdrop.js +55 -0
  192. package/src/render/context.d.ts +82 -0
  193. package/src/render/grain.d.ts +7 -0
  194. package/src/render/grain.js +113 -0
  195. package/src/render/group.d.ts +23 -0
  196. package/src/render/group.js +67 -0
  197. package/src/render/key.d.ts +5 -0
  198. package/src/render/key.js +90 -0
  199. package/src/render/report.d.ts +5 -0
  200. package/src/render/report.js +19 -0
  201. package/src/render/transform.d.ts +36 -0
  202. package/src/render/transform.js +139 -0
  203. package/src/render/use.d.ts +27 -0
  204. package/src/render/use.js +73 -0
  205. package/src/render/wrap.d.ts +14 -0
  206. package/src/render/wrap.js +144 -0
  207. package/src/render.js +343 -215
  208. package/src/text.d.ts +29 -0
  209. package/src/text.js +23 -16
  210. package/src/type/auto-color.d.ts +9 -0
  211. package/src/type/auto-color.js +44 -0
  212. package/src/type/fallback.d.ts +11 -0
  213. package/src/type/fallback.js +92 -0
  214. package/src/type/glyphs.d.ts +24 -0
  215. package/src/type/glyphs.js +87 -0
  216. package/src/type/index.d.ts +5 -0
  217. package/src/type/index.js +10 -0
  218. package/src/type/kern.d.ts +4 -0
  219. package/src/type/kern.js +144 -0
  220. package/src/type/layout-ref.d.ts +10 -0
  221. package/src/type/layout-ref.js +74 -0
  222. package/src/type/line.d.ts +11 -0
  223. package/src/type/line.js +105 -0
  224. package/src/type/live.d.ts +6 -0
  225. package/src/type/live.js +39 -0
  226. package/src/type/name.d.ts +4 -0
  227. package/src/type/name.js +32 -0
  228. package/src/type/path.d.ts +18 -0
  229. package/src/type/path.js +89 -0
  230. package/src/type/pixel.d.ts +7 -0
  231. package/src/type/pixel.js +85 -0
  232. package/src/type/sfnt.d.ts +20 -0
  233. package/src/type/sfnt.js +258 -0
  234. package/src/type/spans.d.ts +33 -0
  235. package/src/type/spans.js +138 -0
  236. package/src/type/symbols.d.ts +7 -0
  237. package/src/type/symbols.js +89 -0
  238. package/src/type/typeset.d.ts +10 -0
  239. package/src/type/typeset.js +363 -0
  240. package/src/type/woff2.d.ts +6 -0
  241. package/src/type/woff2.js +301 -0
  242. package/src/version.js +4 -0
  243. package/src/color.js +0 -246
  244. package/src/png.js +0 -77
  245. package/src/rng.js +0 -95
  246. package/src/svg.js +0 -69
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';
11
- import { createRng } from './rng.js';
12
- import { resolveSize, unit, len } from './geometry.js';
7
+ import { node } from './platform/index.js';
8
+ import { createRng } from '@logomarks/image-effects/rng';
9
+ import { resolveSize, unit } from './geometry.js';
10
+ import { len } from '@logomarks/image-effects/length';
11
+ import { resolveFormat, printMarks, zonesOverlay, safeBox } from './formats/index.js';
12
+ import { animate, isAnimated, renderMotion, createSheet, nameSheet } from './animate/index.js';
13
+ import { preloadIcons } from './icons.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 { rasterize, svgPixels, webpImages } from '@logomarks/image-effects/raster';
24
+ import { IDENTITY, compose } from './render/transform.js';
25
+ import { needsPixels } from '@logomarks/image-effects';
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
- import { attrs, esc, num } from './svg.js';
31
+ import { attrs, esc, num } from '@logomarks/image-effects/svg';
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', 'animate', 'poster']);
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,198 @@ 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;
123
+ if (o.animate === false) /** @type {any} */ (result).animate = false;
124
+ if (o.poster != null) /** @type {any} */ (result).poster = o.poster;
86
125
  built.add(result);
87
126
  return result;
88
127
  }
89
128
 
90
129
  /**
91
- * Render every layer to markup, each with its own seeded stream.
130
+ * @typedef {object} Scope
131
+ * @property {import('./index.js').Cover} c
132
+ * @property {any} sharp
133
+ * @property {import('./index.js').FontSource[]} fonts
134
+ * @property {string} prefix - Id prefix ('' at the top).
135
+ * @property {string} seedPrefix - Stream key prefix ('' at the top).
136
+ * @property {number} width
137
+ * @property {number} height
138
+ * @property {((box: { x: number, y: number, width: number, height: number }) => Promise<string | undefined>) | undefined} backdrop
139
+ * @property {((entry: any) => void) | undefined} report
140
+ * @property {object} doc - Identity of the document being rendered.
141
+ * @property {number} [t] - The time being drawn (seconds), for animated layers: the frame stills show.
142
+ * @property {boolean} [live] - Motion plays in the SVG (CSS keyframes in `sheet`).
143
+ * @property {import('./animate/sheet.js').Sheet} [sheet]
144
+ */
145
+
146
+ /**
147
+ * Render a stack of layers, each with its own seeded stream.
92
148
  *
93
149
  * A layer's stream is keyed by its `seed`, else its `id`, else its type
94
150
  * and how many layers of that type came before it, so adding a layer
95
- * leaves every other layer's random choices alone.
151
+ * leaves every other layer's random choices alone. Nested stacks (groups,
152
+ * masks) key their streams under their parent's.
96
153
  *
97
- * @param {import('./index.js').Cover} c - The cover.
98
- * @param {any} sharp - The sharp module, or undefined.
154
+ * @param {import('./index.js').Layer[]} layers
155
+ * @param {Scope} scope
99
156
  * @returns {Promise<{ layer: import('./index.js').Layer, defs: string, body: string, pad?: number, finest?: number, ctx: import('./layers.js').RenderContext }[]>}
100
157
  */
101
- async function renderLayers(c, sharp) {
102
- const fonts = [...c.fonts, ...bundledFonts()];
158
+ async function renderStack(layers, scope) {
159
+ const { c } = scope;
103
160
  /** @type {Record<string, number>} */
104
161
  const seen = {};
162
+ /** @type {any[]} */
105
163
  const out = [];
106
- for (let i = 0; i < c.layers.length; i++) {
107
- const layer = c.layers[i];
164
+ const top = !scope.prefix;
165
+ const zones = top ? /** @type {any} */ (c).zones : undefined;
166
+ for (let i = 0; i < layers.length; i++) {
167
+ let layer = /** @type {any} */ (layers[i]);
168
+ if (isAnimated(layer)) layer = animate(layer, scope.t ?? 0, /** @type {any} */ (c).loop);
169
+ // `{layout.<id>.bottom}` and the like: where an earlier text block landed.
170
+ if (scope.layout && hasLayoutRef(layer)) layer = resolveLayoutRefs(layer, scope.layout);
171
+ // A format's safe zone: anchored text and icons are laid out inside it.
172
+ if (zones && (layer.type === 'text' || layer.type === 'icon') && layer.x == null && layer.y == null && layer.position == null && layer.area == null && layer.path == null) {
173
+ const [zx, zy, zw, zh] = zones.safe;
174
+ layer = { ...layer, area: { x: zx / c.width, y: zy / c.height, width: zw / c.width, height: zh / c.height } };
175
+ }
176
+ if (!layer || !Object.hasOwn(LAYERS, layer.type)) throw new TypeError(`Unknown layer type "${layer?.type}". Types: ${Object.keys(LAYERS).join(', ')}`);
108
177
  const n = (seen[layer.type] = (seen[layer.type] ?? -1) + 1);
109
178
  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 },
179
+ const stream = scope.seedPrefix ? `${c.seed}:${scope.seedPrefix}/${key}` : `${c.seed}:${key}`;
180
+ const ctx = /** @type {any} */ ({
181
+ width: scope.width,
182
+ height: scope.height,
183
+ canvas: { width: scope.width, height: scope.height },
114
184
  palette: c.palette,
115
- rng: createRng(`${c.seed}:${key}`),
116
- id: `l${i}`,
117
- fonts,
118
- sharp,
185
+ rng: createRng(stream),
186
+ id: top ? `l${i}` : `${scope.prefix}_${i}`,
187
+ fonts: scope.fonts,
188
+ sharp: scope.sharp,
189
+ });
190
+ // 1.1 context, present only when it applies (1.0 layers never read it).
191
+ const print = /** @type {any} */ (c).print;
192
+ if (print && top) ctx.inset = print.bleed + print.safe;
193
+ if (print) ctx.cut = { x: print.bleed, y: print.bleed, width: print.trimWidth, height: print.trimHeight, cut: print.cut, mm: print.mm };
194
+ if (scope.raster) ctx.raster = true;
195
+ if (scope.reach) ctx.noteReach = (/** @type {number} */ px) => { /** @type {any} */ (scope.reach).px = Math.max(/** @type {any} */ (scope.reach).px, px); };
196
+ if (/** @type {any} */ (c).textRender) ctx.textRender = /** @type {any} */ (c).textRender;
197
+ if (scope.report) ctx.report = scope.report;
198
+ ctx.scale = scope.scale ?? 1;
199
+ ctx.res = scope.res ?? 1;
200
+ ctx.matrix = scope.matrix ?? IDENTITY;
201
+ ctx.doc = scope.doc;
202
+ ctx.defs = scope.defs;
203
+ if (scope.layout) ctx.layout = scope.layout;
204
+ ctx.backdrop = top && scope.backdrop ? (/** @type {any} */ box) => /** @type {any} */ (scope.backdrop)(box, out) : undefined;
205
+ // A nested stack (group, mask) inherits the scale of the context it's
206
+ // called on (a transformed layer's copy carries its own), times its grid's.
207
+ ctx.stack = function stack(/** @type {any[]} */ list, /** @type {any} */ sub) {
208
+ const self = this ?? ctx;
209
+ const k = sub.scale ?? 1;
210
+ return renderStack(list, {
211
+ ...scope,
212
+ report: scaledReport(self.report, k),
213
+ scale: (self.scale ?? 1) * k,
214
+ res: (self.res ?? 1) * (sub.res ?? k),
215
+ matrix: compose(self.matrix ?? IDENTITY, sub.matrix ?? IDENTITY),
216
+ prefix: `${ctx.id}${sub.tag}`,
217
+ seedPrefix: `${scope.seedPrefix ? `${scope.seedPrefix}/` : ''}${key}${sub.tag}`,
218
+ width: sub.width ?? self.width,
219
+ height: sub.height ?? self.height,
220
+ });
221
+ };
222
+ // The layer drawn with its stream from the start (motion draws it more than once, and its frames with `extra` context).
223
+ const draw = (/** @type {any} */ l, /** @type {any} */ extra) => {
224
+ ctx.rng = createRng(stream);
225
+ const x = extra ? { ...ctx, ...extra } : ctx;
226
+ return wrapped(l) ? renderWrapped(l, x, scope, renderOne) : renderOne(l, x);
119
227
  };
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);
228
+ // Motion is drawn when it plays, or at a time a still asks for; otherwise the layer is at rest.
229
+ const markup = layer.motion != null && (scope.live || scope.t != null)
230
+ ? await renderMotion(layer, ctx, { live: Boolean(scope.live), t: scope.t, sheet: /** @type {any} */ (scope.sheet), draw })
231
+ : await draw(layer);
125
232
  out.push({ layer, ...markup, ctx });
126
233
  }
127
234
  return out;
128
235
  }
129
236
 
237
+ /**
238
+ * One layer's own markup.
239
+ *
240
+ * @param {any} layer
241
+ * @param {any} ctx
242
+ * @returns {Promise<{ defs: string, body: string, pad?: number, finest?: number }>}
243
+ */
244
+ async function renderOne(layer, ctx) {
245
+ if (layer.type === 'group') return renderGroup(layer, ctx);
246
+ if (layer.type === 'repeat') return renderGroup(expandRepeat(layer, ctx), ctx);
247
+ if (layer.type === 'defs') return registerDefs(layer, ctx);
248
+ if (layer.type === 'use') return renderUse(layer, ctx);
249
+ // A mesh can take its colours from a photo (`from`), extracted at render.
250
+ const resolved = layer.type === 'mesh' && layer.from && !layer.colors
251
+ ? { ...layer, colors: await extractPalette(layer.from, { count: 4 }) }
252
+ : layer;
253
+ return LAYERS[/** @type {keyof typeof LAYERS} */ (layer.type)](/** @type {any} */ (resolved), ctx);
254
+ }
255
+
256
+ /**
257
+ * Render every layer of a cover.
258
+ *
259
+ * @param {import('./index.js').Cover} c
260
+ * @param {any} sharp
261
+ * @param {{ report?: (entry: any) => void, t?: number, live?: boolean, sheet?: import('./animate/sheet.js').Sheet, raster?: boolean, reach?: { px: number } }} [extra] - `t`: the time to draw (default the cover's `poster`); `live`: motion plays (into `sheet`); `raster`: drawn for `renderImage` (librsvg), so effects may take its faster forms; `reach` collects how far filters reach, output px.
262
+ */
263
+ async function renderLayers(c, sharp, extra = {}) {
264
+ // Icon sets named anywhere in the stack load first (from node_modules, or configure({ icons }) elsewhere).
265
+ const names = new Set();
266
+ const scan = (/** @type {unknown} */ v) => {
267
+ 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);
268
+ };
269
+ scan(c.layers);
270
+ if (names.size) await preloadIcons(names);
271
+ const auto = c.layers.some(usesAuto);
272
+ const t = extra.t ?? /** @type {any} */ (c).poster;
273
+ return renderStack(c.layers, {
274
+ c,
275
+ sharp,
276
+ fonts: fontPool(c.fonts),
277
+ prefix: '',
278
+ seedPrefix: '',
279
+ width: c.width,
280
+ height: c.height,
281
+ backdrop: auto ? backdropSampler(c, sharp, documentFor, loadSharp) : undefined,
282
+ report: extra.report,
283
+ doc: {},
284
+ layout: new Map(),
285
+ defs: new Map(),
286
+ t,
287
+ live: extra.live,
288
+ sheet: extra.sheet,
289
+ raster: extra.raster,
290
+ reach: extra.reach,
291
+ });
292
+ }
293
+
294
+ /** @param {any} l @returns {boolean} Whether a layer (or its children) picks its colour from the backdrop. */
295
+ function usesAuto(l) {
296
+ return Boolean(l) && (l.color === 'auto' || (Array.isArray(l.layers) && l.layers.some(usesAuto)));
297
+ }
298
+
130
299
  /**
131
300
  * Assemble an SVG document from rendered layers.
132
301
  *
@@ -143,6 +312,18 @@ function documentFor(c, parts) {
143
312
  body = `<g clip-path="url(#r)">${body}</g>`;
144
313
  }
145
314
  const title = c.alt ? `<title>${esc(c.alt)}</title>` : '';
315
+ 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;
316
+ if (zones) body += zonesOverlay(zones, w, h);
317
+ const print = /** @type {import('./formats.js').PrintInfo | undefined} */ (/** @type {any} */ (c).print);
318
+ if (print && (print.cropMarks || print.cutLine)) {
319
+ const { marks, cut } = printMarks(print, w, h);
320
+ const s = print.slug;
321
+ const W = w + 2 * s;
322
+ const H = h + 2 * s;
323
+ defs += `<clipPath id="bleed"><rect${attrs({ width: w, height: h })}/></clipPath>`;
324
+ const art = `<g transform="translate(${num(s)} ${num(s)})"><g clip-path="url(#bleed)">${body}</g>${cut}</g>`;
325
+ 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>`;
326
+ }
146
327
  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
328
  }
148
329
 
@@ -151,16 +332,102 @@ function documentFor(c, parts) {
151
332
  * needs no fonts; images are embedded as data URIs; nothing is fetched.
152
333
  * The same cover always gives byte-identical output.
153
334
  *
335
+ * Layers with `motion` play in the SVG (CSS keyframes, stopped by
336
+ * `prefers-reduced-motion`), unless the cover has `animate: false` or a
337
+ * time is asked for: then it's a still.
338
+ *
154
339
  * @param {import('./index.js').CoverOptions | import('./index.js').Cover} input - The cover.
340
+ * @param {{ t?: number, images?: 'webp', quality?: number }} [options] - `t`: a still at this time, in seconds, for layers with keyframes or motion (static covers ignore it). `images: 'webp'`: embedded photos and pixel effects re-encoded as WebP where smaller (`quality` for photos, default 82; needs sharp), for SVGs shown in browsers (librsvg can't draw WebP).
155
341
  * @returns {Promise<string>} The SVG document.
156
342
  *
157
343
  * @example
158
344
  * const svg = await renderSvg({ recipe: 'aurora', seed: 'hello', palette: 'paper' });
159
345
  */
160
- export async function renderSvg(input) {
346
+ export async function renderSvg(input, options = {}) {
161
347
  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));
348
+ return svgDocument(c, { ...options, live: options.t == null && /** @type {any} */ (c).animate !== false });
349
+ }
350
+
351
+ /**
352
+ * A cover's SVG document, playing its motion or still.
353
+ *
354
+ * @param {import('./index.js').Cover} c
355
+ * @param {{ t?: number, live?: boolean, images?: 'webp', quality?: number }} options
356
+ * @returns {Promise<string>}
357
+ */
358
+ async function svgDocument(c, options) {
359
+ const sharp = c.layers.some(hasImage) ? await loadSharp(false) : undefined;
360
+ const sheet = options.live ? createSheet() : undefined;
361
+ const parts = await renderLayers(c, sharp, { t: options.t, live: options.live, sheet });
362
+ const svg = sheet && !sheet.empty ? nameSheet(documentFor(c, [{ defs: `<style>${sheet.css()}</style>`, body: '' }, ...parts])) : documentFor(c, parts);
363
+ if (!options.images) return svg;
364
+ if (options.images !== 'webp') throw new TypeError(`Unknown images "${options.images}" (webp)`);
365
+ return webpImages(svg, sharp ?? await loadSharp(false), { quality: options.quality });
366
+ }
367
+
368
+ /**
369
+ * Whether a layer (or one inside it) needs pixels to draw: an image, or an
370
+ * effect computed on pixels (`dither`, a round `border`).
371
+ *
372
+ * @param {any} l
373
+ * @returns {boolean}
374
+ */
375
+ function hasImage(l) {
376
+ if (!l) return false;
377
+ if (l.type === 'image') return true;
378
+ const fx = l.effects == null ? [] : [].concat(l.effects);
379
+ if (fx.some((/** @type {any} */ e) => needsPixels(e) || (e?.mask?.layers ?? []).some(hasImage) || (e?.map?.layers ?? []).some(hasImage))) return true;
380
+ return ['layers', 'mask', 'knockout', 'clip'].some((k) => Array.isArray(l[k]) && l[k].some(hasImage))
381
+ || (l.type === 'repeat' && hasImage(l.layer));
382
+ }
383
+
384
+ /**
385
+ * Render each page of a two-sided piece (a business card, a ticket, a
386
+ * flyer): one SVG per page the recipe declares (`"pages": ["front", "back"]`),
387
+ * or the pages you ask for. A layer is on a page by its `when`
388
+ * (`{ "page": "back" }`); layers without one are on every page.
389
+ *
390
+ * @param {import('./index.js').CoverOptions} input
391
+ * @param {{ pages?: string[] }} [options]
392
+ * @returns {Promise<Record<string, string>>} SVG by page name.
393
+ *
394
+ * @example
395
+ * const { front, back } = await renderPages({ recipe: card, format: 'business-card', name: 'Mira Okafor' });
396
+ */
397
+ export async function renderPages(input, options = {}) {
398
+ const probe = cover({ ...input, page: undefined });
399
+ const pages = options.pages ?? /** @type {any} */ (probe).pages ?? ['front', 'back'];
400
+ /** @type {Record<string, string>} */
401
+ const out = {};
402
+ for (const page of pages) out[page] = await renderSvg({ ...input, page });
403
+ return out;
404
+ }
405
+
406
+ /**
407
+ * Render a cover and note what was drawn (text sizes and boxes), for `lint`.
408
+ *
409
+ * @param {import('./index.js').CoverOptions | import('./index.js').Cover} input
410
+ * @returns {Promise<{ svg: string, drawn: any[] }>}
411
+ */
412
+ export async function renderReport(input) {
413
+ const c = cover(input);
414
+ const sharp = c.layers.some(hasImage) ? await loadSharp(false) : undefined;
415
+ /** @type {any[]} */
416
+ const drawn = [];
417
+ const parts = await renderLayers(c, sharp, { report: (e) => drawn.push(e) });
418
+ return { svg: documentFor(c, parts), drawn };
419
+ }
420
+
421
+ /**
422
+ * Render a cover to an image in the browser (a canvas; no sharp): a still,
423
+ * the cover's `poster` frame or its layers at rest.
424
+ *
425
+ * @param {import('./index.js').CoverOptions | import('./index.js').Cover} input
426
+ * @param {{ format?: 'png' | 'jpeg' | 'webp', quality?: number, scale?: number, width?: number }} [options]
427
+ * @returns {Promise<Blob>}
428
+ */
429
+ export async function renderBlob(input, options = {}) {
430
+ return rasterize(await svgDocument(cover(input), {}), options);
164
431
  }
165
432
 
166
433
  /** @type {Promise<any> | undefined} */
@@ -173,7 +440,9 @@ let sharpPromise;
173
440
  * @returns {Promise<any>} The module's default export, or undefined.
174
441
  */
175
442
  async function loadSharp(required) {
176
- sharpPromise ??= import('sharp').then((m) => m.default ?? m, () => undefined);
443
+ // A computed specifier: bundlers for browsers and Workers leave the optional peer alone.
444
+ const name = 'sharp';
445
+ sharpPromise ??= import(/* @vite-ignore */ name).then((m) => m.default ?? m, () => undefined);
177
446
  const sharp = await sharpPromise;
178
447
  if (!sharp && required) throw new Error('renderImage() needs sharp: npm install sharp');
179
448
  return sharp;
@@ -206,18 +475,22 @@ export async function renderImage(input, options = {}) {
206
475
  if (!Object.hasOwn(MIME, format)) throw new TypeError(`Unknown format "${format}" (webp, png, avif, jpeg)`);
207
476
 
208
477
  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}`);
478
+ const fsp = node('fs/promises');
479
+ const path = node('path');
480
+ if (options.cache && fsp && path) {
481
+ const dir = options.cache === true ? path.join(/** @type {any} */ (globalThis).process.cwd(), 'node_modules', '.cache', 'covers') : path.resolve(String(options.cache));
482
+ cacheFile = path.join(dir, `${await coverKey(c, options)}.${format}`);
212
483
  try {
213
- return await readFile(cacheFile);
484
+ return await fsp.readFile(cacheFile);
214
485
  } catch {
215
486
  // Not cached yet.
216
487
  }
217
488
  }
218
489
 
219
490
  const sharp = await loadSharp(true);
220
- const parts = await renderLayers(c, sharp);
491
+ /** How far any effect's filter reaches, output px (tiles overlap by it). */
492
+ const reach = { px: 0 };
493
+ const parts = await renderLayers(c, sharp, { raster: true, reach });
221
494
  const { width, height } = c;
222
495
 
223
496
  // Split the stack: [svg] grain [svg] …, with each heavily blurred layer
@@ -238,7 +511,9 @@ export async function renderImage(input, options = {}) {
238
511
  if (run.length) steps.push({ svg: run });
239
512
 
240
513
  const raw = { width, height, channels: /** @type {const} */ (4) };
241
- const svgOf = (/** @type {typeof parts} */ p) => Buffer.from(documentFor({ ...c, radius: undefined, alt: undefined }, p));
514
+ // Every step's document carries every layer's defs (a pattern or a used stack defined in another step).
515
+ const allDefs = parts.map((p) => p.defs).join('');
516
+ 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
517
  /** @type {Buffer | undefined} */
243
518
  let pixels;
244
519
  for (const step of steps) {
@@ -263,9 +538,10 @@ export async function renderImage(input, options = {}) {
263
538
  : layerPx;
264
539
  } else if (step.svg) {
265
540
  const svg = svgOf(step.svg);
541
+ const layerPx = await svgPixels(sharp, svg.toString(), width, height, reach.px);
266
542
  pixels = pixels
267
- ? await sharp(pixels, { raw }).composite([{ input: svg }]).raw().toBuffer()
268
- : await sharp(svg).ensureAlpha().raw().toBuffer();
543
+ ? await sharp(pixels, { raw }).composite([{ input: layerPx, raw }]).raw().toBuffer()
544
+ : Buffer.from(layerPx.buffer, layerPx.byteOffset, layerPx.byteLength);
269
545
  } else if (step.grain) {
270
546
  pixels ??= Buffer.alloc(width * height * 4);
271
547
  const g = /** @type {import('./index.js').GrainLayer} */ (step.grain.layer);
@@ -278,6 +554,20 @@ export async function renderImage(input, options = {}) {
278
554
  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
555
  }
280
556
 
557
+ // Preview overlay and print furniture (crop marks, die line) go on last.
558
+ const cx = /** @type {any} */ (c);
559
+ if (cx.overlay && (cx.zones || cx.print)) {
560
+ const z = cx.zones ?? { safe: Object.values(/** @type {any} */ (safeBox(cx))) };
561
+ 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>`) }]);
562
+ }
563
+ if (cx.print && (cx.print.cropMarks || cx.print.cutLine)) {
564
+ const s = cx.print.slug;
565
+ const { marks, cut } = printMarks(cx.print, width, height);
566
+ image = sharp(await image.png().toBuffer()).extend({ top: s, bottom: s, left: s, right: s, background: '#ffffff' })
567
+ .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>`) }]);
568
+ }
569
+ if (cx.print) image = image.withMetadata({ density: cx.print.dpi });
570
+
281
571
  // Grain is the first thing a lossy encoder throws away. Measured on an
282
572
  // aurora cover: WebP q82 keeps ~40% of the grain (11 KB), q90 ~60% (41 KB),
283
573
  // q94 ~78% (125 KB). 90 is the default; raise it for crisper grain.
@@ -289,174 +579,12 @@ export async function renderImage(input, options = {}) {
289
579
  else image = image.png({ compressionLevel: options.effort ?? 6 });
290
580
  const out = await image.toBuffer();
291
581
 
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);
582
+ if (cacheFile && fsp && path) {
583
+ await fsp.mkdir(path.join(cacheFile, '..'), { recursive: true });
584
+ const tmp = `${cacheFile}.${/** @type {any} */ (globalThis).process.pid}.tmp`;
585
+ await fsp.writeFile(tmp, out);
586
+ await fsp.rename(tmp, cacheFile);
297
587
  }
298
588
  return out;
299
589
  }
300
590
 
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
- }