@arraypress/covers 1.0.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +18 -524
  3. package/package.json +34 -5
  4. package/recipes/brand.json +1143 -0
  5. package/recipes/flyers/afterglow-motion.json +540 -0
  6. package/recipes/flyers/afterglow.json +70 -0
  7. package/recipes/flyers/arcfield-mirror-motion.json +397 -0
  8. package/recipes/flyers/arcfield-mirror.json +364 -0
  9. package/recipes/flyers/bauhaus-oktav.json +319 -0
  10. package/recipes/flyers/bauhaus-ritmo.json +735 -0
  11. package/recipes/flyers/bauhaus-volta.json +394 -0
  12. package/recipes/flyers/bloomfall.json +99 -0
  13. package/recipes/flyers/drag-solenne-motion.json +444 -0
  14. package/recipes/flyers/drag-solenne.json +426 -0
  15. package/recipes/flyers/fennick-patches.json +874 -0
  16. package/recipes/flyers/ferro-pass.json +398 -0
  17. package/recipes/flyers/ferrova.json +812 -0
  18. package/recipes/flyers/grid-rest-rhythm.json +125 -0
  19. package/recipes/flyers/groovy-honeycomb.json +816 -0
  20. package/recipes/flyers/groovy-marigold.json +479 -0
  21. package/recipes/flyers/groovy-peachfuzz.json +974 -0
  22. package/recipes/flyers/halden-orbit-motion.json +381 -0
  23. package/recipes/flyers/halden-orbit.json +356 -0
  24. package/recipes/flyers/halvard.json +120 -0
  25. package/recipes/flyers/kairo-weekender.json +696 -0
  26. package/recipes/flyers/linger-motion.json +518 -0
  27. package/recipes/flyers/linger.json +67 -0
  28. package/recipes/flyers/little-orchard.json +105 -0
  29. package/recipes/flyers/lofi-hexmoth.json +912 -0
  30. package/recipes/flyers/lofi-lowbit-motion.json +888 -0
  31. package/recipes/flyers/lofi-lowbit.json +880 -0
  32. package/recipes/flyers/lofi-nullpoint.json +3013 -0
  33. package/recipes/flyers/lumio-stack-motion.json +648 -0
  34. package/recipes/flyers/lumio-stack.json +438 -0
  35. package/recipes/flyers/magazine-cover.json +168 -0
  36. package/recipes/flyers/magazine-extended.json +269 -0
  37. package/recipes/flyers/magazine-knockout.json +40 -0
  38. package/recipes/flyers/northfold-varsity.json +618 -0
  39. package/recipes/flyers/open-studio.json +69 -0
  40. package/recipes/flyers/orrin-merch.json +715 -0
  41. package/recipes/flyers/oxyde.json +113 -0
  42. package/recipes/flyers/quiet-fields.json +610 -0
  43. package/recipes/flyers/scrapbook-halcyon.json +948 -0
  44. package/recipes/flyers/solenne-ticket.json +730 -0
  45. package/recipes/flyers/solvane-motion.json +576 -0
  46. package/recipes/flyers/solvane.json +98 -0
  47. package/recipes/flyers/subduct.json +99 -0
  48. package/recipes/flyers/sunvale-lineup.json +584 -0
  49. package/recipes/flyers/tidewell.json +91 -0
  50. package/recipes/flyers/tour-kairo.json +535 -0
  51. package/recipes/flyers/velora-stickers.json +885 -0
  52. package/recipes/flyers/wheatpaste-black.json +313 -0
  53. package/recipes/flyers/wheatpaste-mark.json +198 -0
  54. package/recipes/flyers/wheatpaste-torn.json +114 -0
  55. package/recipes/flyers/wheatpaste-wall.json +984 -0
  56. package/recipes/flyers/wheatpaste-words.json +233 -0
  57. package/recipes/release/aura.json +137 -0
  58. package/recipes/release/chrome.json +433 -0
  59. package/recipes/release/collage.json +372 -0
  60. package/recipes/release/duotone.json +113 -0
  61. package/recipes/release/film-still.json +102 -0
  62. package/recipes/release/headline.json +70 -0
  63. package/recipes/release/instant.json +170 -0
  64. package/recipes/release/label.json +364 -0
  65. package/recipes/release/liquid-glass.json +301 -0
  66. package/recipes/release/odd-tile-dark.json +688 -0
  67. package/recipes/release/odd-tile-dense.json +1056 -0
  68. package/recipes/release/odd-tile-shift.json +541 -0
  69. package/recipes/release/odd-tile-split.json +571 -0
  70. package/recipes/release/odd-tile.json +633 -0
  71. package/recipes/release/off-edge.json +96 -0
  72. package/recipes/release/plinth.json +794 -0
  73. package/recipes/release/riso.json +108 -0
  74. package/recipes/release/series/fluid-label.json +561 -0
  75. package/recipes/release/series/fluid-pixel.json +334 -0
  76. package/recipes/release/series/hairline.json +305 -0
  77. package/recipes/release/series/halo.json +282 -0
  78. package/recipes/release/series/mark-crop.json +135 -0
  79. package/recipes/release/series/mark-window.json +150 -0
  80. package/recipes/release/series/photo-label.json +475 -0
  81. package/recipes/release/series/smear-field.json +345 -0
  82. package/recipes/release/series/smear.json +202 -0
  83. package/recipes/release/series/spin.json +172 -0
  84. package/recipes/release/series/split-ghost.json +230 -0
  85. package/recipes/release/series/zoom.json +168 -0
  86. package/recipes/release/small-print.json +114 -0
  87. package/recipes/release/stack.json +156 -0
  88. package/recipes/release/swiss.json +117 -0
  89. package/schema/recipe.schema.json +7624 -827
  90. package/src/animate/index.d.ts +9 -0
  91. package/src/animate/index.js +11 -0
  92. package/src/animate/keyframes.d.ts +21 -0
  93. package/src/animate/keyframes.js +119 -0
  94. package/src/animate/marquee.d.ts +30 -0
  95. package/src/animate/marquee.js +93 -0
  96. package/src/animate/morph.d.ts +8 -0
  97. package/src/animate/morph.js +99 -0
  98. package/src/animate/motion.d.ts +59 -0
  99. package/src/animate/motion.js +63 -0
  100. package/src/animate/sheet.d.ts +19 -0
  101. package/src/animate/sheet.js +61 -0
  102. package/src/animate/timing.d.ts +28 -0
  103. package/src/animate/timing.js +146 -0
  104. package/src/animate/tracks.d.ts +18 -0
  105. package/src/animate/tracks.js +139 -0
  106. package/src/builtins.js +4 -0
  107. package/src/diagram.js +4 -3
  108. package/src/extract.js +6 -5
  109. package/src/fonts.d.ts +55 -0
  110. package/src/fonts.js +245 -53
  111. package/src/formats/cut.d.ts +10 -0
  112. package/src/formats/cut.js +77 -0
  113. package/src/formats/index.d.ts +6 -0
  114. package/src/formats/index.js +9 -0
  115. package/src/formats/presets.d.ts +41 -0
  116. package/src/formats/presets.js +89 -0
  117. package/src/formats/print.d.ts +25 -0
  118. package/src/formats/print.js +117 -0
  119. package/src/formats/zones.d.ts +10 -0
  120. package/src/formats/zones.js +64 -0
  121. package/src/geometry.js +5 -35
  122. package/src/grid.js +1 -1
  123. package/src/handmade.js +9 -7
  124. package/src/icons.js +42 -9
  125. package/src/index.d.ts +232 -55
  126. package/src/index.js +14 -6
  127. package/src/inter-data.js +27 -0
  128. package/src/json-recipe.js +201 -27
  129. package/src/kit/index.d.ts +20 -0
  130. package/src/kit/index.js +58 -0
  131. package/src/layer-options.js +43 -4
  132. package/src/layers/barcode.d.ts +19 -0
  133. package/src/layers/barcode.js +63 -0
  134. package/src/layers/code128.d.ts +4 -0
  135. package/src/layers/code128.js +28 -0
  136. package/src/layers/expr.d.ts +6 -0
  137. package/src/layers/expr.js +121 -0
  138. package/src/layers/geometry.d.ts +6 -0
  139. package/src/layers/geometry.js +238 -0
  140. package/src/layers/index.d.ts +18 -0
  141. package/src/layers/index.js +15 -0
  142. package/src/layers/perforation.d.ts +14 -0
  143. package/src/layers/perforation.js +47 -0
  144. package/src/layers/repeat.d.ts +29 -0
  145. package/src/layers/repeat.js +90 -0
  146. package/src/layers/shape.d.ts +46 -0
  147. package/src/layers/shape.js +115 -0
  148. package/src/layers/soft.d.ts +5 -0
  149. package/src/layers/soft.js +43 -0
  150. package/src/layers/svg.d.ts +23 -0
  151. package/src/layers/svg.js +108 -0
  152. package/src/layers/table.d.ts +25 -0
  153. package/src/layers/table.js +80 -0
  154. package/src/layers/tear.d.ts +21 -0
  155. package/src/layers/tear.js +74 -0
  156. package/src/layers/texture.d.ts +17 -0
  157. package/src/layers/texture.js +106 -0
  158. package/src/layers/util.d.ts +8 -0
  159. package/src/layers/util.js +61 -0
  160. package/src/layers.js +129 -61
  161. package/src/lint/image-size.d.ts +4 -0
  162. package/src/lint/image-size.js +34 -0
  163. package/src/lint/index.d.ts +16 -0
  164. package/src/lint/index.js +127 -0
  165. package/src/lint/rules.d.ts +5 -0
  166. package/src/lint/rules.js +19 -0
  167. package/src/node.d.ts +2 -0
  168. package/src/node.js +20 -0
  169. package/src/paint/index.d.ts +49 -0
  170. package/src/paint/index.js +66 -0
  171. package/src/paint/presets.d.ts +10 -0
  172. package/src/paint/presets.js +47 -0
  173. package/src/paint/tile.d.ts +7 -0
  174. package/src/paint/tile.js +55 -0
  175. package/src/palette.js +1 -1
  176. package/src/platform/files.d.ts +6 -0
  177. package/src/platform/files.js +41 -0
  178. package/src/platform/harfbuzz.d.ts +9 -0
  179. package/src/platform/harfbuzz.js +80 -0
  180. package/src/platform/index.d.ts +8 -0
  181. package/src/platform/index.js +15 -0
  182. package/src/platform/load.d.ts +6 -0
  183. package/src/platform/load.js +71 -0
  184. package/src/platform/no-harfbuzz.d.ts +2 -0
  185. package/src/platform/no-harfbuzz.js +11 -0
  186. package/src/platform/settings.d.ts +28 -0
  187. package/src/platform/settings.js +51 -0
  188. package/src/print.js +16 -13
  189. package/src/recipes.js +5 -4
  190. package/src/render/backdrop.d.ts +6 -0
  191. package/src/render/backdrop.js +55 -0
  192. package/src/render/context.d.ts +82 -0
  193. package/src/render/grain.d.ts +7 -0
  194. package/src/render/grain.js +113 -0
  195. package/src/render/group.d.ts +23 -0
  196. package/src/render/group.js +67 -0
  197. package/src/render/key.d.ts +5 -0
  198. package/src/render/key.js +90 -0
  199. package/src/render/report.d.ts +5 -0
  200. package/src/render/report.js +19 -0
  201. package/src/render/transform.d.ts +36 -0
  202. package/src/render/transform.js +139 -0
  203. package/src/render/use.d.ts +27 -0
  204. package/src/render/use.js +73 -0
  205. package/src/render/wrap.d.ts +14 -0
  206. package/src/render/wrap.js +144 -0
  207. package/src/render.js +343 -215
  208. package/src/text.d.ts +29 -0
  209. package/src/text.js +23 -16
  210. package/src/type/auto-color.d.ts +9 -0
  211. package/src/type/auto-color.js +44 -0
  212. package/src/type/fallback.d.ts +11 -0
  213. package/src/type/fallback.js +92 -0
  214. package/src/type/glyphs.d.ts +24 -0
  215. package/src/type/glyphs.js +87 -0
  216. package/src/type/index.d.ts +5 -0
  217. package/src/type/index.js +10 -0
  218. package/src/type/kern.d.ts +4 -0
  219. package/src/type/kern.js +144 -0
  220. package/src/type/layout-ref.d.ts +10 -0
  221. package/src/type/layout-ref.js +74 -0
  222. package/src/type/line.d.ts +11 -0
  223. package/src/type/line.js +105 -0
  224. package/src/type/live.d.ts +6 -0
  225. package/src/type/live.js +39 -0
  226. package/src/type/name.d.ts +4 -0
  227. package/src/type/name.js +32 -0
  228. package/src/type/path.d.ts +18 -0
  229. package/src/type/path.js +89 -0
  230. package/src/type/pixel.d.ts +7 -0
  231. package/src/type/pixel.js +85 -0
  232. package/src/type/sfnt.d.ts +20 -0
  233. package/src/type/sfnt.js +258 -0
  234. package/src/type/spans.d.ts +33 -0
  235. package/src/type/spans.js +138 -0
  236. package/src/type/symbols.d.ts +7 -0
  237. package/src/type/symbols.js +89 -0
  238. package/src/type/typeset.d.ts +10 -0
  239. package/src/type/typeset.js +363 -0
  240. package/src/type/woff2.d.ts +6 -0
  241. package/src/type/woff2.js +301 -0
  242. package/src/version.js +4 -0
  243. package/src/color.js +0 -246
  244. package/src/png.js +0 -77
  245. package/src/rng.js +0 -95
  246. package/src/svg.js +0 -69
@@ -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,45 @@
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
- import { createRng } from './rng.js';
53
- import { len } from './geometry.js';
54
- import { toHex, mixOklab, mixOklch, onBackground, gradientAt } from './color.js';
58
+ import { createRng } from '@logomarks/image-effects/rng';
59
+ import { len } from '@logomarks/image-effects/length';
60
+ import { ANCHORS } from './geometry.js';
61
+ import { toHex, mixOklab, mixOklch, onBackground, gradientAt } from '@logomarks/image-effects/color';
55
62
  import { LAYERS, toneColor } from './layers.js';
56
- import { LAYER_OPTIONS, COMMON } from './layer-options.js';
63
+ import { LAYER_OPTIONS, COMMON, MOTION_OPTIONS } from './layer-options.js';
64
+ import { EFFECT_OPTIONS, EFFECT_COMMON, MASK_GRADIENTS } from '@logomarks/image-effects';
65
+ import { shapesOnly } from './layers/svg.js';
66
+ import { LAYOUT_FIELDS } from './type/layout-ref.js';
67
+ import { MOTION_PRESETS } from './animate/timing.js';
57
68
 
58
69
  /** Options every recipe can read without declaring them. */
59
- const CONTENT = ['title', 'label', 'icon', 'image'];
70
+ const CONTENT = ['title', 'label', 'icon', 'image', 'name', 'mark', 'photo', 'cutout', 'date', 'dates', 'time', 'venue', 'address', 'lines', 'lineup', 'tracklist', 'price', 'meta', 'page'];
60
71
 
61
72
  /** `@` tokens. */
62
73
  const PALETTE = ['bg', 'text', 'accent', 'onAccent', 'muted', 'surface', 'mode', 'colors'];
63
- const CANVAS = ['width', 'height', 'u'];
74
+ const CANVAS = ['width', 'height', 'u', 'bleed', 'safe', 'mm', 'dpi', 'trimWidth', 'trimHeight', 'safeX', 'safeY', 'safeWidth', 'safeHeight'];
64
75
 
65
76
  /** Value operators, by name, with their argument arity. */
66
77
  const OPS = /** @type {Record<string, [number, number]>} */ ({
67
78
  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
79
  add: [1, Infinity], sub: [2, Infinity], mul: [1, Infinity], div: [2, 2], min: [1, Infinity], max: [1, Infinity],
69
80
  ramp: [3, 4], taper: [2, 2], reverse: [1, 1],
81
+ join: [1, 2], upper: [1, 1], lower: [1, 1], slice: [2, 3],
70
82
  });
71
83
 
84
+ /** Layer options holding nested layers. */
85
+ const NESTED = ['layers', 'mask', 'knockout', 'clip'];
86
+
87
+ /** svg-layer options holding raw markup (their `{{name}}` slots are filled; nothing else is touched). */
88
+ const MARKUP = ['markup', 'defs'];
89
+
72
90
  /** Keys of a layer that aren't layer options. */
73
91
  const LAYER_META = new Set(['id', 'type', 'when']);
74
92
 
75
93
  /** Top-level keys of a recipe document. */
76
- const TOP = new Set(['$schema', 'name', 'description', 'params', 'requires', 'vars', 'output', 'layers']);
94
+ const TOP = new Set(['$schema', 'name', 'description', 'params', 'requires', 'vars', 'output', 'layers', 'pages', 'fonts']);
77
95
 
78
96
  /** Recipes defined from the same JSON object are reused. */
79
97
  const defined = new WeakMap();
@@ -139,11 +157,17 @@ export function defineRecipe(json) {
139
157
  export function loadRecipe(path) {
140
158
  let json;
141
159
  try {
142
- json = JSON.parse(readFileSync(path, 'utf8'));
160
+ json = JSON.parse(readTextSync(path));
143
161
  } catch (err) {
144
162
  throw new TypeError(`Couldn't read recipe ${String(path)}: ${/** @type {Error} */ (err).message}`);
145
163
  }
146
164
  if (isPlain(json) && !json.name) json.name = String(path).split('/').pop()?.replace(/\.json$/, '');
165
+ // Fonts given as paths are beside the recipe.
166
+ const p = node('path');
167
+ if (isPlain(json) && Array.isArray(json.fonts) && p) {
168
+ const dir = p.dirname(path instanceof URL ? path.pathname : String(path));
169
+ 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));
170
+ }
147
171
  return defineRecipe(json);
148
172
  }
149
173
 
@@ -158,7 +182,7 @@ export function loadRecipe(path) {
158
182
  * @property {Record<string, unknown>} params
159
183
  * @property {Record<string, unknown>} vars
160
184
  * @property {any} c - The recipe context.
161
- * @property {() => import('./rng.js').Rng} rng - The current stream.
185
+ * @property {() => import('@logomarks/image-effects/rng').Rng} rng - The current stream.
162
186
  */
163
187
 
164
188
  /**
@@ -189,18 +213,60 @@ function build(json, name, options, c) {
189
213
  let stream;
190
214
  const own = { ...scope, rng: () => (stream ??= createRng(`${c.seed ?? ''}:recipe:${layer.id ?? `${layer.type}#${n}`}`)) };
191
215
  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));
216
+ layers.push(/** @type {any} */ (resolveLayer(own, layer)));
200
217
  }
201
218
  return layers;
202
219
  }
203
220
 
221
+ /**
222
+ * Resolve one layer's options (and any layers nested in it).
223
+ *
224
+ * @param {Scope} s
225
+ * @param {any} layer
226
+ * @returns {Record<string, unknown>}
227
+ */
228
+ function resolveLayer(s, layer) {
229
+ /** @type {Record<string, unknown>} */
230
+ const out = { ...(layer.id ? { id: layer.id } : {}), type: layer.type };
231
+ for (const [k, v] of Object.entries(layer)) {
232
+ if (LAYER_META.has(k)) continue;
233
+ let r;
234
+ if (NESTED.includes(k) && Array.isArray(v) && v.every((x) => isPlain(x) && typeof x.type === 'string')) {
235
+ r = v.filter((x) => x.when === undefined || test(s, x.when)).map((x) => resolveLayer(s, x));
236
+ } else if (k === 'layer' && isPlain(v) && typeof v.type === 'string') {
237
+ r = resolveLayer(s, v);
238
+ } else if (layer.type === 'svg' && MARKUP.includes(k) && typeof v === 'string') {
239
+ r = fillSlots(s, v);
240
+ } else {
241
+ r = resolve(s, v);
242
+ }
243
+ if (r !== undefined) out[k] = r;
244
+ }
245
+ return out;
246
+ }
247
+
248
+ /**
249
+ * Fill `{{name}}` slots in raw markup: options and params (escaped; a
250
+ * markup value such as `{{mark}}` between elements goes in as sanitised
251
+ * shapes), vars (`{{$name}}`). Palette and canvas tokens (`{{@accent}}`), `{{id}}` and
252
+ * `{{seed}}` are left for the layer to fill when it's drawn.
253
+ *
254
+ * @param {Scope} s
255
+ * @param {string} markup
256
+ * @returns {string}
257
+ */
258
+ function fillSlots(s, markup) {
259
+ return markup.replace(/\{\{\s*([$\w.]+)\s*\}\}/g, (m, k, at) => {
260
+ if (k === 'id' || k === 'seed') return m;
261
+ const v = k.startsWith('$') ? resolveString(s, k) : valueOf(lookup(s, k));
262
+ if (v == null) return '';
263
+ // Between elements, a value that is markup (a mark's paths) goes in as shapes; anything else is text.
264
+ const inTag = markup.lastIndexOf('<', at) > markup.lastIndexOf('>', at);
265
+ if (!inTag && /^\s*</.test(String(v))) return shapesOnly(String(v));
266
+ return String(v).replace(/[&<>"']/g, (ch) => /** @type {Record<string, string>} */ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
267
+ });
268
+ }
269
+
204
270
  /**
205
271
  * Look up a template name: a param, a content option, or `opt.<key>`.
206
272
  *
@@ -211,7 +277,11 @@ function build(json, name, options, c) {
211
277
  function lookup(s, key) {
212
278
  if (key.startsWith('opt.')) return s.options[key.slice(4)];
213
279
  if (Object.hasOwn(s.params, key)) return s.params[key];
214
- return s.options[key];
280
+ if (Object.hasOwn(s.options, key) || !key.includes('.')) return s.options[key];
281
+ // A path into an option: "lineup.0", "meta.venue".
282
+ const [head, ...rest] = key.split('.');
283
+ const base = Object.hasOwn(s.params, head) ? s.params[head] : s.options[head];
284
+ return rest.reduce((/** @type {any} */ acc, k) => acc?.[k], base);
215
285
  }
216
286
 
217
287
  /**
@@ -255,9 +325,12 @@ function resolveString(s, str) {
255
325
  const [head, ...path] = str.slice(1).split('.');
256
326
  return path.reduce((/** @type {any} */ acc, k) => acc?.[k], s.vars[head]);
257
327
  }
328
+ // Layout references are filled in when the cover is drawn.
329
+ if (/^\{layout\.[\w-]+\.\w+\s*(?:[+-]\s*[\d.]+)?\}$/.test(str)) return str;
258
330
  const whole = str.match(/^\{([\w.]+)\}$/);
259
331
  if (whole) return lookup(s, whole[1]);
260
- return str.replace(/\{([\w.]+)\}/g, (_, k) => {
332
+ return str.replace(/\{([\w.]+)\}/g, (m, k) => {
333
+ if (k.startsWith('layout.')) return m;
261
334
  const x = valueOf(lookup(s, k));
262
335
  return x == null ? '' : String(x);
263
336
  });
@@ -281,6 +354,10 @@ function op(s, name, raw) {
281
354
  const items = Array.isArray(raw) ? raw : resolve(s, raw);
282
355
  return resolve(s, s.rng().pick(/** @type {unknown[]} */ (items)));
283
356
  }
357
+ if (name === 'join' || name === 'slice' || name === 'upper' || name === 'lower') {
358
+ const a = Array.isArray(raw) && (name !== 'upper' && name !== 'lower') ? raw.map((x) => resolve(s, x)) : [resolve(s, raw)];
359
+ return op2(name, /** @type {any[]} */ (a));
360
+ }
284
361
  const args = /** @type {any[]} */ ([].concat(resolve(s, raw)));
285
362
  const nums = () => args.map((a) => (typeof a === 'number' ? a : len(a, s.c)));
286
363
  switch (name) {
@@ -338,7 +415,9 @@ function test(s, cond) {
338
415
  if ('any' in cond) return cond.any.some((/** @type {unknown} */ c) => test(s, c));
339
416
  return Object.entries(cond).every(([k, want]) => {
340
417
  const ratio = s.c.width / s.c.height;
341
- const have = k === 'mode' ? s.c.palette.mode
418
+ const have = k === 'page' ? s.options.page ?? 'front'
419
+ : k === 'format' ? s.c.format
420
+ : k === 'mode' ? s.c.palette.mode
342
421
  : k === 'aspect' ? (ratio > 1.15 ? 'wide' : ratio < 0.87 ? 'tall' : 'square')
343
422
  : valueOf(lookup(s, k));
344
423
  return Array.isArray(want) ? want.includes(have) : have === want;
@@ -367,13 +446,21 @@ export function validate(json) {
367
446
  if (json.params !== undefined && !isPlain(json.params)) fail('params', 'must be an object of defaults');
368
447
  if (json.output !== undefined && !(isPlain(json.output) && Object.keys(json.output).every((k) => k === 'lossless'))) fail('output', 'is { "lossless": true | false }');
369
448
  if (json.vars !== undefined && !isPlain(json.vars)) fail('vars', 'must be an object');
449
+ 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
450
  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
451
  if (!Array.isArray(json.layers) || json.layers.length === 0) fail('layers', 'must be a non-empty array');
452
+ if (json.fonts !== undefined) {
453
+ if (!Array.isArray(json.fonts)) fail('fonts', 'must be an array of { name, src, weight, style, axes }');
454
+ json.fonts.forEach((/** @type {any} */ f, /** @type {number} */ i) => {
455
+ 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)');
456
+ 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)');
457
+ });
458
+ }
372
459
 
373
460
  const names = new Set([...Object.keys(json.params ?? {}), ...CONTENT]);
374
461
  const vars = new Set();
375
462
  const checkName = (/** @type {string} */ path, /** @type {string} */ key) => {
376
- if (!key.startsWith('opt.') && !names.has(key)) {
463
+ if (!key.startsWith('opt.') && !names.has(key.split('.')[0])) {
377
464
  fail(path, `unknown param "${key}" (declare it in "params", or use "{opt.${key}}" for any cover option)`);
378
465
  }
379
466
  };
@@ -389,7 +476,7 @@ export function validate(json) {
389
476
  return c[k].forEach((/** @type {unknown} */ x, /** @type {number} */ i) => checkCond(`${path}.${k}[${i}]`, x));
390
477
  }
391
478
  }
392
- for (const k of Object.keys(c)) if (k !== 'mode' && k !== 'aspect') checkName(`${path}.${k}`, k);
479
+ for (const k of Object.keys(c)) if (k !== 'mode' && k !== 'aspect' && k !== 'page' && k !== 'format') checkName(`${path}.${k}`, k);
393
480
  };
394
481
 
395
482
  const checkValue = (/** @type {string} */ path, /** @type {any} */ v) => {
@@ -407,6 +494,13 @@ export function validate(json) {
407
494
  return;
408
495
  }
409
496
  for (const m of v.matchAll(/\{([^{}]*)\}/g)) {
497
+ // `{#…}`: a repeat instance's expression, filled when drawn.
498
+ if (m[1].startsWith('#')) continue;
499
+ if (/^layout\.[\w-]+\.\w+\s*(?:[+-]\s*[\d.]+)?$/.test(m[1])) {
500
+ const field = m[1].split('.')[2].replace(/[\s+-].*$/, '');
501
+ if (!LAYOUT_FIELDS.includes(/** @type {any} */ (field))) fail(path, `unknown layout field "${field}" (${LAYOUT_FIELDS.join(', ')})`);
502
+ continue;
503
+ }
410
504
  if (!/^[\w.]+$/.test(m[1])) fail(path, `bad template "{${m[1]}}"`);
411
505
  checkName(path, m[1]);
412
506
  }
@@ -442,8 +536,7 @@ export function validate(json) {
442
536
  checkValue(`vars.${k}`, v);
443
537
  vars.add(k);
444
538
  }
445
- json.layers.forEach((/** @type {any} */ layer, /** @type {number} */ i) => {
446
- const path = `layers[${i}]${layer?.id ? ` (${layer.id})` : ''}`;
539
+ const checkLayer = (/** @type {string} */ path, /** @type {any} */ layer) => {
447
540
  if (!isPlain(layer)) return fail(path, 'must be an object');
448
541
  if (!Object.hasOwn(LAYERS, layer.type)) fail(`${path}.type`, `unknown layer type "${layer.type}" (${Object.keys(LAYERS).join(', ')})`);
449
542
  if (layer.id !== undefined && typeof layer.id !== 'string') fail(`${path}.id`, 'must be a string');
@@ -452,7 +545,88 @@ export function validate(json) {
452
545
  for (const [k, v] of Object.entries(layer)) {
453
546
  if (LAYER_META.has(k)) continue;
454
547
  if (!Object.hasOwn(known, k)) fail(`${path}.${k}`, `"${layer.type}" layers have no option "${k}" (${Object.keys(known).join(', ')})`);
455
- checkValue(`${path}.${k}`, v);
548
+ if (k === 'effects') {
549
+ checkEffects(`${path}.effects`, v);
550
+ } else if (k === 'motion') {
551
+ checkMotion(`${path}.motion`, v);
552
+ } else if (NESTED.includes(k) && Array.isArray(v) && v.every((x) => isPlain(x) && typeof x.type === 'string')) {
553
+ v.forEach((x, j) => checkLayer(`${path}.${k}[${j}]${x.id ? ` (${x.id})` : ''}`, x));
554
+ } else if (k === 'layer' && isPlain(v) && typeof v.type === 'string') {
555
+ checkLayer(`${path}.layer`, v);
556
+ } else if (layer.type === 'svg' && MARKUP.includes(k) && typeof v === 'string') {
557
+ for (const m of v.matchAll(/\{\{\s*([$@\w.]+)\s*\}\}/g)) {
558
+ const t = m[1];
559
+ if (t === 'id' || t === 'seed' || t.startsWith('@')) continue;
560
+ if (t.startsWith('$')) { if (!vars.has(t.slice(1).split('.')[0])) fail(`${path}.${k}`, `unknown var "{{${t}}}"`); continue; }
561
+ checkName(`${path}.${k}`, t);
562
+ }
563
+ } else {
564
+ checkValue(`${path}.${k}`, v);
565
+ checkAnchor(`${path}.${k}`, layer.type, k, v);
566
+ }
456
567
  }
457
- });
568
+ };
569
+ // Effects: known kinds, their options, masks and nested layers (computed values are checked when drawn).
570
+ const checkEffects = (/** @type {string} */ path, /** @type {any} */ v) => {
571
+ if (!Array.isArray(v)) return checkValue(path, v);
572
+ v.forEach((e, i) => {
573
+ const at = `${path}[${i}]`;
574
+ if (typeof e === 'string') {
575
+ if (!/[{$@]/.test(e) && !Object.hasOwn(EFFECT_OPTIONS, e)) fail(at, `unknown effect "${e}" (${Object.keys(EFFECT_OPTIONS).join(', ')})`);
576
+ return;
577
+ }
578
+ if (!isPlain(e) || typeof e.kind !== 'string' || 'if' in e) return checkValue(at, e);
579
+ if (!/[{$@]/.test(e.kind) && !Object.hasOwn(EFFECT_OPTIONS, e.kind)) fail(`${at}.kind`, `unknown effect "${e.kind}" (${Object.keys(EFFECT_OPTIONS).join(', ')})`);
580
+ const known = { ...(EFFECT_OPTIONS[e.kind] ?? {}), ...EFFECT_COMMON };
581
+ for (const [k, x] of Object.entries(e)) {
582
+ 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(', ')})`);
583
+ if (k === 'mask' && isPlain(x)) checkEffectMask(`${at}.mask`, x);
584
+ 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));
585
+ else checkValue(`${at}.${k}`, x);
586
+ }
587
+ });
588
+ };
589
+ // Motion: known presets and options (values are checked as values).
590
+ const checkMotion = (/** @type {string} */ path, /** @type {any} */ v) => {
591
+ (Array.isArray(v) ? v : [v]).forEach((m, i) => {
592
+ const at = Array.isArray(v) ? `${path}[${i}]` : path;
593
+ const preset = typeof m === 'string' ? m : m?.preset;
594
+ if (typeof preset === 'string' && !/[{$@]/.test(preset) && !Object.hasOwn(MOTION_PRESETS, preset)) fail(at, `unknown motion preset "${preset}" (${Object.keys(MOTION_PRESETS).join(', ')})`);
595
+ if (!isPlain(m)) return checkValue(at, m);
596
+ for (const [k, x] of Object.entries(m)) {
597
+ if (!Object.hasOwn(MOTION_OPTIONS, k)) fail(`${at}.${k}`, `motions have no option "${k}" (${Object.keys(MOTION_OPTIONS).join(', ')})`);
598
+ checkValue(`${at}.${k}`, x);
599
+ }
600
+ });
601
+ };
602
+ const checkEffectMask = (/** @type {string} */ path, /** @type {any} */ m) => {
603
+ const keys = ['gradient', 'from', 'to', 'center', 'angle', 'aspect', 'invert', 'ease', 'layers'];
604
+ for (const k of Object.keys(m)) if (!keys.includes(k)) fail(`${path}.${k}`, `unknown mask option (${keys.join(', ')})`);
605
+ if (Array.isArray(m.layers)) return m.layers.forEach((/** @type {any} */ l, /** @type {number} */ j) => checkLayer(`${path}.layers[${j}]`, l));
606
+ if (typeof m.gradient !== 'string' || (!/[{$@]/.test(m.gradient) && !MASK_GRADIENTS.includes(m.gradient))) fail(`${path}.gradient`, `"${m.gradient}" (${MASK_GRADIENTS.join(', ')}), or give layers`);
607
+ for (const [k, x] of Object.entries(m)) checkValue(`${path}.${k}`, x);
608
+ };
609
+ // Anchors, as literal strings (templates are checked when drawn).
610
+ const checkAnchor = (/** @type {string} */ path, /** @type {string} */ type, /** @type {string} */ k, /** @type {any} */ v) => {
611
+ if (typeof v !== 'string' || /[{$@]/.test(v)) return;
612
+ const allowed = k === 'anchor' || (k === 'align' && type === 'group') ? Object.keys(ANCHORS) : k === 'focus' && type === 'image' ? [...Object.keys(ANCHORS), 'attention', 'entropy'] : null;
613
+ if (allowed && !allowed.includes(v)) fail(path, `unknown anchor "${v}" (${allowed.join(', ')})`);
614
+ };
615
+ json.layers.forEach((/** @type {any} */ layer, /** @type {number} */ i) => checkLayer(`layers[${i}]${layer?.id ? ` (${layer.id})` : ''}`, layer));
616
+ }
617
+
618
+ /**
619
+ * The string and list operators.
620
+ *
621
+ * @param {string} name
622
+ * @param {any[]} args
623
+ * @returns {unknown}
624
+ */
625
+ function op2(name, args) {
626
+ switch (name) {
627
+ case 'join': return Array.isArray(args[0]) ? args[0].map((x) => valueOf(x)).join(args[1] ?? ' ') : String(valueOf(args[0]) ?? '');
628
+ case 'upper': return String(valueOf(args[0]) ?? '').toUpperCase();
629
+ case 'lower': return String(valueOf(args[0]) ?? '').toLowerCase();
630
+ default: return Array.isArray(args[0]) || typeof args[0] === 'string' ? args[0].slice(args[1], args[2]) : args[0];
631
+ }
458
632
  }
@@ -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,45 @@ 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 docs/effects.md; 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
+ motion: 'A motion, a preset name (drift | spin | breathe | blink | marquee), or a list: plays in the SVG as CSS keyframes (see MOTION_OPTIONS).',
56
+ };
57
+
58
+ /** @type {Record<string, string>} A motion's options. */
59
+ export const MOTION_OPTIONS = {
60
+ preset: 'drift | spin | breathe | blink | marquee: its defaults, under whatever else is set.',
61
+ translate: '[x, y] to move by (px in the layer\'s grid, "%" or lengths), or a list of points [[0, 0], [x, y], …].',
62
+ path: 'Travel a path from where the layer is: path data, { d }, or a text-path kind ({ kind: circle | ellipse | arc | wave, … }).',
63
+ rotate: 'Degrees to turn to, or a list of angles.',
64
+ scale: 'A factor to grow to, or a list.', scaleX: 'Horizontal factor, or a list.', scaleY: 'Vertical factor, or a list.',
65
+ opacity: '0–1 to fade to (times the layer\'s own), or a list.',
66
+ origin: '[x, y] the turn and scale are about (px, "%" or lengths). Default the centre.',
67
+ vary: 'Layer options as lists of stops ({ axes: { wdth: [62, 150] } }): drawn frame by frame, played as outline morphs.',
68
+ frames: 'vary: frames drawn per stop. Default 12.',
69
+ marquee: 'true: text on a path runs along it, one repeat of its words per duration (they must repeat).',
70
+ duration: 'Seconds per iteration. Default 2 (or the preset\'s).',
71
+ delay: 'Seconds before it starts; negative starts it part-way.',
72
+ easing: 'linear | ease | in | out | inOut | step, or [x1, y1, x2, y2]: between each pair of stops.',
73
+ direction: 'normal | reverse | alternate | alternate-reverse',
74
+ iterations: 'A number, or "infinite" (the default).',
75
+ };
37
76
 
@@ -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 '@logomarks/image-effects/svg';
8
+ import { len } from '@logomarks/image-effects/length';
9
+ import { toHex } from '@logomarks/image-effects/color';
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[];