@arraypress/covers 1.0.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -0
- package/README.md +18 -524
- package/package.json +34 -5
- package/recipes/brand.json +1143 -0
- package/recipes/flyers/afterglow-motion.json +540 -0
- package/recipes/flyers/afterglow.json +70 -0
- package/recipes/flyers/arcfield-mirror-motion.json +397 -0
- package/recipes/flyers/arcfield-mirror.json +364 -0
- package/recipes/flyers/bauhaus-oktav.json +319 -0
- package/recipes/flyers/bauhaus-ritmo.json +735 -0
- package/recipes/flyers/bauhaus-volta.json +394 -0
- package/recipes/flyers/bloomfall.json +99 -0
- package/recipes/flyers/drag-solenne-motion.json +444 -0
- package/recipes/flyers/drag-solenne.json +426 -0
- package/recipes/flyers/fennick-patches.json +874 -0
- package/recipes/flyers/ferro-pass.json +398 -0
- package/recipes/flyers/ferrova.json +812 -0
- package/recipes/flyers/grid-rest-rhythm.json +125 -0
- package/recipes/flyers/groovy-honeycomb.json +816 -0
- package/recipes/flyers/groovy-marigold.json +479 -0
- package/recipes/flyers/groovy-peachfuzz.json +974 -0
- package/recipes/flyers/halden-orbit-motion.json +381 -0
- package/recipes/flyers/halden-orbit.json +356 -0
- package/recipes/flyers/halvard.json +120 -0
- package/recipes/flyers/kairo-weekender.json +696 -0
- package/recipes/flyers/linger-motion.json +518 -0
- package/recipes/flyers/linger.json +67 -0
- package/recipes/flyers/little-orchard.json +105 -0
- package/recipes/flyers/lofi-hexmoth.json +912 -0
- package/recipes/flyers/lofi-lowbit-motion.json +888 -0
- package/recipes/flyers/lofi-lowbit.json +880 -0
- package/recipes/flyers/lofi-nullpoint.json +3013 -0
- package/recipes/flyers/lumio-stack-motion.json +648 -0
- package/recipes/flyers/lumio-stack.json +438 -0
- package/recipes/flyers/magazine-cover.json +168 -0
- package/recipes/flyers/magazine-extended.json +269 -0
- package/recipes/flyers/magazine-knockout.json +40 -0
- package/recipes/flyers/northfold-varsity.json +618 -0
- package/recipes/flyers/open-studio.json +69 -0
- package/recipes/flyers/orrin-merch.json +715 -0
- package/recipes/flyers/oxyde.json +113 -0
- package/recipes/flyers/quiet-fields.json +610 -0
- package/recipes/flyers/scrapbook-halcyon.json +948 -0
- package/recipes/flyers/solenne-ticket.json +730 -0
- package/recipes/flyers/solvane-motion.json +576 -0
- package/recipes/flyers/solvane.json +98 -0
- package/recipes/flyers/subduct.json +99 -0
- package/recipes/flyers/sunvale-lineup.json +584 -0
- package/recipes/flyers/tidewell.json +91 -0
- package/recipes/flyers/tour-kairo.json +535 -0
- package/recipes/flyers/velora-stickers.json +885 -0
- package/recipes/flyers/wheatpaste-black.json +313 -0
- package/recipes/flyers/wheatpaste-mark.json +198 -0
- package/recipes/flyers/wheatpaste-torn.json +114 -0
- package/recipes/flyers/wheatpaste-wall.json +984 -0
- package/recipes/flyers/wheatpaste-words.json +233 -0
- package/recipes/release/aura.json +137 -0
- package/recipes/release/chrome.json +433 -0
- package/recipes/release/collage.json +372 -0
- package/recipes/release/duotone.json +113 -0
- package/recipes/release/film-still.json +102 -0
- package/recipes/release/headline.json +70 -0
- package/recipes/release/instant.json +170 -0
- package/recipes/release/label.json +364 -0
- package/recipes/release/liquid-glass.json +301 -0
- package/recipes/release/odd-tile-dark.json +688 -0
- package/recipes/release/odd-tile-dense.json +1056 -0
- package/recipes/release/odd-tile-shift.json +541 -0
- package/recipes/release/odd-tile-split.json +571 -0
- package/recipes/release/odd-tile.json +633 -0
- package/recipes/release/off-edge.json +96 -0
- package/recipes/release/plinth.json +794 -0
- package/recipes/release/riso.json +108 -0
- package/recipes/release/series/fluid-label.json +561 -0
- package/recipes/release/series/fluid-pixel.json +334 -0
- package/recipes/release/series/hairline.json +305 -0
- package/recipes/release/series/halo.json +282 -0
- package/recipes/release/series/mark-crop.json +135 -0
- package/recipes/release/series/mark-window.json +150 -0
- package/recipes/release/series/photo-label.json +475 -0
- package/recipes/release/series/smear-field.json +345 -0
- package/recipes/release/series/smear.json +202 -0
- package/recipes/release/series/spin.json +172 -0
- package/recipes/release/series/split-ghost.json +230 -0
- package/recipes/release/series/zoom.json +168 -0
- package/recipes/release/small-print.json +114 -0
- package/recipes/release/stack.json +156 -0
- package/recipes/release/swiss.json +117 -0
- package/schema/recipe.schema.json +7624 -827
- package/src/animate/index.d.ts +9 -0
- package/src/animate/index.js +11 -0
- package/src/animate/keyframes.d.ts +21 -0
- package/src/animate/keyframes.js +119 -0
- package/src/animate/marquee.d.ts +30 -0
- package/src/animate/marquee.js +93 -0
- package/src/animate/morph.d.ts +8 -0
- package/src/animate/morph.js +99 -0
- package/src/animate/motion.d.ts +59 -0
- package/src/animate/motion.js +63 -0
- package/src/animate/sheet.d.ts +19 -0
- package/src/animate/sheet.js +61 -0
- package/src/animate/timing.d.ts +28 -0
- package/src/animate/timing.js +146 -0
- package/src/animate/tracks.d.ts +18 -0
- package/src/animate/tracks.js +139 -0
- package/src/builtins.js +4 -0
- package/src/diagram.js +4 -3
- package/src/extract.js +6 -5
- package/src/fonts.d.ts +55 -0
- package/src/fonts.js +245 -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 +5 -35
- package/src/grid.js +1 -1
- package/src/handmade.js +9 -7
- package/src/icons.js +42 -9
- package/src/index.d.ts +232 -55
- package/src/index.js +14 -6
- package/src/inter-data.js +27 -0
- package/src/json-recipe.js +201 -27
- package/src/kit/index.d.ts +20 -0
- package/src/kit/index.js +58 -0
- package/src/layer-options.js +43 -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 +108 -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 +61 -0
- package/src/layers.js +129 -61
- 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/paint/index.d.ts +49 -0
- package/src/paint/index.js +66 -0
- package/src/paint/presets.d.ts +10 -0
- package/src/paint/presets.js +47 -0
- package/src/paint/tile.d.ts +7 -0
- package/src/paint/tile.js +55 -0
- package/src/palette.js +1 -1
- package/src/platform/files.d.ts +6 -0
- package/src/platform/files.js +41 -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 +15 -0
- package/src/platform/load.d.ts +6 -0
- package/src/platform/load.js +71 -0
- package/src/platform/no-harfbuzz.d.ts +2 -0
- package/src/platform/no-harfbuzz.js +11 -0
- package/src/platform/settings.d.ts +28 -0
- package/src/platform/settings.js +51 -0
- package/src/print.js +16 -13
- package/src/recipes.js +5 -4
- package/src/render/backdrop.d.ts +6 -0
- package/src/render/backdrop.js +55 -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 +90 -0
- package/src/render/report.d.ts +5 -0
- package/src/render/report.js +19 -0
- package/src/render/transform.d.ts +36 -0
- package/src/render/transform.js +139 -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 +144 -0
- package/src/render.js +343 -215
- package/src/text.d.ts +29 -0
- package/src/text.js +23 -16
- 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 +7 -0
- package/src/type/pixel.js +85 -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 +363 -0
- package/src/type/woff2.d.ts +6 -0
- package/src/type/woff2.js +301 -0
- package/src/version.js +4 -0
- package/src/color.js +0 -246
- package/src/png.js +0 -77
- package/src/rng.js +0 -95
- package/src/svg.js +0 -69
package/src/render.js
CHANGED
|
@@ -4,28 +4,42 @@
|
|
|
4
4
|
* @module render
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
7
|
+
import { node } from './platform/index.js';
|
|
8
|
+
import { createRng } from '@logomarks/image-effects/rng';
|
|
9
|
+
import { resolveSize, unit } from './geometry.js';
|
|
10
|
+
import { len } from '@logomarks/image-effects/length';
|
|
11
|
+
import { resolveFormat, printMarks, zonesOverlay, safeBox } from './formats/index.js';
|
|
12
|
+
import { animate, isAnimated, renderMotion, createSheet, nameSheet } from './animate/index.js';
|
|
13
|
+
import { preloadIcons } from './icons.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 { rasterize, svgPixels, webpImages } from '@logomarks/image-effects/raster';
|
|
24
|
+
import { IDENTITY, compose } from './render/transform.js';
|
|
25
|
+
import { needsPixels } from '@logomarks/image-effects';
|
|
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
|
-
import { attrs, esc, num } from '
|
|
31
|
+
import { attrs, esc, num } from '@logomarks/image-effects/svg';
|
|
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', 'animate', 'poster']);
|
|
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,198 @@ 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;
|
|
123
|
+
if (o.animate === false) /** @type {any} */ (result).animate = false;
|
|
124
|
+
if (o.poster != null) /** @type {any} */ (result).poster = o.poster;
|
|
86
125
|
built.add(result);
|
|
87
126
|
return result;
|
|
88
127
|
}
|
|
89
128
|
|
|
90
129
|
/**
|
|
91
|
-
*
|
|
130
|
+
* @typedef {object} Scope
|
|
131
|
+
* @property {import('./index.js').Cover} c
|
|
132
|
+
* @property {any} sharp
|
|
133
|
+
* @property {import('./index.js').FontSource[]} fonts
|
|
134
|
+
* @property {string} prefix - Id prefix ('' at the top).
|
|
135
|
+
* @property {string} seedPrefix - Stream key prefix ('' at the top).
|
|
136
|
+
* @property {number} width
|
|
137
|
+
* @property {number} height
|
|
138
|
+
* @property {((box: { x: number, y: number, width: number, height: number }) => Promise<string | undefined>) | undefined} backdrop
|
|
139
|
+
* @property {((entry: any) => void) | undefined} report
|
|
140
|
+
* @property {object} doc - Identity of the document being rendered.
|
|
141
|
+
* @property {number} [t] - The time being drawn (seconds), for animated layers: the frame stills show.
|
|
142
|
+
* @property {boolean} [live] - Motion plays in the SVG (CSS keyframes in `sheet`).
|
|
143
|
+
* @property {import('./animate/sheet.js').Sheet} [sheet]
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Render a stack of layers, each with its own seeded stream.
|
|
92
148
|
*
|
|
93
149
|
* A layer's stream is keyed by its `seed`, else its `id`, else its type
|
|
94
150
|
* and how many layers of that type came before it, so adding a layer
|
|
95
|
-
* leaves every other layer's random choices alone.
|
|
151
|
+
* leaves every other layer's random choices alone. Nested stacks (groups,
|
|
152
|
+
* masks) key their streams under their parent's.
|
|
96
153
|
*
|
|
97
|
-
* @param {import('./index.js').
|
|
98
|
-
* @param {
|
|
154
|
+
* @param {import('./index.js').Layer[]} layers
|
|
155
|
+
* @param {Scope} scope
|
|
99
156
|
* @returns {Promise<{ layer: import('./index.js').Layer, defs: string, body: string, pad?: number, finest?: number, ctx: import('./layers.js').RenderContext }[]>}
|
|
100
157
|
*/
|
|
101
|
-
async function
|
|
102
|
-
const
|
|
158
|
+
async function renderStack(layers, scope) {
|
|
159
|
+
const { c } = scope;
|
|
103
160
|
/** @type {Record<string, number>} */
|
|
104
161
|
const seen = {};
|
|
162
|
+
/** @type {any[]} */
|
|
105
163
|
const out = [];
|
|
106
|
-
|
|
107
|
-
|
|
164
|
+
const top = !scope.prefix;
|
|
165
|
+
const zones = top ? /** @type {any} */ (c).zones : undefined;
|
|
166
|
+
for (let i = 0; i < layers.length; i++) {
|
|
167
|
+
let layer = /** @type {any} */ (layers[i]);
|
|
168
|
+
if (isAnimated(layer)) layer = animate(layer, scope.t ?? 0, /** @type {any} */ (c).loop);
|
|
169
|
+
// `{layout.<id>.bottom}` and the like: where an earlier text block landed.
|
|
170
|
+
if (scope.layout && hasLayoutRef(layer)) layer = resolveLayoutRefs(layer, scope.layout);
|
|
171
|
+
// A format's safe zone: anchored text and icons are laid out inside it.
|
|
172
|
+
if (zones && (layer.type === 'text' || layer.type === 'icon') && layer.x == null && layer.y == null && layer.position == null && layer.area == null && layer.path == null) {
|
|
173
|
+
const [zx, zy, zw, zh] = zones.safe;
|
|
174
|
+
layer = { ...layer, area: { x: zx / c.width, y: zy / c.height, width: zw / c.width, height: zh / c.height } };
|
|
175
|
+
}
|
|
176
|
+
if (!layer || !Object.hasOwn(LAYERS, layer.type)) throw new TypeError(`Unknown layer type "${layer?.type}". Types: ${Object.keys(LAYERS).join(', ')}`);
|
|
108
177
|
const n = (seen[layer.type] = (seen[layer.type] ?? -1) + 1);
|
|
109
178
|
const key = layer.seed ?? layer.id ?? `${layer.type}#${n}`;
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
179
|
+
const stream = scope.seedPrefix ? `${c.seed}:${scope.seedPrefix}/${key}` : `${c.seed}:${key}`;
|
|
180
|
+
const ctx = /** @type {any} */ ({
|
|
181
|
+
width: scope.width,
|
|
182
|
+
height: scope.height,
|
|
183
|
+
canvas: { width: scope.width, height: scope.height },
|
|
114
184
|
palette: c.palette,
|
|
115
|
-
rng: createRng(
|
|
116
|
-
id: `l${i}`,
|
|
117
|
-
fonts,
|
|
118
|
-
sharp,
|
|
185
|
+
rng: createRng(stream),
|
|
186
|
+
id: top ? `l${i}` : `${scope.prefix}_${i}`,
|
|
187
|
+
fonts: scope.fonts,
|
|
188
|
+
sharp: scope.sharp,
|
|
189
|
+
});
|
|
190
|
+
// 1.1 context, present only when it applies (1.0 layers never read it).
|
|
191
|
+
const print = /** @type {any} */ (c).print;
|
|
192
|
+
if (print && top) ctx.inset = print.bleed + print.safe;
|
|
193
|
+
if (print) ctx.cut = { x: print.bleed, y: print.bleed, width: print.trimWidth, height: print.trimHeight, cut: print.cut, mm: print.mm };
|
|
194
|
+
if (scope.raster) ctx.raster = true;
|
|
195
|
+
if (scope.reach) ctx.noteReach = (/** @type {number} */ px) => { /** @type {any} */ (scope.reach).px = Math.max(/** @type {any} */ (scope.reach).px, px); };
|
|
196
|
+
if (/** @type {any} */ (c).textRender) ctx.textRender = /** @type {any} */ (c).textRender;
|
|
197
|
+
if (scope.report) ctx.report = scope.report;
|
|
198
|
+
ctx.scale = scope.scale ?? 1;
|
|
199
|
+
ctx.res = scope.res ?? 1;
|
|
200
|
+
ctx.matrix = scope.matrix ?? IDENTITY;
|
|
201
|
+
ctx.doc = scope.doc;
|
|
202
|
+
ctx.defs = scope.defs;
|
|
203
|
+
if (scope.layout) ctx.layout = scope.layout;
|
|
204
|
+
ctx.backdrop = top && scope.backdrop ? (/** @type {any} */ box) => /** @type {any} */ (scope.backdrop)(box, out) : undefined;
|
|
205
|
+
// A nested stack (group, mask) inherits the scale of the context it's
|
|
206
|
+
// called on (a transformed layer's copy carries its own), times its grid's.
|
|
207
|
+
ctx.stack = function stack(/** @type {any[]} */ list, /** @type {any} */ sub) {
|
|
208
|
+
const self = this ?? ctx;
|
|
209
|
+
const k = sub.scale ?? 1;
|
|
210
|
+
return renderStack(list, {
|
|
211
|
+
...scope,
|
|
212
|
+
report: scaledReport(self.report, k),
|
|
213
|
+
scale: (self.scale ?? 1) * k,
|
|
214
|
+
res: (self.res ?? 1) * (sub.res ?? k),
|
|
215
|
+
matrix: compose(self.matrix ?? IDENTITY, sub.matrix ?? IDENTITY),
|
|
216
|
+
prefix: `${ctx.id}${sub.tag}`,
|
|
217
|
+
seedPrefix: `${scope.seedPrefix ? `${scope.seedPrefix}/` : ''}${key}${sub.tag}`,
|
|
218
|
+
width: sub.width ?? self.width,
|
|
219
|
+
height: sub.height ?? self.height,
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
// The layer drawn with its stream from the start (motion draws it more than once, and its frames with `extra` context).
|
|
223
|
+
const draw = (/** @type {any} */ l, /** @type {any} */ extra) => {
|
|
224
|
+
ctx.rng = createRng(stream);
|
|
225
|
+
const x = extra ? { ...ctx, ...extra } : ctx;
|
|
226
|
+
return wrapped(l) ? renderWrapped(l, x, scope, renderOne) : renderOne(l, x);
|
|
119
227
|
};
|
|
120
|
-
//
|
|
121
|
-
const
|
|
122
|
-
?
|
|
123
|
-
: layer;
|
|
124
|
-
const markup = await LAYERS[layer.type](/** @type {any} */ (resolved), ctx);
|
|
228
|
+
// Motion is drawn when it plays, or at a time a still asks for; otherwise the layer is at rest.
|
|
229
|
+
const markup = layer.motion != null && (scope.live || scope.t != null)
|
|
230
|
+
? await renderMotion(layer, ctx, { live: Boolean(scope.live), t: scope.t, sheet: /** @type {any} */ (scope.sheet), draw })
|
|
231
|
+
: await draw(layer);
|
|
125
232
|
out.push({ layer, ...markup, ctx });
|
|
126
233
|
}
|
|
127
234
|
return out;
|
|
128
235
|
}
|
|
129
236
|
|
|
237
|
+
/**
|
|
238
|
+
* One layer's own markup.
|
|
239
|
+
*
|
|
240
|
+
* @param {any} layer
|
|
241
|
+
* @param {any} ctx
|
|
242
|
+
* @returns {Promise<{ defs: string, body: string, pad?: number, finest?: number }>}
|
|
243
|
+
*/
|
|
244
|
+
async function renderOne(layer, ctx) {
|
|
245
|
+
if (layer.type === 'group') return renderGroup(layer, ctx);
|
|
246
|
+
if (layer.type === 'repeat') return renderGroup(expandRepeat(layer, ctx), ctx);
|
|
247
|
+
if (layer.type === 'defs') return registerDefs(layer, ctx);
|
|
248
|
+
if (layer.type === 'use') return renderUse(layer, ctx);
|
|
249
|
+
// A mesh can take its colours from a photo (`from`), extracted at render.
|
|
250
|
+
const resolved = layer.type === 'mesh' && layer.from && !layer.colors
|
|
251
|
+
? { ...layer, colors: await extractPalette(layer.from, { count: 4 }) }
|
|
252
|
+
: layer;
|
|
253
|
+
return LAYERS[/** @type {keyof typeof LAYERS} */ (layer.type)](/** @type {any} */ (resolved), ctx);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Render every layer of a cover.
|
|
258
|
+
*
|
|
259
|
+
* @param {import('./index.js').Cover} c
|
|
260
|
+
* @param {any} sharp
|
|
261
|
+
* @param {{ report?: (entry: any) => void, t?: number, live?: boolean, sheet?: import('./animate/sheet.js').Sheet, raster?: boolean, reach?: { px: number } }} [extra] - `t`: the time to draw (default the cover's `poster`); `live`: motion plays (into `sheet`); `raster`: drawn for `renderImage` (librsvg), so effects may take its faster forms; `reach` collects how far filters reach, output px.
|
|
262
|
+
*/
|
|
263
|
+
async function renderLayers(c, sharp, extra = {}) {
|
|
264
|
+
// Icon sets named anywhere in the stack load first (from node_modules, or configure({ icons }) elsewhere).
|
|
265
|
+
const names = new Set();
|
|
266
|
+
const scan = (/** @type {unknown} */ v) => {
|
|
267
|
+
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);
|
|
268
|
+
};
|
|
269
|
+
scan(c.layers);
|
|
270
|
+
if (names.size) await preloadIcons(names);
|
|
271
|
+
const auto = c.layers.some(usesAuto);
|
|
272
|
+
const t = extra.t ?? /** @type {any} */ (c).poster;
|
|
273
|
+
return renderStack(c.layers, {
|
|
274
|
+
c,
|
|
275
|
+
sharp,
|
|
276
|
+
fonts: fontPool(c.fonts),
|
|
277
|
+
prefix: '',
|
|
278
|
+
seedPrefix: '',
|
|
279
|
+
width: c.width,
|
|
280
|
+
height: c.height,
|
|
281
|
+
backdrop: auto ? backdropSampler(c, sharp, documentFor, loadSharp) : undefined,
|
|
282
|
+
report: extra.report,
|
|
283
|
+
doc: {},
|
|
284
|
+
layout: new Map(),
|
|
285
|
+
defs: new Map(),
|
|
286
|
+
t,
|
|
287
|
+
live: extra.live,
|
|
288
|
+
sheet: extra.sheet,
|
|
289
|
+
raster: extra.raster,
|
|
290
|
+
reach: extra.reach,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** @param {any} l @returns {boolean} Whether a layer (or its children) picks its colour from the backdrop. */
|
|
295
|
+
function usesAuto(l) {
|
|
296
|
+
return Boolean(l) && (l.color === 'auto' || (Array.isArray(l.layers) && l.layers.some(usesAuto)));
|
|
297
|
+
}
|
|
298
|
+
|
|
130
299
|
/**
|
|
131
300
|
* Assemble an SVG document from rendered layers.
|
|
132
301
|
*
|
|
@@ -143,6 +312,18 @@ function documentFor(c, parts) {
|
|
|
143
312
|
body = `<g clip-path="url(#r)">${body}</g>`;
|
|
144
313
|
}
|
|
145
314
|
const title = c.alt ? `<title>${esc(c.alt)}</title>` : '';
|
|
315
|
+
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;
|
|
316
|
+
if (zones) body += zonesOverlay(zones, w, h);
|
|
317
|
+
const print = /** @type {import('./formats.js').PrintInfo | undefined} */ (/** @type {any} */ (c).print);
|
|
318
|
+
if (print && (print.cropMarks || print.cutLine)) {
|
|
319
|
+
const { marks, cut } = printMarks(print, w, h);
|
|
320
|
+
const s = print.slug;
|
|
321
|
+
const W = w + 2 * s;
|
|
322
|
+
const H = h + 2 * s;
|
|
323
|
+
defs += `<clipPath id="bleed"><rect${attrs({ width: w, height: h })}/></clipPath>`;
|
|
324
|
+
const art = `<g transform="translate(${num(s)} ${num(s)})"><g clip-path="url(#bleed)">${body}</g>${cut}</g>`;
|
|
325
|
+
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>`;
|
|
326
|
+
}
|
|
146
327
|
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
328
|
}
|
|
148
329
|
|
|
@@ -151,16 +332,102 @@ function documentFor(c, parts) {
|
|
|
151
332
|
* needs no fonts; images are embedded as data URIs; nothing is fetched.
|
|
152
333
|
* The same cover always gives byte-identical output.
|
|
153
334
|
*
|
|
335
|
+
* Layers with `motion` play in the SVG (CSS keyframes, stopped by
|
|
336
|
+
* `prefers-reduced-motion`), unless the cover has `animate: false` or a
|
|
337
|
+
* time is asked for: then it's a still.
|
|
338
|
+
*
|
|
154
339
|
* @param {import('./index.js').CoverOptions | import('./index.js').Cover} input - The cover.
|
|
340
|
+
* @param {{ t?: number, images?: 'webp', quality?: number }} [options] - `t`: a still at this time, in seconds, for layers with keyframes or motion (static covers ignore it). `images: 'webp'`: embedded photos and pixel effects re-encoded as WebP where smaller (`quality` for photos, default 82; needs sharp), for SVGs shown in browsers (librsvg can't draw WebP).
|
|
155
341
|
* @returns {Promise<string>} The SVG document.
|
|
156
342
|
*
|
|
157
343
|
* @example
|
|
158
344
|
* const svg = await renderSvg({ recipe: 'aurora', seed: 'hello', palette: 'paper' });
|
|
159
345
|
*/
|
|
160
|
-
export async function renderSvg(input) {
|
|
346
|
+
export async function renderSvg(input, options = {}) {
|
|
161
347
|
const c = cover(input);
|
|
162
|
-
|
|
163
|
-
|
|
348
|
+
return svgDocument(c, { ...options, live: options.t == null && /** @type {any} */ (c).animate !== false });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* A cover's SVG document, playing its motion or still.
|
|
353
|
+
*
|
|
354
|
+
* @param {import('./index.js').Cover} c
|
|
355
|
+
* @param {{ t?: number, live?: boolean, images?: 'webp', quality?: number }} options
|
|
356
|
+
* @returns {Promise<string>}
|
|
357
|
+
*/
|
|
358
|
+
async function svgDocument(c, options) {
|
|
359
|
+
const sharp = c.layers.some(hasImage) ? await loadSharp(false) : undefined;
|
|
360
|
+
const sheet = options.live ? createSheet() : undefined;
|
|
361
|
+
const parts = await renderLayers(c, sharp, { t: options.t, live: options.live, sheet });
|
|
362
|
+
const svg = sheet && !sheet.empty ? nameSheet(documentFor(c, [{ defs: `<style>${sheet.css()}</style>`, body: '' }, ...parts])) : documentFor(c, parts);
|
|
363
|
+
if (!options.images) return svg;
|
|
364
|
+
if (options.images !== 'webp') throw new TypeError(`Unknown images "${options.images}" (webp)`);
|
|
365
|
+
return webpImages(svg, sharp ?? await loadSharp(false), { quality: options.quality });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Whether a layer (or one inside it) needs pixels to draw: an image, or an
|
|
370
|
+
* effect computed on pixels (`dither`, a round `border`).
|
|
371
|
+
*
|
|
372
|
+
* @param {any} l
|
|
373
|
+
* @returns {boolean}
|
|
374
|
+
*/
|
|
375
|
+
function hasImage(l) {
|
|
376
|
+
if (!l) return false;
|
|
377
|
+
if (l.type === 'image') return true;
|
|
378
|
+
const fx = l.effects == null ? [] : [].concat(l.effects);
|
|
379
|
+
if (fx.some((/** @type {any} */ e) => needsPixels(e) || (e?.mask?.layers ?? []).some(hasImage) || (e?.map?.layers ?? []).some(hasImage))) return true;
|
|
380
|
+
return ['layers', 'mask', 'knockout', 'clip'].some((k) => Array.isArray(l[k]) && l[k].some(hasImage))
|
|
381
|
+
|| (l.type === 'repeat' && hasImage(l.layer));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Render each page of a two-sided piece (a business card, a ticket, a
|
|
386
|
+
* flyer): one SVG per page the recipe declares (`"pages": ["front", "back"]`),
|
|
387
|
+
* or the pages you ask for. A layer is on a page by its `when`
|
|
388
|
+
* (`{ "page": "back" }`); layers without one are on every page.
|
|
389
|
+
*
|
|
390
|
+
* @param {import('./index.js').CoverOptions} input
|
|
391
|
+
* @param {{ pages?: string[] }} [options]
|
|
392
|
+
* @returns {Promise<Record<string, string>>} SVG by page name.
|
|
393
|
+
*
|
|
394
|
+
* @example
|
|
395
|
+
* const { front, back } = await renderPages({ recipe: card, format: 'business-card', name: 'Mira Okafor' });
|
|
396
|
+
*/
|
|
397
|
+
export async function renderPages(input, options = {}) {
|
|
398
|
+
const probe = cover({ ...input, page: undefined });
|
|
399
|
+
const pages = options.pages ?? /** @type {any} */ (probe).pages ?? ['front', 'back'];
|
|
400
|
+
/** @type {Record<string, string>} */
|
|
401
|
+
const out = {};
|
|
402
|
+
for (const page of pages) out[page] = await renderSvg({ ...input, page });
|
|
403
|
+
return out;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Render a cover and note what was drawn (text sizes and boxes), for `lint`.
|
|
408
|
+
*
|
|
409
|
+
* @param {import('./index.js').CoverOptions | import('./index.js').Cover} input
|
|
410
|
+
* @returns {Promise<{ svg: string, drawn: any[] }>}
|
|
411
|
+
*/
|
|
412
|
+
export async function renderReport(input) {
|
|
413
|
+
const c = cover(input);
|
|
414
|
+
const sharp = c.layers.some(hasImage) ? await loadSharp(false) : undefined;
|
|
415
|
+
/** @type {any[]} */
|
|
416
|
+
const drawn = [];
|
|
417
|
+
const parts = await renderLayers(c, sharp, { report: (e) => drawn.push(e) });
|
|
418
|
+
return { svg: documentFor(c, parts), drawn };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Render a cover to an image in the browser (a canvas; no sharp): a still,
|
|
423
|
+
* the cover's `poster` frame or its layers at rest.
|
|
424
|
+
*
|
|
425
|
+
* @param {import('./index.js').CoverOptions | import('./index.js').Cover} input
|
|
426
|
+
* @param {{ format?: 'png' | 'jpeg' | 'webp', quality?: number, scale?: number, width?: number }} [options]
|
|
427
|
+
* @returns {Promise<Blob>}
|
|
428
|
+
*/
|
|
429
|
+
export async function renderBlob(input, options = {}) {
|
|
430
|
+
return rasterize(await svgDocument(cover(input), {}), options);
|
|
164
431
|
}
|
|
165
432
|
|
|
166
433
|
/** @type {Promise<any> | undefined} */
|
|
@@ -173,7 +440,9 @@ let sharpPromise;
|
|
|
173
440
|
* @returns {Promise<any>} The module's default export, or undefined.
|
|
174
441
|
*/
|
|
175
442
|
async function loadSharp(required) {
|
|
176
|
-
|
|
443
|
+
// A computed specifier: bundlers for browsers and Workers leave the optional peer alone.
|
|
444
|
+
const name = 'sharp';
|
|
445
|
+
sharpPromise ??= import(/* @vite-ignore */ name).then((m) => m.default ?? m, () => undefined);
|
|
177
446
|
const sharp = await sharpPromise;
|
|
178
447
|
if (!sharp && required) throw new Error('renderImage() needs sharp: npm install sharp');
|
|
179
448
|
return sharp;
|
|
@@ -206,18 +475,22 @@ export async function renderImage(input, options = {}) {
|
|
|
206
475
|
if (!Object.hasOwn(MIME, format)) throw new TypeError(`Unknown format "${format}" (webp, png, avif, jpeg)`);
|
|
207
476
|
|
|
208
477
|
let cacheFile;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
478
|
+
const fsp = node('fs/promises');
|
|
479
|
+
const path = node('path');
|
|
480
|
+
if (options.cache && fsp && path) {
|
|
481
|
+
const dir = options.cache === true ? path.join(/** @type {any} */ (globalThis).process.cwd(), 'node_modules', '.cache', 'covers') : path.resolve(String(options.cache));
|
|
482
|
+
cacheFile = path.join(dir, `${await coverKey(c, options)}.${format}`);
|
|
212
483
|
try {
|
|
213
|
-
return await readFile(cacheFile);
|
|
484
|
+
return await fsp.readFile(cacheFile);
|
|
214
485
|
} catch {
|
|
215
486
|
// Not cached yet.
|
|
216
487
|
}
|
|
217
488
|
}
|
|
218
489
|
|
|
219
490
|
const sharp = await loadSharp(true);
|
|
220
|
-
|
|
491
|
+
/** How far any effect's filter reaches, output px (tiles overlap by it). */
|
|
492
|
+
const reach = { px: 0 };
|
|
493
|
+
const parts = await renderLayers(c, sharp, { raster: true, reach });
|
|
221
494
|
const { width, height } = c;
|
|
222
495
|
|
|
223
496
|
// Split the stack: [svg] grain [svg] …, with each heavily blurred layer
|
|
@@ -238,7 +511,9 @@ export async function renderImage(input, options = {}) {
|
|
|
238
511
|
if (run.length) steps.push({ svg: run });
|
|
239
512
|
|
|
240
513
|
const raw = { width, height, channels: /** @type {const} */ (4) };
|
|
241
|
-
|
|
514
|
+
// Every step's document carries every layer's defs (a pattern or a used stack defined in another step).
|
|
515
|
+
const allDefs = parts.map((p) => p.defs).join('');
|
|
516
|
+
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
517
|
/** @type {Buffer | undefined} */
|
|
243
518
|
let pixels;
|
|
244
519
|
for (const step of steps) {
|
|
@@ -263,9 +538,10 @@ export async function renderImage(input, options = {}) {
|
|
|
263
538
|
: layerPx;
|
|
264
539
|
} else if (step.svg) {
|
|
265
540
|
const svg = svgOf(step.svg);
|
|
541
|
+
const layerPx = await svgPixels(sharp, svg.toString(), width, height, reach.px);
|
|
266
542
|
pixels = pixels
|
|
267
|
-
? await sharp(pixels, { raw }).composite([{ input:
|
|
268
|
-
:
|
|
543
|
+
? await sharp(pixels, { raw }).composite([{ input: layerPx, raw }]).raw().toBuffer()
|
|
544
|
+
: Buffer.from(layerPx.buffer, layerPx.byteOffset, layerPx.byteLength);
|
|
269
545
|
} else if (step.grain) {
|
|
270
546
|
pixels ??= Buffer.alloc(width * height * 4);
|
|
271
547
|
const g = /** @type {import('./index.js').GrainLayer} */ (step.grain.layer);
|
|
@@ -278,6 +554,20 @@ export async function renderImage(input, options = {}) {
|
|
|
278
554
|
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
555
|
}
|
|
280
556
|
|
|
557
|
+
// Preview overlay and print furniture (crop marks, die line) go on last.
|
|
558
|
+
const cx = /** @type {any} */ (c);
|
|
559
|
+
if (cx.overlay && (cx.zones || cx.print)) {
|
|
560
|
+
const z = cx.zones ?? { safe: Object.values(/** @type {any} */ (safeBox(cx))) };
|
|
561
|
+
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>`) }]);
|
|
562
|
+
}
|
|
563
|
+
if (cx.print && (cx.print.cropMarks || cx.print.cutLine)) {
|
|
564
|
+
const s = cx.print.slug;
|
|
565
|
+
const { marks, cut } = printMarks(cx.print, width, height);
|
|
566
|
+
image = sharp(await image.png().toBuffer()).extend({ top: s, bottom: s, left: s, right: s, background: '#ffffff' })
|
|
567
|
+
.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>`) }]);
|
|
568
|
+
}
|
|
569
|
+
if (cx.print) image = image.withMetadata({ density: cx.print.dpi });
|
|
570
|
+
|
|
281
571
|
// Grain is the first thing a lossy encoder throws away. Measured on an
|
|
282
572
|
// aurora cover: WebP q82 keeps ~40% of the grain (11 KB), q90 ~60% (41 KB),
|
|
283
573
|
// q94 ~78% (125 KB). 90 is the default; raise it for crisper grain.
|
|
@@ -289,174 +579,12 @@ export async function renderImage(input, options = {}) {
|
|
|
289
579
|
else image = image.png({ compressionLevel: options.effort ?? 6 });
|
|
290
580
|
const out = await image.toBuffer();
|
|
291
581
|
|
|
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);
|
|
582
|
+
if (cacheFile && fsp && path) {
|
|
583
|
+
await fsp.mkdir(path.join(cacheFile, '..'), { recursive: true });
|
|
584
|
+
const tmp = `${cacheFile}.${/** @type {any} */ (globalThis).process.pid}.tmp`;
|
|
585
|
+
await fsp.writeFile(tmp, out);
|
|
586
|
+
await fsp.rename(tmp, cacheFile);
|
|
297
587
|
}
|
|
298
588
|
return out;
|
|
299
589
|
}
|
|
300
590
|
|
|
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
|
-
}
|