@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
package/src/layers.js
ADDED
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer renderers.
|
|
3
|
+
*
|
|
4
|
+
* Each layer type is one function from a layer (its options) and a render
|
|
5
|
+
* context to SVG markup: `{ defs, body }`. Layers paint in order, bottom
|
|
6
|
+
* first. Every option has a default, drawn from the palette, the canvas
|
|
7
|
+
* size and the layer's own seeded random stream, so `{ type: 'mesh' }` on
|
|
8
|
+
* its own is already a finished background.
|
|
9
|
+
*
|
|
10
|
+
* Common to every layer: `id` (names it for recipe overrides), `opacity`
|
|
11
|
+
* (0–1) and `blend` (a CSS `mix-blend-mode`: `multiply`, `screen`,
|
|
12
|
+
* `overlay`, `soft-light`, …).
|
|
13
|
+
*
|
|
14
|
+
* @module layers
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { loadBytes } from './platform/index.js';
|
|
18
|
+
import { toBase64, latin1, utf8 } from './platform/bytes.js';
|
|
19
|
+
import { attrs, esc, layerAttrs, num } from './svg.js';
|
|
20
|
+
import { len, place, anchorAlign, unit, regionPx } from './geometry.js';
|
|
21
|
+
import { toHex, mixOklab, toOklch, fromOklch } from './color.js';
|
|
22
|
+
import { harfbuzz, loadFont, pickFont } from './fonts.js';
|
|
23
|
+
import { layout, runPath, caseText } from './text.js';
|
|
24
|
+
import { resolveIcon, parseSvgIcon } from './icons.js';
|
|
25
|
+
import { gridPath } from './grid.js';
|
|
26
|
+
import { diagram } from './diagram.js';
|
|
27
|
+
import { paper, scribble } from './handmade.js';
|
|
28
|
+
import { ink, dither, contour, waveform, tiles, rule } from './print.js';
|
|
29
|
+
import { typeset, wantsType } from './type/index.js';
|
|
30
|
+
import { recordLayout } from './type/layout-ref.js';
|
|
31
|
+
import { shape, svg, table, barcode, perforation, tear, texture } from './layers/index.js';
|
|
32
|
+
import { pos } from './layers/util.js';
|
|
33
|
+
import { paint } from './effects/paint.js';
|
|
34
|
+
import { imageSize } from './lint/image-size.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Everything a layer renderer needs.
|
|
38
|
+
*
|
|
39
|
+
* @typedef {object} RenderContext
|
|
40
|
+
* @property {number} width - Canvas width in px.
|
|
41
|
+
* @property {number} height - Canvas height in px.
|
|
42
|
+
* @property {{ width: number, height: number }} canvas - The same, as an object.
|
|
43
|
+
* @property {import('./index.js').Palette} palette - The resolved palette.
|
|
44
|
+
* @property {import('./rng.js').Rng} rng - This layer's own random stream.
|
|
45
|
+
* @property {string} id - A unique id prefix for this layer's defs.
|
|
46
|
+
* @property {import('./index.js').FontSource[]} fonts - Available fonts (Inter last).
|
|
47
|
+
* @property {any} [sharp] - The `sharp` module, when available.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/** @typedef {{ defs: string, body: string, pad?: number, finest?: number }} Markup */
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wrap a body in the layer's opacity/blend group (only when needed).
|
|
54
|
+
*
|
|
55
|
+
* @param {any} layer - The layer.
|
|
56
|
+
* @param {string} body - Its markup.
|
|
57
|
+
* @returns {string} Markup.
|
|
58
|
+
*/
|
|
59
|
+
export function wrap(layer, body) {
|
|
60
|
+
const a = attrs(layerAttrs(layer));
|
|
61
|
+
return a ? `<g${a}>${body}</g>` : body;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Colour stops for a gradient. With `ease`, each pair of colours gets
|
|
66
|
+
* intermediate stops along a smoothstep curve (mixed in OKLab), which
|
|
67
|
+
* removes the hard "edge" a linear gradient shows where it starts and ends.
|
|
68
|
+
*
|
|
69
|
+
* @param {string[]} colors - Two or more colours.
|
|
70
|
+
* @param {boolean} ease - Ease between them.
|
|
71
|
+
* @param {number[]} [opacities] - Per-colour stop opacity.
|
|
72
|
+
* @returns {string} `<stop>` elements.
|
|
73
|
+
*/
|
|
74
|
+
function stops(colors, ease, opacities) {
|
|
75
|
+
const out = [];
|
|
76
|
+
const n = colors.length - 1;
|
|
77
|
+
const steps = ease ? 6 : 1;
|
|
78
|
+
for (let i = 0; i <= n; i++) {
|
|
79
|
+
if (i === n || !ease) {
|
|
80
|
+
out.push({ o: n === 0 ? 0 : i / n, c: colors[i], a: opacities?.[i] ?? 1 });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
for (let s = 0; s < steps; s++) {
|
|
84
|
+
const t = s / steps;
|
|
85
|
+
const e = t * t * (3 - 2 * t);
|
|
86
|
+
const a0 = opacities?.[i] ?? 1;
|
|
87
|
+
const a1 = opacities?.[i + 1] ?? 1;
|
|
88
|
+
out.push({ o: (i + t) / n, c: mixOklab(colors[i], colors[i + 1], e), a: a0 + (a1 - a0) * e });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out.map((s) => `<stop${attrs({ offset: num(s.o, 4), 'stop-color': s.c, 'stop-opacity': s.a < 1 ? num(s.a, 3) : undefined })}/>`).join('');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @param {any} v @param {string} fallback @returns {string} */
|
|
95
|
+
const color = (v, fallback) => (v ? toHex(v) : fallback);
|
|
96
|
+
|
|
97
|
+
/** @param {string[] | undefined} list @param {string[]} fallback @returns {string[]} */
|
|
98
|
+
const colorList = (list, fallback) => (list?.length ? list.map(toHex) : fallback);
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Backgrounds
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `solid` — one flat colour.
|
|
106
|
+
*
|
|
107
|
+
* @param {import('./index.js').SolidLayer} layer
|
|
108
|
+
* @param {RenderContext} ctx
|
|
109
|
+
* @returns {Markup}
|
|
110
|
+
*/
|
|
111
|
+
export function solid(layer, ctx) {
|
|
112
|
+
return { defs: '', body: wrap(layer, `<rect${attrs({ width: ctx.width, height: ctx.height, fill: color(layer.color, ctx.palette.bg) })}/>`) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `linear` — a linear gradient at a CSS angle (0° points up, 90° right).
|
|
117
|
+
*
|
|
118
|
+
* @param {import('./index.js').LinearLayer} layer
|
|
119
|
+
* @param {RenderContext} ctx
|
|
120
|
+
* @returns {Markup}
|
|
121
|
+
*/
|
|
122
|
+
export function linear(layer, ctx) {
|
|
123
|
+
const p = ctx.palette;
|
|
124
|
+
const colors = colorList(layer.colors, [p.colors[0], p.colors[1] ?? p.bg]);
|
|
125
|
+
const angle = layer.angle ?? Math.round(ctx.rng.range(0, 360));
|
|
126
|
+
const a = (angle * Math.PI) / 180;
|
|
127
|
+
const { width: w, height: h } = ctx;
|
|
128
|
+
const L = Math.abs(w * Math.sin(a)) + Math.abs(h * Math.cos(a));
|
|
129
|
+
const dx = (Math.sin(a) * L) / 2;
|
|
130
|
+
const dy = (-Math.cos(a) * L) / 2;
|
|
131
|
+
const id = `${ctx.id}g`;
|
|
132
|
+
const defs = `<linearGradient${attrs({ id, gradientUnits: 'userSpaceOnUse', x1: w / 2 - dx, y1: h / 2 - dy, x2: w / 2 + dx, y2: h / 2 + dy })}>${stops(colors, layer.ease !== false, layer.opacities)}</linearGradient>`;
|
|
133
|
+
return { defs, body: wrap(layer, `<rect${attrs({ width: w, height: h, fill: `url(#${id})` })}/>`) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `radial` — a radial gradient from a centre (fractions of the canvas).
|
|
138
|
+
*
|
|
139
|
+
* @param {import('./index.js').RadialLayer} layer
|
|
140
|
+
* @param {RenderContext} ctx
|
|
141
|
+
* @returns {Markup}
|
|
142
|
+
*/
|
|
143
|
+
export function radial(layer, ctx) {
|
|
144
|
+
const p = ctx.palette;
|
|
145
|
+
const colors = colorList(layer.colors, [p.colors[0], p.bg]);
|
|
146
|
+
const x = (layer.x ?? ctx.rng.range(0.2, 0.8)) * ctx.width;
|
|
147
|
+
const y = (layer.y ?? ctx.rng.range(0.2, 0.8)) * ctx.height;
|
|
148
|
+
const r = len(layer.radius, ctx.canvas, '80vmax');
|
|
149
|
+
const id = `${ctx.id}g`;
|
|
150
|
+
const defs = `<radialGradient${attrs({ id, gradientUnits: 'userSpaceOnUse', cx: x, cy: y, r })}>${stops(colors, layer.ease !== false)}</radialGradient>`;
|
|
151
|
+
return { defs, body: wrap(layer, `<rect${attrs({ width: ctx.width, height: ctx.height, fill: `url(#${id})` })}/>`) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `mesh` — soft blurred fields of colour: the "mesh gradient" look.
|
|
156
|
+
*
|
|
157
|
+
* The default `sweep` arrangement lays a few large, elongated fields along
|
|
158
|
+
* one seeded direction, with centres free to sit beyond the frame, and
|
|
159
|
+
* blurs them more along that direction than across it (`streak`), which is
|
|
160
|
+
* how editorial gradients read: broad diagonal washes, not round spots.
|
|
161
|
+
* `ring` spreads rounder blobs around the centre instead.
|
|
162
|
+
*
|
|
163
|
+
* Under the fields sits a gradient across the sweep between its first two
|
|
164
|
+
* colours (`base: 'auto'`), so colour reaches every edge and corner and the
|
|
165
|
+
* page colour never shows through as a vignette. `tone` pulls every colour into a lightness and
|
|
166
|
+
* chroma band (OKLCH), keeping its hue: pastel palettes become clear
|
|
167
|
+
* mid-tones, and a dark palette can't fall to black between fields.
|
|
168
|
+
*
|
|
169
|
+
* @param {import('./index.js').MeshLayer} layer
|
|
170
|
+
* @param {RenderContext} ctx
|
|
171
|
+
* @returns {Markup}
|
|
172
|
+
*/
|
|
173
|
+
export function mesh(layer, ctx) {
|
|
174
|
+
const { width: w, height: h, rng, palette: p } = ctx;
|
|
175
|
+
const max = Math.max(w, h);
|
|
176
|
+
// A few hues read as a colour story; every hue at once reads as a rainbow.
|
|
177
|
+
const pool = rng.shuffle(colorList(layer.colors, p.colors));
|
|
178
|
+
const want = Math.max(1, layer.hues ?? (layer.colors?.length ? pool.length : 3));
|
|
179
|
+
let colors = layer.hueSpan ? neighbours(pool, want, layer.hueSpan) : pool.slice(0, want);
|
|
180
|
+
if (layer.tone) colors = colors.map((c) => toneColor(c, /** @type {any} */ (layer.tone)));
|
|
181
|
+
const count = layer.blobs?.length ?? layer.count ?? 4;
|
|
182
|
+
const sweep = (layer.arrangement ?? 'sweep') === 'sweep';
|
|
183
|
+
const spread = layer.spread ?? (sweep ? 1 : 0.7);
|
|
184
|
+
const [smin, smax] = layer.size ?? (sweep ? [0.42, 0.72] : [0.3, 0.52]);
|
|
185
|
+
const blur = len(layer.blur, ctx.canvas, '11vmax');
|
|
186
|
+
const streak = Math.max(0.2, layer.streak ?? (sweep ? 1.5 : 1));
|
|
187
|
+
const angle = layer.angle ?? rng.range(0, 180);
|
|
188
|
+
const phase = rng.range(0, Math.PI * 2);
|
|
189
|
+
|
|
190
|
+
const blobs = [];
|
|
191
|
+
for (let i = 0; i < count; i++) {
|
|
192
|
+
const given = layer.blobs?.[i];
|
|
193
|
+
const rx = (given?.r ?? rng.range(smin, smax)) * max;
|
|
194
|
+
let cx;
|
|
195
|
+
let cy;
|
|
196
|
+
let squash;
|
|
197
|
+
let rot;
|
|
198
|
+
if (sweep) {
|
|
199
|
+
// Along the sweep (u) and across it (v), in units of the longer side,
|
|
200
|
+
// relative to the centre; then rotated into place by the group.
|
|
201
|
+
const t = count === 1 ? 0 : i / (count - 1) - 0.5;
|
|
202
|
+
const u = (t * 1.15 + rng.range(-0.12, 0.12)) * spread;
|
|
203
|
+
const v = rng.range(-0.32, 0.32) * spread;
|
|
204
|
+
cx = u * max;
|
|
205
|
+
cy = v * max;
|
|
206
|
+
squash = rng.range(0.26, 0.5);
|
|
207
|
+
rot = rng.range(-14, 14);
|
|
208
|
+
} else {
|
|
209
|
+
const ang = phase + (i / count) * Math.PI * 2 + rng.range(-0.45, 0.45);
|
|
210
|
+
const dist = spread * rng.range(0.45, 0.85);
|
|
211
|
+
cx = (0.5 + Math.cos(ang) * dist * 0.72) * w;
|
|
212
|
+
cy = (0.5 + Math.sin(ang) * dist * 0.72) * h;
|
|
213
|
+
squash = rng.range(0.55, 0.95);
|
|
214
|
+
rot = rng.range(0, 180);
|
|
215
|
+
}
|
|
216
|
+
blobs.push({
|
|
217
|
+
cx: given?.x != null ? (sweep ? (given.x - 0.5) * w : given.x * w) : cx,
|
|
218
|
+
cy: given?.y != null ? (sweep ? (given.y - 0.5) * h : given.y * h) : cy,
|
|
219
|
+
rx,
|
|
220
|
+
ry: rx * (given?.squash ?? squash),
|
|
221
|
+
rot: given?.rotate ?? rot,
|
|
222
|
+
fill: given?.color ? toHex(given.color) : colors[i % colors.length],
|
|
223
|
+
opacity: given?.opacity ?? 1,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const id = `${ctx.id}b`;
|
|
228
|
+
let defs_extra = '';
|
|
229
|
+
const std = sweep ? `${num(blur * streak)} ${num(blur / streak)}` : num(blur);
|
|
230
|
+
const defs = blur > 0
|
|
231
|
+
? `<filter${attrs({ id, x: '-50%', y: '-50%', width: '200%', height: '200%', 'color-interpolation-filters': 'sRGB' })}><feGaussianBlur stdDeviation="${std}"/></filter>`
|
|
232
|
+
: '';
|
|
233
|
+
// The base: by default a gradient across the sweep between its first two
|
|
234
|
+
// colours (so the field is full-bleed and never muddy), else a flat fill.
|
|
235
|
+
let base = '';
|
|
236
|
+
if (layer.base !== null && layer.base !== false) {
|
|
237
|
+
if (layer.base === undefined || layer.base === 'auto') {
|
|
238
|
+
const reach = Math.hypot(w, h) / 2;
|
|
239
|
+
const gid = `${ctx.id}g`;
|
|
240
|
+
const ends = [colors[0], colors[1 % colors.length]];
|
|
241
|
+
defs_extra = `<linearGradient${attrs({ id: gid, gradientUnits: 'userSpaceOnUse', x1: 0, y1: -reach, x2: 0, y2: reach })}>${stops(ends, true)}</linearGradient>`;
|
|
242
|
+
base = `<g transform="translate(${num(w / 2)} ${num(h / 2)}) rotate(${num(sweep ? angle : 0)})"><rect${attrs({ x: -reach, y: -reach, width: reach * 2, height: reach * 2, fill: `url(#${gid})` })}/></g>`;
|
|
243
|
+
} else {
|
|
244
|
+
base = `<rect${attrs({ width: w, height: h, fill: layer.base === 'bg' ? p.bg : toHex(layer.base) })}/>`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const shapes = blobs
|
|
248
|
+
.map((b) => `<ellipse${attrs({ cx: b.cx, cy: b.cy, rx: b.rx, ry: b.ry, fill: b.fill, opacity: b.opacity < 1 ? b.opacity : undefined, transform: `rotate(${num(b.rot)} ${num(b.cx)} ${num(b.cy)})` })}/>`)
|
|
249
|
+
.join('');
|
|
250
|
+
let field = `<g${attrs({ filter: blur > 0 ? `url(#${id})` : undefined })}>${shapes}</g>`;
|
|
251
|
+
const reach = Math.hypot(w, h) / 2 + blur * 2;
|
|
252
|
+
|
|
253
|
+
// A fold: a second field meeting the first along a soft curve, with a
|
|
254
|
+
// sharper edge than the blur elsewhere and a faint sheen along it (silk).
|
|
255
|
+
if (layer.fold) {
|
|
256
|
+
const f = layer.fold === true ? {} : layer.fold;
|
|
257
|
+
const fc = f.color ? toHex(f.color) : colors[2 % colors.length];
|
|
258
|
+
const far = f.to ? toHex(f.to) : mixOklab(fc, colors[0], 0.35);
|
|
259
|
+
const at = (f.position ?? rng.range(-0.15, 0.2)) * max;
|
|
260
|
+
const bend = (f.bend ?? rng.range(-0.28, 0.28)) * max;
|
|
261
|
+
const v = [at - bend * 0.6, at + bend, at - bend, at + bend * 0.5];
|
|
262
|
+
const curve = `M${num(-reach)} ${num(v[0])}C${num(-reach / 3)} ${num(v[1])} ${num(reach / 3)} ${num(v[2])} ${num(reach)} ${num(v[3])}`;
|
|
263
|
+
const fid = `${ctx.id}f`;
|
|
264
|
+
const gid2 = `${ctx.id}fg`;
|
|
265
|
+
const soft = blur * (f.softness ?? 0.16);
|
|
266
|
+
defs_extra += `<filter${attrs({ id: fid, x: '-20%', y: '-20%', width: '140%', height: '140%' })}><feGaussianBlur stdDeviation="${num(soft * streak)} ${num(soft)}"/></filter>`
|
|
267
|
+
+ `<linearGradient${attrs({ id: gid2, gradientUnits: 'userSpaceOnUse', x1: 0, y1: at, x2: 0, y2: reach })}>${stops([fc, far], true)}</linearGradient>`;
|
|
268
|
+
const sheen = f.sheen ?? 0.4;
|
|
269
|
+
field += `<g filter="url(#${fid})"><path${attrs({ d: `${curve}V${num(reach)}H${num(-reach)}Z`, fill: `url(#${gid2})` })}/>`
|
|
270
|
+
+ (sheen > 0 ? `<path${attrs({ d: curve, fill: 'none', stroke: mixOklab(fc, '#ffffff', 0.55), 'stroke-width': blur * 0.5, opacity: sheen })}/>` : '')
|
|
271
|
+
+ '</g>';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Streaks: thin, long, light ellipses blurred hard along the sweep (motion).
|
|
275
|
+
const nStreaks = layer.streaks ?? 0;
|
|
276
|
+
if (nStreaks > 0) {
|
|
277
|
+
const sid = `${ctx.id}s`;
|
|
278
|
+
defs_extra += `<filter${attrs({ id: sid, x: '-50%', y: '-50%', width: '200%', height: '200%' })}><feGaussianBlur stdDeviation="${num(blur * 0.7)} ${num(blur * 0.05)}"/></filter>`;
|
|
279
|
+
let lines = '';
|
|
280
|
+
for (let i = 0; i < nStreaks; i++) {
|
|
281
|
+
const rx = rng.range(0.35, 0.8) * max;
|
|
282
|
+
lines += `<ellipse${attrs({
|
|
283
|
+
cx: rng.range(-0.45, 0.45) * max,
|
|
284
|
+
cy: rng.range(-0.4, 0.4) * max,
|
|
285
|
+
rx,
|
|
286
|
+
ry: rx * rng.range(0.04, 0.11),
|
|
287
|
+
fill: mixOklab(colors[i % colors.length], '#ffffff', rng.range(0, 0.3)),
|
|
288
|
+
opacity: num(rng.range(0.45, 0.85), 2),
|
|
289
|
+
transform: `rotate(${num(rng.range(-6, 6))})`,
|
|
290
|
+
})}/>`;
|
|
291
|
+
}
|
|
292
|
+
field += `<g filter="url(#${sid})">${lines}</g>`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// The sweep's group is rotated, so its (anisotropic) blur runs along the sweep.
|
|
296
|
+
const placed = sweep ? `<g transform="translate(${num(w / 2)} ${num(h / 2)}) rotate(${num(angle)})">${field}</g>` : field;
|
|
297
|
+
// For renderImage: librsvg clips a filter's input to the canvas, so blur
|
|
298
|
+
// near an edge pulls in transparency. It renders this layer on a padded
|
|
299
|
+
// canvas instead (`pad`), at a reduced scale the softest detail allows.
|
|
300
|
+
const finest = Math.min(sweep ? blur / streak : blur, layer.fold ? blur * ((layer.fold === true ? undefined : layer.fold.softness) ?? 0.16) : Infinity, nStreaks > 0 ? blur * 0.05 : Infinity);
|
|
301
|
+
return { defs: defs + defs_extra, body: wrap(layer, `${base}${placed}`), pad: Math.ceil(3 * blur * (sweep ? Math.max(1, streak) : 1)), finest };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Neighbouring hues only: the first colour, then others whose OKLCH hue is
|
|
306
|
+
* within `span`° of it; when the pool has too few, more are made by turning
|
|
307
|
+
* the first colour's hue (keeping its lightness and chroma) inside the span.
|
|
308
|
+
* Blue with violet, teal with blue, orange with pink, never olive with teal.
|
|
309
|
+
*
|
|
310
|
+
* @param {string[]} pool - Candidate colours (already shuffled).
|
|
311
|
+
* @param {number} want - How many.
|
|
312
|
+
* @param {number} span - Degrees of hue the colours may spread over.
|
|
313
|
+
* @returns {string[]}
|
|
314
|
+
*/
|
|
315
|
+
function neighbours(pool, want, span) {
|
|
316
|
+
const first = toOklch(pool[0]);
|
|
317
|
+
const dist = (/** @type {number} */ h) => Math.abs((((h - first.h) % 360) + 540) % 360 - 180);
|
|
318
|
+
const chosen = [pool[0]];
|
|
319
|
+
if (first.c >= 0.02) {
|
|
320
|
+
for (const c of pool.slice(1)) {
|
|
321
|
+
const o = toOklch(c);
|
|
322
|
+
if (chosen.length < want && o.c >= 0.02 && dist(o.h) <= span * 0.45) chosen.push(c);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Inside the span with a little room: gamut mapping can nudge a hue.
|
|
326
|
+
const turns = [span * 0.45, -span * 0.45, span * 0.22, -span * 0.22];
|
|
327
|
+
for (let i = 0; chosen.length < want && i < turns.length; i++) {
|
|
328
|
+
chosen.push(first.c < 0.02 ? pool[(i + 1) % pool.length] : fromOklch({ l: first.l, c: first.c, h: (first.h + turns[i] + 360) % 360 }));
|
|
329
|
+
}
|
|
330
|
+
return chosen;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Pull a colour into a lightness / chroma band in OKLCH, keeping its hue.
|
|
335
|
+
* Greys (chroma under 0.02) keep their chroma, since their hue means nothing.
|
|
336
|
+
* A yellow pulled dark is turned toward amber, since dark yellow is olive.
|
|
337
|
+
*
|
|
338
|
+
* @param {string} c - The colour.
|
|
339
|
+
* @param {{ l?: [number, number], c?: [number, number] }} band - Ranges to clamp into.
|
|
340
|
+
* @returns {string} `#rrggbb`.
|
|
341
|
+
*/
|
|
342
|
+
export function toneColor(c, band) {
|
|
343
|
+
const o = toOklch(c);
|
|
344
|
+
const clamp = (/** @type {number} */ v, /** @type {[number, number] | undefined} */ r) => (r ? Math.min(r[1], Math.max(r[0], v)) : v);
|
|
345
|
+
const l = clamp(o.l, band.l);
|
|
346
|
+
// Yellow can't be dark: below ~0.7 lightness it reads as olive. Turn it
|
|
347
|
+
// toward amber as it darkens instead.
|
|
348
|
+
let h = o.h;
|
|
349
|
+
if (o.c >= 0.02 && h > 70 && h < 115 && l < 0.7) h = 70 - (0.7 - l) * 60 * ((115 - h) / 45 + 0.5);
|
|
350
|
+
return fromOklch({ l, c: o.c < 0.02 ? o.c : clamp(o.c, band.c), h });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* `stripes` — bands of colour across a region: vertical, horizontal or at
|
|
355
|
+
* an angle. Widths can be even, random, tapering (each narrower than the
|
|
356
|
+
* last: the classic stripe band) or given as weights.
|
|
357
|
+
*
|
|
358
|
+
* @param {import('./index.js').StripesLayer} layer
|
|
359
|
+
* @param {RenderContext} ctx
|
|
360
|
+
* @returns {Markup}
|
|
361
|
+
*/
|
|
362
|
+
export function stripes(layer, ctx) {
|
|
363
|
+
const { rng, palette: p } = ctx;
|
|
364
|
+
const r = regionPx(layer.region, ctx.canvas);
|
|
365
|
+
const direction = layer.direction ?? 'vertical';
|
|
366
|
+
const angle = direction === 'vertical' ? 0 : direction === 'horizontal' ? 90 : layer.angle ?? 45;
|
|
367
|
+
const colors = colorList(layer.colors, p.colors);
|
|
368
|
+
const count = layer.count ?? (Array.isArray(layer.widths) ? layer.widths.length : 8);
|
|
369
|
+
const gap = len(layer.gap, ctx.canvas, 0);
|
|
370
|
+
const radius = len(layer.radius, ctx.canvas, 0);
|
|
371
|
+
|
|
372
|
+
// Bands run along the rotated x axis; for a slant they must cover the diagonal.
|
|
373
|
+
const diagonal = angle % 90 !== 0;
|
|
374
|
+
const extent = diagonal ? Math.hypot(r.width, r.height) : angle === 0 ? r.width : r.height;
|
|
375
|
+
const cross = diagonal ? extent : angle === 0 ? r.height : r.width;
|
|
376
|
+
|
|
377
|
+
const mode = layer.widths ?? 'even';
|
|
378
|
+
const weights = Array.from({ length: count }, (_, i) =>
|
|
379
|
+
Array.isArray(mode) ? mode[i % mode.length] : mode === 'random' ? rng.range(0.35, 1.65) : mode === 'taper' ? (layer.taper ?? 0.78) ** i : 1,
|
|
380
|
+
);
|
|
381
|
+
const total = weights.reduce((a, b) => a + b, 0);
|
|
382
|
+
const free = Math.max(0, extent - gap * (count - 1));
|
|
383
|
+
|
|
384
|
+
let x = -extent / 2;
|
|
385
|
+
const bands = weights.map((wt, i) => {
|
|
386
|
+
const bw = (wt / total) * free;
|
|
387
|
+
const band = `<rect${attrs({ x, y: -cross / 2, width: Math.max(0, bw), height: cross, rx: radius || undefined, fill: colors[i % colors.length] })}/>`;
|
|
388
|
+
x += bw + gap;
|
|
389
|
+
return band;
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const id = `${ctx.id}c`;
|
|
393
|
+
const cx = r.x + r.width / 2;
|
|
394
|
+
const cy = r.y + r.height / 2;
|
|
395
|
+
const defs = `<clipPath id="${id}"><rect${attrs({ x: r.x, y: r.y, width: r.width, height: r.height, rx: len(layer.regionRadius, ctx.canvas, 0) || undefined })}/></clipPath>`;
|
|
396
|
+
const rot = angle === 0 ? '' : ` rotate(${num(angle)})`;
|
|
397
|
+
return { defs, body: wrap(layer, `<g clip-path="url(#${id})"><g transform="translate(${num(cx)} ${num(cy)})${rot}">${bands.join('')}</g></g>`) };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* `image` — a local image, cover- or contain-fit into a region, with an
|
|
402
|
+
* optional duotone, tint, greyscale or blur.
|
|
403
|
+
*
|
|
404
|
+
* With `sharp` installed the image is resized and cropped to its box
|
|
405
|
+
* before embedding (smaller SVGs, and `focus: 'attention'` / `'entropy'`
|
|
406
|
+
* smart crops). Without it the original bytes are embedded and fitted by
|
|
407
|
+
* `preserveAspectRatio` (PNG, JPEG and SVG only, since librsvg can't decode
|
|
408
|
+
* WebP or AVIF).
|
|
409
|
+
*
|
|
410
|
+
* @param {import('./index.js').ImageLayer} layer
|
|
411
|
+
* @param {RenderContext} ctx
|
|
412
|
+
* @returns {Promise<Markup>}
|
|
413
|
+
*/
|
|
414
|
+
export async function image(layer, ctx) {
|
|
415
|
+
if (!layer.src) throw new TypeError('An image layer needs a src (a file path or bytes)');
|
|
416
|
+
const L = /** @type {any} */ (layer);
|
|
417
|
+
// 1.1: an explicit box in px (x, y, width, height), as an SVG <image> would take.
|
|
418
|
+
const box = L.x != null || L.y != null || L.width != null || L.height != null
|
|
419
|
+
? { x: pos(L.x ?? 0, 'x', ctx.canvas), y: pos(L.y ?? 0, 'y', ctx.canvas), width: L.width != null ? pos(L.width, 'x', ctx.canvas) : ctx.width, height: L.height != null ? pos(L.height, 'y', ctx.canvas) : ctx.height }
|
|
420
|
+
: regionPx(layer.region, ctx.canvas);
|
|
421
|
+
const fit = layer.fit ?? 'cover';
|
|
422
|
+
const focus = layer.focus ?? 'center';
|
|
423
|
+
let bytes = typeof layer.src === 'string' || layer.src instanceof URL ? await readSource(layer.src) : await loadBytes(layer.src, 'image');
|
|
424
|
+
let mime = sniff(bytes);
|
|
425
|
+
let preserve = fit === 'stretch' ? 'none' : `${alignFor(focus)} ${fit === 'contain' ? 'meet' : 'slice'}`;
|
|
426
|
+
let sharpBlurred = false;
|
|
427
|
+
|
|
428
|
+
/** Source pixels and what was embedded, for lint. */
|
|
429
|
+
let source = imageSize(bytes);
|
|
430
|
+
let embedded = source;
|
|
431
|
+
if (ctx.sharp && mime !== 'image/svg+xml') {
|
|
432
|
+
const out = await resample(ctx, bytes, box, fit, focus, layer);
|
|
433
|
+
({ bytes, mime, source, embedded } = out);
|
|
434
|
+
sharpBlurred = Boolean(layer.blur);
|
|
435
|
+
preserve = 'none';
|
|
436
|
+
} else if (!mime) {
|
|
437
|
+
throw new TypeError('Unrecognised image format (use PNG, JPEG, WebP, AVIF, GIF or SVG)');
|
|
438
|
+
}
|
|
439
|
+
const res = /** @type {any} */ (ctx).res ?? 1;
|
|
440
|
+
/** @type {any} */ (ctx).report?.({ kind: 'image', id: layer.id, drawn: { width: box.width * res, height: box.height * res }, source, embedded, fit });
|
|
441
|
+
|
|
442
|
+
const p = ctx.palette;
|
|
443
|
+
const id = `${ctx.id}f`;
|
|
444
|
+
const filters = [];
|
|
445
|
+
if (layer.blur && !sharpBlurred) filters.push(`<feGaussianBlur stdDeviation="${num(len(layer.blur, ctx.canvas))}" edgeMode="duplicate"/>`);
|
|
446
|
+
if (layer.duotone || layer.grayscale) {
|
|
447
|
+
filters.push('<feColorMatrix type="matrix" values=".2126 .7152 .0722 0 0 .2126 .7152 .0722 0 0 .2126 .7152 .0722 0 0 0 0 0 1 0"/>');
|
|
448
|
+
}
|
|
449
|
+
if (layer.duotone) {
|
|
450
|
+
const [lo, hi] = Array.isArray(layer.duotone) ? layer.duotone.map(toHex) : defaultDuotone(p);
|
|
451
|
+
const [a, b] = [lo, hi].map((c) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16) / 255));
|
|
452
|
+
filters.push(
|
|
453
|
+
`<feComponentTransfer>${['R', 'G', 'B'].map((ch, i) => `<feFunc${ch} type="table" tableValues="${num(a[i], 4)} ${num(b[i], 4)}"/>`).join('')}</feComponentTransfer>`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
const defs = filters.length
|
|
457
|
+
? `<filter${attrs({ id, x: 0, y: 0, width: '100%', height: '100%', 'color-interpolation-filters': 'sRGB' })}>${filters.join('')}</filter>`
|
|
458
|
+
: '';
|
|
459
|
+
|
|
460
|
+
let body = `<image${attrs({ x: box.x, y: box.y, width: box.width, height: box.height, preserveAspectRatio: preserve, filter: defs ? `url(#${id})` : undefined, href: `data:${mime};base64,${toBase64(bytes)}` })}/>`;
|
|
461
|
+
if (layer.tint) {
|
|
462
|
+
const t = typeof layer.tint === 'string' ? { color: layer.tint } : layer.tint;
|
|
463
|
+
body += `<rect${attrs({ x: box.x, y: box.y, width: box.width, height: box.height, fill: color(t.color, p.accent), opacity: t.amount ?? 0.35, style: `mix-blend-mode:${t.blend ?? 'multiply'}` })}/>`;
|
|
464
|
+
}
|
|
465
|
+
return { defs, body: wrap(layer, body) };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Embeds up to this many pixels at top level are sized to their box, as in 1.0, even past the source's pixels. */
|
|
469
|
+
const CAP_FREE = 4e6;
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Resize and crop an image to its box with sharp, at the pixels it's drawn
|
|
473
|
+
* at (its box times the group and transform scale above it), never above
|
|
474
|
+
* the source's own pixels (the SVG scales it up instead, so a small photo
|
|
475
|
+
* isn't embedded at output size). Opaque results are JPEG, even from a
|
|
476
|
+
* source with an unused alpha channel; PNG only where there's transparency.
|
|
477
|
+
*
|
|
478
|
+
* @param {RenderContext} ctx
|
|
479
|
+
* @param {Uint8Array} bytes
|
|
480
|
+
* @param {{ width: number, height: number }} box
|
|
481
|
+
* @param {string} fit
|
|
482
|
+
* @param {any} focus
|
|
483
|
+
* @param {import('./index.js').ImageLayer} layer
|
|
484
|
+
* @returns {Promise<{ bytes: Uint8Array, mime: string, source: { width: number, height: number } | undefined, embedded: { width: number, height: number } }>}
|
|
485
|
+
*/
|
|
486
|
+
async function resample(ctx, bytes, box, fit, focus, layer) {
|
|
487
|
+
const sharp = /** @type {any} */ (ctx.sharp);
|
|
488
|
+
const meta = await sharp(bytes).metadata();
|
|
489
|
+
// EXIF orientations 5–8 turn the image a quarter.
|
|
490
|
+
const [sw, sh] = (meta.orientation ?? 1) >= 5 ? [meta.height, meta.width] : [meta.width, meta.height];
|
|
491
|
+
const res = /** @type {any} */ (ctx).res ?? 1;
|
|
492
|
+
let w = box.width * res;
|
|
493
|
+
let hgt = box.height * res;
|
|
494
|
+
// How much the fit would enlarge the source: past 1, embed at the source's
|
|
495
|
+
// own size. (A top-level image under 4 MP keeps 1.0's box-sized embed, so
|
|
496
|
+
// 1.0 covers stay byte-identical.)
|
|
497
|
+
const cap = res !== 1 || w * hgt > CAP_FREE;
|
|
498
|
+
const grow = !cap || fit === 'stretch' ? 1 : fit === 'contain' ? Math.min(w / sw, hgt / sh) : Math.max(w / sw, hgt / sh);
|
|
499
|
+
if (grow > 1) { w /= grow; hgt /= grow; }
|
|
500
|
+
if (cap && fit === 'stretch') { w = Math.min(w, sw); hgt = Math.min(hgt, sh); }
|
|
501
|
+
w = Math.max(1, Math.round(w));
|
|
502
|
+
hgt = Math.max(1, Math.round(hgt));
|
|
503
|
+
const position = typeof focus === 'string' && ['attention', 'entropy'].includes(focus) ? focus : (POSITIONS[focus] ?? 'centre');
|
|
504
|
+
let img = sharp(bytes).rotate().resize(w, hgt, { fit: fit === 'stretch' ? 'fill' : fit, position, background: { r: 0, g: 0, b: 0, alpha: 0 } });
|
|
505
|
+
// Stretch the tones first, so a duotone spans both of its colours.
|
|
506
|
+
if (layer.normalize ?? Boolean(layer.duotone || layer.grayscale)) img = img.normalise();
|
|
507
|
+
// Blur here rather than in SVG: sharp clamps at the edges, where an SVG
|
|
508
|
+
// blur (in librsvg) fades the photo to transparent. In the pixels drawn.
|
|
509
|
+
if (layer.blur) img = img.blur(Math.max(0.3, len(layer.blur, ctx.canvas) * res / Math.max(1, grow)));
|
|
510
|
+
const embedded = { width: w, height: hgt };
|
|
511
|
+
const source = { width: sw, height: sh };
|
|
512
|
+
if (!meta.hasAlpha && fit !== 'contain') return { bytes: await img.jpeg({ quality: 90 }).toBuffer(), mime: 'image/jpeg', source, embedded };
|
|
513
|
+
// An alpha channel: PNG only if it's used (a letterbox, a cutout).
|
|
514
|
+
const { data, info } = await img.clone().ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
515
|
+
const raw = { raw: { width: info.width, height: info.height, channels: 4 } };
|
|
516
|
+
let opaque = true;
|
|
517
|
+
for (let i = 3; i < data.length; i += 4) if (data[i] !== 255) { opaque = false; break; }
|
|
518
|
+
if (opaque) return { bytes: await sharp(data, raw).removeAlpha().jpeg({ quality: 90 }).toBuffer(), mime: 'image/jpeg', source, embedded };
|
|
519
|
+
if (fit === 'contain' && !meta.hasAlpha && res === 1 && grow <= 1) {
|
|
520
|
+
// As 1.0 encoded it (the letterbox's alpha), byte for byte.
|
|
521
|
+
return { bytes: await img.png().toBuffer(), mime: 'image/png', source, embedded };
|
|
522
|
+
}
|
|
523
|
+
return { bytes: await sharp(data, raw).png().toBuffer(), mime: 'image/png', source, embedded };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Anchor name to sharp's crop `position`. */
|
|
527
|
+
const POSITIONS = /** @type {Record<string, string>} */ ({
|
|
528
|
+
center: 'centre', top: 'top', bottom: 'bottom', left: 'left', right: 'right',
|
|
529
|
+
'top-left': 'left top', 'top-right': 'right top', 'bottom-left': 'left bottom', 'bottom-right': 'right bottom',
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
/** @param {string} focus @returns {string} preserveAspectRatio alignment. */
|
|
533
|
+
function alignFor(focus) {
|
|
534
|
+
const x = focus.includes('left') ? 'xMin' : focus.includes('right') ? 'xMax' : 'xMid';
|
|
535
|
+
const y = focus.includes('top') ? 'YMin' : focus.includes('bottom') ? 'YMax' : 'YMid';
|
|
536
|
+
return x + y;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** @param {import('./index.js').Palette} p @returns {[string, string]} Shadow and highlight. */
|
|
540
|
+
function defaultDuotone(p) {
|
|
541
|
+
const dark = p.mode === 'dark' ? p.bg : p.text;
|
|
542
|
+
const light = p.mode === 'dark' ? p.text : p.bg;
|
|
543
|
+
return [mixOklab(dark, p.accent, 0.12), mixOklab(p.accent, light, 0.2)];
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** @param {string} src @returns {Promise<Uint8Array>} */
|
|
547
|
+
async function readSource(src) {
|
|
548
|
+
return loadBytes(src, 'image');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Sniff an image's MIME type from its first bytes.
|
|
553
|
+
*
|
|
554
|
+
* @param {Uint8Array} b - Image bytes.
|
|
555
|
+
* @returns {string | undefined} The MIME type.
|
|
556
|
+
*/
|
|
557
|
+
export function sniff(b) {
|
|
558
|
+
if (b[0] === 0x89 && b[1] === 0x50) return 'image/png';
|
|
559
|
+
if (b[0] === 0xff && b[1] === 0xd8) return 'image/jpeg';
|
|
560
|
+
if (b[0] === 0x47 && b[1] === 0x49) return 'image/gif';
|
|
561
|
+
const head = latin1(b.subarray(0, 32));
|
|
562
|
+
if (head.startsWith('RIFF') && head.slice(8, 12) === 'WEBP') return 'image/webp';
|
|
563
|
+
if (head.slice(4, 12) === 'ftypavif' || head.slice(4, 12) === 'ftypavis') return 'image/avif';
|
|
564
|
+
if (/^\s*(<\?xml|<svg)/i.test(utf8(b.subarray(0, 256)))) return 'image/svg+xml';
|
|
565
|
+
return undefined;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
// Texture
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* `grain` — film grain. In SVG output it's `feTurbulence` (tiny, and
|
|
574
|
+
* browsers render it fast). {@link import('./render.js').renderImage}
|
|
575
|
+
* swaps it for seeded pixel noise composited by sharp, since librsvg's
|
|
576
|
+
* turbulence costs ~200ms at 1200×1200 against ~10ms for the noise.
|
|
577
|
+
*
|
|
578
|
+
* @param {import('./index.js').GrainLayer} layer
|
|
579
|
+
* @param {RenderContext} ctx
|
|
580
|
+
* @returns {Markup}
|
|
581
|
+
*/
|
|
582
|
+
export function grain(layer, ctx) {
|
|
583
|
+
const amount = layer.amount ?? 0.08;
|
|
584
|
+
const size = layer.size ?? 1;
|
|
585
|
+
const id = `${ctx.id}n`;
|
|
586
|
+
const seed = ctx.rng.int(1, 9999);
|
|
587
|
+
const gray = layer.monochrome === false ? '' : '<feColorMatrix type="saturate" values="0"/>';
|
|
588
|
+
// Stretch the turbulence's narrow range to the full 0–1 and make it opaque.
|
|
589
|
+
const contrast = '<feComponentTransfer><feFuncR type="linear" slope="2.2" intercept="-0.6"/><feFuncG type="linear" slope="2.2" intercept="-0.6"/><feFuncB type="linear" slope="2.2" intercept="-0.6"/><feFuncA type="linear" slope="0" intercept="1"/></feComponentTransfer>';
|
|
590
|
+
const defs = `<filter${attrs({ id, x: 0, y: 0, width: '100%', height: '100%', filterUnits: 'userSpaceOnUse', 'color-interpolation-filters': 'sRGB' })}><feTurbulence${attrs({ type: 'fractalNoise', baseFrequency: num(0.9 / size, 3), numOctaves: 2, seed, stitchTiles: 'stitch' })}/>${gray}${contrast}</filter>`;
|
|
591
|
+
let mask;
|
|
592
|
+
let maskDefs = '';
|
|
593
|
+
if (layer.vary === 'edges') {
|
|
594
|
+
// Heavier toward the edges and corners, like spray paint.
|
|
595
|
+
const mid = `${ctx.id}nm`;
|
|
596
|
+
maskDefs = `<radialGradient${attrs({ id: `${mid}g`, gradientUnits: 'userSpaceOnUse', cx: ctx.width / 2, cy: ctx.height / 2, r: Math.hypot(ctx.width, ctx.height) / 2 })}><stop offset="0" stop-color="#fff" stop-opacity="${num(VARY_FLOOR, 3)}"/><stop offset="1" stop-color="#fff"/></radialGradient>`
|
|
597
|
+
+ `<mask${attrs({ id: mid, maskUnits: 'userSpaceOnUse', x: 0, y: 0, width: ctx.width, height: ctx.height })}><rect${attrs({ width: ctx.width, height: ctx.height, fill: `url(#${mid}g)` })}/></mask>`;
|
|
598
|
+
mask = `url(#${mid})`;
|
|
599
|
+
}
|
|
600
|
+
const body = `<rect${attrs({ width: ctx.width, height: ctx.height, filter: `url(#${id})`, opacity: Math.min(1, amount * (layer.vary === 'edges' ? VARY_PEAK : 1)), mask, style: `mix-blend-mode:${layer.blend ?? 'overlay'}` })}/>`;
|
|
601
|
+
return { defs: defs + maskDefs, body: layer.opacity != null && layer.opacity < 1 ? `<g opacity="${num(layer.opacity)}">${body}</g>` : body };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** `grain.vary: 'edges'`: the centre's share of the edge strength, and the edge's multiple of `amount`. */
|
|
605
|
+
export const VARY_FLOOR = 0.35;
|
|
606
|
+
export const VARY_PEAK = 1.8;
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* `pattern` — a line texture: `grid`, `dots`, `lines`, `rings` or `waves`,
|
|
610
|
+
* optionally faded out toward the edges with a mask.
|
|
611
|
+
*
|
|
612
|
+
* Grid lines are drawn as paths across the canvas (not an SVG
|
|
613
|
+
* `<pattern>`, which clips a line on a tile edge to half its width) and
|
|
614
|
+
* centred, so the canvas's middle lands on a line.
|
|
615
|
+
*
|
|
616
|
+
* @param {import('./index.js').PatternLayer} layer
|
|
617
|
+
* @param {RenderContext} ctx
|
|
618
|
+
* @returns {Markup}
|
|
619
|
+
*/
|
|
620
|
+
export function pattern(layer, ctx) {
|
|
621
|
+
const { width: w, height: h, palette: p } = ctx;
|
|
622
|
+
const kind = layer.kind ?? 'grid';
|
|
623
|
+
const size = Math.max(2, len(layer.size, ctx.canvas, kind === 'dots' ? '3.2vmin' : kind === 'rings' ? '7vmin' : '8.33vmin'));
|
|
624
|
+
const stroke = len(layer.stroke, ctx.canvas, kind === 'dots' ? '0.22vmin' : '0.1vmin');
|
|
625
|
+
const col = color(layer.color, p.text);
|
|
626
|
+
const dash = layer.dash === 'dotted' ? `0 ${num(stroke * 4)}` : layer.dash === 'dashed' || layer.dash === true ? `${num(stroke * 4)} ${num(stroke * 5)}`
|
|
627
|
+
: Array.isArray(layer.dash) ? layer.dash.map((d) => num(len(d, ctx.canvas))).join(' ') : undefined;
|
|
628
|
+
const dashAttrs = dash ? { 'stroke-dasharray': dash, 'stroke-linecap': layer.dash === 'dotted' ? 'round' : undefined } : {};
|
|
629
|
+
const cx = (layer.center?.x ?? 0.5) * w;
|
|
630
|
+
const cy = (layer.center?.y ?? 0.5) * h;
|
|
631
|
+
let shapes = '';
|
|
632
|
+
let defs = '';
|
|
633
|
+
|
|
634
|
+
if (kind === 'grid' || kind === 'lines') {
|
|
635
|
+
const angle = kind === 'lines' ? layer.angle ?? 0 : 0;
|
|
636
|
+
const d = [];
|
|
637
|
+
if (angle === 0) {
|
|
638
|
+
// Rows and columns: every `size` from the centre, or `divisions` equal
|
|
639
|
+
// cells (inner lines only); broken wherever they cross a `gaps` region.
|
|
640
|
+
const n = layer.divisions;
|
|
641
|
+
const ys = n ? Array.from({ length: n - 1 }, (_, k) => ((k + 1) * h) / n) : range(cy % size, h, size);
|
|
642
|
+
const xs = kind !== 'grid' ? [] : n ? Array.from({ length: n - 1 }, (_, k) => ((k + 1) * w) / n) : range(cx % size, w, size);
|
|
643
|
+
const gaps = (layer.gaps ?? []).map((g) => regionPx(g, ctx.canvas));
|
|
644
|
+
d.push(gridPath(xs, ys, w, h, gaps));
|
|
645
|
+
shapes = `<path${attrs({ d: d.join(''), fill: 'none', stroke: col, 'stroke-width': stroke, ...dashAttrs })}/>`;
|
|
646
|
+
} else {
|
|
647
|
+
const reach = Math.hypot(w, h) / 2;
|
|
648
|
+
for (let y = -Math.ceil(reach / size) * size; y <= reach; y += size) d.push(`M${num(-reach)} ${num(y)}H${num(reach)}`);
|
|
649
|
+
shapes = `<path${attrs({ d: d.join(''), fill: 'none', stroke: col, 'stroke-width': stroke, ...dashAttrs, transform: `translate(${num(cx)} ${num(cy)}) rotate(${num(angle)})` })}/>`;
|
|
650
|
+
}
|
|
651
|
+
} else if (kind === 'dots') {
|
|
652
|
+
const id = `${ctx.id}p`;
|
|
653
|
+
defs += `<pattern${attrs({ id, patternUnits: 'userSpaceOnUse', x: cx % size - size / 2, y: cy % size - size / 2, width: size, height: size })}><circle${attrs({ cx: size / 2, cy: size / 2, r: stroke, fill: col })}/></pattern>`;
|
|
654
|
+
shapes = `<rect${attrs({ width: w, height: h, fill: `url(#${id})` })}/>`;
|
|
655
|
+
} else if (kind === 'rings') {
|
|
656
|
+
const reach = Math.hypot(Math.max(cx, w - cx), Math.max(cy, h - cy));
|
|
657
|
+
for (let r = size; r <= reach; r += size) shapes += `<circle${attrs({ cx, cy, r })}/>`;
|
|
658
|
+
shapes = `<g${attrs({ fill: 'none', stroke: col, 'stroke-width': stroke, ...dashAttrs })}>${shapes}</g>`;
|
|
659
|
+
} else if (kind === 'waves') {
|
|
660
|
+
const wavelength = len(layer.wavelength, ctx.canvas, size * 3.2);
|
|
661
|
+
const amp = len(layer.amplitude, ctx.canvas, size * 0.22);
|
|
662
|
+
const phase = ctx.rng.range(0, Math.PI * 2);
|
|
663
|
+
const step = wavelength / 24;
|
|
664
|
+
const d = [];
|
|
665
|
+
for (let y = cy % size; y <= h + amp; y += size) {
|
|
666
|
+
const shift = phase + y * 0.004;
|
|
667
|
+
let line = '';
|
|
668
|
+
for (let x = 0; x <= w + step; x += step) {
|
|
669
|
+
line += `${x === 0 ? 'M' : 'L'}${num(x)} ${num(y + Math.sin((x / wavelength) * Math.PI * 2 + shift) * amp)}`;
|
|
670
|
+
}
|
|
671
|
+
d.push(line);
|
|
672
|
+
}
|
|
673
|
+
shapes = `<path${attrs({ d: d.join(''), fill: 'none', stroke: col, 'stroke-width': stroke, 'stroke-linecap': 'round', ...dashAttrs })}/>`;
|
|
674
|
+
} else {
|
|
675
|
+
throw new TypeError(`Unknown pattern kind "${kind}" (grid, dots, lines, rings, waves)`);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const fade = fadeMask(layer.fade ?? 'none', ctx, cx, cy);
|
|
679
|
+
defs += fade.defs;
|
|
680
|
+
let body = `<g${attrs({ opacity: layer.opacity ?? 0.14, mask: fade.ref })}>${shapes}</g>`;
|
|
681
|
+
if (layer.region) {
|
|
682
|
+
const r = regionPx(layer.region, ctx.canvas);
|
|
683
|
+
defs += `<clipPath id="${ctx.id}r"><rect${attrs(r)}/></clipPath>`;
|
|
684
|
+
body = `<g clip-path="url(#${ctx.id}r)">${body}</g>`;
|
|
685
|
+
}
|
|
686
|
+
return { defs, body: layer.blend ? `<g style="mix-blend-mode:${esc(layer.blend)}">${body}</g>` : body };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* `[from, from + step, …]` up to `to`.
|
|
691
|
+
*
|
|
692
|
+
* @param {number} from @param {number} to @param {number} step
|
|
693
|
+
* @returns {number[]}
|
|
694
|
+
*/
|
|
695
|
+
function range(from, to, step) {
|
|
696
|
+
const out = [];
|
|
697
|
+
for (let v = from; v <= to; v += step) out.push(v);
|
|
698
|
+
return out;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* A luminance mask that fades a layer: `'radial'` (strong in the middle),
|
|
703
|
+
* `'edges'` (a softer vignette), or toward a side (`'top'` fades out toward
|
|
704
|
+
* the top, etc.).
|
|
705
|
+
*
|
|
706
|
+
* @param {string} fade - The fade.
|
|
707
|
+
* @param {RenderContext} ctx
|
|
708
|
+
* @param {number} cx - Centre x for radial fades.
|
|
709
|
+
* @param {number} cy - Centre y for radial fades.
|
|
710
|
+
* @returns {{ defs: string, ref: string | undefined }} Mask defs and its `url()`.
|
|
711
|
+
*/
|
|
712
|
+
function fadeMask(fade, ctx, cx, cy) {
|
|
713
|
+
if (!fade || fade === 'none') return { defs: '', ref: undefined };
|
|
714
|
+
const { width: w, height: h } = ctx;
|
|
715
|
+
const id = `${ctx.id}m`;
|
|
716
|
+
const gid = `${ctx.id}mg`;
|
|
717
|
+
let grad;
|
|
718
|
+
if (fade === 'radial' || fade === 'edges') {
|
|
719
|
+
const r = fade === 'radial' ? Math.max(w, h) * 0.62 : Math.hypot(w, h) * 0.6;
|
|
720
|
+
const inner = fade === 'radial' ? 0.05 : 0.45;
|
|
721
|
+
grad = `<radialGradient${attrs({ id: gid, gradientUnits: 'userSpaceOnUse', cx, cy, r })}><stop offset="${inner}" stop-color="#fff"/><stop offset="0.55" stop-color="#fff" stop-opacity="0.6"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>`;
|
|
722
|
+
} else {
|
|
723
|
+
const to = { top: [0, 1, 0, 0], bottom: [0, 0, 0, 1], left: [1, 0, 0, 0], right: [0, 0, 1, 0] }[fade];
|
|
724
|
+
if (!to) throw new TypeError(`Unknown fade "${fade}" (none, radial, edges, top, bottom, left, right)`);
|
|
725
|
+
grad = `<linearGradient${attrs({ id: gid, x1: to[0], y1: to[1], x2: to[2], y2: to[3] })}><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>`;
|
|
726
|
+
}
|
|
727
|
+
return {
|
|
728
|
+
defs: `${grad}<mask${attrs({ id, maskUnits: 'userSpaceOnUse', x: 0, y: 0, width: w, height: h })}><rect${attrs({ width: w, height: h, fill: `url(#${gid})` })}/></mask>`,
|
|
729
|
+
ref: `url(#${id})`,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ---------------------------------------------------------------------------
|
|
734
|
+
// Objects
|
|
735
|
+
// ---------------------------------------------------------------------------
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* `shapes` — circles, squares and pills, scattered (without overlapping
|
|
739
|
+
* where they can help it), in a row, or placed one by one with `items`.
|
|
740
|
+
*
|
|
741
|
+
* @param {import('./index.js').ShapesLayer} layer
|
|
742
|
+
* @param {RenderContext} ctx
|
|
743
|
+
* @returns {Markup}
|
|
744
|
+
*/
|
|
745
|
+
export function shapes(layer, ctx) {
|
|
746
|
+
const { rng, palette: p } = ctx;
|
|
747
|
+
const r = regionPx(layer.region, ctx.canvas);
|
|
748
|
+
const colors = colorList(layer.colors, p.colors);
|
|
749
|
+
const [smin, smax] = (layer.size ?? ['7vmin', '20vmin']).map((v) => len(v, ctx.canvas));
|
|
750
|
+
const kinds = layer.kind === 'mixed' || !layer.kind ? (layer.kind === 'mixed' ? ['circle', 'square', 'pill'] : ['circle']) : [layer.kind];
|
|
751
|
+
const outline = layer.outline ? len(layer.outline === true ? '0.35vmin' : layer.outline, ctx.canvas) : 0;
|
|
752
|
+
|
|
753
|
+
/** @type {{ kind: string, x: number, y: number, size: number, color: string, rotate: number, opacity: number }[]} */
|
|
754
|
+
let items;
|
|
755
|
+
if (layer.items?.length) {
|
|
756
|
+
items = layer.items.map((it, i) => ({
|
|
757
|
+
kind: it.kind ?? kinds[i % kinds.length],
|
|
758
|
+
x: r.x + (it.x ?? 0.5) * r.width,
|
|
759
|
+
y: r.y + (it.y ?? 0.5) * r.height,
|
|
760
|
+
size: len(it.size, ctx.canvas, smax),
|
|
761
|
+
color: it.color ? toHex(it.color) : colors[i % colors.length],
|
|
762
|
+
rotate: it.rotate ?? 0,
|
|
763
|
+
opacity: it.opacity ?? 1,
|
|
764
|
+
}));
|
|
765
|
+
} else {
|
|
766
|
+
const count = layer.count ?? 6;
|
|
767
|
+
const placement = layer.placement ?? 'scatter';
|
|
768
|
+
items = [];
|
|
769
|
+
for (let i = 0; i < count; i++) {
|
|
770
|
+
const size = placement === 'scatter' ? rng.range(smin, smax) : smax;
|
|
771
|
+
let x = r.x + r.width / 2;
|
|
772
|
+
let y = r.y + r.height / 2;
|
|
773
|
+
if (placement === 'row') {
|
|
774
|
+
x = r.x + ((i + 0.5) / count) * r.width;
|
|
775
|
+
} else if (placement === 'column') {
|
|
776
|
+
y = r.y + ((i + 0.5) / count) * r.height;
|
|
777
|
+
} else {
|
|
778
|
+
// Best of a few candidates: the one furthest from everything placed.
|
|
779
|
+
let best = -Infinity;
|
|
780
|
+
for (let t = 0; t < 24; t++) {
|
|
781
|
+
const cxp = r.x + size / 2 + rng.next() * Math.max(0, r.width - size);
|
|
782
|
+
const cyp = r.y + size / 2 + rng.next() * Math.max(0, r.height - size);
|
|
783
|
+
const gap = items.reduce((m, o) => Math.min(m, Math.hypot(o.x - cxp, o.y - cyp) - (o.size + size) / 2), Infinity);
|
|
784
|
+
if (gap > best) { best = gap; x = cxp; y = cyp; }
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const kind = kinds[placement === 'scatter' ? rng.int(0, kinds.length - 1) : i % kinds.length];
|
|
788
|
+
items.push({
|
|
789
|
+
kind,
|
|
790
|
+
x,
|
|
791
|
+
y,
|
|
792
|
+
size,
|
|
793
|
+
color: colors[i % colors.length],
|
|
794
|
+
rotate: layer.rotate === false || kind === 'circle' ? 0 : placement === 'scatter' ? rng.range(-35, 35) : 0,
|
|
795
|
+
opacity: 1,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const radius = layer.radius ?? 0.22;
|
|
801
|
+
const body = items
|
|
802
|
+
.map((it) => {
|
|
803
|
+
const paint = outline ? { fill: 'none', stroke: it.color, 'stroke-width': outline } : { fill: it.color };
|
|
804
|
+
const op = it.opacity < 1 ? it.opacity : undefined;
|
|
805
|
+
if (it.kind === 'circle') return `<circle${attrs({ cx: it.x, cy: it.y, r: it.size / 2, ...paint, opacity: op })}/>`;
|
|
806
|
+
const wd = it.kind === 'pill' ? it.size * 2.2 : it.size;
|
|
807
|
+
const ht = it.size;
|
|
808
|
+
const rx = it.kind === 'pill' ? ht / 2 : ht * radius;
|
|
809
|
+
return `<rect${attrs({ x: it.x - wd / 2, y: it.y - ht / 2, width: wd, height: ht, rx, ...paint, opacity: op, transform: it.rotate ? `rotate(${num(it.rotate)} ${num(it.x)} ${num(it.y)})` : undefined })}/>`;
|
|
810
|
+
})
|
|
811
|
+
.join('');
|
|
812
|
+
return { defs: '', body: wrap(layer, body) };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* `icon` — an Iconify icon by name (`'lucide:feather'`), a raw SVG string or
|
|
817
|
+
* a path, optionally on a rounded tile with a soft shadow.
|
|
818
|
+
*
|
|
819
|
+
* @param {import('./index.js').IconLayer} layer
|
|
820
|
+
* @param {RenderContext} ctx
|
|
821
|
+
* @returns {Markup}
|
|
822
|
+
*/
|
|
823
|
+
export function icon(layer, ctx) {
|
|
824
|
+
const p = ctx.palette;
|
|
825
|
+
// A name that is markup is a raw SVG (so one `icon` option can carry either).
|
|
826
|
+
const name = typeof layer.name === 'string' ? layer.name.trim() : undefined;
|
|
827
|
+
const markup = /** @type {any} */ (layer).markup;
|
|
828
|
+
const svg = layer.svg ?? (name?.startsWith('<') ? name : undefined) ?? (markup ? `<svg viewBox="${(/** @type {any} */ (layer).viewBox ?? [0, 0, 512, 512]).join(' ')}"><g fill="currentColor">${markup}</g></svg>` : undefined);
|
|
829
|
+
const data = svg
|
|
830
|
+
? parseSvgIcon(svg)
|
|
831
|
+
: name
|
|
832
|
+
? resolveIcon(name)
|
|
833
|
+
: layer.path
|
|
834
|
+
? { body: `<path fill="currentColor" d="${esc(layer.path)}"/>`, viewBox: /** @type {[number, number, number, number]} */ (layer.viewBox ?? [0, 0, 24, 24]) }
|
|
835
|
+
: null;
|
|
836
|
+
if (!data) throw new TypeError('An icon layer needs a name, svg or path');
|
|
837
|
+
|
|
838
|
+
const tileOpts = layer.tile === true ? {} : layer.tile || null;
|
|
839
|
+
const size = len(layer.size, ctx.canvas, tileOpts ? '15vmin' : '24vmin');
|
|
840
|
+
const tileSize = tileOpts ? len(tileOpts.size, ctx.canvas, size / 0.5) : size;
|
|
841
|
+
const margin = len(layer.margin, ctx.canvas, /** @type {any} */ (ctx).inset ?? unit(ctx.canvas) * 0.07);
|
|
842
|
+
// 1.1: `x`, `y` place the icon's top-left corner in px (a mark at translate(x y) scale(size / 512)).
|
|
843
|
+
const at = /** @type {any} */ (layer).x != null || /** @type {any} */ (layer).y != null
|
|
844
|
+
? { x: pos(/** @type {any} */ (layer).x ?? 0, 'x', ctx.canvas), y: pos(/** @type {any} */ (layer).y ?? 0, 'y', ctx.canvas) }
|
|
845
|
+
: place({ width: tileSize, height: tileSize, canvas: ctx.canvas, anchor: layer.anchor ?? 'center', margin, position: layer.position, offset: layer.offset });
|
|
846
|
+
|
|
847
|
+
let defs = '';
|
|
848
|
+
let body = '';
|
|
849
|
+
if (tileOpts) {
|
|
850
|
+
const radius = len(tileOpts.radius, ctx.canvas, tileSize * 0.24);
|
|
851
|
+
const fill = color(tileOpts.color, p.surface);
|
|
852
|
+
const shadow = tileOpts.shadow ?? 'soft';
|
|
853
|
+
const fid = `${ctx.id}s`;
|
|
854
|
+
if (shadow === 'soft') {
|
|
855
|
+
const strength = p.mode === 'dark' ? 2.2 : 1;
|
|
856
|
+
defs += `<filter${attrs({ id: fid, x: '-60%', y: '-60%', width: '220%', height: '220%', 'color-interpolation-filters': 'sRGB' })}>`
|
|
857
|
+
+ shadowPass('a', tileSize * 0.12, tileSize * 0.08, 0.12 * strength)
|
|
858
|
+
+ shadowPass('b', tileSize * 0.015, tileSize * 0.012, 0.08 * strength)
|
|
859
|
+
+ '<feMerge><feMergeNode in="a"/><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>';
|
|
860
|
+
}
|
|
861
|
+
const border = tileOpts.border === false ? null : tileOpts.border ?? (p.mode === 'dark' ? mixOklab(fill, p.text, 0.12) : mixOklab(fill, p.text, 0.08));
|
|
862
|
+
body += `<rect${attrs({ x: at.x, y: at.y, width: tileSize, height: tileSize, rx: radius, fill, filter: shadow === 'soft' ? `url(#${fid})` : undefined })}/>`;
|
|
863
|
+
if (border) body += `<rect${attrs({ x: at.x + 0.5, y: at.y + 0.5, width: tileSize - 1, height: tileSize - 1, rx: Math.max(0, radius - 0.5), fill: 'none', stroke: toHex(border), 'stroke-width': 1 })}/>`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const [vx, vy, vw, vh] = data.viewBox;
|
|
867
|
+
const fitScale = Math.min(size / vw, size / vh);
|
|
868
|
+
const iw = vw * fitScale;
|
|
869
|
+
const ih = vh * fitScale;
|
|
870
|
+
let inner = data.body;
|
|
871
|
+
if (layer.strokeWidth != null) inner = inner.replace(/stroke-width="[^"]*"/g, `stroke-width="${num(layer.strokeWidth)}"`);
|
|
872
|
+
// 1.1: `fill` paints the icon with a gradient, tile or preset, laid over its grid.
|
|
873
|
+
const fill = /** @type {any} */ (layer).fill;
|
|
874
|
+
if (fill != null && typeof fill === 'object') {
|
|
875
|
+
const fp = paint(fill, 'currentColor', { id: `${ctx.id}i`, box: { x: vx, y: vy, width: vw, height: vh }, canvas: ctx.canvas });
|
|
876
|
+
defs += fp.defs;
|
|
877
|
+
inner = inner.replace(/currentColor/g, fp.value);
|
|
878
|
+
}
|
|
879
|
+
body += `<svg${attrs({
|
|
880
|
+
x: at.x + (tileSize - iw) / 2,
|
|
881
|
+
y: at.y + (tileSize - ih) / 2,
|
|
882
|
+
width: iw,
|
|
883
|
+
height: ih,
|
|
884
|
+
viewBox: `${num(vx)} ${num(vy)} ${num(vw)} ${num(vh)}`,
|
|
885
|
+
overflow: 'visible',
|
|
886
|
+
color: typeof /** @type {any} */ (layer).fill === 'string' ? toHex(/** @type {any} */ (layer).fill) : color(layer.color, tileOpts ? p.accent : p.text),
|
|
887
|
+
})}>${inner}</svg>`;
|
|
888
|
+
return { defs, body: wrap(layer, body) };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* One drop-shadow pass for a filter (blurred alpha, offset down, tinted).
|
|
893
|
+
*
|
|
894
|
+
* @param {string} name - Result name.
|
|
895
|
+
* @param {number} blur - Blur std deviation.
|
|
896
|
+
* @param {number} dy - Downward offset.
|
|
897
|
+
* @param {number} opacity - Shadow opacity.
|
|
898
|
+
* @returns {string} Filter primitives.
|
|
899
|
+
*/
|
|
900
|
+
function shadowPass(name, blur, dy, opacity) {
|
|
901
|
+
return `<feGaussianBlur in="SourceAlpha" stdDeviation="${num(blur)}"/><feOffset dy="${num(dy)}" result="${name}o"/>`
|
|
902
|
+
+ `<feFlood flood-color="#000" flood-opacity="${num(Math.min(1, opacity), 3)}"/><feComposite in2="${name}o" operator="in" result="${name}"/>`;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* `text` — type set as outlines: wrapped, balanced, truncated with an
|
|
907
|
+
* ellipsis past `maxLines`, optionally shrunk to fit (`minSize`), with an
|
|
908
|
+
* optional label above and a pill or box behind.
|
|
909
|
+
*
|
|
910
|
+
* @param {import('./index.js').TextLayer} layer
|
|
911
|
+
* @param {RenderContext} ctx
|
|
912
|
+
* @returns {Promise<Markup>}
|
|
913
|
+
*/
|
|
914
|
+
export async function text(layer, ctx) {
|
|
915
|
+
// 1.1 options (baseline placement, justification, fitting, paths, live text, auto colour).
|
|
916
|
+
if (wantsType(layer)) return typeset(layer, ctx);
|
|
917
|
+
// An override object in `content` (a recipe's "{title}") means its content.
|
|
918
|
+
const raw = layer.content && typeof layer.content === 'object' ? /** @type {any} */ (layer.content).content : layer.content;
|
|
919
|
+
const content = caseText(String(raw ?? ''), layer.transform);
|
|
920
|
+
if (!content.trim()) return { defs: '', body: '' };
|
|
921
|
+
const hb = await harfbuzz();
|
|
922
|
+
const p = ctx.palette;
|
|
923
|
+
const u = unit(ctx.canvas);
|
|
924
|
+
const margin = len(layer.margin, ctx.canvas, /** @type {any} */ (ctx).inset ?? u * 0.07);
|
|
925
|
+
const anchor = layer.anchor ?? 'bottom-left';
|
|
926
|
+
const align = layer.align ?? anchorAlign(anchor);
|
|
927
|
+
const bg = layer.background ? (typeof layer.background === 'string' ? { color: layer.background } : layer.background) : null;
|
|
928
|
+
const pad = bg ? padding(bg.padding, ctx.canvas, len(layer.size, ctx.canvas, u * 0.06)) : [0, 0];
|
|
929
|
+
const maxWidth = len(layer.maxWidth, ctx.canvas, ctx.width - 2 * margin) - 2 * pad[1];
|
|
930
|
+
|
|
931
|
+
const face = await loadFont(pickFont(ctx.fonts, layer.font ?? 'Inter', layer.weight ?? 600, layer.style));
|
|
932
|
+
const set = layout({
|
|
933
|
+
face, hb,
|
|
934
|
+
text: content,
|
|
935
|
+
size: len(layer.size, ctx.canvas, u * 0.06),
|
|
936
|
+
minSize: layer.minSize != null ? len(layer.minSize, ctx.canvas) : undefined,
|
|
937
|
+
letterSpacing: layer.letterSpacing ?? -0.02,
|
|
938
|
+
maxWidth,
|
|
939
|
+
maxLines: layer.maxLines ?? 3,
|
|
940
|
+
ellipsis: layer.ellipsis,
|
|
941
|
+
balance: layer.balance ?? true,
|
|
942
|
+
});
|
|
943
|
+
const lh = set.size * (layer.lineHeight ?? 1.12);
|
|
944
|
+
const cap = (face.capHeight / face.upem) * set.size;
|
|
945
|
+
const titleH = cap + (set.lines.length - 1) * lh;
|
|
946
|
+
|
|
947
|
+
// The label sits above, in its own face and size.
|
|
948
|
+
let labelOpts = layer.label ? (typeof layer.label === 'string' ? { content: layer.label } : layer.label) : null;
|
|
949
|
+
// A label whose content is itself an options object: its fields win.
|
|
950
|
+
if (labelOpts && labelOpts.content && typeof labelOpts.content === 'object') {
|
|
951
|
+
labelOpts = { ...labelOpts, ...labelOpts.content };
|
|
952
|
+
if (typeof labelOpts.content === 'object') labelOpts.content = undefined;
|
|
953
|
+
}
|
|
954
|
+
let label = null;
|
|
955
|
+
if (labelOpts?.content) {
|
|
956
|
+
const lface = await loadFont(pickFont(ctx.fonts, labelOpts.font ?? layer.font ?? 'Inter', labelOpts.weight ?? 500, labelOpts.style));
|
|
957
|
+
const lsize = len(labelOpts.size, ctx.canvas, Math.max(12, set.size * 0.32, u * 0.02));
|
|
958
|
+
const lset = layout({
|
|
959
|
+
face: lface, hb,
|
|
960
|
+
text: caseText(labelOpts.content, labelOpts.transform ?? 'uppercase'),
|
|
961
|
+
size: lsize,
|
|
962
|
+
letterSpacing: labelOpts.letterSpacing ?? 0.08,
|
|
963
|
+
maxWidth,
|
|
964
|
+
maxLines: labelOpts.maxLines ?? 1,
|
|
965
|
+
});
|
|
966
|
+
const llh = lsize * (labelOpts.lineHeight ?? 1.3);
|
|
967
|
+
const lcap = (lface.capHeight / lface.upem) * lsize;
|
|
968
|
+
label = { face: lface, set: lset, lh: llh, cap: lcap, height: lcap + (lset.lines.length - 1) * llh, gap: len(labelOpts.gap, ctx.canvas, Math.max(lsize * 1.4, set.size * 0.42)), color: color(labelOpts.color, p.muted) };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const blockW = Math.max(set.width, label?.set.width ?? 0);
|
|
972
|
+
const blockH = titleH + (label ? label.height + label.gap : 0);
|
|
973
|
+
const boxW = blockW + 2 * pad[1];
|
|
974
|
+
const boxH = blockH + 2 * pad[0];
|
|
975
|
+
const at = place({ width: boxW, height: boxH, canvas: ctx.canvas, anchor, margin, position: layer.position, offset: layer.offset });
|
|
976
|
+
const left = at.x + pad[1];
|
|
977
|
+
const top = at.y + pad[0];
|
|
978
|
+
|
|
979
|
+
/** @param {number} w @returns {number} */
|
|
980
|
+
const lineX = (w) => (align === 'center' ? left + (blockW - w) / 2 : align === 'right' ? left + blockW - w : left);
|
|
981
|
+
|
|
982
|
+
let body = '';
|
|
983
|
+
if (bg) {
|
|
984
|
+
const r = bg.radius != null ? len(bg.radius, ctx.canvas) : Math.min(boxH, boxW) / 2;
|
|
985
|
+
body += `<rect${attrs({ x: at.x, y: at.y, width: boxW, height: boxH, rx: r, fill: color(bg.color, p.surface), opacity: bg.opacity })}/>`;
|
|
986
|
+
}
|
|
987
|
+
let y = top;
|
|
988
|
+
if (label) {
|
|
989
|
+
const d = label.set.lines.map((l, i) => runPath(label.face, l.run, label.set.size, lineX(l.run.width), y + label.cap + i * label.lh)).join('');
|
|
990
|
+
body += `<path${attrs({ d, fill: label.color })}/>`;
|
|
991
|
+
y += label.height + label.gap;
|
|
992
|
+
}
|
|
993
|
+
const d = set.lines.map((l, i) => runPath(face, l.run, set.size, lineX(l.run.width), y + cap + i * lh)).join('');
|
|
994
|
+
body += `<path${attrs({ d, fill: color(layer.color, p.text) })}/>`;
|
|
995
|
+
// Where the block landed, for later layers ({layout.<id>.bottom}); what was drawn, for lint (the label too).
|
|
996
|
+
recordLayout(ctx, layer.id, { x: left, y: top, width: blockW, height: blockH }, { x: lineX(set.lines[0]?.run.width ?? 0), y: y + cap, baseline: y + cap + (set.lines.length - 1) * lh, size: set.size, lines: set.lines.length });
|
|
997
|
+
const report = /** @type {any} */ (ctx).report;
|
|
998
|
+
if (report) {
|
|
999
|
+
report({ kind: 'text', id: layer.id, content, size: set.size, capHeight: cap, box: { x: at.x, y: at.y, width: boxW, height: boxH } });
|
|
1000
|
+
if (label) report({ kind: 'text', id: layer.id, content: label.set.lines.map((l) => l.text).join('\n'), size: label.set.size, capHeight: label.cap, box: null });
|
|
1001
|
+
}
|
|
1002
|
+
return { defs: '', body: wrap(layer, body) };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Padding as `[vertical, horizontal]` pixels.
|
|
1008
|
+
*
|
|
1009
|
+
* @param {import('./index.js').Length | [import('./index.js').Length, import('./index.js').Length] | undefined} v
|
|
1010
|
+
* @param {{ width: number, height: number }} canvas
|
|
1011
|
+
* @param {number} size - The text size, for the default.
|
|
1012
|
+
* @returns {[number, number]}
|
|
1013
|
+
*/
|
|
1014
|
+
function padding(v, canvas, size) {
|
|
1015
|
+
if (Array.isArray(v)) return [len(v[0], canvas), len(v[1], canvas)];
|
|
1016
|
+
if (v != null) return [len(v, canvas), len(v, canvas)];
|
|
1017
|
+
return [size * 0.5, size * 0.75];
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* `frame` — an inset border (and optional fill), like a mount or a card.
|
|
1022
|
+
*
|
|
1023
|
+
* @param {import('./index.js').FrameLayer} layer
|
|
1024
|
+
* @param {RenderContext} ctx
|
|
1025
|
+
* @returns {Markup}
|
|
1026
|
+
*/
|
|
1027
|
+
export function frame(layer, ctx) {
|
|
1028
|
+
const inset = len(layer.inset, ctx.canvas, '3.5vmin');
|
|
1029
|
+
const stroke = len(layer.stroke, ctx.canvas, '0.15vmin');
|
|
1030
|
+
const half = layer.fill ? 0 : stroke / 2;
|
|
1031
|
+
const body = `<rect${attrs({
|
|
1032
|
+
x: inset + half,
|
|
1033
|
+
y: inset + half,
|
|
1034
|
+
width: Math.max(0, ctx.width - 2 * (inset + half)),
|
|
1035
|
+
height: Math.max(0, ctx.height - 2 * (inset + half)),
|
|
1036
|
+
rx: len(layer.radius, ctx.canvas, '2vmin'),
|
|
1037
|
+
fill: layer.fill ? toHex(layer.fill) : 'none',
|
|
1038
|
+
stroke: stroke > 0 ? color(layer.color, ctx.palette.text) : undefined,
|
|
1039
|
+
'stroke-width': stroke > 0 ? stroke : undefined,
|
|
1040
|
+
})}/>`;
|
|
1041
|
+
return { defs: '', body: wrap({ ...layer, opacity: layer.opacity ?? (layer.fill ? 1 : 0.22) }, body) };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** Every layer type, by name. */
|
|
1045
|
+
/**
|
|
1046
|
+
* `group` (and `repeat`, `defs`, `use`) are drawn by the renderer (they
|
|
1047
|
+
* render nested stacks); this entry registers the type and explains itself
|
|
1048
|
+
* if called directly.
|
|
1049
|
+
*
|
|
1050
|
+
* @returns {never}
|
|
1051
|
+
*/
|
|
1052
|
+
function group() {
|
|
1053
|
+
throw new TypeError('A group layer is drawn by renderSvg()/renderImage(), not on its own');
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
export const LAYERS = Object.freeze({ solid, linear, radial, mesh, stripes, image, grain, pattern, shapes, icon, text, frame, diagram, paper, scribble, ink, dither, contour, waveform, tiles, rule, group, shape, svg, table, barcode, perforation, tear, texture, repeat: group, defs: group, use: group });
|