@arraypress/covers 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/README.md +296 -11
- package/package.json +33 -5
- package/recipes/brand.json +1143 -0
- package/schema/recipe.schema.json +7300 -770
- package/src/animate/index.d.ts +19 -0
- package/src/animate/index.js +116 -0
- package/src/builtins.js +4 -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 +4 -3
- package/src/fonts.d.ts +55 -0
- package/src/fonts.js +246 -53
- 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 +1 -1
- package/src/icons.js +43 -9
- package/src/index.d.ts +205 -19
- package/src/index.js +12 -4
- package/src/inter-data.js +27 -0
- package/src/json-recipe.js +181 -23
- package/src/kit/index.d.ts +20 -0
- package/src/kit/index.js +58 -0
- package/src/layer-options.js +23 -4
- 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 +125 -41
- 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/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 +77 -28
- package/src/print.js +8 -7
- package/src/raster/index.d.ts +24 -0
- package/src/raster/index.js +171 -0
- package/src/recipes.js +4 -3
- 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 +303 -212
- package/src/text.d.ts +29 -0
- package/src/text.js +22 -15
- 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/render.js
CHANGED
|
@@ -4,28 +4,42 @@
|
|
|
4
4
|
* @module render
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
9
|
-
import { join, resolve } from 'node:path';
|
|
10
|
-
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { node } from './platform/index.js';
|
|
11
8
|
import { createRng } from './rng.js';
|
|
12
9
|
import { resolveSize, unit, len } from './geometry.js';
|
|
10
|
+
import { resolveFormat, printMarks, zonesOverlay, safeBox } from './formats/index.js';
|
|
11
|
+
import { animate, isAnimated } from './animate/index.js';
|
|
12
|
+
import { preloadIcons } from './icons.js';
|
|
13
|
+
import { rasterize } from './raster/index.js';
|
|
13
14
|
import { resolvePalette } from './palette.js';
|
|
14
|
-
import {
|
|
15
|
-
import { LAYERS
|
|
15
|
+
import { fontPool } from './fonts.js';
|
|
16
|
+
import { LAYERS } from './layers.js';
|
|
17
|
+
import { wrapped, renderWrapped } from './render/wrap.js';
|
|
18
|
+
import { renderGroup } from './render/group.js';
|
|
19
|
+
import { registerDefs, renderUse } from './render/use.js';
|
|
20
|
+
import { expandRepeat } from './layers/repeat.js';
|
|
21
|
+
import { scaledReport } from './render/report.js';
|
|
22
|
+
import { hasLayoutRef, resolveLayoutRefs } from './type/layout-ref.js';
|
|
23
|
+
import { svgPixels } from './render/tiles.js';
|
|
24
|
+
import { IDENTITY, compose } from './effects/transform.js';
|
|
25
|
+
import { needsPixels } from './effects/index.js';
|
|
26
|
+
import { backdropSampler } from './render/backdrop.js';
|
|
27
|
+
import { noise, applyGrain } from './render/grain.js';
|
|
16
28
|
import { extractPalette } from './extract.js';
|
|
17
29
|
import { recipes, applyOverrides } from './recipes.js';
|
|
18
30
|
import { defineRecipe } from './json-recipe.js';
|
|
19
31
|
import { attrs, esc, num } from './svg.js';
|
|
20
32
|
|
|
21
|
-
|
|
22
|
-
export
|
|
33
|
+
export { VERSION } from './version.js';
|
|
34
|
+
export { coverKey } from './render/key.js';
|
|
35
|
+
import { coverKey } from './render/key.js';
|
|
36
|
+
export { safeBox } from './formats/index.js';
|
|
23
37
|
|
|
24
38
|
/** Covers built by {@link cover}, so rendering one doesn't resolve it twice. */
|
|
25
39
|
const built = new WeakSet();
|
|
26
40
|
|
|
27
41
|
/** Options that belong to the cover itself, never to a recipe layer. */
|
|
28
|
-
const COVER_KEYS = new Set(['width', 'height', 'aspect', 'seed', 'palette', 'recipe', 'layers', 'fonts', 'alt', 'radius', 'output']);
|
|
42
|
+
const COVER_KEYS = new Set(['width', 'height', 'aspect', 'seed', 'palette', 'recipe', 'layers', 'fonts', 'alt', 'radius', 'output', 'format', 'dpi', 'bleed', 'safe', 'cropMarks', 'cutLine', 'cut', 'textRender', 'overlay', 'loop']);
|
|
29
43
|
|
|
30
44
|
/**
|
|
31
45
|
* Build a cover: resolve its size and palette and expand its recipe into a
|
|
@@ -43,13 +57,19 @@ const COVER_KEYS = new Set(['width', 'height', 'aspect', 'seed', 'palette', 'rec
|
|
|
43
57
|
export function cover(options) {
|
|
44
58
|
if (built.has(/** @type {object} */ (options))) return /** @type {import('./index.js').Cover} */ (options);
|
|
45
59
|
const o = /** @type {import('./index.js').CoverOptions} */ (options ?? {});
|
|
46
|
-
const
|
|
60
|
+
const fmt = resolveFormat(o);
|
|
61
|
+
const { width, height } = fmt ?? resolveSize(o);
|
|
62
|
+
const print = fmt?.print;
|
|
47
63
|
const palette = resolvePalette(o.palette);
|
|
48
64
|
const seed = String(o.seed ?? (typeof o.title === 'string' ? o.title : 'cover'));
|
|
49
65
|
|
|
50
66
|
let layers;
|
|
51
67
|
/** @type {{ lossless?: boolean } | undefined} */
|
|
52
68
|
let output;
|
|
69
|
+
/** @type {string[] | undefined} */
|
|
70
|
+
let pages;
|
|
71
|
+
/** @type {import('./index.js').FontSource[] | undefined} */
|
|
72
|
+
let recipeFonts;
|
|
53
73
|
if (o.recipe) {
|
|
54
74
|
const recipe = typeof o.recipe === 'function'
|
|
55
75
|
? o.recipe
|
|
@@ -57,10 +77,17 @@ export function cover(options) {
|
|
|
57
77
|
? defineRecipe(o.recipe)
|
|
58
78
|
: Object.hasOwn(recipes, o.recipe) ? recipes[/** @type {keyof typeof recipes} */ (o.recipe)] : undefined;
|
|
59
79
|
if (!recipe) throw new TypeError(`Unknown recipe "${o.recipe}". Recipes: ${Object.keys(recipes).join(', ')}`);
|
|
80
|
+
/** @type {Record<string, unknown>} */
|
|
60
81
|
const ctx = { width, height, palette, rng: createRng(`${seed}:recipe`), u: unit({ width, height }), seed };
|
|
82
|
+
if (print) Object.assign(ctx, { bleed: print.bleed, safe: print.safe, trimWidth: print.trimWidth, trimHeight: print.trimHeight, mm: print.mm, dpi: print.dpi });
|
|
83
|
+
const sb = safeBox({ width, height, print, zones: fmt?.zones }) ?? { x: 0, y: 0, width, height };
|
|
84
|
+
Object.assign(ctx, { safeX: sb.x, safeY: sb.y, safeWidth: sb.width, safeHeight: sb.height });
|
|
85
|
+
if (fmt?.format) ctx.format = fmt.format;
|
|
61
86
|
const recipeOptions = Object.fromEntries(Object.entries(o).filter(([k]) => !COVER_KEYS.has(k)));
|
|
62
|
-
layers = [...applyOverrides(recipe(/** @type {any} */ (recipeOptions), ctx), recipeOptions), ...(o.layers ?? [])];
|
|
87
|
+
layers = [...applyOverrides(recipe(/** @type {any} */ (recipeOptions), /** @type {any} */ (ctx)), recipeOptions), ...(o.layers ?? [])];
|
|
63
88
|
output = /** @type {any} */ (recipe).json?.output;
|
|
89
|
+
recipeFonts = /** @type {any} */ (recipe).json?.fonts;
|
|
90
|
+
pages = /** @type {any} */ (recipe).json?.pages;
|
|
64
91
|
} else {
|
|
65
92
|
layers = o.layers ?? [{ type: 'mesh' }, { type: 'grain' }];
|
|
66
93
|
}
|
|
@@ -77,56 +104,182 @@ export function cover(options) {
|
|
|
77
104
|
seed,
|
|
78
105
|
palette,
|
|
79
106
|
layers,
|
|
80
|
-
|
|
107
|
+
// The caller's fonts first, then any the recipe declares.
|
|
108
|
+
fonts: recipeFonts?.length ? [...(o.fonts ?? []), ...recipeFonts] : o.fonts ?? [],
|
|
81
109
|
alt: o.alt ?? (typeof o.title === 'string' ? o.title : undefined),
|
|
82
110
|
radius: o.radius,
|
|
83
111
|
// A recipe's preferred encoding (e.g. dither: lossless). renderImage options win.
|
|
84
112
|
output: o.output ?? output,
|
|
85
113
|
};
|
|
114
|
+
// 1.1 fields, set only when used (a 1.0 cover's data is unchanged).
|
|
115
|
+
if (print) /** @type {any} */ (result).print = print;
|
|
116
|
+
if (fmt?.format) /** @type {any} */ (result).format = fmt.format;
|
|
117
|
+
if (o.page) /** @type {any} */ (result).page = o.page;
|
|
118
|
+
if (pages) /** @type {any} */ (result).pages = pages;
|
|
119
|
+
if (o.textRender) /** @type {any} */ (result).textRender = o.textRender;
|
|
120
|
+
if (fmt?.zones) /** @type {any} */ (result).zones = fmt.zones;
|
|
121
|
+
if (o.overlay) /** @type {any} */ (result).overlay = o.overlay;
|
|
122
|
+
if (o.loop != null) /** @type {any} */ (result).loop = o.loop;
|
|
86
123
|
built.add(result);
|
|
87
124
|
return result;
|
|
88
125
|
}
|
|
89
126
|
|
|
90
127
|
/**
|
|
91
|
-
*
|
|
128
|
+
* @typedef {object} Scope
|
|
129
|
+
* @property {import('./index.js').Cover} c
|
|
130
|
+
* @property {any} sharp
|
|
131
|
+
* @property {import('./index.js').FontSource[]} fonts
|
|
132
|
+
* @property {string} prefix - Id prefix ('' at the top).
|
|
133
|
+
* @property {string} seedPrefix - Stream key prefix ('' at the top).
|
|
134
|
+
* @property {number} width
|
|
135
|
+
* @property {number} height
|
|
136
|
+
* @property {((box: { x: number, y: number, width: number, height: number }) => Promise<string | undefined>) | undefined} backdrop
|
|
137
|
+
* @property {((entry: any) => void) | undefined} report
|
|
138
|
+
* @property {object} doc - Identity of the document being rendered.
|
|
139
|
+
* @property {number} [t] - The time being drawn (seconds), for animated layers.
|
|
140
|
+
*/
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Render a stack of layers, each with its own seeded stream.
|
|
92
144
|
*
|
|
93
145
|
* A layer's stream is keyed by its `seed`, else its `id`, else its type
|
|
94
146
|
* and how many layers of that type came before it, so adding a layer
|
|
95
|
-
* leaves every other layer's random choices alone.
|
|
147
|
+
* leaves every other layer's random choices alone. Nested stacks (groups,
|
|
148
|
+
* masks) key their streams under their parent's.
|
|
96
149
|
*
|
|
97
|
-
* @param {import('./index.js').
|
|
98
|
-
* @param {
|
|
150
|
+
* @param {import('./index.js').Layer[]} layers
|
|
151
|
+
* @param {Scope} scope
|
|
99
152
|
* @returns {Promise<{ layer: import('./index.js').Layer, defs: string, body: string, pad?: number, finest?: number, ctx: import('./layers.js').RenderContext }[]>}
|
|
100
153
|
*/
|
|
101
|
-
async function
|
|
102
|
-
const
|
|
154
|
+
async function renderStack(layers, scope) {
|
|
155
|
+
const { c } = scope;
|
|
103
156
|
/** @type {Record<string, number>} */
|
|
104
157
|
const seen = {};
|
|
158
|
+
/** @type {any[]} */
|
|
105
159
|
const out = [];
|
|
106
|
-
|
|
107
|
-
|
|
160
|
+
const top = !scope.prefix;
|
|
161
|
+
const zones = top ? /** @type {any} */ (c).zones : undefined;
|
|
162
|
+
for (let i = 0; i < layers.length; i++) {
|
|
163
|
+
let layer = /** @type {any} */ (layers[i]);
|
|
164
|
+
if (isAnimated(layer)) layer = animate(layer, scope.t ?? 0, /** @type {any} */ (c).loop);
|
|
165
|
+
// `{layout.<id>.bottom}` and the like: where an earlier text block landed.
|
|
166
|
+
if (scope.layout && hasLayoutRef(layer)) layer = resolveLayoutRefs(layer, scope.layout);
|
|
167
|
+
// A format's safe zone: anchored text and icons are laid out inside it.
|
|
168
|
+
if (zones && (layer.type === 'text' || layer.type === 'icon') && layer.x == null && layer.y == null && layer.position == null && layer.area == null && layer.path == null) {
|
|
169
|
+
const [zx, zy, zw, zh] = zones.safe;
|
|
170
|
+
layer = { ...layer, area: { x: zx / c.width, y: zy / c.height, width: zw / c.width, height: zh / c.height } };
|
|
171
|
+
}
|
|
172
|
+
if (!layer || !Object.hasOwn(LAYERS, layer.type)) throw new TypeError(`Unknown layer type "${layer?.type}". Types: ${Object.keys(LAYERS).join(', ')}`);
|
|
108
173
|
const n = (seen[layer.type] = (seen[layer.type] ?? -1) + 1);
|
|
109
174
|
const key = layer.seed ?? layer.id ?? `${layer.type}#${n}`;
|
|
110
|
-
const ctx = {
|
|
111
|
-
width:
|
|
112
|
-
height:
|
|
113
|
-
canvas: { width:
|
|
175
|
+
const ctx = /** @type {any} */ ({
|
|
176
|
+
width: scope.width,
|
|
177
|
+
height: scope.height,
|
|
178
|
+
canvas: { width: scope.width, height: scope.height },
|
|
114
179
|
palette: c.palette,
|
|
115
|
-
rng: createRng(`${c.seed}:${key}`),
|
|
116
|
-
id: `l${i}`,
|
|
117
|
-
fonts,
|
|
118
|
-
sharp,
|
|
180
|
+
rng: createRng(scope.seedPrefix ? `${c.seed}:${scope.seedPrefix}/${key}` : `${c.seed}:${key}`),
|
|
181
|
+
id: top ? `l${i}` : `${scope.prefix}_${i}`,
|
|
182
|
+
fonts: scope.fonts,
|
|
183
|
+
sharp: scope.sharp,
|
|
184
|
+
});
|
|
185
|
+
// 1.1 context, present only when it applies (1.0 layers never read it).
|
|
186
|
+
const print = /** @type {any} */ (c).print;
|
|
187
|
+
if (print && top) ctx.inset = print.bleed + print.safe;
|
|
188
|
+
if (print) ctx.cut = { x: print.bleed, y: print.bleed, width: print.trimWidth, height: print.trimHeight, cut: print.cut, mm: print.mm };
|
|
189
|
+
if (scope.raster) ctx.raster = true;
|
|
190
|
+
if (scope.reach) ctx.noteReach = (/** @type {number} */ px) => { /** @type {any} */ (scope.reach).px = Math.max(/** @type {any} */ (scope.reach).px, px); };
|
|
191
|
+
if (/** @type {any} */ (c).textRender) ctx.textRender = /** @type {any} */ (c).textRender;
|
|
192
|
+
if (scope.report) ctx.report = scope.report;
|
|
193
|
+
ctx.scale = scope.scale ?? 1;
|
|
194
|
+
ctx.res = scope.res ?? 1;
|
|
195
|
+
ctx.matrix = scope.matrix ?? IDENTITY;
|
|
196
|
+
ctx.doc = scope.doc;
|
|
197
|
+
ctx.defs = scope.defs;
|
|
198
|
+
if (scope.layout) ctx.layout = scope.layout;
|
|
199
|
+
ctx.backdrop = top && scope.backdrop ? (/** @type {any} */ box) => /** @type {any} */ (scope.backdrop)(box, out) : undefined;
|
|
200
|
+
// A nested stack (group, mask) inherits the scale of the context it's
|
|
201
|
+
// called on (a transformed layer's copy carries its own), times its grid's.
|
|
202
|
+
ctx.stack = function stack(/** @type {any[]} */ list, /** @type {any} */ sub) {
|
|
203
|
+
const self = this ?? ctx;
|
|
204
|
+
const k = sub.scale ?? 1;
|
|
205
|
+
return renderStack(list, {
|
|
206
|
+
...scope,
|
|
207
|
+
report: scaledReport(self.report, k),
|
|
208
|
+
scale: (self.scale ?? 1) * k,
|
|
209
|
+
res: (self.res ?? 1) * (sub.res ?? k),
|
|
210
|
+
matrix: compose(self.matrix ?? IDENTITY, sub.matrix ?? IDENTITY),
|
|
211
|
+
prefix: `${ctx.id}${sub.tag}`,
|
|
212
|
+
seedPrefix: `${scope.seedPrefix ? `${scope.seedPrefix}/` : ''}${key}${sub.tag}`,
|
|
213
|
+
width: sub.width ?? self.width,
|
|
214
|
+
height: sub.height ?? self.height,
|
|
215
|
+
});
|
|
119
216
|
};
|
|
120
|
-
|
|
121
|
-
const resolved = layer.type === 'mesh' && /** @type {any} */ (layer).from && !/** @type {any} */ (layer).colors
|
|
122
|
-
? { ...layer, colors: await extractPalette(/** @type {any} */ (layer).from, { count: 4 }) }
|
|
123
|
-
: layer;
|
|
124
|
-
const markup = await LAYERS[layer.type](/** @type {any} */ (resolved), ctx);
|
|
217
|
+
const markup = wrapped(layer) ? await renderWrapped(/** @type {any} */ (layer), ctx, scope, renderOne) : await renderOne(/** @type {any} */ (layer), ctx);
|
|
125
218
|
out.push({ layer, ...markup, ctx });
|
|
126
219
|
}
|
|
127
220
|
return out;
|
|
128
221
|
}
|
|
129
222
|
|
|
223
|
+
/**
|
|
224
|
+
* One layer's own markup.
|
|
225
|
+
*
|
|
226
|
+
* @param {any} layer
|
|
227
|
+
* @param {any} ctx
|
|
228
|
+
* @returns {Promise<{ defs: string, body: string, pad?: number, finest?: number }>}
|
|
229
|
+
*/
|
|
230
|
+
async function renderOne(layer, ctx) {
|
|
231
|
+
if (layer.type === 'group') return renderGroup(layer, ctx);
|
|
232
|
+
if (layer.type === 'repeat') return renderGroup(expandRepeat(layer, ctx), ctx);
|
|
233
|
+
if (layer.type === 'defs') return registerDefs(layer, ctx);
|
|
234
|
+
if (layer.type === 'use') return renderUse(layer, ctx);
|
|
235
|
+
// A mesh can take its colours from a photo (`from`), extracted at render.
|
|
236
|
+
const resolved = layer.type === 'mesh' && layer.from && !layer.colors
|
|
237
|
+
? { ...layer, colors: await extractPalette(layer.from, { count: 4 }) }
|
|
238
|
+
: layer;
|
|
239
|
+
return LAYERS[/** @type {keyof typeof LAYERS} */ (layer.type)](/** @type {any} */ (resolved), ctx);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Render every layer of a cover.
|
|
244
|
+
*
|
|
245
|
+
* @param {import('./index.js').Cover} c
|
|
246
|
+
* @param {any} sharp
|
|
247
|
+
* @param {{ report?: (entry: any) => void, t?: number, raster?: boolean, reach?: { px: number } }} [extra] - `raster`: drawn for `renderImage` (librsvg), so effects may take its faster forms; `reach` collects how far filters reach, output px.
|
|
248
|
+
*/
|
|
249
|
+
async function renderLayers(c, sharp, extra = {}) {
|
|
250
|
+
// Icon sets named anywhere in the stack load first (from node_modules, or configure({ icons }) elsewhere).
|
|
251
|
+
const names = new Set();
|
|
252
|
+
const scan = (/** @type {unknown} */ v) => {
|
|
253
|
+
if (typeof v === 'string') { if (/^[a-z0-9-]+:[a-z0-9-]+$/i.test(v)) names.add(v); } else if (Array.isArray(v)) v.forEach(scan); else if (v && typeof v === 'object' && !(v instanceof Uint8Array)) Object.values(v).forEach(scan);
|
|
254
|
+
};
|
|
255
|
+
scan(c.layers);
|
|
256
|
+
if (names.size) await preloadIcons(names);
|
|
257
|
+
const auto = c.layers.some(usesAuto);
|
|
258
|
+
const t = /** @type {any} */ (extra).t;
|
|
259
|
+
return renderStack(c.layers, {
|
|
260
|
+
c,
|
|
261
|
+
sharp,
|
|
262
|
+
fonts: fontPool(c.fonts),
|
|
263
|
+
prefix: '',
|
|
264
|
+
seedPrefix: '',
|
|
265
|
+
width: c.width,
|
|
266
|
+
height: c.height,
|
|
267
|
+
backdrop: auto ? backdropSampler(c, sharp, documentFor, loadSharp) : undefined,
|
|
268
|
+
report: extra.report,
|
|
269
|
+
doc: {},
|
|
270
|
+
layout: new Map(),
|
|
271
|
+
defs: new Map(),
|
|
272
|
+
t,
|
|
273
|
+
raster: extra.raster,
|
|
274
|
+
reach: extra.reach,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** @param {any} l @returns {boolean} Whether a layer (or its children) picks its colour from the backdrop. */
|
|
279
|
+
function usesAuto(l) {
|
|
280
|
+
return Boolean(l) && (l.color === 'auto' || (Array.isArray(l.layers) && l.layers.some(usesAuto)));
|
|
281
|
+
}
|
|
282
|
+
|
|
130
283
|
/**
|
|
131
284
|
* Assemble an SVG document from rendered layers.
|
|
132
285
|
*
|
|
@@ -143,6 +296,18 @@ function documentFor(c, parts) {
|
|
|
143
296
|
body = `<g clip-path="url(#r)">${body}</g>`;
|
|
144
297
|
}
|
|
145
298
|
const title = c.alt ? `<title>${esc(c.alt)}</title>` : '';
|
|
299
|
+
const zones = /** @type {any} */ (c).overlay ? /** @type {any} */ (c).zones ?? (/** @type {any} */ (c).print ? { safe: Object.values(/** @type {any} */ (safeBox(/** @type {any} */ (c))) ?? {}) } : undefined) : undefined;
|
|
300
|
+
if (zones) body += zonesOverlay(zones, w, h);
|
|
301
|
+
const print = /** @type {import('./formats.js').PrintInfo | undefined} */ (/** @type {any} */ (c).print);
|
|
302
|
+
if (print && (print.cropMarks || print.cutLine)) {
|
|
303
|
+
const { marks, cut } = printMarks(print, w, h);
|
|
304
|
+
const s = print.slug;
|
|
305
|
+
const W = w + 2 * s;
|
|
306
|
+
const H = h + 2 * s;
|
|
307
|
+
defs += `<clipPath id="bleed"><rect${attrs({ width: w, height: h })}/></clipPath>`;
|
|
308
|
+
const art = `<g transform="translate(${num(s)} ${num(s)})"><g clip-path="url(#bleed)">${body}</g>${cut}</g>`;
|
|
309
|
+
return `<svg xmlns="http://www.w3.org/2000/svg"${attrs({ width: W, height: H, viewBox: `0 0 ${num(W)} ${num(H)}`, role: c.alt ? 'img' : undefined })}>${title}${defs ? `<defs>${defs}</defs>` : ''}${s ? `<rect width="${num(W)}" height="${num(H)}" fill="#fff"/>` : ''}${art}${marks}</svg>`;
|
|
310
|
+
}
|
|
146
311
|
return `<svg xmlns="http://www.w3.org/2000/svg"${attrs({ width: w, height: h, viewBox: `0 0 ${num(w)} ${num(h)}`, role: c.alt ? 'img' : undefined })}>${title}${defs ? `<defs>${defs}</defs>` : ''}${body}</svg>`;
|
|
147
312
|
}
|
|
148
313
|
|
|
@@ -152,15 +317,80 @@ function documentFor(c, parts) {
|
|
|
152
317
|
* The same cover always gives byte-identical output.
|
|
153
318
|
*
|
|
154
319
|
* @param {import('./index.js').CoverOptions | import('./index.js').Cover} input - The cover.
|
|
320
|
+
* @param {{ t?: number }} [options] - `t`: the time to draw, in seconds, for layers with keyframes (static covers ignore it).
|
|
155
321
|
* @returns {Promise<string>} The SVG document.
|
|
156
322
|
*
|
|
157
323
|
* @example
|
|
158
324
|
* const svg = await renderSvg({ recipe: 'aurora', seed: 'hello', palette: 'paper' });
|
|
159
325
|
*/
|
|
160
|
-
export async function renderSvg(input) {
|
|
326
|
+
export async function renderSvg(input, options = {}) {
|
|
327
|
+
const c = cover(input);
|
|
328
|
+
const sharp = c.layers.some(hasImage) ? await loadSharp(false) : undefined;
|
|
329
|
+
return documentFor(c, await renderLayers(c, sharp, { t: options.t }));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Whether a layer (or one inside it) needs pixels to draw: an image, or an
|
|
334
|
+
* effect computed on pixels (`dither`, a round `border`).
|
|
335
|
+
*
|
|
336
|
+
* @param {any} l
|
|
337
|
+
* @returns {boolean}
|
|
338
|
+
*/
|
|
339
|
+
function hasImage(l) {
|
|
340
|
+
if (!l) return false;
|
|
341
|
+
if (l.type === 'image') return true;
|
|
342
|
+
const fx = l.effects == null ? [] : [].concat(l.effects);
|
|
343
|
+
if (fx.some((/** @type {any} */ e) => needsPixels(e) || (e?.mask?.layers ?? []).some(hasImage) || (e?.map?.layers ?? []).some(hasImage))) return true;
|
|
344
|
+
return ['layers', 'mask', 'knockout', 'clip'].some((k) => Array.isArray(l[k]) && l[k].some(hasImage))
|
|
345
|
+
|| (l.type === 'repeat' && hasImage(l.layer));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Render each page of a two-sided piece (a business card, a ticket, a
|
|
350
|
+
* flyer): one SVG per page the recipe declares (`"pages": ["front", "back"]`),
|
|
351
|
+
* or the pages you ask for. A layer is on a page by its `when`
|
|
352
|
+
* (`{ "page": "back" }`); layers without one are on every page.
|
|
353
|
+
*
|
|
354
|
+
* @param {import('./index.js').CoverOptions} input
|
|
355
|
+
* @param {{ pages?: string[] }} [options]
|
|
356
|
+
* @returns {Promise<Record<string, string>>} SVG by page name.
|
|
357
|
+
*
|
|
358
|
+
* @example
|
|
359
|
+
* const { front, back } = await renderPages({ recipe: card, format: 'business-card', name: 'Mira Okafor' });
|
|
360
|
+
*/
|
|
361
|
+
export async function renderPages(input, options = {}) {
|
|
362
|
+
const probe = cover({ ...input, page: undefined });
|
|
363
|
+
const pages = options.pages ?? /** @type {any} */ (probe).pages ?? ['front', 'back'];
|
|
364
|
+
/** @type {Record<string, string>} */
|
|
365
|
+
const out = {};
|
|
366
|
+
for (const page of pages) out[page] = await renderSvg({ ...input, page });
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Render a cover and note what was drawn (text sizes and boxes), for `lint`.
|
|
372
|
+
*
|
|
373
|
+
* @param {import('./index.js').CoverOptions | import('./index.js').Cover} input
|
|
374
|
+
* @returns {Promise<{ svg: string, drawn: any[] }>}
|
|
375
|
+
*/
|
|
376
|
+
export async function renderReport(input) {
|
|
161
377
|
const c = cover(input);
|
|
162
|
-
const sharp = c.layers.some(
|
|
163
|
-
|
|
378
|
+
const sharp = c.layers.some(hasImage) ? await loadSharp(false) : undefined;
|
|
379
|
+
/** @type {any[]} */
|
|
380
|
+
const drawn = [];
|
|
381
|
+
const parts = await renderLayers(c, sharp, { report: (e) => drawn.push(e) });
|
|
382
|
+
return { svg: documentFor(c, parts), drawn };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Render a cover to an image in the browser (a canvas; no sharp).
|
|
387
|
+
*
|
|
388
|
+
* @param {import('./index.js').CoverOptions | import('./index.js').Cover} input
|
|
389
|
+
* @param {{ format?: 'png' | 'jpeg' | 'webp', quality?: number, scale?: number, width?: number }} [options]
|
|
390
|
+
* @returns {Promise<Blob>}
|
|
391
|
+
*/
|
|
392
|
+
export async function renderBlob(input, options = {}) {
|
|
393
|
+
return rasterize(await renderSvg(input), options);
|
|
164
394
|
}
|
|
165
395
|
|
|
166
396
|
/** @type {Promise<any> | undefined} */
|
|
@@ -173,7 +403,9 @@ let sharpPromise;
|
|
|
173
403
|
* @returns {Promise<any>} The module's default export, or undefined.
|
|
174
404
|
*/
|
|
175
405
|
async function loadSharp(required) {
|
|
176
|
-
|
|
406
|
+
// A computed specifier: bundlers for browsers and Workers leave the optional peer alone.
|
|
407
|
+
const name = 'sharp';
|
|
408
|
+
sharpPromise ??= import(/* @vite-ignore */ name).then((m) => m.default ?? m, () => undefined);
|
|
177
409
|
const sharp = await sharpPromise;
|
|
178
410
|
if (!sharp && required) throw new Error('renderImage() needs sharp: npm install sharp');
|
|
179
411
|
return sharp;
|
|
@@ -206,18 +438,22 @@ export async function renderImage(input, options = {}) {
|
|
|
206
438
|
if (!Object.hasOwn(MIME, format)) throw new TypeError(`Unknown format "${format}" (webp, png, avif, jpeg)`);
|
|
207
439
|
|
|
208
440
|
let cacheFile;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
441
|
+
const fsp = node('fs/promises');
|
|
442
|
+
const path = node('path');
|
|
443
|
+
if (options.cache && fsp && path) {
|
|
444
|
+
const dir = options.cache === true ? path.join(/** @type {any} */ (globalThis).process.cwd(), 'node_modules', '.cache', 'covers') : path.resolve(String(options.cache));
|
|
445
|
+
cacheFile = path.join(dir, `${await coverKey(c, options)}.${format}`);
|
|
212
446
|
try {
|
|
213
|
-
return await readFile(cacheFile);
|
|
447
|
+
return await fsp.readFile(cacheFile);
|
|
214
448
|
} catch {
|
|
215
449
|
// Not cached yet.
|
|
216
450
|
}
|
|
217
451
|
}
|
|
218
452
|
|
|
219
453
|
const sharp = await loadSharp(true);
|
|
220
|
-
|
|
454
|
+
/** How far any effect's filter reaches, output px (tiles overlap by it). */
|
|
455
|
+
const reach = { px: 0 };
|
|
456
|
+
const parts = await renderLayers(c, sharp, { raster: true, reach });
|
|
221
457
|
const { width, height } = c;
|
|
222
458
|
|
|
223
459
|
// Split the stack: [svg] grain [svg] …, with each heavily blurred layer
|
|
@@ -238,7 +474,9 @@ export async function renderImage(input, options = {}) {
|
|
|
238
474
|
if (run.length) steps.push({ svg: run });
|
|
239
475
|
|
|
240
476
|
const raw = { width, height, channels: /** @type {const} */ (4) };
|
|
241
|
-
|
|
477
|
+
// Every step's document carries every layer's defs (a pattern or a used stack defined in another step).
|
|
478
|
+
const allDefs = parts.map((p) => p.defs).join('');
|
|
479
|
+
const svgOf = (/** @type {typeof parts} */ p) => Buffer.from(documentFor(/** @type {any} */ ({ ...c, radius: undefined, alt: undefined, print: undefined, overlay: undefined }), [{ defs: allDefs, body: '' }, ...p.map((x) => ({ ...x, defs: '' }))]));
|
|
242
480
|
/** @type {Buffer | undefined} */
|
|
243
481
|
let pixels;
|
|
244
482
|
for (const step of steps) {
|
|
@@ -263,9 +501,10 @@ export async function renderImage(input, options = {}) {
|
|
|
263
501
|
: layerPx;
|
|
264
502
|
} else if (step.svg) {
|
|
265
503
|
const svg = svgOf(step.svg);
|
|
504
|
+
const layerPx = await svgPixels(sharp, svg.toString(), width, height, reach.px);
|
|
266
505
|
pixels = pixels
|
|
267
|
-
? await sharp(pixels, { raw }).composite([{ input:
|
|
268
|
-
:
|
|
506
|
+
? await sharp(pixels, { raw }).composite([{ input: layerPx, raw }]).raw().toBuffer()
|
|
507
|
+
: Buffer.from(layerPx.buffer, layerPx.byteOffset, layerPx.byteLength);
|
|
269
508
|
} else if (step.grain) {
|
|
270
509
|
pixels ??= Buffer.alloc(width * height * 4);
|
|
271
510
|
const g = /** @type {import('./index.js').GrainLayer} */ (step.grain.layer);
|
|
@@ -278,6 +517,20 @@ export async function renderImage(input, options = {}) {
|
|
|
278
517
|
image = image.composite([{ input: Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="${width}" height="${height}" rx="${num(rx)}"/></svg>`), blend: 'dest-in' }]);
|
|
279
518
|
}
|
|
280
519
|
|
|
520
|
+
// Preview overlay and print furniture (crop marks, die line) go on last.
|
|
521
|
+
const cx = /** @type {any} */ (c);
|
|
522
|
+
if (cx.overlay && (cx.zones || cx.print)) {
|
|
523
|
+
const z = cx.zones ?? { safe: Object.values(/** @type {any} */ (safeBox(cx))) };
|
|
524
|
+
image = sharp(await image.png().toBuffer()).composite([{ input: Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${zonesOverlay(z, width, height)}</svg>`) }]);
|
|
525
|
+
}
|
|
526
|
+
if (cx.print && (cx.print.cropMarks || cx.print.cutLine)) {
|
|
527
|
+
const s = cx.print.slug;
|
|
528
|
+
const { marks, cut } = printMarks(cx.print, width, height);
|
|
529
|
+
image = sharp(await image.png().toBuffer()).extend({ top: s, bottom: s, left: s, right: s, background: '#ffffff' })
|
|
530
|
+
.composite([{ input: Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${width + 2 * s}" height="${height + 2 * s}"><g transform="translate(${s} ${s})">${cut}</g>${marks}</svg>`) }]);
|
|
531
|
+
}
|
|
532
|
+
if (cx.print) image = image.withMetadata({ density: cx.print.dpi });
|
|
533
|
+
|
|
281
534
|
// Grain is the first thing a lossy encoder throws away. Measured on an
|
|
282
535
|
// aurora cover: WebP q82 keeps ~40% of the grain (11 KB), q90 ~60% (41 KB),
|
|
283
536
|
// q94 ~78% (125 KB). 90 is the default; raise it for crisper grain.
|
|
@@ -289,174 +542,12 @@ export async function renderImage(input, options = {}) {
|
|
|
289
542
|
else image = image.png({ compressionLevel: options.effort ?? 6 });
|
|
290
543
|
const out = await image.toBuffer();
|
|
291
544
|
|
|
292
|
-
if (cacheFile) {
|
|
293
|
-
await mkdir(join(cacheFile, '..'), { recursive: true });
|
|
294
|
-
const tmp = `${cacheFile}.${process.pid}.tmp`;
|
|
295
|
-
await writeFile(tmp, out);
|
|
296
|
-
await rename(tmp, cacheFile);
|
|
545
|
+
if (cacheFile && fsp && path) {
|
|
546
|
+
await fsp.mkdir(path.join(cacheFile, '..'), { recursive: true });
|
|
547
|
+
const tmp = `${cacheFile}.${/** @type {any} */ (globalThis).process.pid}.tmp`;
|
|
548
|
+
await fsp.writeFile(tmp, out);
|
|
549
|
+
await fsp.rename(tmp, cacheFile);
|
|
297
550
|
}
|
|
298
551
|
return out;
|
|
299
552
|
}
|
|
300
553
|
|
|
301
|
-
/**
|
|
302
|
-
* Seeded film-grain values, one per pixel (three with `monochrome: false`),
|
|
303
|
-
* at `size` px per grain (upscaled smoothly). Each value is the mean of two
|
|
304
|
-
* uniform draws, a triangular distribution: fewer harsh extremes than flat
|
|
305
|
-
* noise, closer to film.
|
|
306
|
-
*
|
|
307
|
-
* @param {any} sharp - The sharp module.
|
|
308
|
-
* @param {number} width @param {number} height
|
|
309
|
-
* @param {import('./index.js').GrainLayer} layer
|
|
310
|
-
* @param {import('./rng.js').Rng} rng - The layer's stream.
|
|
311
|
-
* @returns {Promise<{ data: Buffer, channels: 1 | 3 }>} Full-size noise.
|
|
312
|
-
*/
|
|
313
|
-
async function noise(sharp, width, height, layer, rng) {
|
|
314
|
-
const size = Math.max(1, layer.size ?? 1);
|
|
315
|
-
const channels = layer.monochrome === false ? 3 : 1;
|
|
316
|
-
const nw = Math.ceil(width / size);
|
|
317
|
-
const nh = Math.ceil(height / size);
|
|
318
|
-
const data = Buffer.alloc(nw * nh * channels);
|
|
319
|
-
// xorshift32 seeded from the layer's stream: one 32-bit draw gives two
|
|
320
|
-
// values, so 1.4M pixels cost a few milliseconds.
|
|
321
|
-
let x = (rng.next() * 4294967296) >>> 0 || 1;
|
|
322
|
-
for (let i = 0; i < data.length; i++) {
|
|
323
|
-
x ^= x << 13; x ^= x >>> 17; x ^= x << 5;
|
|
324
|
-
data[i] = ((x & 255) + ((x >>> 8) & 255)) >>> 1;
|
|
325
|
-
}
|
|
326
|
-
if (nw === width && nh === height) return { data, channels };
|
|
327
|
-
const out = await sharp(data, { raw: { width: nw, height: nh, channels } })
|
|
328
|
-
.resize(width, height, { kernel: 'cubic', fit: 'fill' })
|
|
329
|
-
.raw()
|
|
330
|
-
.toBuffer();
|
|
331
|
-
return { data: out, channels };
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/** Separable blend modes (W3C Compositing Level 1), channel values 0–1. */
|
|
335
|
-
const BLENDS = /** @type {Record<string, (b: number, s: number) => number>} */ ({
|
|
336
|
-
normal: (_b, s) => s,
|
|
337
|
-
multiply: (b, s) => b * s,
|
|
338
|
-
screen: (b, s) => b + s - b * s,
|
|
339
|
-
overlay: (b, s) => (b <= 0.5 ? 2 * b * s : 1 - 2 * (1 - b) * (1 - s)),
|
|
340
|
-
'soft-light': (b, s) => {
|
|
341
|
-
if (s <= 0.5) return b - (1 - 2 * s) * b * (1 - b);
|
|
342
|
-
const d = b <= 0.25 ? ((16 * b - 12) * b + 4) * b : Math.sqrt(b);
|
|
343
|
-
return b + (2 * s - 1) * (d - b);
|
|
344
|
-
},
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* A 256×256 lookup (backdrop × noise → result) for one blend and strength:
|
|
349
|
-
* cheaper than the blend maths per pixel.
|
|
350
|
-
*
|
|
351
|
-
* @param {(b: number, s: number) => number} blend
|
|
352
|
-
* @param {number} amount
|
|
353
|
-
* @returns {Uint8Array}
|
|
354
|
-
*/
|
|
355
|
-
function grainTable(blend, amount) {
|
|
356
|
-
const table = new Uint8Array(65536);
|
|
357
|
-
for (let b = 0; b < 256; b++) {
|
|
358
|
-
for (let s = 0; s < 256; s++) {
|
|
359
|
-
const cb = b / 255;
|
|
360
|
-
table[(b << 8) | s] = Math.round((cb + amount * (blend(cb, s / 255) - cb)) * 255);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
return table;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Blend grain into RGBA pixels in place, at the layer's `amount`.
|
|
368
|
-
* Done in JS rather than with sharp's `composite` blend modes, whose
|
|
369
|
-
* overlay with a partly transparent layer flattens the noise to almost
|
|
370
|
-
* nothing (measured σ 0.5 against the expected 5.9 on mid grey).
|
|
371
|
-
*
|
|
372
|
-
* @param {Buffer} pixels - RGBA pixels (modified).
|
|
373
|
-
* @param {{ data: Buffer, channels: 1 | 3, width: number, height: number }} grain - Noise from {@link noise}, and the canvas size.
|
|
374
|
-
* @param {import('./index.js').GrainLayer} layer - The grain layer.
|
|
375
|
-
* @returns {void}
|
|
376
|
-
*/
|
|
377
|
-
function applyGrain(pixels, grain, layer) {
|
|
378
|
-
const amount = Math.max(0, Math.min(1, (layer.amount ?? 0.08) * (layer.opacity ?? 1)));
|
|
379
|
-
const blend = BLENDS[layer.blend ?? 'overlay'];
|
|
380
|
-
if (!blend) throw new TypeError(`Unknown grain blend "${layer.blend}" (${Object.keys(BLENDS).join(', ')})`);
|
|
381
|
-
const table = grainTable(blend, amount);
|
|
382
|
-
const { data, channels } = grain;
|
|
383
|
-
if (layer.vary === 'edges') {
|
|
384
|
-
// Heavier toward the edges: per-pixel strength from the centre out, as in the SVG mask.
|
|
385
|
-
const { width, height } = grain;
|
|
386
|
-
const peak = Math.min(1, amount * VARY_PEAK);
|
|
387
|
-
const tables = Array.from({ length: 17 }, (_, k) => grainTable(blend, peak * (VARY_FLOOR + (1 - VARY_FLOOR) * (k / 16))));
|
|
388
|
-
const half = Math.hypot(width, height) / 2;
|
|
389
|
-
for (let y = 0, i = 0, j = 0; y < height; y++) {
|
|
390
|
-
for (let x = 0; x < width; x++, i += 4, j += channels) {
|
|
391
|
-
const t = tables[Math.min(16, Math.round((Math.hypot(x - width / 2, y - height / 2) / half) * 16))];
|
|
392
|
-
pixels[i] = t[(pixels[i] << 8) | data[j]];
|
|
393
|
-
pixels[i + 1] = t[(pixels[i + 1] << 8) | data[j + (channels === 3 ? 1 : 0)]];
|
|
394
|
-
pixels[i + 2] = t[(pixels[i + 2] << 8) | data[j + (channels === 3 ? 2 : 0)]];
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
for (let i = 0, j = 0; i < pixels.length; i += 4, j += channels) {
|
|
400
|
-
pixels[i] = table[(pixels[i] << 8) | data[j]];
|
|
401
|
-
pixels[i + 1] = table[(pixels[i + 1] << 8) | data[j + (channels === 3 ? 1 : 0)]];
|
|
402
|
-
pixels[i + 2] = table[(pixels[i + 2] << 8) | data[j + (channels === 3 ? 2 : 0)]];
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* A stable cache key for a cover and its output options: a SHA-256 of the
|
|
408
|
-
* resolved cover (font and image bytes hashed by content), the output
|
|
409
|
-
* format and quality, and this package's version.
|
|
410
|
-
*
|
|
411
|
-
* @param {import('./index.js').CoverOptions | import('./index.js').Cover} input - The cover.
|
|
412
|
-
* @param {import('./index.js').RenderImageOptions} [options]
|
|
413
|
-
* @returns {Promise<string>} 40 hex characters.
|
|
414
|
-
*
|
|
415
|
-
* @example
|
|
416
|
-
* await coverKey({ recipe: 'aurora', seed: 'a' }, { format: 'webp' }); // 'f3a9…'
|
|
417
|
-
*/
|
|
418
|
-
export async function coverKey(input, options = {}) {
|
|
419
|
-
const c = cover(input);
|
|
420
|
-
const h = createHash('sha256');
|
|
421
|
-
h.update(`covers@${VERSION}\n${options.format ?? 'webp'}:${options.quality ?? ''}:${options.effort ?? ''}:${options.lossless ?? c.output?.lossless ?? ''}\n`);
|
|
422
|
-
// Files a layer reads (an image's src, a mesh's photo, a waveform's audio) are hashed by content.
|
|
423
|
-
const file = async (/** @type {any} */ v) => (typeof v === 'string' && !v.startsWith('data:') ? digest(await readFile(v)) : v instanceof Uint8Array ? digest(v) : v);
|
|
424
|
-
const layers = await Promise.all(c.layers.map(async (l) => {
|
|
425
|
-
const x = /** @type {any} */ (l);
|
|
426
|
-
if (l.type === 'image') return { ...l, src: await file(x.src) };
|
|
427
|
-
if (l.type === 'mesh' && x.from) return { ...l, from: await file(x.from) };
|
|
428
|
-
if (l.type === 'waveform' && x.audio) return { ...l, audio: await file(x.audio) };
|
|
429
|
-
return l;
|
|
430
|
-
}));
|
|
431
|
-
h.update(stable({
|
|
432
|
-
width: c.width,
|
|
433
|
-
height: c.height,
|
|
434
|
-
seed: c.seed,
|
|
435
|
-
palette: c.palette,
|
|
436
|
-
layers,
|
|
437
|
-
radius: c.radius,
|
|
438
|
-
fonts: c.fonts.map((f) => ({ ...f, data: digest(f.data) })),
|
|
439
|
-
}));
|
|
440
|
-
return h.digest('hex').slice(0, 40);
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/** @param {string | Uint8Array | ArrayBuffer} data @returns {string} */
|
|
444
|
-
function digest(data) {
|
|
445
|
-
return createHash('sha1').update(data instanceof ArrayBuffer ? new Uint8Array(data) : data).digest('hex');
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/**
|
|
449
|
-
* JSON with sorted keys and bytes hashed, for hashing.
|
|
450
|
-
*
|
|
451
|
-
* @param {unknown} v
|
|
452
|
-
* @returns {string}
|
|
453
|
-
*/
|
|
454
|
-
function stable(v) {
|
|
455
|
-
if (v instanceof Uint8Array || v instanceof ArrayBuffer) return JSON.stringify(digest(v));
|
|
456
|
-
if (Array.isArray(v)) return `[${v.map(stable).join(',')}]`;
|
|
457
|
-
if (v && typeof v === 'object') {
|
|
458
|
-
return `{${Object.keys(v).sort().filter((k) => /** @type {any} */ (v)[k] !== undefined).map((k) => `${JSON.stringify(k)}:${stable(/** @type {any} */ (v)[k])}`).join(',')}}`;
|
|
459
|
-
}
|
|
460
|
-
if (typeof v === 'function') return JSON.stringify(String(v));
|
|
461
|
-
return JSON.stringify(v) ?? 'null';
|
|
462
|
-
}
|