@arraypress/covers 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/LICENSE +29 -0
  3. package/README.md +846 -2
  4. package/fonts/OFL.txt +93 -0
  5. package/fonts/inter-latin-400.woff +0 -0
  6. package/fonts/inter-latin-500.woff +0 -0
  7. package/fonts/inter-latin-600.woff +0 -0
  8. package/fonts/inter-latin-700.woff +0 -0
  9. package/package.json +124 -4
  10. package/recipes/aurora.json +132 -0
  11. package/recipes/bauhaus.json +113 -0
  12. package/recipes/bigType.json +103 -0
  13. package/recipes/brand.json +1143 -0
  14. package/recipes/contour.json +109 -0
  15. package/recipes/cutout.json +191 -0
  16. package/recipes/diagram.json +92 -0
  17. package/recipes/dither.json +170 -0
  18. package/recipes/duotone.json +75 -0
  19. package/recipes/gridTitle.json +136 -0
  20. package/recipes/iconTile.json +214 -0
  21. package/recipes/issue.json +221 -0
  22. package/recipes/mark.json +170 -0
  23. package/recipes/photo.json +156 -0
  24. package/recipes/riso.json +387 -0
  25. package/recipes/stripes.json +206 -0
  26. package/recipes/waveform.json +154 -0
  27. package/schema/recipe.schema.json +8822 -0
  28. package/src/animate/index.d.ts +19 -0
  29. package/src/animate/index.js +116 -0
  30. package/src/astro.d.ts +31 -0
  31. package/src/astro.js +86 -0
  32. package/src/builtins.js +4 -0
  33. package/src/color.js +246 -0
  34. package/src/diagram.js +248 -0
  35. package/src/effects/ascii.d.ts +7 -0
  36. package/src/effects/ascii.js +93 -0
  37. package/src/effects/buffer.d.ts +60 -0
  38. package/src/effects/buffer.js +472 -0
  39. package/src/effects/detail.d.ts +12 -0
  40. package/src/effects/detail.js +124 -0
  41. package/src/effects/digital.d.ts +14 -0
  42. package/src/effects/digital.js +216 -0
  43. package/src/effects/distort.d.ts +20 -0
  44. package/src/effects/distort.js +314 -0
  45. package/src/effects/dither.d.ts +8 -0
  46. package/src/effects/dither.js +147 -0
  47. package/src/effects/drop-shadow.d.ts +4 -0
  48. package/src/effects/drop-shadow.js +45 -0
  49. package/src/effects/extrude.d.ts +5 -0
  50. package/src/effects/extrude.js +55 -0
  51. package/src/effects/film.d.ts +11 -0
  52. package/src/effects/film.js +204 -0
  53. package/src/effects/glow.d.ts +8 -0
  54. package/src/effects/glow.js +56 -0
  55. package/src/effects/grade.d.ts +30 -0
  56. package/src/effects/grade.js +274 -0
  57. package/src/effects/halftone.d.ts +8 -0
  58. package/src/effects/halftone.js +95 -0
  59. package/src/effects/index.d.ts +125 -0
  60. package/src/effects/index.js +277 -0
  61. package/src/effects/ink.d.ts +12 -0
  62. package/src/effects/ink.js +70 -0
  63. package/src/effects/lens.d.ts +14 -0
  64. package/src/effects/lens.js +277 -0
  65. package/src/effects/light.d.ts +10 -0
  66. package/src/effects/light.js +59 -0
  67. package/src/effects/looks.d.ts +9 -0
  68. package/src/effects/looks.js +74 -0
  69. package/src/effects/mask.d.ts +52 -0
  70. package/src/effects/mask.js +314 -0
  71. package/src/effects/motion.d.ts +6 -0
  72. package/src/effects/motion.js +60 -0
  73. package/src/effects/newsprint.d.ts +6 -0
  74. package/src/effects/newsprint.js +89 -0
  75. package/src/effects/optics.d.ts +10 -0
  76. package/src/effects/optics.js +213 -0
  77. package/src/effects/options.d.ts +6 -0
  78. package/src/effects/options.js +106 -0
  79. package/src/effects/paint-presets.d.ts +10 -0
  80. package/src/effects/paint-presets.js +47 -0
  81. package/src/effects/paint.d.ts +49 -0
  82. package/src/effects/paint.js +66 -0
  83. package/src/effects/pixels.d.ts +24 -0
  84. package/src/effects/pixels.js +138 -0
  85. package/src/effects/polar.d.ts +10 -0
  86. package/src/effects/polar.js +265 -0
  87. package/src/effects/region.d.ts +8 -0
  88. package/src/effects/region.js +136 -0
  89. package/src/effects/round-border.d.ts +5 -0
  90. package/src/effects/round-border.js +62 -0
  91. package/src/effects/screen.d.ts +14 -0
  92. package/src/effects/screen.js +212 -0
  93. package/src/effects/sort.d.ts +8 -0
  94. package/src/effects/sort.js +125 -0
  95. package/src/effects/symmetry.d.ts +6 -0
  96. package/src/effects/symmetry.js +74 -0
  97. package/src/effects/tile.d.ts +7 -0
  98. package/src/effects/tile.js +55 -0
  99. package/src/effects/tone.d.ts +16 -0
  100. package/src/effects/tone.js +117 -0
  101. package/src/effects/transform.d.ts +35 -0
  102. package/src/effects/transform.js +127 -0
  103. package/src/effects/util.d.ts +16 -0
  104. package/src/effects/util.js +85 -0
  105. package/src/extract.js +91 -0
  106. package/src/flatten.js +234 -0
  107. package/src/fonts.d.ts +55 -0
  108. package/src/fonts.js +393 -0
  109. package/src/formats/cut.d.ts +10 -0
  110. package/src/formats/cut.js +77 -0
  111. package/src/formats/index.d.ts +6 -0
  112. package/src/formats/index.js +9 -0
  113. package/src/formats/presets.d.ts +41 -0
  114. package/src/formats/presets.js +89 -0
  115. package/src/formats/print.d.ts +25 -0
  116. package/src/formats/print.js +117 -0
  117. package/src/formats/zones.d.ts +10 -0
  118. package/src/formats/zones.js +64 -0
  119. package/src/geometry.js +187 -0
  120. package/src/grid.js +41 -0
  121. package/src/handmade.js +253 -0
  122. package/src/icons.js +191 -0
  123. package/src/index.d.ts +1083 -0
  124. package/src/index.js +27 -0
  125. package/src/inter-data.js +27 -0
  126. package/src/json-recipe.js +616 -0
  127. package/src/kit/index.d.ts +20 -0
  128. package/src/kit/index.js +58 -0
  129. package/src/layer-options.js +56 -0
  130. package/src/layers/barcode.d.ts +19 -0
  131. package/src/layers/barcode.js +63 -0
  132. package/src/layers/code128.d.ts +4 -0
  133. package/src/layers/code128.js +28 -0
  134. package/src/layers/expr.d.ts +6 -0
  135. package/src/layers/expr.js +121 -0
  136. package/src/layers/geometry.d.ts +6 -0
  137. package/src/layers/geometry.js +238 -0
  138. package/src/layers/index.d.ts +18 -0
  139. package/src/layers/index.js +15 -0
  140. package/src/layers/perforation.d.ts +14 -0
  141. package/src/layers/perforation.js +47 -0
  142. package/src/layers/repeat.d.ts +29 -0
  143. package/src/layers/repeat.js +90 -0
  144. package/src/layers/shape.d.ts +46 -0
  145. package/src/layers/shape.js +115 -0
  146. package/src/layers/soft.d.ts +5 -0
  147. package/src/layers/soft.js +43 -0
  148. package/src/layers/svg.d.ts +23 -0
  149. package/src/layers/svg.js +109 -0
  150. package/src/layers/table.d.ts +25 -0
  151. package/src/layers/table.js +80 -0
  152. package/src/layers/tear.d.ts +21 -0
  153. package/src/layers/tear.js +74 -0
  154. package/src/layers/texture.d.ts +17 -0
  155. package/src/layers/texture.js +106 -0
  156. package/src/layers/util.d.ts +8 -0
  157. package/src/layers/util.js +48 -0
  158. package/src/layers.js +1056 -0
  159. package/src/lint/image-size.d.ts +4 -0
  160. package/src/lint/image-size.js +34 -0
  161. package/src/lint/index.d.ts +16 -0
  162. package/src/lint/index.js +127 -0
  163. package/src/lint/rules.d.ts +5 -0
  164. package/src/lint/rules.js +19 -0
  165. package/src/node.d.ts +2 -0
  166. package/src/node.js +20 -0
  167. package/src/palette.js +185 -0
  168. package/src/platform/bytes.d.ts +22 -0
  169. package/src/platform/bytes.js +103 -0
  170. package/src/platform/compress.d.ts +12 -0
  171. package/src/platform/compress.js +81 -0
  172. package/src/platform/harfbuzz.d.ts +9 -0
  173. package/src/platform/harfbuzz.js +80 -0
  174. package/src/platform/index.d.ts +8 -0
  175. package/src/platform/index.js +13 -0
  176. package/src/platform/load.d.ts +6 -0
  177. package/src/platform/load.js +72 -0
  178. package/src/platform/no-harfbuzz.d.ts +2 -0
  179. package/src/platform/no-harfbuzz.js +11 -0
  180. package/src/platform/node.d.ts +26 -0
  181. package/src/platform/node.js +86 -0
  182. package/src/platform/settings.d.ts +28 -0
  183. package/src/platform/settings.js +51 -0
  184. package/src/png.js +126 -0
  185. package/src/print.js +659 -0
  186. package/src/raster/index.d.ts +24 -0
  187. package/src/raster/index.js +171 -0
  188. package/src/recipes.js +79 -0
  189. package/src/render/backdrop.d.ts +6 -0
  190. package/src/render/backdrop.js +55 -0
  191. package/src/render/content.d.ts +5 -0
  192. package/src/render/content.js +58 -0
  193. package/src/render/context.d.ts +82 -0
  194. package/src/render/grain.d.ts +7 -0
  195. package/src/render/grain.js +113 -0
  196. package/src/render/group.d.ts +23 -0
  197. package/src/render/group.js +67 -0
  198. package/src/render/key.d.ts +5 -0
  199. package/src/render/key.js +91 -0
  200. package/src/render/report.d.ts +5 -0
  201. package/src/render/report.js +19 -0
  202. package/src/render/tiles.d.ts +6 -0
  203. package/src/render/tiles.js +77 -0
  204. package/src/render/use.d.ts +27 -0
  205. package/src/render/use.js +73 -0
  206. package/src/render/wrap.d.ts +14 -0
  207. package/src/render/wrap.js +199 -0
  208. package/src/render.js +553 -0
  209. package/src/rng.js +95 -0
  210. package/src/svg.js +69 -0
  211. package/src/text.d.ts +29 -0
  212. package/src/text.js +237 -0
  213. package/src/type/auto-color.d.ts +9 -0
  214. package/src/type/auto-color.js +44 -0
  215. package/src/type/fallback.d.ts +11 -0
  216. package/src/type/fallback.js +92 -0
  217. package/src/type/glyphs.d.ts +24 -0
  218. package/src/type/glyphs.js +87 -0
  219. package/src/type/index.d.ts +5 -0
  220. package/src/type/index.js +10 -0
  221. package/src/type/kern.d.ts +4 -0
  222. package/src/type/kern.js +144 -0
  223. package/src/type/layout-ref.d.ts +10 -0
  224. package/src/type/layout-ref.js +74 -0
  225. package/src/type/line.d.ts +11 -0
  226. package/src/type/line.js +105 -0
  227. package/src/type/live.d.ts +6 -0
  228. package/src/type/live.js +39 -0
  229. package/src/type/name.d.ts +4 -0
  230. package/src/type/name.js +32 -0
  231. package/src/type/path.d.ts +18 -0
  232. package/src/type/path.js +89 -0
  233. package/src/type/pixel.d.ts +9 -0
  234. package/src/type/pixel.js +115 -0
  235. package/src/type/sfnt.d.ts +20 -0
  236. package/src/type/sfnt.js +258 -0
  237. package/src/type/spans.d.ts +33 -0
  238. package/src/type/spans.js +138 -0
  239. package/src/type/symbols.d.ts +7 -0
  240. package/src/type/symbols.js +89 -0
  241. package/src/type/typeset.d.ts +10 -0
  242. package/src/type/typeset.js +352 -0
  243. package/src/type/woff2.d.ts +6 -0
  244. package/src/type/woff2.js +301 -0
  245. package/src/version.js +4 -0
package/src/render.js ADDED
@@ -0,0 +1,553 @@
1
+ /**
2
+ * Building a cover and rendering it to SVG or a raster image.
3
+ *
4
+ * @module render
5
+ */
6
+
7
+ import { node } from './platform/index.js';
8
+ import { createRng } from './rng.js';
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';
14
+ import { resolvePalette } from './palette.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';
28
+ import { extractPalette } from './extract.js';
29
+ import { recipes, applyOverrides } from './recipes.js';
30
+ import { defineRecipe } from './json-recipe.js';
31
+ import { attrs, esc, num } from './svg.js';
32
+
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';
37
+
38
+ /** Covers built by {@link cover}, so rendering one doesn't resolve it twice. */
39
+ const built = new WeakSet();
40
+
41
+ /** Options that belong to the cover itself, never to a recipe layer. */
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']);
43
+
44
+ /**
45
+ * Build a cover: resolve its size and palette and expand its recipe into a
46
+ * layer stack. Pure and synchronous; the result is plain data you can
47
+ * inspect, tweak and hand to {@link renderSvg} or {@link renderImage}.
48
+ *
49
+ * @param {import('./index.js').CoverOptions | import('./index.js').Cover} options - The cover.
50
+ * @returns {import('./index.js').Cover} The resolved cover.
51
+ * @throws {TypeError} On an unknown recipe or layer type.
52
+ *
53
+ * @example
54
+ * const c = cover({ recipe: 'gridTitle', seed: 'my-post', title: 'Shipping covers', palette: 'dusk' });
55
+ * c.layers.map((l) => l.id); // ['bg', 'mesh', 'grid', 'grain', 'title']
56
+ */
57
+ export function cover(options) {
58
+ if (built.has(/** @type {object} */ (options))) return /** @type {import('./index.js').Cover} */ (options);
59
+ const o = /** @type {import('./index.js').CoverOptions} */ (options ?? {});
60
+ const fmt = resolveFormat(o);
61
+ const { width, height } = fmt ?? resolveSize(o);
62
+ const print = fmt?.print;
63
+ const palette = resolvePalette(o.palette);
64
+ const seed = String(o.seed ?? (typeof o.title === 'string' ? o.title : 'cover'));
65
+
66
+ let layers;
67
+ /** @type {{ lossless?: boolean } | undefined} */
68
+ let output;
69
+ /** @type {string[] | undefined} */
70
+ let pages;
71
+ /** @type {import('./index.js').FontSource[] | undefined} */
72
+ let recipeFonts;
73
+ if (o.recipe) {
74
+ const recipe = typeof o.recipe === 'function'
75
+ ? o.recipe
76
+ : typeof o.recipe === 'object'
77
+ ? defineRecipe(o.recipe)
78
+ : Object.hasOwn(recipes, o.recipe) ? recipes[/** @type {keyof typeof recipes} */ (o.recipe)] : undefined;
79
+ if (!recipe) throw new TypeError(`Unknown recipe "${o.recipe}". Recipes: ${Object.keys(recipes).join(', ')}`);
80
+ /** @type {Record<string, unknown>} */
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;
86
+ const recipeOptions = Object.fromEntries(Object.entries(o).filter(([k]) => !COVER_KEYS.has(k)));
87
+ layers = [...applyOverrides(recipe(/** @type {any} */ (recipeOptions), /** @type {any} */ (ctx)), recipeOptions), ...(o.layers ?? [])];
88
+ output = /** @type {any} */ (recipe).json?.output;
89
+ recipeFonts = /** @type {any} */ (recipe).json?.fonts;
90
+ pages = /** @type {any} */ (recipe).json?.pages;
91
+ } else {
92
+ layers = o.layers ?? [{ type: 'mesh' }, { type: 'grain' }];
93
+ }
94
+
95
+ for (const layer of layers) {
96
+ if (!layer || !Object.hasOwn(LAYERS, layer.type)) {
97
+ throw new TypeError(`Unknown layer type "${layer?.type}". Types: ${Object.keys(LAYERS).join(', ')}`);
98
+ }
99
+ }
100
+
101
+ const result = {
102
+ width,
103
+ height,
104
+ seed,
105
+ palette,
106
+ layers,
107
+ // The caller's fonts first, then any the recipe declares.
108
+ fonts: recipeFonts?.length ? [...(o.fonts ?? []), ...recipeFonts] : o.fonts ?? [],
109
+ alt: o.alt ?? (typeof o.title === 'string' ? o.title : undefined),
110
+ radius: o.radius,
111
+ // A recipe's preferred encoding (e.g. dither: lossless). renderImage options win.
112
+ output: o.output ?? output,
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
+ built.add(result);
124
+ return result;
125
+ }
126
+
127
+ /**
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.
144
+ *
145
+ * A layer's stream is keyed by its `seed`, else its `id`, else its type
146
+ * and how many layers of that type came before it, so adding a layer
147
+ * leaves every other layer's random choices alone. Nested stacks (groups,
148
+ * masks) key their streams under their parent's.
149
+ *
150
+ * @param {import('./index.js').Layer[]} layers
151
+ * @param {Scope} scope
152
+ * @returns {Promise<{ layer: import('./index.js').Layer, defs: string, body: string, pad?: number, finest?: number, ctx: import('./layers.js').RenderContext }[]>}
153
+ */
154
+ async function renderStack(layers, scope) {
155
+ const { c } = scope;
156
+ /** @type {Record<string, number>} */
157
+ const seen = {};
158
+ /** @type {any[]} */
159
+ const out = [];
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(', ')}`);
173
+ const n = (seen[layer.type] = (seen[layer.type] ?? -1) + 1);
174
+ const key = layer.seed ?? layer.id ?? `${layer.type}#${n}`;
175
+ const ctx = /** @type {any} */ ({
176
+ width: scope.width,
177
+ height: scope.height,
178
+ canvas: { width: scope.width, height: scope.height },
179
+ palette: c.palette,
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
+ });
216
+ };
217
+ const markup = wrapped(layer) ? await renderWrapped(/** @type {any} */ (layer), ctx, scope, renderOne) : await renderOne(/** @type {any} */ (layer), ctx);
218
+ out.push({ layer, ...markup, ctx });
219
+ }
220
+ return out;
221
+ }
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
+
283
+ /**
284
+ * Assemble an SVG document from rendered layers.
285
+ *
286
+ * @param {import('./index.js').Cover} c - The cover.
287
+ * @param {{ defs: string, body: string }[]} parts - Rendered layers.
288
+ * @returns {string} The SVG document.
289
+ */
290
+ function documentFor(c, parts) {
291
+ const { width: w, height: h } = c;
292
+ let defs = parts.map((p) => p.defs).join('');
293
+ let body = parts.map((p) => p.body).join('');
294
+ if (c.radius) {
295
+ defs += `<clipPath id="r"><rect${attrs({ width: w, height: h, rx: len(c.radius, c) })}/></clipPath>`;
296
+ body = `<g clip-path="url(#r)">${body}</g>`;
297
+ }
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
+ }
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>`;
312
+ }
313
+
314
+ /**
315
+ * Render a cover to an SVG string. Text and icons are outlines, so the SVG
316
+ * needs no fonts; images are embedded as data URIs; nothing is fetched.
317
+ * The same cover always gives byte-identical output.
318
+ *
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).
321
+ * @returns {Promise<string>} The SVG document.
322
+ *
323
+ * @example
324
+ * const svg = await renderSvg({ recipe: 'aurora', seed: 'hello', palette: 'paper' });
325
+ */
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) {
377
+ const c = cover(input);
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);
394
+ }
395
+
396
+ /** @type {Promise<any> | undefined} */
397
+ let sharpPromise;
398
+
399
+ /**
400
+ * Import sharp (an optional peer dependency).
401
+ *
402
+ * @param {boolean} required - Throw when it's missing.
403
+ * @returns {Promise<any>} The module's default export, or undefined.
404
+ */
405
+ async function loadSharp(required) {
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);
409
+ const sharp = await sharpPromise;
410
+ if (!sharp && required) throw new Error('renderImage() needs sharp: npm install sharp');
411
+ return sharp;
412
+ }
413
+
414
+ /** Default encoder quality, by format (see the grain note in {@link renderImage}). */
415
+ export const DEFAULT_QUALITY = Object.freeze({ webp: 90, avif: 72, jpeg: 88, png: 100 });
416
+
417
+ /** Output MIME types, by format. */
418
+ export const MIME = Object.freeze({ webp: 'image/webp', png: 'image/png', avif: 'image/avif', jpeg: 'image/jpeg' });
419
+
420
+ /**
421
+ * Render a cover to image bytes with sharp.
422
+ *
423
+ * Grain is the one layer not rasterised through the SVG: librsvg's
424
+ * `feTurbulence` costs ~200ms at 1200×1200, so each grain layer becomes
425
+ * seeded pixel noise composited by sharp (~10ms) between the SVG renders
426
+ * of the layers below and above it. Everything else goes through librsvg.
427
+ *
428
+ * @param {import('./index.js').CoverOptions | import('./index.js').Cover} input - The cover.
429
+ * @param {import('./index.js').RenderImageOptions} [options]
430
+ * @returns {Promise<Buffer>} The encoded image.
431
+ *
432
+ * @example
433
+ * const webp = await renderImage({ recipe: 'bigType', seed: 'x', title: 'Hello' }, { format: 'webp' });
434
+ */
435
+ export async function renderImage(input, options = {}) {
436
+ const c = cover(input);
437
+ const format = options.format ?? 'webp';
438
+ if (!Object.hasOwn(MIME, format)) throw new TypeError(`Unknown format "${format}" (webp, png, avif, jpeg)`);
439
+
440
+ let cacheFile;
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}`);
446
+ try {
447
+ return await fsp.readFile(cacheFile);
448
+ } catch {
449
+ // Not cached yet.
450
+ }
451
+ }
452
+
453
+ const sharp = await loadSharp(true);
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 });
457
+ const { width, height } = c;
458
+
459
+ // Split the stack: [svg] grain [svg] …, with each heavily blurred layer
460
+ // (a mesh) in a step of its own, rendered padded (see `soft` below).
461
+ /** @type {{ svg?: typeof parts, grain?: typeof parts[number], soft?: typeof parts[number] }[]} */
462
+ const steps = [];
463
+ let run = [];
464
+ for (const part of parts) {
465
+ const kind = part.layer.type === 'grain' ? 'grain' : part.pad ? 'soft' : null;
466
+ if (kind) {
467
+ if (run.length) steps.push({ svg: run });
468
+ steps.push({ [kind]: part });
469
+ run = [];
470
+ } else {
471
+ run.push(part);
472
+ }
473
+ }
474
+ if (run.length) steps.push({ svg: run });
475
+
476
+ const raw = { width, height, channels: /** @type {const} */ (4) };
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: '' }))]));
480
+ /** @type {Buffer | undefined} */
481
+ let pixels;
482
+ for (const step of steps) {
483
+ if (step.soft) {
484
+ // librsvg clips a filter's input to the canvas, so a blur near an edge
485
+ // fades to transparent there (a vignette). Render the layer on a canvas
486
+ // padded by 3σ (so the edge sees the colour beyond it), at the smallest
487
+ // scale that keeps its finest blur above ~1.6px, then crop and upscale.
488
+ const pad = /** @type {number} */ (step.soft.pad);
489
+ const scale = Math.min(1, Math.max(0.2, 1.6 / (step.soft.finest || 1)));
490
+ const pw = width + pad * 2;
491
+ const ph = height + pad * 2;
492
+ const doc = `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(pw * scale)}" height="${Math.round(ph * scale)}" viewBox="${-pad} ${-pad} ${pw} ${ph}"><defs>${step.soft.defs}</defs>${step.soft.body}</svg>`;
493
+ const layerPx = await sharp(Buffer.from(doc))
494
+ .extract({ left: Math.round(pad * scale), top: Math.round(pad * scale), width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) })
495
+ .resize(width, height, { kernel: 'cubic', fit: 'fill' })
496
+ .ensureAlpha()
497
+ .raw()
498
+ .toBuffer();
499
+ pixels = pixels
500
+ ? await sharp(pixels, { raw }).composite([{ input: layerPx, raw }]).raw().toBuffer()
501
+ : layerPx;
502
+ } else if (step.svg) {
503
+ const svg = svgOf(step.svg);
504
+ const layerPx = await svgPixels(sharp, svg.toString(), width, height, reach.px);
505
+ pixels = pixels
506
+ ? await sharp(pixels, { raw }).composite([{ input: layerPx, raw }]).raw().toBuffer()
507
+ : Buffer.from(layerPx.buffer, layerPx.byteOffset, layerPx.byteLength);
508
+ } else if (step.grain) {
509
+ pixels ??= Buffer.alloc(width * height * 4);
510
+ const g = /** @type {import('./index.js').GrainLayer} */ (step.grain.layer);
511
+ applyGrain(pixels, { ...(await noise(sharp, width, height, g, step.grain.ctx.rng)), width, height }, g);
512
+ }
513
+ }
514
+ let image = sharp(pixels ?? Buffer.alloc(width * height * 4), { raw });
515
+ if (c.radius) {
516
+ const rx = len(c.radius, c);
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' }]);
518
+ }
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
+
534
+ // Grain is the first thing a lossy encoder throws away. Measured on an
535
+ // aurora cover: WebP q82 keeps ~40% of the grain (11 KB), q90 ~60% (41 KB),
536
+ // q94 ~78% (125 KB). 90 is the default; raise it for crisper grain.
537
+ const quality = options.quality ?? DEFAULT_QUALITY[format];
538
+ const lossless = options.lossless ?? c.output?.lossless ?? false;
539
+ if (format === 'webp') image = image.webp(lossless ? { lossless: true, effort: options.effort ?? 4 } : { quality, effort: options.effort ?? 4 });
540
+ else if (format === 'avif') image = image.avif(lossless ? { lossless: true, effort: options.effort ?? 4 } : { quality, effort: options.effort ?? 4 });
541
+ else if (format === 'jpeg') image = image.flatten({ background: c.palette.bg }).jpeg({ quality, mozjpeg: true });
542
+ else image = image.png({ compressionLevel: options.effort ?? 6 });
543
+ const out = await image.toBuffer();
544
+
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);
550
+ }
551
+ return out;
552
+ }
553
+
package/src/rng.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Seeded randomness.
3
+ *
4
+ * Every random choice a cover makes comes from a generator seeded by the
5
+ * cover's `seed` string, so the same input always draws the same image and
6
+ * two posts with different slugs draw different ones. Each layer gets its
7
+ * own stream (seed + layer index + layer type), so adding or removing one
8
+ * layer never reshuffles the others.
9
+ *
10
+ * @module rng
11
+ */
12
+
13
+ /**
14
+ * Hash a string to a 32-bit unsigned integer (cyrb53, folded to 32 bits).
15
+ * Fast, well distributed, and stable across platforms and Node versions.
16
+ *
17
+ * @param {string} str - Any string.
18
+ * @param {number} [salt=0] - Optional salt, to get several independent hashes of one string.
19
+ * @returns {number} An integer in `[0, 2^32)`.
20
+ *
21
+ * @example
22
+ * hashString('my-post'); // 1838215339 (always)
23
+ */
24
+ export function hashString(str, salt = 0) {
25
+ let h1 = 0xdeadbeef ^ salt;
26
+ let h2 = 0x41c6ce57 ^ salt;
27
+ for (let i = 0; i < str.length; i++) {
28
+ const ch = str.charCodeAt(i);
29
+ h1 = Math.imul(h1 ^ ch, 2654435761);
30
+ h2 = Math.imul(h2 ^ ch, 1597334677);
31
+ }
32
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
33
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
34
+ return (h1 ^ h2) >>> 0;
35
+ }
36
+
37
+ /**
38
+ * A small seeded pseudo-random generator (sfc32) with helpers.
39
+ *
40
+ * @typedef {object} Rng
41
+ * @property {() => number} next - A float in `[0, 1)`.
42
+ * @property {(min: number, max: number) => number} range - A float in `[min, max)`.
43
+ * @property {(min: number, max: number) => number} int - An integer in `[min, max]`.
44
+ * @property {<T>(items: readonly T[]) => T} pick - One item.
45
+ * @property {<T>(items: readonly T[]) => T[]} shuffle - A shuffled copy.
46
+ * @property {(chance?: number) => boolean} chance - `true` with the given probability (default 0.5).
47
+ */
48
+
49
+ /**
50
+ * Create a seeded generator. Strings are hashed; numbers are used as-is.
51
+ *
52
+ * @param {string | number} seed - The seed.
53
+ * @returns {Rng} The generator.
54
+ *
55
+ * @example
56
+ * const rng = createRng('my-post');
57
+ * rng.range(0, 360); // same angle every run
58
+ */
59
+ export function createRng(seed) {
60
+ const s = String(seed);
61
+ let a = hashString(s, 1);
62
+ let b = hashString(s, 2);
63
+ let c = hashString(s, 3);
64
+ let d = hashString(s, 4);
65
+
66
+ const next = () => {
67
+ a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0;
68
+ let t = (a + b) | 0;
69
+ a = b ^ (b >>> 9);
70
+ b = (c + (c << 3)) | 0;
71
+ c = (c << 21) | (c >>> 11);
72
+ d = (d + 1) | 0;
73
+ t = (t + d) | 0;
74
+ c = (c + t) | 0;
75
+ return (t >>> 0) / 4294967296;
76
+ };
77
+ // Warm up: the first few sfc32 outputs are correlated with the seed.
78
+ for (let i = 0; i < 12; i++) next();
79
+
80
+ return {
81
+ next,
82
+ range: (min, max) => min + next() * (max - min),
83
+ int: (min, max) => Math.floor(min + next() * (max - min + 1)),
84
+ pick: (items) => items[Math.floor(next() * items.length)],
85
+ shuffle: (items) => {
86
+ const out = items.slice();
87
+ for (let i = out.length - 1; i > 0; i--) {
88
+ const j = Math.floor(next() * (i + 1));
89
+ [out[i], out[j]] = [out[j], out[i]];
90
+ }
91
+ return out;
92
+ },
93
+ chance: (p = 0.5) => next() < p,
94
+ };
95
+ }