@arraypress/covers 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/LICENSE +29 -0
- package/README.md +846 -2
- package/fonts/OFL.txt +93 -0
- package/fonts/inter-latin-400.woff +0 -0
- package/fonts/inter-latin-500.woff +0 -0
- package/fonts/inter-latin-600.woff +0 -0
- package/fonts/inter-latin-700.woff +0 -0
- package/package.json +124 -4
- package/recipes/aurora.json +132 -0
- package/recipes/bauhaus.json +113 -0
- package/recipes/bigType.json +103 -0
- package/recipes/brand.json +1143 -0
- package/recipes/contour.json +109 -0
- package/recipes/cutout.json +191 -0
- package/recipes/diagram.json +92 -0
- package/recipes/dither.json +170 -0
- package/recipes/duotone.json +75 -0
- package/recipes/gridTitle.json +136 -0
- package/recipes/iconTile.json +214 -0
- package/recipes/issue.json +221 -0
- package/recipes/mark.json +170 -0
- package/recipes/photo.json +156 -0
- package/recipes/riso.json +387 -0
- package/recipes/stripes.json +206 -0
- package/recipes/waveform.json +154 -0
- package/schema/recipe.schema.json +8822 -0
- package/src/animate/index.d.ts +19 -0
- package/src/animate/index.js +116 -0
- package/src/astro.d.ts +31 -0
- package/src/astro.js +86 -0
- package/src/builtins.js +4 -0
- package/src/color.js +246 -0
- package/src/diagram.js +248 -0
- package/src/effects/ascii.d.ts +7 -0
- package/src/effects/ascii.js +93 -0
- package/src/effects/buffer.d.ts +60 -0
- package/src/effects/buffer.js +472 -0
- package/src/effects/detail.d.ts +12 -0
- package/src/effects/detail.js +124 -0
- package/src/effects/digital.d.ts +14 -0
- package/src/effects/digital.js +216 -0
- package/src/effects/distort.d.ts +20 -0
- package/src/effects/distort.js +314 -0
- package/src/effects/dither.d.ts +8 -0
- package/src/effects/dither.js +147 -0
- package/src/effects/drop-shadow.d.ts +4 -0
- package/src/effects/drop-shadow.js +45 -0
- package/src/effects/extrude.d.ts +5 -0
- package/src/effects/extrude.js +55 -0
- package/src/effects/film.d.ts +11 -0
- package/src/effects/film.js +204 -0
- package/src/effects/glow.d.ts +8 -0
- package/src/effects/glow.js +56 -0
- package/src/effects/grade.d.ts +30 -0
- package/src/effects/grade.js +274 -0
- package/src/effects/halftone.d.ts +8 -0
- package/src/effects/halftone.js +95 -0
- package/src/effects/index.d.ts +125 -0
- package/src/effects/index.js +277 -0
- package/src/effects/ink.d.ts +12 -0
- package/src/effects/ink.js +70 -0
- package/src/effects/lens.d.ts +14 -0
- package/src/effects/lens.js +277 -0
- package/src/effects/light.d.ts +10 -0
- package/src/effects/light.js +59 -0
- package/src/effects/looks.d.ts +9 -0
- package/src/effects/looks.js +74 -0
- package/src/effects/mask.d.ts +52 -0
- package/src/effects/mask.js +314 -0
- package/src/effects/motion.d.ts +6 -0
- package/src/effects/motion.js +60 -0
- package/src/effects/newsprint.d.ts +6 -0
- package/src/effects/newsprint.js +89 -0
- package/src/effects/optics.d.ts +10 -0
- package/src/effects/optics.js +213 -0
- package/src/effects/options.d.ts +6 -0
- package/src/effects/options.js +106 -0
- package/src/effects/paint-presets.d.ts +10 -0
- package/src/effects/paint-presets.js +47 -0
- package/src/effects/paint.d.ts +49 -0
- package/src/effects/paint.js +66 -0
- package/src/effects/pixels.d.ts +24 -0
- package/src/effects/pixels.js +138 -0
- package/src/effects/polar.d.ts +10 -0
- package/src/effects/polar.js +265 -0
- package/src/effects/region.d.ts +8 -0
- package/src/effects/region.js +136 -0
- package/src/effects/round-border.d.ts +5 -0
- package/src/effects/round-border.js +62 -0
- package/src/effects/screen.d.ts +14 -0
- package/src/effects/screen.js +212 -0
- package/src/effects/sort.d.ts +8 -0
- package/src/effects/sort.js +125 -0
- package/src/effects/symmetry.d.ts +6 -0
- package/src/effects/symmetry.js +74 -0
- package/src/effects/tile.d.ts +7 -0
- package/src/effects/tile.js +55 -0
- package/src/effects/tone.d.ts +16 -0
- package/src/effects/tone.js +117 -0
- package/src/effects/transform.d.ts +35 -0
- package/src/effects/transform.js +127 -0
- package/src/effects/util.d.ts +16 -0
- package/src/effects/util.js +85 -0
- package/src/extract.js +91 -0
- package/src/flatten.js +234 -0
- package/src/fonts.d.ts +55 -0
- package/src/fonts.js +393 -0
- package/src/formats/cut.d.ts +10 -0
- package/src/formats/cut.js +77 -0
- package/src/formats/index.d.ts +6 -0
- package/src/formats/index.js +9 -0
- package/src/formats/presets.d.ts +41 -0
- package/src/formats/presets.js +89 -0
- package/src/formats/print.d.ts +25 -0
- package/src/formats/print.js +117 -0
- package/src/formats/zones.d.ts +10 -0
- package/src/formats/zones.js +64 -0
- package/src/geometry.js +187 -0
- package/src/grid.js +41 -0
- package/src/handmade.js +253 -0
- package/src/icons.js +191 -0
- package/src/index.d.ts +1083 -0
- package/src/index.js +27 -0
- package/src/inter-data.js +27 -0
- package/src/json-recipe.js +616 -0
- package/src/kit/index.d.ts +20 -0
- package/src/kit/index.js +58 -0
- package/src/layer-options.js +56 -0
- package/src/layers/barcode.d.ts +19 -0
- package/src/layers/barcode.js +63 -0
- package/src/layers/code128.d.ts +4 -0
- package/src/layers/code128.js +28 -0
- package/src/layers/expr.d.ts +6 -0
- package/src/layers/expr.js +121 -0
- package/src/layers/geometry.d.ts +6 -0
- package/src/layers/geometry.js +238 -0
- package/src/layers/index.d.ts +18 -0
- package/src/layers/index.js +15 -0
- package/src/layers/perforation.d.ts +14 -0
- package/src/layers/perforation.js +47 -0
- package/src/layers/repeat.d.ts +29 -0
- package/src/layers/repeat.js +90 -0
- package/src/layers/shape.d.ts +46 -0
- package/src/layers/shape.js +115 -0
- package/src/layers/soft.d.ts +5 -0
- package/src/layers/soft.js +43 -0
- package/src/layers/svg.d.ts +23 -0
- package/src/layers/svg.js +109 -0
- package/src/layers/table.d.ts +25 -0
- package/src/layers/table.js +80 -0
- package/src/layers/tear.d.ts +21 -0
- package/src/layers/tear.js +74 -0
- package/src/layers/texture.d.ts +17 -0
- package/src/layers/texture.js +106 -0
- package/src/layers/util.d.ts +8 -0
- package/src/layers/util.js +48 -0
- package/src/layers.js +1056 -0
- package/src/lint/image-size.d.ts +4 -0
- package/src/lint/image-size.js +34 -0
- package/src/lint/index.d.ts +16 -0
- package/src/lint/index.js +127 -0
- package/src/lint/rules.d.ts +5 -0
- package/src/lint/rules.js +19 -0
- package/src/node.d.ts +2 -0
- package/src/node.js +20 -0
- package/src/palette.js +185 -0
- package/src/platform/bytes.d.ts +22 -0
- package/src/platform/bytes.js +103 -0
- package/src/platform/compress.d.ts +12 -0
- package/src/platform/compress.js +81 -0
- package/src/platform/harfbuzz.d.ts +9 -0
- package/src/platform/harfbuzz.js +80 -0
- package/src/platform/index.d.ts +8 -0
- package/src/platform/index.js +13 -0
- package/src/platform/load.d.ts +6 -0
- package/src/platform/load.js +72 -0
- package/src/platform/no-harfbuzz.d.ts +2 -0
- package/src/platform/no-harfbuzz.js +11 -0
- package/src/platform/node.d.ts +26 -0
- package/src/platform/node.js +86 -0
- package/src/platform/settings.d.ts +28 -0
- package/src/platform/settings.js +51 -0
- package/src/png.js +126 -0
- package/src/print.js +659 -0
- package/src/raster/index.d.ts +24 -0
- package/src/raster/index.js +171 -0
- package/src/recipes.js +79 -0
- package/src/render/backdrop.d.ts +6 -0
- package/src/render/backdrop.js +55 -0
- package/src/render/content.d.ts +5 -0
- package/src/render/content.js +58 -0
- package/src/render/context.d.ts +82 -0
- package/src/render/grain.d.ts +7 -0
- package/src/render/grain.js +113 -0
- package/src/render/group.d.ts +23 -0
- package/src/render/group.js +67 -0
- package/src/render/key.d.ts +5 -0
- package/src/render/key.js +91 -0
- package/src/render/report.d.ts +5 -0
- package/src/render/report.js +19 -0
- package/src/render/tiles.d.ts +6 -0
- package/src/render/tiles.js +77 -0
- package/src/render/use.d.ts +27 -0
- package/src/render/use.js +73 -0
- package/src/render/wrap.d.ts +14 -0
- package/src/render/wrap.js +199 -0
- package/src/render.js +553 -0
- package/src/rng.js +95 -0
- package/src/svg.js +69 -0
- package/src/text.d.ts +29 -0
- package/src/text.js +237 -0
- package/src/type/auto-color.d.ts +9 -0
- package/src/type/auto-color.js +44 -0
- package/src/type/fallback.d.ts +11 -0
- package/src/type/fallback.js +92 -0
- package/src/type/glyphs.d.ts +24 -0
- package/src/type/glyphs.js +87 -0
- package/src/type/index.d.ts +5 -0
- package/src/type/index.js +10 -0
- package/src/type/kern.d.ts +4 -0
- package/src/type/kern.js +144 -0
- package/src/type/layout-ref.d.ts +10 -0
- package/src/type/layout-ref.js +74 -0
- package/src/type/line.d.ts +11 -0
- package/src/type/line.js +105 -0
- package/src/type/live.d.ts +6 -0
- package/src/type/live.js +39 -0
- package/src/type/name.d.ts +4 -0
- package/src/type/name.js +32 -0
- package/src/type/path.d.ts +18 -0
- package/src/type/path.js +89 -0
- package/src/type/pixel.d.ts +9 -0
- package/src/type/pixel.js +115 -0
- package/src/type/sfnt.d.ts +20 -0
- package/src/type/sfnt.js +258 -0
- package/src/type/spans.d.ts +33 -0
- package/src/type/spans.js +138 -0
- package/src/type/symbols.d.ts +7 -0
- package/src/type/symbols.js +89 -0
- package/src/type/typeset.d.ts +10 -0
- package/src/type/typeset.js +352 -0
- package/src/type/woff2.d.ts +6 -0
- package/src/type/woff2.js +301 -0
- package/src/version.js +4 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative recipes: a cover style written as JSON.
|
|
3
|
+
*
|
|
4
|
+
* ```json
|
|
5
|
+
* {
|
|
6
|
+
* "$schema": "https://unpkg.com/@arraypress/covers/schema/recipe.schema.json",
|
|
7
|
+
* "name": "poster",
|
|
8
|
+
* "params": { "background": "solid" },
|
|
9
|
+
* "vars": { "ink": { "readable": "@accent" } },
|
|
10
|
+
* "layers": [
|
|
11
|
+
* { "id": "bg", "type": "solid", "color": "@accent", "when": { "background": "solid" } },
|
|
12
|
+
* { "id": "bg", "type": "linear", "colors": ["@accent", "@colors.1"], "when": { "background": "gradient" } },
|
|
13
|
+
* { "id": "title", "type": "text", "content": "{title}", "color": "$ink", "when": "title" }
|
|
14
|
+
* ]
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Values are resolved when the cover is built:
|
|
19
|
+
*
|
|
20
|
+
* | Form | Means |
|
|
21
|
+
* |---|---|
|
|
22
|
+
* | `"@bg"` … | palette fields (`bg`, `text`, `accent`, `onAccent`, `muted`, `surface`, `mode`, `colors`, `colors.N`) and canvas numbers (`width`, `height`, `u`) |
|
|
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 |
|
|
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. |
|
|
27
|
+
* | `{ "mix": [a, b, t, space?] }` | two colours mixed in OKLab (or `"oklch"`: hue the short way, stays vivid) |
|
|
28
|
+
* | `{ "readable": c }` | white, or near-black on a light colour (`[c, light, dark]` to choose) |
|
|
29
|
+
* | `{ "readable": [bgs, ratio, prefer] }` | `prefer` (`"white"`, `"black"`, a colour) if it clears `ratio` on every background, else the better of white / near-black |
|
|
30
|
+
* | `{ "tone": [color, { l: [min, max], c: [min, max] }] }` | the colour pulled into an OKLCH band (its hue kept) |
|
|
31
|
+
* | `{ "gradientUnder": [colors, angle, size] }` | the colours a centred square of `size` covers in a linear gradient |
|
|
32
|
+
* | `{ "pick": [...] }`, `{ "range": [min, max] }`, `{ "int": [min, max] }` | seeded choices |
|
|
33
|
+
* | `{ "add" \| "sub" \| "mul" \| "div" \| "min" \| "max": [a, b, …] }` | arithmetic; lengths like `"54vw"` become pixels |
|
|
34
|
+
* | `{ "ramp": [from, to, steps, amount?] }` | `steps` colours from `from` toward `to` |
|
|
35
|
+
* | `{ "taper": [ratio, steps] }` | `[1, ratio, ratio², …]` |
|
|
36
|
+
* | `{ "length": value }` | characters in a string (or a number's digits), items in an array |
|
|
37
|
+
* | `{ "reverse": [...] }` | the array backwards |
|
|
38
|
+
* | `{ "if": cond, "then": a, "else": b }` | a choice (only the chosen side is evaluated) |
|
|
39
|
+
*
|
|
40
|
+
* Conditions (`when` on a layer, `if`): `"title"` (the option is set),
|
|
41
|
+
* `{ "background": "gradient" }` (a param or option equals a value; `mode`
|
|
42
|
+
* is the palette's, `aspect` the canvas's: `wide`, `square` or `tall`),
|
|
43
|
+
* `{ "not": c }`, `{ "all": [...] }`, `{ "any": [...] }`.
|
|
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
|
+
*
|
|
49
|
+
* Seeding: `vars` draw from the cover's recipe stream in order; draws inside
|
|
50
|
+
* a layer use that layer's own stream, so adding a layer never changes
|
|
51
|
+
* another's choices.
|
|
52
|
+
*
|
|
53
|
+
* @module json-recipe
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { readTextSync, node } from './platform/index.js';
|
|
57
|
+
import { readableText } from '@arraypress/color-utils';
|
|
58
|
+
import { createRng } from './rng.js';
|
|
59
|
+
import { len, ANCHORS } from './geometry.js';
|
|
60
|
+
import { toHex, mixOklab, mixOklch, onBackground, gradientAt } from './color.js';
|
|
61
|
+
import { LAYERS, toneColor } from './layers.js';
|
|
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';
|
|
67
|
+
|
|
68
|
+
/** Options every recipe can read without declaring them. */
|
|
69
|
+
const CONTENT = ['title', 'label', 'icon', 'image', 'name', 'mark', 'photo', 'cutout', 'date', 'dates', 'time', 'venue', 'address', 'lines', 'lineup', 'tracklist', 'price', 'meta', 'page'];
|
|
70
|
+
|
|
71
|
+
/** `@` tokens. */
|
|
72
|
+
const PALETTE = ['bg', 'text', 'accent', 'onAccent', 'muted', 'surface', 'mode', 'colors'];
|
|
73
|
+
const CANVAS = ['width', 'height', 'u', 'bleed', 'safe', 'mm', 'dpi', 'trimWidth', 'trimHeight', 'safeX', 'safeY', 'safeWidth', 'safeHeight'];
|
|
74
|
+
|
|
75
|
+
/** Value operators, by name, with their argument arity. */
|
|
76
|
+
const OPS = /** @type {Record<string, [number, number]>} */ ({
|
|
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],
|
|
78
|
+
add: [1, Infinity], sub: [2, Infinity], mul: [1, Infinity], div: [2, 2], min: [1, Infinity], max: [1, Infinity],
|
|
79
|
+
ramp: [3, 4], taper: [2, 2], reverse: [1, 1],
|
|
80
|
+
join: [1, 2], upper: [1, 1], lower: [1, 1], slice: [2, 3],
|
|
81
|
+
});
|
|
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
|
+
|
|
89
|
+
/** Keys of a layer that aren't layer options. */
|
|
90
|
+
const LAYER_META = new Set(['id', 'type', 'when']);
|
|
91
|
+
|
|
92
|
+
/** Top-level keys of a recipe document. */
|
|
93
|
+
const TOP = new Set(['$schema', 'name', 'description', 'params', 'requires', 'vars', 'output', 'layers', 'pages', 'fonts']);
|
|
94
|
+
|
|
95
|
+
/** Recipes defined from the same JSON object are reused. */
|
|
96
|
+
const defined = new WeakMap();
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The value of a content-like option for conditions: a string, number or
|
|
100
|
+
* bytes as-is; an override object's `content`, `name`, `svg` or `src`.
|
|
101
|
+
*
|
|
102
|
+
* @param {unknown} v
|
|
103
|
+
* @returns {unknown}
|
|
104
|
+
*/
|
|
105
|
+
function valueOf(v) {
|
|
106
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Uint8Array)) {
|
|
107
|
+
const o = /** @type {any} */ (v);
|
|
108
|
+
return o.content ?? o.name ?? o.svg ?? o.src;
|
|
109
|
+
}
|
|
110
|
+
return v;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @param {unknown} v @returns {boolean} */
|
|
114
|
+
const truthy = (v) => {
|
|
115
|
+
const x = valueOf(v);
|
|
116
|
+
return x !== undefined && x !== null && x !== false && x !== '' && !(Array.isArray(x) && x.length === 0);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** @param {unknown} v @returns {v is Record<string, any>} */
|
|
120
|
+
const isPlain = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Uint8Array);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Turn a JSON recipe into a recipe function. The document is validated
|
|
124
|
+
* first; any problem throws with the recipe's name and the JSON path.
|
|
125
|
+
*
|
|
126
|
+
* @param {import('./index.js').RecipeJson} json - The recipe document.
|
|
127
|
+
* @returns {import('./index.js').Recipe} The recipe (with `.recipeName` and `.json`).
|
|
128
|
+
* @throws {TypeError} On an invalid document.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* const poster = defineRecipe({ name: 'poster', layers: [{ type: 'solid', color: '@accent' }] });
|
|
132
|
+
* await renderSvg({ recipe: poster, seed: 'x' });
|
|
133
|
+
*/
|
|
134
|
+
export function defineRecipe(json) {
|
|
135
|
+
if (defined.has(json)) return defined.get(json);
|
|
136
|
+
validate(json);
|
|
137
|
+
const name = json.name ?? 'custom';
|
|
138
|
+
/** @type {any} */
|
|
139
|
+
const recipe = (/** @type {Record<string, unknown>} */ options, /** @type {any} */ c) => build(json, name, options ?? {}, c);
|
|
140
|
+
recipe.recipeName = name;
|
|
141
|
+
recipe.json = json;
|
|
142
|
+
defined.set(json, recipe);
|
|
143
|
+
return recipe;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Read and define a JSON recipe file (synchronous, for use in configs).
|
|
148
|
+
*
|
|
149
|
+
* @param {string | URL} path - A `.json` file.
|
|
150
|
+
* @returns {import('./index.js').Recipe} The recipe.
|
|
151
|
+
* @throws {TypeError} On invalid JSON or an invalid recipe (the message names the file).
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* const house = loadRecipe('./src/covers/house.json');
|
|
155
|
+
*/
|
|
156
|
+
export function loadRecipe(path) {
|
|
157
|
+
let json;
|
|
158
|
+
try {
|
|
159
|
+
json = JSON.parse(readTextSync(path));
|
|
160
|
+
} catch (err) {
|
|
161
|
+
throw new TypeError(`Couldn't read recipe ${String(path)}: ${/** @type {Error} */ (err).message}`);
|
|
162
|
+
}
|
|
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
|
+
}
|
|
170
|
+
return defineRecipe(json);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Building
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @typedef {object} Scope
|
|
179
|
+
* @property {string} name
|
|
180
|
+
* @property {Record<string, unknown>} options
|
|
181
|
+
* @property {Record<string, unknown>} params
|
|
182
|
+
* @property {Record<string, unknown>} vars
|
|
183
|
+
* @property {any} c - The recipe context.
|
|
184
|
+
* @property {() => import('./rng.js').Rng} rng - The current stream.
|
|
185
|
+
*/
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Expand a recipe document into layers.
|
|
189
|
+
*
|
|
190
|
+
* @param {import('./index.js').RecipeJson} json
|
|
191
|
+
* @param {string} name
|
|
192
|
+
* @param {Record<string, unknown>} options
|
|
193
|
+
* @param {any} c
|
|
194
|
+
* @returns {import('./index.js').Layer[]}
|
|
195
|
+
*/
|
|
196
|
+
function build(json, name, options, c) {
|
|
197
|
+
const params = { ...(json.params ?? {}) };
|
|
198
|
+
for (const k of Object.keys(params)) if (options[k] !== undefined) params[k] = options[k];
|
|
199
|
+
/** @type {Scope} */
|
|
200
|
+
const scope = { name, options, params, vars: {}, c, rng: () => c.rng };
|
|
201
|
+
|
|
202
|
+
for (const req of json.requires ?? []) {
|
|
203
|
+
if (!truthy(lookup(scope, req))) throw new TypeError(`Recipe "${name}" needs the "${req}" option`);
|
|
204
|
+
}
|
|
205
|
+
for (const [k, v] of Object.entries(json.vars ?? {})) scope.vars[k] = resolve(scope, v);
|
|
206
|
+
|
|
207
|
+
/** @type {Record<string, number>} */
|
|
208
|
+
const seen = {};
|
|
209
|
+
const layers = [];
|
|
210
|
+
for (const layer of json.layers) {
|
|
211
|
+
const n = (seen[layer.type] = (seen[layer.type] ?? -1) + 1);
|
|
212
|
+
let stream;
|
|
213
|
+
const own = { ...scope, rng: () => (stream ??= createRng(`${c.seed ?? ''}:recipe:${layer.id ?? `${layer.type}#${n}`}`)) };
|
|
214
|
+
if (layer.when !== undefined && !test(own, layer.when)) continue;
|
|
215
|
+
layers.push(/** @type {any} */ (resolveLayer(own, layer)));
|
|
216
|
+
}
|
|
217
|
+
return layers;
|
|
218
|
+
}
|
|
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>} */ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Look up a template name: a param, a content option, or `opt.<key>`.
|
|
271
|
+
*
|
|
272
|
+
* @param {Scope} s
|
|
273
|
+
* @param {string} key
|
|
274
|
+
* @returns {unknown}
|
|
275
|
+
*/
|
|
276
|
+
function lookup(s, key) {
|
|
277
|
+
if (key.startsWith('opt.')) return s.options[key.slice(4)];
|
|
278
|
+
if (Object.hasOwn(s.params, key)) return s.params[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);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Resolve one value.
|
|
288
|
+
*
|
|
289
|
+
* @param {Scope} s
|
|
290
|
+
* @param {unknown} v
|
|
291
|
+
* @returns {unknown}
|
|
292
|
+
*/
|
|
293
|
+
function resolve(s, v) {
|
|
294
|
+
if (typeof v === 'string') return resolveString(s, v);
|
|
295
|
+
if (Array.isArray(v)) return v.map((x) => resolve(s, x));
|
|
296
|
+
if (!isPlain(v)) return v;
|
|
297
|
+
if ('if' in v) return test(s, v.if) ? resolve(s, v.then) : resolve(s, v.else);
|
|
298
|
+
const keys = Object.keys(v);
|
|
299
|
+
if (keys.length === 1 && Object.hasOwn(OPS, keys[0])) return op(s, keys[0], v[keys[0]]);
|
|
300
|
+
/** @type {Record<string, unknown>} */
|
|
301
|
+
const out = {};
|
|
302
|
+
for (const [k, x] of Object.entries(v)) {
|
|
303
|
+
const r = resolve(s, x);
|
|
304
|
+
if (r !== undefined) out[k] = r;
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* @param {Scope} s
|
|
311
|
+
* @param {string} str
|
|
312
|
+
* @returns {unknown}
|
|
313
|
+
*/
|
|
314
|
+
function resolveString(s, str) {
|
|
315
|
+
if (str.startsWith('@@') || str.startsWith('$$')) return str.slice(1);
|
|
316
|
+
if (str.startsWith('@')) {
|
|
317
|
+
const [head, idx] = str.slice(1).split('.');
|
|
318
|
+
if (CANVAS.includes(head)) return s.c[head];
|
|
319
|
+
const p = s.c.palette;
|
|
320
|
+
if (head === 'colors' && idx !== undefined) return p.colors[Number(idx) % p.colors.length];
|
|
321
|
+
return head === 'colors' ? p.colors.slice() : p[head];
|
|
322
|
+
}
|
|
323
|
+
if (str.startsWith('$')) {
|
|
324
|
+
const [head, ...path] = str.slice(1).split('.');
|
|
325
|
+
return path.reduce((/** @type {any} */ acc, k) => acc?.[k], s.vars[head]);
|
|
326
|
+
}
|
|
327
|
+
// Layout references are filled in when the cover is drawn.
|
|
328
|
+
if (/^\{layout\.[\w-]+\.\w+\s*(?:[+-]\s*[\d.]+)?\}$/.test(str)) return str;
|
|
329
|
+
const whole = str.match(/^\{([\w.]+)\}$/);
|
|
330
|
+
if (whole) return lookup(s, whole[1]);
|
|
331
|
+
return str.replace(/\{([\w.]+)\}/g, (m, k) => {
|
|
332
|
+
if (k.startsWith('layout.')) return m;
|
|
333
|
+
const x = valueOf(lookup(s, k));
|
|
334
|
+
return x == null ? '' : String(x);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Evaluate an operator.
|
|
340
|
+
*
|
|
341
|
+
* @param {Scope} s
|
|
342
|
+
* @param {string} name
|
|
343
|
+
* @param {any} raw - Its arguments, unresolved.
|
|
344
|
+
* @returns {unknown}
|
|
345
|
+
*/
|
|
346
|
+
function op(s, name, raw) {
|
|
347
|
+
if (name === 'reverse') {
|
|
348
|
+
const arr = resolve(s, raw);
|
|
349
|
+
if (!Array.isArray(arr)) throw new TypeError(`Recipe "${s.name}": "reverse" needs an array`);
|
|
350
|
+
return arr.slice().reverse();
|
|
351
|
+
}
|
|
352
|
+
if (name === 'pick') {
|
|
353
|
+
const items = Array.isArray(raw) ? raw : resolve(s, raw);
|
|
354
|
+
return resolve(s, s.rng().pick(/** @type {unknown[]} */ (items)));
|
|
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
|
+
}
|
|
360
|
+
const args = /** @type {any[]} */ ([].concat(resolve(s, raw)));
|
|
361
|
+
const nums = () => args.map((a) => (typeof a === 'number' ? a : len(a, s.c)));
|
|
362
|
+
switch (name) {
|
|
363
|
+
case 'readable': {
|
|
364
|
+
// [backgrounds, ratio, prefer]: prefer a colour that clears a ratio (3 for icons).
|
|
365
|
+
if (typeof args[1] === 'number') return onBackground(args[0], args[1], args[2] ?? 'white');
|
|
366
|
+
return readableText(toHex(args[0]), args[1] ? toHex(args[1]) : '#ffffff', args[2] ? toHex(args[2]) : '#111111');
|
|
367
|
+
}
|
|
368
|
+
case 'length': return Array.isArray(resolve(s, raw)) ? /** @type {unknown[]} */ (resolve(s, raw)).length : String(args[0] ?? '').length;
|
|
369
|
+
case 'tone': return toneColor(toHex(args[0]), args[1] ?? {});
|
|
370
|
+
case 'gradientUnder': {
|
|
371
|
+
// The colours a centred square of `size` sits on in a linear gradient at `angle`.
|
|
372
|
+
const [colors, angle, size] = args;
|
|
373
|
+
const box = typeof size === 'number' ? size : len(size, s.c);
|
|
374
|
+
const a = (Number(angle) * Math.PI) / 180;
|
|
375
|
+
const line = Math.abs(s.c.width * Math.sin(a)) + Math.abs(s.c.height * Math.cos(a));
|
|
376
|
+
const half = ((box / 2) * (Math.abs(Math.sin(a)) + Math.abs(Math.cos(a)))) / line;
|
|
377
|
+
return [-1, -0.5, 0, 0.5, 1].map((k) => gradientAt(colors, 0.5 + k * half));
|
|
378
|
+
}
|
|
379
|
+
case 'mix': {
|
|
380
|
+
const mixer = args[3] === 'oklch' ? mixOklch : mixOklab;
|
|
381
|
+
if (args[3] !== undefined && args[3] !== 'oklch' && args[3] !== 'oklab') throw new TypeError(`Recipe "${s.name}": mix space must be "oklab" or "oklch"`);
|
|
382
|
+
return mixer(toHex(args[0]), toHex(args[1]), Number(args[2]));
|
|
383
|
+
}
|
|
384
|
+
case 'range': { const [a, b] = nums(); return s.rng().range(a, b); }
|
|
385
|
+
case 'int': { const [a, b] = nums(); return s.rng().int(a, b); }
|
|
386
|
+
case 'add': return nums().reduce((a, b) => a + b);
|
|
387
|
+
case 'sub': return nums().reduce((a, b) => a - b);
|
|
388
|
+
case 'mul': return nums().reduce((a, b) => a * b);
|
|
389
|
+
case 'div': { const [a, b] = nums(); return a / b; }
|
|
390
|
+
case 'min': return Math.min(...nums());
|
|
391
|
+
case 'max': return Math.max(...nums());
|
|
392
|
+
case 'ramp': {
|
|
393
|
+
const [from, to, steps, amount = 1] = args;
|
|
394
|
+
return Array.from({ length: steps }, (_, i) => mixOklab(toHex(from), toHex(to), (i / steps) * amount));
|
|
395
|
+
}
|
|
396
|
+
case 'taper': { const [ratio, steps] = nums(); return Array.from({ length: steps }, (_, i) => ratio ** i); }
|
|
397
|
+
default: throw new TypeError(`Unknown operator "${name}"`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Evaluate a condition.
|
|
403
|
+
*
|
|
404
|
+
* @param {Scope} s
|
|
405
|
+
* @param {unknown} cond
|
|
406
|
+
* @returns {boolean}
|
|
407
|
+
*/
|
|
408
|
+
function test(s, cond) {
|
|
409
|
+
if (typeof cond === 'string') return truthy(lookup(s, cond));
|
|
410
|
+
if (typeof cond === 'boolean') return cond;
|
|
411
|
+
if (!isPlain(cond)) return false;
|
|
412
|
+
if ('not' in cond) return !test(s, cond.not);
|
|
413
|
+
if ('all' in cond) return cond.all.every((/** @type {unknown} */ c) => test(s, c));
|
|
414
|
+
if ('any' in cond) return cond.any.some((/** @type {unknown} */ c) => test(s, c));
|
|
415
|
+
return Object.entries(cond).every(([k, want]) => {
|
|
416
|
+
const ratio = s.c.width / s.c.height;
|
|
417
|
+
const have = k === 'page' ? s.options.page ?? 'front'
|
|
418
|
+
: k === 'format' ? s.c.format
|
|
419
|
+
: k === 'mode' ? s.c.palette.mode
|
|
420
|
+
: k === 'aspect' ? (ratio > 1.15 ? 'wide' : ratio < 0.87 ? 'tall' : 'square')
|
|
421
|
+
: valueOf(lookup(s, k));
|
|
422
|
+
return Array.isArray(want) ? want.includes(have) : have === want;
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// Validation
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Check a recipe document, throwing on the first problem.
|
|
432
|
+
*
|
|
433
|
+
* @param {any} json
|
|
434
|
+
* @returns {void}
|
|
435
|
+
* @throws {TypeError}
|
|
436
|
+
*/
|
|
437
|
+
export function validate(json) {
|
|
438
|
+
if (!isPlain(json)) throw new TypeError('A JSON recipe must be an object');
|
|
439
|
+
const name = typeof json.name === 'string' ? json.name : 'custom';
|
|
440
|
+
const fail = (/** @type {string} */ path, /** @type {string} */ msg) => {
|
|
441
|
+
throw new TypeError(`Recipe "${name}": ${path ? `${path}: ` : ''}${msg}`);
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
for (const k of Object.keys(json)) if (!TOP.has(k)) fail(k, `unknown key (expected ${[...TOP].join(', ')})`);
|
|
445
|
+
if (json.params !== undefined && !isPlain(json.params)) fail('params', 'must be an object of defaults');
|
|
446
|
+
if (json.output !== undefined && !(isPlain(json.output) && Object.keys(json.output).every((k) => k === 'lossless'))) fail('output', 'is { "lossless": true | false }');
|
|
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"])');
|
|
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');
|
|
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
|
+
}
|
|
458
|
+
|
|
459
|
+
const names = new Set([...Object.keys(json.params ?? {}), ...CONTENT]);
|
|
460
|
+
const vars = new Set();
|
|
461
|
+
const checkName = (/** @type {string} */ path, /** @type {string} */ key) => {
|
|
462
|
+
if (!key.startsWith('opt.') && !names.has(key.split('.')[0])) {
|
|
463
|
+
fail(path, `unknown param "${key}" (declare it in "params", or use "{opt.${key}}" for any cover option)`);
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const checkCond = (/** @type {string} */ path, /** @type {any} */ c) => {
|
|
468
|
+
if (typeof c === 'string') return checkName(path, c);
|
|
469
|
+
if (typeof c === 'boolean') return;
|
|
470
|
+
if (!isPlain(c) || Object.keys(c).length === 0) return fail(path, 'a condition is an option name, { "not": … }, { "all": [...] }, { "any": [...] } or { "option": value }');
|
|
471
|
+
if ('not' in c) return checkCond(`${path}.not`, c.not);
|
|
472
|
+
for (const k of ['all', 'any']) {
|
|
473
|
+
if (k in c) {
|
|
474
|
+
if (!Array.isArray(c[k])) return fail(`${path}.${k}`, 'must be an array of conditions');
|
|
475
|
+
return c[k].forEach((/** @type {unknown} */ x, /** @type {number} */ i) => checkCond(`${path}.${k}[${i}]`, x));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
for (const k of Object.keys(c)) if (k !== 'mode' && k !== 'aspect' && k !== 'page' && k !== 'format') checkName(`${path}.${k}`, k);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const checkValue = (/** @type {string} */ path, /** @type {any} */ v) => {
|
|
482
|
+
if (typeof v === 'string') {
|
|
483
|
+
if (v.startsWith('@@') || v.startsWith('$$')) return;
|
|
484
|
+
if (v.startsWith('@')) {
|
|
485
|
+
const [head, idx, extra] = v.slice(1).split('.');
|
|
486
|
+
const ok = extra === undefined && (CANVAS.includes(head) ? idx === undefined : PALETTE.includes(head) && (idx === undefined || (head === 'colors' && /^\d+$/.test(idx))));
|
|
487
|
+
if (!ok) fail(path, `unknown token "${v}" (use @${PALETTE.join(', @')}, @colors.N, @${CANVAS.join(', @')})`);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (v.startsWith('$')) {
|
|
491
|
+
const head = v.slice(1).split('.')[0];
|
|
492
|
+
if (!vars.has(head)) fail(path, `unknown var "${v}" (declare it in "vars" first)`);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
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
|
+
}
|
|
503
|
+
if (!/^[\w.]+$/.test(m[1])) fail(path, `bad template "{${m[1]}}"`);
|
|
504
|
+
checkName(path, m[1]);
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (Array.isArray(v)) return v.forEach((x, i) => checkValue(`${path}[${i}]`, x));
|
|
509
|
+
if (!isPlain(v)) return;
|
|
510
|
+
if ('if' in v) {
|
|
511
|
+
for (const k of Object.keys(v)) if (!['if', 'then', 'else'].includes(k)) fail(path, `unexpected "${k}" in an "if" (use if, then, else)`);
|
|
512
|
+
if (!('then' in v)) fail(path, '"if" needs a "then"');
|
|
513
|
+
checkCond(`${path}.if`, v.if);
|
|
514
|
+
checkValue(`${path}.then`, v.then);
|
|
515
|
+
if ('else' in v) checkValue(`${path}.else`, v.else);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const keys = Object.keys(v);
|
|
519
|
+
if (keys.length === 1 && Object.hasOwn(OPS, keys[0])) {
|
|
520
|
+
const [lo, hi] = OPS[keys[0]];
|
|
521
|
+
const arg = v[keys[0]];
|
|
522
|
+
const count = Array.isArray(arg) ? arg.length : 1;
|
|
523
|
+
// `reverse` takes one array (literal or computed); the rest take argument lists.
|
|
524
|
+
if (keys[0] !== 'reverse' && (count < lo || count > hi)) fail(`${path}.${keys[0]}`, `takes ${lo === hi ? lo : `${lo}${hi === Infinity ? '+' : `–${hi}`}`} argument${hi === 1 ? '' : 's'}, got ${count}`);
|
|
525
|
+
if (keys[0] === 'mix' && typeof arg[3] === 'string' && !['oklab', 'oklch'].includes(arg[3])) fail(`${path}.mix`, 'the space is "oklab" or "oklch"');
|
|
526
|
+
if (keys[0] === 'pick' && Array.isArray(arg) && arg.length === 0) fail(`${path}.pick`, 'needs at least one item');
|
|
527
|
+
return checkValue(`${path}.${keys[0]}`, arg);
|
|
528
|
+
}
|
|
529
|
+
for (const [k, x] of Object.entries(v)) checkValue(`${path}.${k}`, x);
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
for (const [k, v] of Object.entries(json.params ?? {})) checkValue(`params.${k}`, v);
|
|
533
|
+
for (const r of json.requires ?? []) checkName('requires', r);
|
|
534
|
+
for (const [k, v] of Object.entries(json.vars ?? {})) {
|
|
535
|
+
checkValue(`vars.${k}`, v);
|
|
536
|
+
vars.add(k);
|
|
537
|
+
}
|
|
538
|
+
const checkLayer = (/** @type {string} */ path, /** @type {any} */ layer) => {
|
|
539
|
+
if (!isPlain(layer)) return fail(path, 'must be an object');
|
|
540
|
+
if (!Object.hasOwn(LAYERS, layer.type)) fail(`${path}.type`, `unknown layer type "${layer.type}" (${Object.keys(LAYERS).join(', ')})`);
|
|
541
|
+
if (layer.id !== undefined && typeof layer.id !== 'string') fail(`${path}.id`, 'must be a string');
|
|
542
|
+
if (layer.when !== undefined) checkCond(`${path}.when`, layer.when);
|
|
543
|
+
const known = { ...LAYER_OPTIONS[layer.type], ...COMMON };
|
|
544
|
+
for (const [k, v] of Object.entries(layer)) {
|
|
545
|
+
if (LAYER_META.has(k)) continue;
|
|
546
|
+
if (!Object.hasOwn(known, k)) fail(`${path}.${k}`, `"${layer.type}" layers have no option "${k}" (${Object.keys(known).join(', ')})`);
|
|
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
|
+
}
|
|
564
|
+
}
|
|
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
|
+
}
|
|
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>>>;
|