@arraypress/covers 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +296 -11
  3. package/package.json +33 -5
  4. package/recipes/brand.json +1143 -0
  5. package/schema/recipe.schema.json +7300 -770
  6. package/src/animate/index.d.ts +19 -0
  7. package/src/animate/index.js +116 -0
  8. package/src/builtins.js +4 -0
  9. package/src/effects/ascii.d.ts +7 -0
  10. package/src/effects/ascii.js +93 -0
  11. package/src/effects/buffer.d.ts +60 -0
  12. package/src/effects/buffer.js +472 -0
  13. package/src/effects/detail.d.ts +12 -0
  14. package/src/effects/detail.js +124 -0
  15. package/src/effects/digital.d.ts +14 -0
  16. package/src/effects/digital.js +216 -0
  17. package/src/effects/distort.d.ts +20 -0
  18. package/src/effects/distort.js +314 -0
  19. package/src/effects/dither.d.ts +8 -0
  20. package/src/effects/dither.js +147 -0
  21. package/src/effects/drop-shadow.d.ts +4 -0
  22. package/src/effects/drop-shadow.js +45 -0
  23. package/src/effects/extrude.d.ts +5 -0
  24. package/src/effects/extrude.js +55 -0
  25. package/src/effects/film.d.ts +11 -0
  26. package/src/effects/film.js +204 -0
  27. package/src/effects/glow.d.ts +8 -0
  28. package/src/effects/glow.js +56 -0
  29. package/src/effects/grade.d.ts +30 -0
  30. package/src/effects/grade.js +274 -0
  31. package/src/effects/halftone.d.ts +8 -0
  32. package/src/effects/halftone.js +95 -0
  33. package/src/effects/index.d.ts +125 -0
  34. package/src/effects/index.js +277 -0
  35. package/src/effects/ink.d.ts +12 -0
  36. package/src/effects/ink.js +70 -0
  37. package/src/effects/lens.d.ts +14 -0
  38. package/src/effects/lens.js +277 -0
  39. package/src/effects/light.d.ts +10 -0
  40. package/src/effects/light.js +59 -0
  41. package/src/effects/looks.d.ts +9 -0
  42. package/src/effects/looks.js +74 -0
  43. package/src/effects/mask.d.ts +52 -0
  44. package/src/effects/mask.js +314 -0
  45. package/src/effects/motion.d.ts +6 -0
  46. package/src/effects/motion.js +60 -0
  47. package/src/effects/newsprint.d.ts +6 -0
  48. package/src/effects/newsprint.js +89 -0
  49. package/src/effects/optics.d.ts +10 -0
  50. package/src/effects/optics.js +213 -0
  51. package/src/effects/options.d.ts +6 -0
  52. package/src/effects/options.js +106 -0
  53. package/src/effects/paint-presets.d.ts +10 -0
  54. package/src/effects/paint-presets.js +47 -0
  55. package/src/effects/paint.d.ts +49 -0
  56. package/src/effects/paint.js +66 -0
  57. package/src/effects/pixels.d.ts +24 -0
  58. package/src/effects/pixels.js +138 -0
  59. package/src/effects/polar.d.ts +10 -0
  60. package/src/effects/polar.js +265 -0
  61. package/src/effects/region.d.ts +8 -0
  62. package/src/effects/region.js +136 -0
  63. package/src/effects/round-border.d.ts +5 -0
  64. package/src/effects/round-border.js +62 -0
  65. package/src/effects/screen.d.ts +14 -0
  66. package/src/effects/screen.js +212 -0
  67. package/src/effects/sort.d.ts +8 -0
  68. package/src/effects/sort.js +125 -0
  69. package/src/effects/symmetry.d.ts +6 -0
  70. package/src/effects/symmetry.js +74 -0
  71. package/src/effects/tile.d.ts +7 -0
  72. package/src/effects/tile.js +55 -0
  73. package/src/effects/tone.d.ts +16 -0
  74. package/src/effects/tone.js +117 -0
  75. package/src/effects/transform.d.ts +35 -0
  76. package/src/effects/transform.js +127 -0
  77. package/src/effects/util.d.ts +16 -0
  78. package/src/effects/util.js +85 -0
  79. package/src/extract.js +4 -3
  80. package/src/fonts.d.ts +55 -0
  81. package/src/fonts.js +246 -53
  82. package/src/formats/cut.d.ts +10 -0
  83. package/src/formats/cut.js +77 -0
  84. package/src/formats/index.d.ts +6 -0
  85. package/src/formats/index.js +9 -0
  86. package/src/formats/presets.d.ts +41 -0
  87. package/src/formats/presets.js +89 -0
  88. package/src/formats/print.d.ts +25 -0
  89. package/src/formats/print.js +117 -0
  90. package/src/formats/zones.d.ts +10 -0
  91. package/src/formats/zones.js +64 -0
  92. package/src/geometry.js +1 -1
  93. package/src/icons.js +43 -9
  94. package/src/index.d.ts +205 -19
  95. package/src/index.js +12 -4
  96. package/src/inter-data.js +27 -0
  97. package/src/json-recipe.js +181 -23
  98. package/src/kit/index.d.ts +20 -0
  99. package/src/kit/index.js +58 -0
  100. package/src/layer-options.js +23 -4
  101. package/src/layers/barcode.d.ts +19 -0
  102. package/src/layers/barcode.js +63 -0
  103. package/src/layers/code128.d.ts +4 -0
  104. package/src/layers/code128.js +28 -0
  105. package/src/layers/expr.d.ts +6 -0
  106. package/src/layers/expr.js +121 -0
  107. package/src/layers/geometry.d.ts +6 -0
  108. package/src/layers/geometry.js +238 -0
  109. package/src/layers/index.d.ts +18 -0
  110. package/src/layers/index.js +15 -0
  111. package/src/layers/perforation.d.ts +14 -0
  112. package/src/layers/perforation.js +47 -0
  113. package/src/layers/repeat.d.ts +29 -0
  114. package/src/layers/repeat.js +90 -0
  115. package/src/layers/shape.d.ts +46 -0
  116. package/src/layers/shape.js +115 -0
  117. package/src/layers/soft.d.ts +5 -0
  118. package/src/layers/soft.js +43 -0
  119. package/src/layers/svg.d.ts +23 -0
  120. package/src/layers/svg.js +109 -0
  121. package/src/layers/table.d.ts +25 -0
  122. package/src/layers/table.js +80 -0
  123. package/src/layers/tear.d.ts +21 -0
  124. package/src/layers/tear.js +74 -0
  125. package/src/layers/texture.d.ts +17 -0
  126. package/src/layers/texture.js +106 -0
  127. package/src/layers/util.d.ts +8 -0
  128. package/src/layers/util.js +48 -0
  129. package/src/layers.js +125 -41
  130. package/src/lint/image-size.d.ts +4 -0
  131. package/src/lint/image-size.js +34 -0
  132. package/src/lint/index.d.ts +16 -0
  133. package/src/lint/index.js +127 -0
  134. package/src/lint/rules.d.ts +5 -0
  135. package/src/lint/rules.js +19 -0
  136. package/src/node.d.ts +2 -0
  137. package/src/node.js +20 -0
  138. package/src/platform/bytes.d.ts +22 -0
  139. package/src/platform/bytes.js +103 -0
  140. package/src/platform/compress.d.ts +12 -0
  141. package/src/platform/compress.js +81 -0
  142. package/src/platform/harfbuzz.d.ts +9 -0
  143. package/src/platform/harfbuzz.js +80 -0
  144. package/src/platform/index.d.ts +8 -0
  145. package/src/platform/index.js +13 -0
  146. package/src/platform/load.d.ts +6 -0
  147. package/src/platform/load.js +72 -0
  148. package/src/platform/no-harfbuzz.d.ts +2 -0
  149. package/src/platform/no-harfbuzz.js +11 -0
  150. package/src/platform/node.d.ts +26 -0
  151. package/src/platform/node.js +86 -0
  152. package/src/platform/settings.d.ts +28 -0
  153. package/src/platform/settings.js +51 -0
  154. package/src/png.js +77 -28
  155. package/src/print.js +8 -7
  156. package/src/raster/index.d.ts +24 -0
  157. package/src/raster/index.js +171 -0
  158. package/src/recipes.js +4 -3
  159. package/src/render/backdrop.d.ts +6 -0
  160. package/src/render/backdrop.js +55 -0
  161. package/src/render/content.d.ts +5 -0
  162. package/src/render/content.js +58 -0
  163. package/src/render/context.d.ts +82 -0
  164. package/src/render/grain.d.ts +7 -0
  165. package/src/render/grain.js +113 -0
  166. package/src/render/group.d.ts +23 -0
  167. package/src/render/group.js +67 -0
  168. package/src/render/key.d.ts +5 -0
  169. package/src/render/key.js +91 -0
  170. package/src/render/report.d.ts +5 -0
  171. package/src/render/report.js +19 -0
  172. package/src/render/tiles.d.ts +6 -0
  173. package/src/render/tiles.js +77 -0
  174. package/src/render/use.d.ts +27 -0
  175. package/src/render/use.js +73 -0
  176. package/src/render/wrap.d.ts +14 -0
  177. package/src/render/wrap.js +199 -0
  178. package/src/render.js +303 -212
  179. package/src/text.d.ts +29 -0
  180. package/src/text.js +22 -15
  181. package/src/type/auto-color.d.ts +9 -0
  182. package/src/type/auto-color.js +44 -0
  183. package/src/type/fallback.d.ts +11 -0
  184. package/src/type/fallback.js +92 -0
  185. package/src/type/glyphs.d.ts +24 -0
  186. package/src/type/glyphs.js +87 -0
  187. package/src/type/index.d.ts +5 -0
  188. package/src/type/index.js +10 -0
  189. package/src/type/kern.d.ts +4 -0
  190. package/src/type/kern.js +144 -0
  191. package/src/type/layout-ref.d.ts +10 -0
  192. package/src/type/layout-ref.js +74 -0
  193. package/src/type/line.d.ts +11 -0
  194. package/src/type/line.js +105 -0
  195. package/src/type/live.d.ts +6 -0
  196. package/src/type/live.js +39 -0
  197. package/src/type/name.d.ts +4 -0
  198. package/src/type/name.js +32 -0
  199. package/src/type/path.d.ts +18 -0
  200. package/src/type/path.js +89 -0
  201. package/src/type/pixel.d.ts +9 -0
  202. package/src/type/pixel.js +115 -0
  203. package/src/type/sfnt.d.ts +20 -0
  204. package/src/type/sfnt.js +258 -0
  205. package/src/type/spans.d.ts +33 -0
  206. package/src/type/spans.js +138 -0
  207. package/src/type/symbols.d.ts +7 -0
  208. package/src/type/symbols.js +89 -0
  209. package/src/type/typeset.d.ts +10 -0
  210. package/src/type/typeset.js +352 -0
  211. package/src/type/woff2.d.ts +6 -0
  212. package/src/type/woff2.js +301 -0
  213. package/src/version.js +4 -0
@@ -21,6 +21,8 @@
21
21
  * |---|---|
22
22
  * | `"@bg"` … | palette fields (`bg`, `text`, `accent`, `onAccent`, `muted`, `surface`, `mode`, `colors`, `colors.N`) and canvas numbers (`width`, `height`, `u`) |
23
23
  * | `"$name"`, `"$name.0"` | a value from `vars` (and an item or field of it) |
24
+ * | `"{layout.title.bottom}"`, `"{layout.title.bottom + 12}"` | where an earlier text layer's block landed (`top`, `bottom`, `left`, `right`, `width`, `height`, `x`, `y`, `baseline`, `size`, `lines`), filled in when drawn |
25
+ * | `"{#col * 40}"` | inside a `repeat`: an expression over the instance (`i`, `row`, `col`, `n`, `odd`, …), filled when drawn |
24
26
  * | `"{title}"` | a param or content option (`title`, `label`, `icon`, `image`); `"{opt.x}"` any cover option. A whole-string token keeps the value's type; inside a longer string it's interpolated. |
25
27
  * | `{ "mix": [a, b, t, space?] }` | two colours mixed in OKLab (or `"oklch"`: hue the short way, stays vivid) |
26
28
  * | `{ "readable": c }` | white, or near-black on a light colour (`[c, light, dark]` to choose) |
@@ -40,6 +42,10 @@
40
42
  * is the palette's, `aspect` the canvas's: `wide`, `square` or `tall`),
41
43
  * `{ "not": c }`, `{ "all": [...] }`, `{ "any": [...] }`.
42
44
  *
45
+ * Fonts: `"fonts": [{ "name": "Fraunces", "src": "fonts/Fraunces.woff2", "weight": [100, 900] }]`
46
+ * declares the faces a recipe uses (a path beside the recipe file, or a URL),
47
+ * so a caller needn't register them.
48
+ *
43
49
  * Seeding: `vars` draw from the cover's recipe stream in order; draws inside
44
50
  * a layer use that layer's own stream, so adding a layer never changes
45
51
  * another's choices.
@@ -47,33 +53,44 @@
47
53
  * @module json-recipe
48
54
  */
49
55
 
50
- import { readFileSync } from 'node:fs';
56
+ import { readTextSync, node } from './platform/index.js';
51
57
  import { readableText } from '@arraypress/color-utils';
52
58
  import { createRng } from './rng.js';
53
- import { len } from './geometry.js';
59
+ import { len, ANCHORS } from './geometry.js';
54
60
  import { toHex, mixOklab, mixOklch, onBackground, gradientAt } from './color.js';
55
61
  import { LAYERS, toneColor } from './layers.js';
56
62
  import { LAYER_OPTIONS, COMMON } from './layer-options.js';
63
+ import { EFFECT_OPTIONS, EFFECT_COMMON } from './effects/options.js';
64
+ import { MASK_GRADIENTS } from './effects/mask.js';
65
+ import { shapesOnly } from './layers/svg.js';
66
+ import { LAYOUT_FIELDS } from './type/layout-ref.js';
57
67
 
58
68
  /** Options every recipe can read without declaring them. */
59
- const CONTENT = ['title', 'label', 'icon', 'image'];
69
+ const CONTENT = ['title', 'label', 'icon', 'image', 'name', 'mark', 'photo', 'cutout', 'date', 'dates', 'time', 'venue', 'address', 'lines', 'lineup', 'tracklist', 'price', 'meta', 'page'];
60
70
 
61
71
  /** `@` tokens. */
62
72
  const PALETTE = ['bg', 'text', 'accent', 'onAccent', 'muted', 'surface', 'mode', 'colors'];
63
- const CANVAS = ['width', 'height', 'u'];
73
+ const CANVAS = ['width', 'height', 'u', 'bleed', 'safe', 'mm', 'dpi', 'trimWidth', 'trimHeight', 'safeX', 'safeY', 'safeWidth', 'safeHeight'];
64
74
 
65
75
  /** Value operators, by name, with their argument arity. */
66
76
  const OPS = /** @type {Record<string, [number, number]>} */ ({
67
77
  mix: [3, 4], readable: [1, 3], gradientUnder: [3, 3], tone: [2, 2], length: [1, 1], pick: [1, Infinity], range: [2, 2], int: [2, 2],
68
78
  add: [1, Infinity], sub: [2, Infinity], mul: [1, Infinity], div: [2, 2], min: [1, Infinity], max: [1, Infinity],
69
79
  ramp: [3, 4], taper: [2, 2], reverse: [1, 1],
80
+ join: [1, 2], upper: [1, 1], lower: [1, 1], slice: [2, 3],
70
81
  });
71
82
 
83
+ /** Layer options holding nested layers. */
84
+ const NESTED = ['layers', 'mask', 'knockout', 'clip'];
85
+
86
+ /** svg-layer options holding raw markup (their `{{name}}` slots are filled; nothing else is touched). */
87
+ const MARKUP = ['markup', 'defs'];
88
+
72
89
  /** Keys of a layer that aren't layer options. */
73
90
  const LAYER_META = new Set(['id', 'type', 'when']);
74
91
 
75
92
  /** Top-level keys of a recipe document. */
76
- const TOP = new Set(['$schema', 'name', 'description', 'params', 'requires', 'vars', 'output', 'layers']);
93
+ const TOP = new Set(['$schema', 'name', 'description', 'params', 'requires', 'vars', 'output', 'layers', 'pages', 'fonts']);
77
94
 
78
95
  /** Recipes defined from the same JSON object are reused. */
79
96
  const defined = new WeakMap();
@@ -139,11 +156,17 @@ export function defineRecipe(json) {
139
156
  export function loadRecipe(path) {
140
157
  let json;
141
158
  try {
142
- json = JSON.parse(readFileSync(path, 'utf8'));
159
+ json = JSON.parse(readTextSync(path));
143
160
  } catch (err) {
144
161
  throw new TypeError(`Couldn't read recipe ${String(path)}: ${/** @type {Error} */ (err).message}`);
145
162
  }
146
163
  if (isPlain(json) && !json.name) json.name = String(path).split('/').pop()?.replace(/\.json$/, '');
164
+ // Fonts given as paths are beside the recipe.
165
+ const p = node('path');
166
+ if (isPlain(json) && Array.isArray(json.fonts) && p) {
167
+ const dir = p.dirname(path instanceof URL ? path.pathname : String(path));
168
+ json.fonts = json.fonts.map((/** @type {any} */ f) => (isPlain(f) && typeof f.src === 'string' && !/^[a-z][\w+.-]*:|^\//i.test(f.src) ? { ...f, src: p.resolve(dir, f.src) } : f));
169
+ }
147
170
  return defineRecipe(json);
148
171
  }
149
172
 
@@ -189,18 +212,60 @@ function build(json, name, options, c) {
189
212
  let stream;
190
213
  const own = { ...scope, rng: () => (stream ??= createRng(`${c.seed ?? ''}:recipe:${layer.id ?? `${layer.type}#${n}`}`)) };
191
214
  if (layer.when !== undefined && !test(own, layer.when)) continue;
192
- /** @type {Record<string, unknown>} */
193
- const out = { ...(layer.id ? { id: layer.id } : {}), type: layer.type };
194
- for (const [k, v] of Object.entries(layer)) {
195
- if (LAYER_META.has(k)) continue;
196
- const r = resolve(own, v);
197
- if (r !== undefined) out[k] = r;
198
- }
199
- layers.push(/** @type {any} */ (out));
215
+ layers.push(/** @type {any} */ (resolveLayer(own, layer)));
200
216
  }
201
217
  return layers;
202
218
  }
203
219
 
220
+ /**
221
+ * Resolve one layer's options (and any layers nested in it).
222
+ *
223
+ * @param {Scope} s
224
+ * @param {any} layer
225
+ * @returns {Record<string, unknown>}
226
+ */
227
+ function resolveLayer(s, layer) {
228
+ /** @type {Record<string, unknown>} */
229
+ const out = { ...(layer.id ? { id: layer.id } : {}), type: layer.type };
230
+ for (const [k, v] of Object.entries(layer)) {
231
+ if (LAYER_META.has(k)) continue;
232
+ let r;
233
+ if (NESTED.includes(k) && Array.isArray(v) && v.every((x) => isPlain(x) && typeof x.type === 'string')) {
234
+ r = v.filter((x) => x.when === undefined || test(s, x.when)).map((x) => resolveLayer(s, x));
235
+ } else if (k === 'layer' && isPlain(v) && typeof v.type === 'string') {
236
+ r = resolveLayer(s, v);
237
+ } else if (layer.type === 'svg' && MARKUP.includes(k) && typeof v === 'string') {
238
+ r = fillSlots(s, v);
239
+ } else {
240
+ r = resolve(s, v);
241
+ }
242
+ if (r !== undefined) out[k] = r;
243
+ }
244
+ return out;
245
+ }
246
+
247
+ /**
248
+ * Fill `{{name}}` slots in raw markup: options and params (escaped; a
249
+ * markup value such as `{{mark}}` between elements goes in as sanitised
250
+ * shapes), vars (`{{$name}}`). Palette and canvas tokens (`{{@accent}}`), `{{id}}` and
251
+ * `{{seed}}` are left for the layer to fill when it's drawn.
252
+ *
253
+ * @param {Scope} s
254
+ * @param {string} markup
255
+ * @returns {string}
256
+ */
257
+ function fillSlots(s, markup) {
258
+ return markup.replace(/\{\{\s*([$\w.]+)\s*\}\}/g, (m, k, at) => {
259
+ if (k === 'id' || k === 'seed') return m;
260
+ const v = k.startsWith('$') ? resolveString(s, k) : valueOf(lookup(s, k));
261
+ if (v == null) return '';
262
+ // Between elements, a value that is markup (a mark's paths) goes in as shapes; anything else is text.
263
+ const inTag = markup.lastIndexOf('<', at) > markup.lastIndexOf('>', at);
264
+ if (!inTag && /^\s*</.test(String(v))) return shapesOnly(String(v));
265
+ return String(v).replace(/[&<>"']/g, (ch) => /** @type {Record<string, string>} */ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
266
+ });
267
+ }
268
+
204
269
  /**
205
270
  * Look up a template name: a param, a content option, or `opt.<key>`.
206
271
  *
@@ -211,7 +276,11 @@ function build(json, name, options, c) {
211
276
  function lookup(s, key) {
212
277
  if (key.startsWith('opt.')) return s.options[key.slice(4)];
213
278
  if (Object.hasOwn(s.params, key)) return s.params[key];
214
- return s.options[key];
279
+ if (Object.hasOwn(s.options, key) || !key.includes('.')) return s.options[key];
280
+ // A path into an option: "lineup.0", "meta.venue".
281
+ const [head, ...rest] = key.split('.');
282
+ const base = Object.hasOwn(s.params, head) ? s.params[head] : s.options[head];
283
+ return rest.reduce((/** @type {any} */ acc, k) => acc?.[k], base);
215
284
  }
216
285
 
217
286
  /**
@@ -255,9 +324,12 @@ function resolveString(s, str) {
255
324
  const [head, ...path] = str.slice(1).split('.');
256
325
  return path.reduce((/** @type {any} */ acc, k) => acc?.[k], s.vars[head]);
257
326
  }
327
+ // Layout references are filled in when the cover is drawn.
328
+ if (/^\{layout\.[\w-]+\.\w+\s*(?:[+-]\s*[\d.]+)?\}$/.test(str)) return str;
258
329
  const whole = str.match(/^\{([\w.]+)\}$/);
259
330
  if (whole) return lookup(s, whole[1]);
260
- return str.replace(/\{([\w.]+)\}/g, (_, k) => {
331
+ return str.replace(/\{([\w.]+)\}/g, (m, k) => {
332
+ if (k.startsWith('layout.')) return m;
261
333
  const x = valueOf(lookup(s, k));
262
334
  return x == null ? '' : String(x);
263
335
  });
@@ -281,6 +353,10 @@ function op(s, name, raw) {
281
353
  const items = Array.isArray(raw) ? raw : resolve(s, raw);
282
354
  return resolve(s, s.rng().pick(/** @type {unknown[]} */ (items)));
283
355
  }
356
+ if (name === 'join' || name === 'slice' || name === 'upper' || name === 'lower') {
357
+ const a = Array.isArray(raw) && (name !== 'upper' && name !== 'lower') ? raw.map((x) => resolve(s, x)) : [resolve(s, raw)];
358
+ return op2(name, /** @type {any[]} */ (a));
359
+ }
284
360
  const args = /** @type {any[]} */ ([].concat(resolve(s, raw)));
285
361
  const nums = () => args.map((a) => (typeof a === 'number' ? a : len(a, s.c)));
286
362
  switch (name) {
@@ -338,7 +414,9 @@ function test(s, cond) {
338
414
  if ('any' in cond) return cond.any.some((/** @type {unknown} */ c) => test(s, c));
339
415
  return Object.entries(cond).every(([k, want]) => {
340
416
  const ratio = s.c.width / s.c.height;
341
- const have = k === 'mode' ? s.c.palette.mode
417
+ const have = k === 'page' ? s.options.page ?? 'front'
418
+ : k === 'format' ? s.c.format
419
+ : k === 'mode' ? s.c.palette.mode
342
420
  : k === 'aspect' ? (ratio > 1.15 ? 'wide' : ratio < 0.87 ? 'tall' : 'square')
343
421
  : valueOf(lookup(s, k));
344
422
  return Array.isArray(want) ? want.includes(have) : have === want;
@@ -367,13 +445,21 @@ export function validate(json) {
367
445
  if (json.params !== undefined && !isPlain(json.params)) fail('params', 'must be an object of defaults');
368
446
  if (json.output !== undefined && !(isPlain(json.output) && Object.keys(json.output).every((k) => k === 'lossless'))) fail('output', 'is { "lossless": true | false }');
369
447
  if (json.vars !== undefined && !isPlain(json.vars)) fail('vars', 'must be an object');
448
+ if (json.pages !== undefined && !(Array.isArray(json.pages) && json.pages.every((/** @type {unknown} */ p) => typeof p === 'string'))) fail('pages', 'must be an array of page names (["front", "back"])');
370
449
  if (json.requires !== undefined && !(Array.isArray(json.requires) && json.requires.every((/** @type {unknown} */ r) => typeof r === 'string'))) fail('requires', 'must be an array of option names');
371
450
  if (!Array.isArray(json.layers) || json.layers.length === 0) fail('layers', 'must be a non-empty array');
451
+ if (json.fonts !== undefined) {
452
+ if (!Array.isArray(json.fonts)) fail('fonts', 'must be an array of { name, src, weight, style, axes }');
453
+ json.fonts.forEach((/** @type {any} */ f, /** @type {number} */ i) => {
454
+ if (!isPlain(f) || typeof f.name !== 'string' || typeof f.src !== 'string') fail(`fonts[${i}]`, 'needs a name and a src (a path, beside the recipe, or a URL)');
455
+ for (const k of Object.keys(f)) if (!['name', 'src', 'weight', 'style', 'axes', 'fallback'].includes(k)) fail(`fonts[${i}].${k}`, 'unknown key (name, src, weight, style, axes, fallback)');
456
+ });
457
+ }
372
458
 
373
459
  const names = new Set([...Object.keys(json.params ?? {}), ...CONTENT]);
374
460
  const vars = new Set();
375
461
  const checkName = (/** @type {string} */ path, /** @type {string} */ key) => {
376
- if (!key.startsWith('opt.') && !names.has(key)) {
462
+ if (!key.startsWith('opt.') && !names.has(key.split('.')[0])) {
377
463
  fail(path, `unknown param "${key}" (declare it in "params", or use "{opt.${key}}" for any cover option)`);
378
464
  }
379
465
  };
@@ -389,7 +475,7 @@ export function validate(json) {
389
475
  return c[k].forEach((/** @type {unknown} */ x, /** @type {number} */ i) => checkCond(`${path}.${k}[${i}]`, x));
390
476
  }
391
477
  }
392
- for (const k of Object.keys(c)) if (k !== 'mode' && k !== 'aspect') checkName(`${path}.${k}`, k);
478
+ for (const k of Object.keys(c)) if (k !== 'mode' && k !== 'aspect' && k !== 'page' && k !== 'format') checkName(`${path}.${k}`, k);
393
479
  };
394
480
 
395
481
  const checkValue = (/** @type {string} */ path, /** @type {any} */ v) => {
@@ -407,6 +493,13 @@ export function validate(json) {
407
493
  return;
408
494
  }
409
495
  for (const m of v.matchAll(/\{([^{}]*)\}/g)) {
496
+ // `{#…}`: a repeat instance's expression, filled when drawn.
497
+ if (m[1].startsWith('#')) continue;
498
+ if (/^layout\.[\w-]+\.\w+\s*(?:[+-]\s*[\d.]+)?$/.test(m[1])) {
499
+ const field = m[1].split('.')[2].replace(/[\s+-].*$/, '');
500
+ if (!LAYOUT_FIELDS.includes(/** @type {any} */ (field))) fail(path, `unknown layout field "${field}" (${LAYOUT_FIELDS.join(', ')})`);
501
+ continue;
502
+ }
410
503
  if (!/^[\w.]+$/.test(m[1])) fail(path, `bad template "{${m[1]}}"`);
411
504
  checkName(path, m[1]);
412
505
  }
@@ -442,8 +535,7 @@ export function validate(json) {
442
535
  checkValue(`vars.${k}`, v);
443
536
  vars.add(k);
444
537
  }
445
- json.layers.forEach((/** @type {any} */ layer, /** @type {number} */ i) => {
446
- const path = `layers[${i}]${layer?.id ? ` (${layer.id})` : ''}`;
538
+ const checkLayer = (/** @type {string} */ path, /** @type {any} */ layer) => {
447
539
  if (!isPlain(layer)) return fail(path, 'must be an object');
448
540
  if (!Object.hasOwn(LAYERS, layer.type)) fail(`${path}.type`, `unknown layer type "${layer.type}" (${Object.keys(LAYERS).join(', ')})`);
449
541
  if (layer.id !== undefined && typeof layer.id !== 'string') fail(`${path}.id`, 'must be a string');
@@ -452,7 +544,73 @@ export function validate(json) {
452
544
  for (const [k, v] of Object.entries(layer)) {
453
545
  if (LAYER_META.has(k)) continue;
454
546
  if (!Object.hasOwn(known, k)) fail(`${path}.${k}`, `"${layer.type}" layers have no option "${k}" (${Object.keys(known).join(', ')})`);
455
- checkValue(`${path}.${k}`, v);
547
+ if (k === 'effects') {
548
+ checkEffects(`${path}.effects`, v);
549
+ } else if (NESTED.includes(k) && Array.isArray(v) && v.every((x) => isPlain(x) && typeof x.type === 'string')) {
550
+ v.forEach((x, j) => checkLayer(`${path}.${k}[${j}]${x.id ? ` (${x.id})` : ''}`, x));
551
+ } else if (k === 'layer' && isPlain(v) && typeof v.type === 'string') {
552
+ checkLayer(`${path}.layer`, v);
553
+ } else if (layer.type === 'svg' && MARKUP.includes(k) && typeof v === 'string') {
554
+ for (const m of v.matchAll(/\{\{\s*([$@\w.]+)\s*\}\}/g)) {
555
+ const t = m[1];
556
+ if (t === 'id' || t === 'seed' || t.startsWith('@')) continue;
557
+ if (t.startsWith('$')) { if (!vars.has(t.slice(1).split('.')[0])) fail(`${path}.${k}`, `unknown var "{{${t}}}"`); continue; }
558
+ checkName(`${path}.${k}`, t);
559
+ }
560
+ } else {
561
+ checkValue(`${path}.${k}`, v);
562
+ checkAnchor(`${path}.${k}`, layer.type, k, v);
563
+ }
456
564
  }
457
- });
565
+ };
566
+ // Effects: known kinds, their options, masks and nested layers (computed values are checked when drawn).
567
+ const checkEffects = (/** @type {string} */ path, /** @type {any} */ v) => {
568
+ if (!Array.isArray(v)) return checkValue(path, v);
569
+ v.forEach((e, i) => {
570
+ const at = `${path}[${i}]`;
571
+ if (typeof e === 'string') {
572
+ if (!/[{$@]/.test(e) && !Object.hasOwn(EFFECT_OPTIONS, e)) fail(at, `unknown effect "${e}" (${Object.keys(EFFECT_OPTIONS).join(', ')})`);
573
+ return;
574
+ }
575
+ if (!isPlain(e) || typeof e.kind !== 'string' || 'if' in e) return checkValue(at, e);
576
+ if (!/[{$@]/.test(e.kind) && !Object.hasOwn(EFFECT_OPTIONS, e.kind)) fail(`${at}.kind`, `unknown effect "${e.kind}" (${Object.keys(EFFECT_OPTIONS).join(', ')})`);
577
+ const known = { ...(EFFECT_OPTIONS[e.kind] ?? {}), ...EFFECT_COMMON };
578
+ for (const [k, x] of Object.entries(e)) {
579
+ if (Object.hasOwn(EFFECT_OPTIONS, e.kind) && !Object.hasOwn(known, k)) fail(`${at}.${k}`, `"${e.kind}" effects have no option "${k}" (${Object.keys(known).join(', ')})`);
580
+ if (k === 'mask' && isPlain(x)) checkEffectMask(`${at}.mask`, x);
581
+ else if (k === 'map' && isPlain(x) && Array.isArray(x.layers)) x.layers.forEach((/** @type {any} */ l, /** @type {number} */ j) => checkLayer(`${at}.map.layers[${j}]`, l));
582
+ else checkValue(`${at}.${k}`, x);
583
+ }
584
+ });
585
+ };
586
+ const checkEffectMask = (/** @type {string} */ path, /** @type {any} */ m) => {
587
+ const keys = ['gradient', 'from', 'to', 'center', 'angle', 'aspect', 'invert', 'ease', 'layers'];
588
+ for (const k of Object.keys(m)) if (!keys.includes(k)) fail(`${path}.${k}`, `unknown mask option (${keys.join(', ')})`);
589
+ if (Array.isArray(m.layers)) return m.layers.forEach((/** @type {any} */ l, /** @type {number} */ j) => checkLayer(`${path}.layers[${j}]`, l));
590
+ if (typeof m.gradient !== 'string' || (!/[{$@]/.test(m.gradient) && !MASK_GRADIENTS.includes(m.gradient))) fail(`${path}.gradient`, `"${m.gradient}" (${MASK_GRADIENTS.join(', ')}), or give layers`);
591
+ for (const [k, x] of Object.entries(m)) checkValue(`${path}.${k}`, x);
592
+ };
593
+ // Anchors, as literal strings (templates are checked when drawn).
594
+ const checkAnchor = (/** @type {string} */ path, /** @type {string} */ type, /** @type {string} */ k, /** @type {any} */ v) => {
595
+ if (typeof v !== 'string' || /[{$@]/.test(v)) return;
596
+ const allowed = k === 'anchor' || (k === 'align' && type === 'group') ? Object.keys(ANCHORS) : k === 'focus' && type === 'image' ? [...Object.keys(ANCHORS), 'attention', 'entropy'] : null;
597
+ if (allowed && !allowed.includes(v)) fail(path, `unknown anchor "${v}" (${allowed.join(', ')})`);
598
+ };
599
+ json.layers.forEach((/** @type {any} */ layer, /** @type {number} */ i) => checkLayer(`layers[${i}]${layer?.id ? ` (${layer.id})` : ''}`, layer));
600
+ }
601
+
602
+ /**
603
+ * The string and list operators.
604
+ *
605
+ * @param {string} name
606
+ * @param {any[]} args
607
+ * @returns {unknown}
608
+ */
609
+ function op2(name, args) {
610
+ switch (name) {
611
+ case 'join': return Array.isArray(args[0]) ? args[0].map((x) => valueOf(x)).join(args[1] ?? ' ') : String(valueOf(args[0]) ?? '');
612
+ case 'upper': return String(valueOf(args[0]) ?? '').toUpperCase();
613
+ case 'lower': return String(valueOf(args[0]) ?? '').toLowerCase();
614
+ default: return Array.isArray(args[0]) || typeof args[0] === 'string' ? args[0].slice(args[1], args[2]) : args[0];
615
+ }
458
616
  }
@@ -0,0 +1,20 @@
1
+ // A brand kit in one call.
2
+ import type { CoverOptions, Recipe, RecipeJson, RecipeName, FormatName } from '../index.js';
3
+
4
+ /** The formats a kit renders by default, by group. */
5
+ export const KIT_FORMATS: Readonly<{ social: readonly FormatName[]; release: readonly FormatName[]; print: readonly FormatName[] }>;
6
+
7
+ /** One rendered piece: SVG (or image bytes), or its pages. */
8
+ export type KitFile = string | Uint8Array | Record<string, string | Uint8Array>;
9
+
10
+ /** Render a brand on many formats at once. */
11
+ export function kit(brand: CoverOptions, options?: {
12
+ formats?: FormatName[];
13
+ /** One recipe for all, or by format (with `default`). Default the built-in `brand`. */
14
+ recipe?: RecipeName | Recipe | RecipeJson | Partial<Record<FormatName | 'default', RecipeName | Recipe | RecipeJson>>;
15
+ /** Pages per format (business cards are `['front', 'back']` by default). */
16
+ pages?: Partial<Record<FormatName, string[]>>;
17
+ /** `'svg'` (default) or an image format (Node). */
18
+ render?: 'svg' | 'webp' | 'png' | 'jpeg' | 'avif';
19
+ overlay?: boolean;
20
+ }): Promise<Partial<Record<FormatName, KitFile>>>;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A brand kit: one brand, every format, one call.
3
+ *
4
+ * @module kit
5
+ */
6
+
7
+ import { renderSvg, renderPages, renderImage } from '../render.js';
8
+
9
+ /** The formats a kit renders by default. */
10
+ export const KIT_FORMATS = Object.freeze({
11
+ social: ['avatar', 'x-header', 'linkedin-banner', 'linkedin-company', 'youtube-banner', 'youtube-thumbnail', 'facebook-cover', 'instagram-post', 'instagram-square', 'instagram-story', 'instagram-reel', 'tiktok-cover', 'pinterest', 'twitch-banner', 'discord-banner', 'bluesky-banner', 'og'],
12
+ release: ['release', 'spotify-canvas', 'bandcamp-header'],
13
+ print: ['business-card', 'letterhead', 'compliment-slip', 'sticker', 'ticket', 'lanyard-pass'],
14
+ });
15
+
16
+ /** Formats printed on both sides by default. */
17
+ const TWO_SIDED = new Set(['business-card', 'business-card-us']);
18
+
19
+ /**
20
+ * Render a brand on many formats at once.
21
+ *
22
+ * `brand` is any cover options shared by every piece: `name`, `tagline`,
23
+ * `palette`, `mark` (path markup on a 512 grid), `photo`, `fonts`, `lines`
24
+ * (contact lines), `person` and `role`, … `recipe` is one recipe for all
25
+ * formats (the built-in `brand` by default) or a map from format to recipe
26
+ * (with `default`). Two-sided formats (business cards, or any in `pages`)
27
+ * come back as `{ front, back }`.
28
+ *
29
+ * @param {Record<string, any>} brand
30
+ * @param {{ formats?: string[], recipe?: any, pages?: Record<string, string[]>, render?: 'svg' | 'webp' | 'png' | 'jpeg' | 'avif', overlay?: boolean }} [options]
31
+ * @returns {Promise<Record<string, any>>} By format: an SVG string (or image bytes), or `{ front, back }`.
32
+ *
33
+ * @example
34
+ * const files = await kit({ name: 'Hollow Fern', palette: { accent: '#2f6b4f' }, mark }, { formats: ['avatar', 'x-header', 'business-card'] });
35
+ * files['business-card'].back; // '<svg …'
36
+ */
37
+ export async function kit(brand, options = {}) {
38
+ const formats = options.formats ?? [...KIT_FORMATS.social, ...KIT_FORMATS.release, ...KIT_FORMATS.print];
39
+ const pick = (/** @type {string} */ f) => {
40
+ const r = options.recipe;
41
+ if (r && typeof r === 'object' && !Array.isArray(r) && !r.layers) return r[f] ?? r.default ?? 'brand';
42
+ return r ?? 'brand';
43
+ };
44
+ /** @type {Record<string, any>} */
45
+ const out = {};
46
+ for (const format of formats) {
47
+ const input = { seed: brand.seed ?? brand.name ?? 'brand', ...brand, recipe: pick(format), format, overlay: options.overlay };
48
+ const pages = options.pages?.[format] ?? (TWO_SIDED.has(format) ? ['front', 'back'] : null);
49
+ const render = options.render ?? 'svg';
50
+ if (pages) {
51
+ if (render === 'svg') out[format] = await renderPages(input, { pages });
52
+ else out[format] = Object.fromEntries(await Promise.all(pages.map(async (page) => [page, await renderImage({ ...input, page }, { format: render })])));
53
+ } else {
54
+ out[format] = render === 'svg' ? await renderSvg(input) : await renderImage(input, { format: render });
55
+ }
56
+ }
57
+ return out;
58
+ }
@@ -14,12 +14,14 @@ export const LAYER_OPTIONS = {
14
14
  radial: { colors: 'Centre → edge.', x: 'Centre x, 0–1.', y: 'Centre y, 0–1.', radius: 'Length. Default "80vmax".', ease: 'Default true.' },
15
15
  mesh: { colors: 'Blob colours.', hues: 'Distinct colours to use. Default 3.', hueSpan: 'Keep the colours within this many degrees of hue (OKLCH): neighbouring hues only.', count: 'Blobs. Default 4.', blur: 'Length. Default "11vmax".', spread: '0–1. Default 1 (sweep), 0.7 (ring).', size: '[min, max] radius, fractions of the longer side.', base: 'Fill under the fields: "auto" (a gradient across the sweep between the first two colours; default), "bg", a colour, or null.', arrangement: 'sweep (default: long fields along one direction) | ring', angle: 'Sweep direction in degrees. Default seeded.', streak: 'How much more the sweep blurs along than across. Default 1.5.', tone: '{ l: [min, max], c: [min, max] }: pull colours into an OKLCH band.', fold: 'true or { color, to, position, bend, softness, sheen }: a second field meeting the first along a soft curve.', streaks: 'Thin light motion streaks along the sweep. Default 0.', from: 'A photo (path or bytes) to take the colours from (needs sharp).', blobs: 'Explicit [{ x, y, r, squash, rotate, color, opacity }].' },
16
16
  stripes: { direction: 'vertical | horizontal | diagonal', angle: 'For diagonal.', count: 'Stripes. Default 8.', widths: 'even | random | taper | [weights]', taper: 'Ratio for widths: taper.', colors: 'Cycled.', gap: 'Length.', radius: 'Length.', region: '{ x, y, width, height } fractions.', regionRadius: 'Length.' },
17
- image: { src: 'A local path, data: URI or bytes ("{image}").', fit: 'cover | contain', focus: 'An anchor, attention or entropy.', region: 'Fractions.', duotone: 'true or [shadow, highlight].', grayscale: 'boolean', normalize: 'Stretch tones first.', tint: 'A colour or { color, amount, blend }.', blur: 'Length.' },
17
+ image: { x: 'Box left, px or "%" (with y, width, height: an explicit box instead of region).', y: 'Box top.', width: 'Box width.', height: 'Box height.', src: 'A local path, data: URI, URL (where allowed) or bytes ("{image}").', fit: 'cover | contain | stretch', focus: 'An anchor, attention or entropy.', region: 'Fractions.', duotone: 'true or [shadow, highlight].', grayscale: 'boolean', normalize: 'Stretch tones first.', tint: 'A colour or { color, amount, blend }.', blur: 'Length.' },
18
18
  grain: { amount: '0–1. Default 0.08.', size: 'Grain px. Default 1.', monochrome: 'Default true.', vary: '"edges": heavier toward the edges (spray paint).' },
19
19
  pattern: { kind: 'grid | dots | lines | rings | waves', size: 'Cell. Length.', stroke: 'Length.', color: 'Default @text.', angle: 'For lines.', center: '{ x, y } fractions.', wavelength: 'Length.', amplitude: 'Length.', fade: 'none | radial | edges | top | bottom | left | right', dash: 'dotted | dashed | [on, off] lengths.', divisions: 'Grid/lines: N equal cells (inner lines only) instead of size.', gaps: '[{ x, y, width, height }] fractions the lines break around.', region: 'Fractions.' },
20
20
  shapes: { kind: 'circle | square | pill | mixed', count: 'Default 6.', size: '[min, max] lengths.', colors: 'Cycled.', placement: 'scatter | row | column', items: '[{ kind, x, y, size, color, rotate, opacity }]', region: 'Fractions.', rotate: 'Tilt squares and pills.', radius: 'Square corner, fraction of size.', outline: 'true or a width.' },
21
- icon: { name: 'Iconify name ("lucide:feather") or raw SVG ("{icon}").', svg: 'Raw SVG.', path: 'A path d.', viewBox: '[x, y, w, h] for path.', size: 'Length. Default "24vmin".', color: 'Default the accent on a tile, else @text.', strokeWidth: 'Stroke icons.', anchor: 'One of nine anchors.', position: '{ x, y } fractions.', offset: '{ x, y } lengths.', margin: 'Length.', tile: 'true or { size, radius, color, shadow, border }.' },
22
- text: { content: 'The text ("{title}").', font: 'Family. Default Inter.', weight: 'Default 600.', style: 'normal | italic', size: 'Length.', minSize: 'Shrink toward this before truncating.', lineHeight: 'Default 1.12.', letterSpacing: 'Em. Default -0.02.', color: 'Default @text.', align: 'left | center | right', anchor: 'Default bottom-left.', position: '{ x, y } fractions.', offset: '{ x, y } lengths.', margin: 'Length.', maxWidth: 'Length.', maxLines: 'Default 3.', ellipsis: "Default '…'.", balance: 'Default true.', transform: 'none | uppercase | lowercase', label: 'A string or { content, size, weight, color, letterSpacing, transform, gap }.', background: 'A colour or { color, radius, padding, opacity }.' },
21
+ icon: { fill: 'A gradient, tile or preset paint for the icon (instead of color).', markup: 'Inner SVG markup on a 512 grid (a mark\'s paths); viewBox overrides the grid.', x: 'Left, px (with y: placed by its top-left corner).', y: 'Top, px.', name: 'Iconify name ("lucide:feather") or raw SVG ("{icon}").', svg: 'Raw SVG.', path: 'A path d.', viewBox: '[x, y, w, h] for path.', size: 'Length. Default "24vmin".', color: 'Default the accent on a tile, else @text.', strokeWidth: 'Stroke icons.', anchor: 'One of nine anchors.', position: '{ x, y } fractions.', offset: '{ x, y } lengths.', margin: 'Length.', tile: 'true or { size, radius, color, shadow, border }.' },
22
+ text: { content: 'The text ("{title}").', font: 'Family. Default Inter.', weight: 'Default 600.', style: 'normal | italic', size: 'Length.', minSize: 'Shrink toward this before truncating.', lineHeight: 'Default 1.12.', letterSpacing: 'Em. Default -0.02.', color: 'Default @text.', align: 'left | center | right', anchor: 'Default bottom-left.', position: '{ x, y } fractions.', offset: '{ x, y } lengths.', margin: 'Length.', maxWidth: 'Length.', maxLines: 'Default 3.', ellipsis: "Default '…'.", balance: 'Default true.', transform: 'none | uppercase | lowercase', label: 'A string or { content, size, weight, color, letterSpacing, transform, gap }.', background: 'A colour or { color, radius, padding, opacity }.',
23
+ x: 'First baseline x, px or "%" (like SVG <text>; align is the text-anchor).', y: 'First baseline y.', width: 'Justify every line to this width (like textLength).', justify: 'Justify every line to maxWidth.', lengthAdjust: 'spacingAndGlyphs (default: scale) | spacing', fit: 'width (the block fills width) | lines (each line fills width) | words (a word a line, the widest filling width) | wrap (wrap at width, then fill it) | shrink', stretch: '[sx, sy] or sy: stretch about the first baseline.', axes: 'Variable-font axes: { wdth: 50, SOFT: 100, WONK: 1, opsz: 144 }.', stroke: 'Outline colour.', strokeWidth: 'Outline width.', strokes: '[{ color, width }]: keylines, widest first, under the fill.', fill: 'A colour, a gradient, or "none" (outline type).', path: 'Text on a path: { kind: circle | arc | ellipse | wave, cx, cy, r, start, end, direction } or { d }.', startOffset: 'Along the path: 0–1 or px. Default 0.5 (centred).', fitPath: 'Space the text to fill the path (a ring); a number leaves that gap.', dy: 'Shift off the path.', render: 'outline (default) | live (<text> with @font-face)', autoColors: '[light, dark] for color: "auto". Default ["#ffffff", "#0b0b0b"].', autoThreshold: 'color: "auto" takes the dark colour above this backdrop luma (0–1). Default 0.72.', lines: 'Reserved.',
24
+ spans: '[{ content, font, weight, style, axes, scale, size, color, letterSpacing, dy }]: mixed runs on the same lines.', glyphs: '{ scale, scaleX, scaleY, dx, dy, rotate, color, origin, advance }: per-glyph arrays (cycled; spaces skipped).', fallback: 'A family (or list) for characters the font lacks; then fonts registered with fallback: true, then the built-in symbols.', split: 'words: a word a line.', dash: 'Dashes on the stroke: [on, off] lengths, dashed or dotted.', extrude: 'A depth, or { depth, angle, color, to, steps }: block extrusion.', maxHeight: 'Shrink the block to this height.' },
23
25
  diagram: { icon: 'One icon (Iconify name or raw SVG), drawn as white line-art.', nodes: '[{ icon, shape: square | circle | none, size, brackets }]', layout: 'single | row | triangle | square | stack | hub', arrows: 'true | false | loop | both | cycle', shape: 'For a single icon: none (default) | square | circle.', size: 'Node size. Length.', gap: 'Space between nodes. Length.', stroke: 'Line width. Default "0.3vmin".', color: 'Line colour. Default white.', radius: 'Tile corner radius.', iconScale: 'Icon size inside a tile, 0–1. Default 0.44.', position: '{ x, y } centre, fractions.', grid: 'true or { divisions, opacity, stroke, color, pad }: a dotted grid that breaks around the drawing.' },
24
26
  paper: { count: 'Shapes in the collage. Default 4.', kind: 'mixed | rect | square | circle | blob | arch | pill', color: 'Default #faf9f5 (paper).', region: 'Where the collage sits, fractions.', items: '[{ kind, x, y, width, height, size, rotate, color }] placed in the region.', wobble: 'How rough the cut edges are, 0–1. Default 0.5.' },
25
27
  scribble: { icon: 'An icon to re-draw by hand (Iconify name or raw SVG).', graph: 'true or { nodes, dot }: a hand-drawn node graph instead.', color: 'Default #faf9f5.', stroke: 'Line width. Default "1.3vmin".', size: 'Length. Default "46vmin".', position: '{ x, y } centre, fractions.', wobble: '0–1. Default 0.5.' },
@@ -30,8 +32,25 @@ export const LAYER_OPTIONS = {
30
32
  tiles: { cols: 'Columns. Default from the series.', rows: 'Rows. Default to keep cells near square.', series: 'Seeds the grid rhythm shared by a series.', colors: 'Shape and cell colours.', ground: 'The page colour among them.', kinds: 'Subset of quarter, half, circle, square, triangle.', merge: 'A 2×2 merged cell. Default true.', region: 'Fractions.' },
31
33
  rule: { y: 'Height, fraction. Default 0.15.', from: '{ x, y } fractions.', to: '{ x, y } fractions.', inset: 'Side inset. Length.', color: 'Default @text.', stroke: 'Default "0.12vmin".' },
32
34
  frame: { inset: 'Length.', radius: 'Length.', stroke: 'Length.', color: 'Default @text.', fill: 'A colour (a card).' },
35
+ repeat: { layer: 'The layer to repeat (or layers, drawn as a group). {#…} expressions use i, n, row, col, rows, cols, x, y, angle, odd.', layers: 'Several layers to repeat as one.', rows: 'Grid rows.', cols: 'Grid columns.', count: 'Instances in a ring or a line.', step: '[dx, dy] between instances (a grid divides the canvas by default).', x: 'Where the first instance goes.', y: 'Where the first instance goes.', cx: 'Ring centre x.', cy: 'Ring centre y.', radius: 'Ring radius.', start: 'Ring start, degrees (0 = up, clockwise).', sweep: 'Ring sweep, degrees. Default 360.', turn: 'Turn ring instances to their angle. Default true.', odd: 'Overrides for odd instances (a checkerboard on a grid).', cycle: '[overrides] taken in turn.', skip: 'An expression ({#…}): leave the instance out when true.' },
36
+ defs: { layers: 'Layers with ids, for use layers to draw (nothing is drawn here).' },
37
+ use: { ref: 'The id of a layer in an earlier defs layer.', x: 'Where the copy goes.', y: 'Where the copy goes.', rotate: 'Degrees, about the copy\'s origin.', scale: 'A number or [sx, sy].', place: 'A transform instead of x, y, rotate and scale.', with: 'Options for this copy only.' },
38
+ group: { layers: 'The layers inside.', viewBox: '[x, y, w, h]: its own design grid (children use its units).', x: 'Box left, px.', y: 'Box top.', width: 'Box width.', height: 'Box height.', fit: 'contain (default) | cover | stretch | width | height', align: 'Where a fitted grid sits: center, top-left, …', clipToBox: 'Clip to the box.' },
39
+ shape: { kind: 'rect | circle | ellipse | line | polygon | polyline | star | burst | scallop | rays | ring | sector | arch | wave | path | cut (the print format\'s die-cut)', origin: 'rotate about [x, y] (px or "%") instead of the centre.', open: 'arch: the arc and sides only (stroked).', soft: 'A blur radius: soft orbs, drawn padded (no edge fade, no square edge).', dashOffset: 'Where the dash pattern starts.', outset: 'cut: grow (or shrink) the outline.', items: '[{ kind, … }]: several shapes in one layer.', x: 'px or "%".', y: 'px or "%".', width: 'px.', height: 'px.', cx: 'Centre x.', cy: 'Centre y.', r: 'Radius.', rx: 'Ellipse x radius.', ry: 'Ellipse y radius.', x1: 'Line/wave start x.', y1: 'Line start y.', x2: 'End x.', y2: 'End y.', points: 'Polygon [[x, y], …], or star/burst/scallop point count.', inner: 'Star/burst inner radius (≤1: fraction) or ring inner radius.', count: 'Rays.', start: 'Sector start, degrees (0 = up, clockwise).', end: 'Sector end.', depth: 'Scallop depth.', amplitude: 'Wave height.', wavelength: 'Wave length.', phase: 'Wave phase, radians.', band: 'Wave: fill a band this tall.', d: 'Path data.', viewBox: 'Path: its grid, drawn into x, y, width, height.', radius: 'Rect corner radius.', edge: 'Rect edges: deckle | torn | zigzag | wave | scallop, or { kind, size, step, sides }.', rotate: 'Degrees about its centre.', fill: 'A colour, a gradient { type, colors, stops, opacities, angle, spread, length, rx, ry, … }, a tile { type: "tile", kind: checker | stripes | dots | grid | custom, size, colors, ratio, angle, markup } or a preset { preset: "chrome" | "steel" | "gold" }.', stroke: 'A colour or a gradient.', strokeWidth: 'Length.', strokes: '[{ color, width, dash }]: keylines under the shape.', dash: 'dashed | dotted | [on, off].', linecap: 'butt | round | square', linejoin: 'miter | round | bevel', fillRule: 'nonzero | evenodd' },
40
+ svg: { markup: 'Raw SVG, drawn in the canvas (or from viewBox). Tokens: {{@accent}}, {{@colors.1}}, {{@width}}, {{id}}, {{seed}}, and in JSON recipes {{title}} or any option.', defs: 'Raw SVG for <defs>.', viewBox: '[x, y, w, h] of the markup.', x: 'Box left.', y: 'Box top.', width: 'Box width.', height: 'Box height.', fit: 'contain (default) | cover | stretch', align: 'xMidYMid etc.' },
41
+ table: { rows: 'Rows: arrays of cells, or objects read by column key ("{tracklist}"). A cell is text or { content, weight, size, color, align, … }.', columns: '[{ x, align, width, key, weight, size, color, font, letterSpacing, transform, opacity }]', x: 'Left, px.', y: 'First baseline, px.', size: 'Type size. Default "2.2vmin".', lineHeight: 'Line step inside a cell.', rowHeight: 'Fixed row step.', rowGap: 'Extra space between rows.', font: 'Family.', weight: 'Default 400.', style: 'normal | italic', axes: 'Variable axes.', color: 'Default @text.', letterSpacing: 'Em.', header: 'Style for the first row.', rules: 'true or { at: between | each | top, color, width, opacity, x1, x2, above }.', columnWidth: 'Column step when columns give no x.' },
42
+ barcode: { value: 'Text to encode (Code 128).', content: 'Same as value.', kind: 'code128 (default) | seeded (decorative)', x: 'px.', y: 'px.', width: 'px.', height: 'px.', color: 'Default @text.', text: 'true (the value) or text under the bars.', size: 'Text size.', font: 'Family.', weight: 'Text weight.', letterSpacing: 'Em.' },
43
+ perforation: { kind: 'holes (default) | dashes | notches', x: 'A vertical line at x.', x1: 'Start x.', y1: 'Start y.', x2: 'End x.', y2: 'End y.', size: 'Hole or notch radius.', gap: 'Spacing.', inset: 'Space before the first hole.', color: 'Default @text (or punch it with knockout).', strokeWidth: 'Dashes width.' },
44
+ tear: { from: '[x, y] where the tear starts (edge to edge).', to: '[x, y] where it ends.', side: 'Which side keeps the sheet: above (default) | below | left | right', color: 'The sheet (a colour, a gradient, or "none" for just the edge).', sheetOpacity: 'The sheet\'s opacity.', step: 'Spacing of the jagged points.', jitter: 'How jagged.', rip: 'false, or { color, width, opacity }: the torn white edge.', fibres: 'false, or { color, width, opacity, length: [min, max], spread }.', ripColor: 'Colour of the rip and fibres. Default #fff6fb.' },
45
+ texture: { kind: 'paper | creases | concrete | speck | wood | folds | dust', color: 'Fibre, aggregate or dust colour.', amount: 'Paper: fibre density.', frequency: 'Noise frequency (number or [x, y]).', octaves: 'Noise octaves.', depth: 'Creases: relief.', azimuth: 'Creases: light direction.', elevation: 'Creases: light height.', x: 'Folds: vertical fold positions, fractions.', y: 'Folds: horizontal fold positions, fractions.', width: 'Folds: band width.', strength: 'Folds: shading.', count: 'Dust: specks.', size: 'Dust: [min, max] radius.', region: 'Clip to { x, y, width, height } (px or "%").' },
33
46
  };
34
47
 
35
48
  /** @type {Record<string, string>} Options every layer takes (besides type, id and when). */
36
- export const COMMON = { opacity: '0–1.', blend: 'A CSS mix-blend-mode.', seed: "This layer's own random stream." };
49
+ export const COMMON = {
50
+ opacity: '0–1.', blend: 'A CSS mix-blend-mode.', seed: "This layer's own random stream.",
51
+ transform: '{ translate, rotate, scale, skewX, skewY, mirror: x | y, origin } (on text, a case keyword is still the case).',
52
+ effects: '[{ kind, mask, colorSpace, … }]: filters, pixel effects and copies, in order (see the README\'s Effects table; kinds in EFFECTS).',
53
+ mask: 'Layers whose luminance masks this one (white shows).', knockout: 'Layers cut out of this one.', clip: 'Layers this one shows only inside.',
54
+ area: 'trim | safe | bleed, or { x, y, width, height } fractions: draw the layer in that box.',
55
+ };
37
56
 
@@ -0,0 +1,19 @@
1
+ // The `barcode` layer.
2
+ import type { Color, Length, BaseLayer, Anchor } from '../index.js';
3
+ import type { RenderContext, Markup } from '../render/context.js';
4
+
5
+ export interface BarcodeLayer extends BaseLayer {
6
+ type: 'barcode';
7
+ /** Encoded as Code 128 (set B). */
8
+ value?: string;
9
+ content?: string;
10
+ kind?: 'code128' | 'seeded';
11
+ x?: Length | `${number}%`; y?: Length | `${number}%`; width?: Length | `${number}%`; height?: Length | `${number}%`;
12
+ color?: Color;
13
+ text?: boolean | string;
14
+ size?: Length; font?: string; weight?: number; letterSpacing?: number;
15
+ }
16
+
17
+
18
+ /** Draw a Code 128 barcode (or decorative bars). */
19
+ export function barcode(layer: BarcodeLayer, ctx: RenderContext): Promise<Markup>;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The `barcode` layer.
3
+ *
4
+ * @module layers/barcode
5
+ */
6
+
7
+ import { attrs, num } from '../svg.js';
8
+ import { len } from '../geometry.js';
9
+ import { toHex } from '../color.js';
10
+ import { faceFor, setLine } from '../type/line.js';
11
+ import { pos, wrap, contentOf } from './util.js';
12
+ import { code128 } from './code128.js';
13
+
14
+ /**
15
+ * `barcode` — a real Code 128 barcode of `value` (scannable at print
16
+ * sizes), or `kind: 'seeded'` decorative bars. `x`, `y`, `width`, `height`
17
+ * in px; `color`; `text: true` (the value under it) or any text; `quiet`
18
+ * zones are left to you.
19
+ *
20
+ * @param {any} layer
21
+ * @param {import('../layers.js').RenderContext} ctx
22
+ * @returns {Promise<import('../layers.js').Markup>}
23
+ */
24
+ export async function barcode(layer, ctx) {
25
+ const x = pos(layer.x ?? '10%', 'x', ctx.canvas);
26
+ const y = pos(layer.y ?? '80%', 'y', ctx.canvas);
27
+ const w = layer.width != null ? pos(layer.width, 'x', ctx.canvas) : ctx.width * 0.3;
28
+ const h = layer.height != null ? pos(layer.height, 'y', ctx.canvas) : ctx.height * 0.08;
29
+ const color = layer.color ? toHex(layer.color) : ctx.palette.text;
30
+ /** @type {number[]} */
31
+ let widths;
32
+ if (layer.kind === 'seeded') {
33
+ widths = [];
34
+ let total = 0;
35
+ const target = 100;
36
+ while (total < target) {
37
+ const bar = ctx.rng.pick([0.7, 1.1, 1.6, 2.3]);
38
+ const gap = ctx.rng.pick([0.7, 1.1, 1.7]);
39
+ widths.push(bar, gap);
40
+ total += bar + gap;
41
+ }
42
+ widths.pop();
43
+ } else {
44
+ widths = code128(String(layer.value ?? contentOf(layer.content) ?? '0000'));
45
+ }
46
+ const total = widths.reduce((a, b) => a + b, 0);
47
+ const k = w / total;
48
+ let d = '';
49
+ let at = x;
50
+ widths.forEach((m, i) => {
51
+ if (i % 2 === 0) d += `M${num(at, 3)} ${num(y)}h${num(m * k, 3)}v${num(h)}h${num(-m * k, 3)}Z`;
52
+ at += m * k;
53
+ });
54
+ let body = `<path${attrs({ d, fill: color })}/>`;
55
+ if (layer.text) {
56
+ const label = layer.text === true ? String(layer.value ?? '') : String(layer.text);
57
+ const size = len(layer.size, ctx.canvas, h * 0.18);
58
+ const face = await faceFor(ctx, { font: layer.font, weight: layer.weight ?? 400 });
59
+ const line = setLine({ face, text: label, size, tracking: (layer.letterSpacing ?? 0.1) * size, x: x + w / 2, y: y + h + size * 1.1, align: 'center' });
60
+ body += `<path${attrs({ d: line.d, fill: color })}/>`;
61
+ }
62
+ return { defs: '', body: wrap(layer, body) };
63
+ }
@@ -0,0 +1,4 @@
1
+ // Code 128 (set B).
2
+
3
+ /** Module widths (bar, space, bar, …) for a string, start to stop with its check symbol. */
4
+ export function code128(value: string): number[];
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Code 128 (set B) encoding.
3
+ *
4
+ * @module layers/code128
5
+ */
6
+
7
+
8
+ /** Code 128 bar/space widths, values 0–106 (106 is the stop pattern). */
9
+ const CODE128 = '212222 222122 222221 121223 121322 131222 122213 122312 132212 221213 221312 231212 112232 122132 122231 113222 123122 123221 223211 221132 221231 213212 223112 312131 311222 321122 321221 312212 322112 322211 212123 212321 232121 111323 131123 131321 112313 132113 132311 211313 231113 231311 112133 112331 132131 113123 113321 133121 313121 211331 231131 213113 213311 213131 311123 311321 331121 312113 312311 332111 314111 221411 431111 111224 111422 121124 121421 141122 141221 112214 112412 122114 122411 142112 142211 241211 221114 413111 241112 134111 111242 121142 121241 114212 124112 124211 411212 421112 421211 212141 214121 412121 111143 111341 131141 114113 114311 411113 411311 113141 114131 311141 411131 211412 211214 211232 2331112'.split(' ');
10
+
11
+ /**
12
+ * Code 128 (set B) module widths for a string, start to stop with the
13
+ * check symbol: bars and spaces alternating, bar first.
14
+ *
15
+ * @param {string} value - Printable ASCII.
16
+ * @returns {number[]}
17
+ */
18
+ export function code128(value) {
19
+ const codes = [104];
20
+ for (const ch of String(value)) {
21
+ const c = ch.charCodeAt(0);
22
+ if (c < 32 || c > 126) throw new TypeError(`Code 128 (set B) can't encode "${ch}"`);
23
+ codes.push(c - 32);
24
+ }
25
+ const check = codes.reduce((sum, v, i) => sum + v * (i || 1), 0) % 103;
26
+ codes.push(check, 106);
27
+ return codes.flatMap((v) => [...CODE128[v]].map(Number));
28
+ }
@@ -0,0 +1,6 @@
1
+ // `{#…}` expressions for repeat instances.
2
+
3
+ /** Evaluate an expression over variables (no `eval`: a small parser). */
4
+ export function evaluate(src: string, vars: Record<string, number | string | boolean>): unknown;
5
+ /** A value with its `{#…}` expressions filled in (nested repeats left alone). */
6
+ export function fill<T>(v: T, vars: Record<string, number | string | boolean>): T;