@arraypress/covers 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/LICENSE +29 -0
- package/README.md +846 -2
- package/fonts/OFL.txt +93 -0
- package/fonts/inter-latin-400.woff +0 -0
- package/fonts/inter-latin-500.woff +0 -0
- package/fonts/inter-latin-600.woff +0 -0
- package/fonts/inter-latin-700.woff +0 -0
- package/package.json +124 -4
- package/recipes/aurora.json +132 -0
- package/recipes/bauhaus.json +113 -0
- package/recipes/bigType.json +103 -0
- package/recipes/brand.json +1143 -0
- package/recipes/contour.json +109 -0
- package/recipes/cutout.json +191 -0
- package/recipes/diagram.json +92 -0
- package/recipes/dither.json +170 -0
- package/recipes/duotone.json +75 -0
- package/recipes/gridTitle.json +136 -0
- package/recipes/iconTile.json +214 -0
- package/recipes/issue.json +221 -0
- package/recipes/mark.json +170 -0
- package/recipes/photo.json +156 -0
- package/recipes/riso.json +387 -0
- package/recipes/stripes.json +206 -0
- package/recipes/waveform.json +154 -0
- package/schema/recipe.schema.json +8822 -0
- package/src/animate/index.d.ts +19 -0
- package/src/animate/index.js +116 -0
- package/src/astro.d.ts +31 -0
- package/src/astro.js +86 -0
- package/src/builtins.js +4 -0
- package/src/color.js +246 -0
- package/src/diagram.js +248 -0
- package/src/effects/ascii.d.ts +7 -0
- package/src/effects/ascii.js +93 -0
- package/src/effects/buffer.d.ts +60 -0
- package/src/effects/buffer.js +472 -0
- package/src/effects/detail.d.ts +12 -0
- package/src/effects/detail.js +124 -0
- package/src/effects/digital.d.ts +14 -0
- package/src/effects/digital.js +216 -0
- package/src/effects/distort.d.ts +20 -0
- package/src/effects/distort.js +314 -0
- package/src/effects/dither.d.ts +8 -0
- package/src/effects/dither.js +147 -0
- package/src/effects/drop-shadow.d.ts +4 -0
- package/src/effects/drop-shadow.js +45 -0
- package/src/effects/extrude.d.ts +5 -0
- package/src/effects/extrude.js +55 -0
- package/src/effects/film.d.ts +11 -0
- package/src/effects/film.js +204 -0
- package/src/effects/glow.d.ts +8 -0
- package/src/effects/glow.js +56 -0
- package/src/effects/grade.d.ts +30 -0
- package/src/effects/grade.js +274 -0
- package/src/effects/halftone.d.ts +8 -0
- package/src/effects/halftone.js +95 -0
- package/src/effects/index.d.ts +125 -0
- package/src/effects/index.js +277 -0
- package/src/effects/ink.d.ts +12 -0
- package/src/effects/ink.js +70 -0
- package/src/effects/lens.d.ts +14 -0
- package/src/effects/lens.js +277 -0
- package/src/effects/light.d.ts +10 -0
- package/src/effects/light.js +59 -0
- package/src/effects/looks.d.ts +9 -0
- package/src/effects/looks.js +74 -0
- package/src/effects/mask.d.ts +52 -0
- package/src/effects/mask.js +314 -0
- package/src/effects/motion.d.ts +6 -0
- package/src/effects/motion.js +60 -0
- package/src/effects/newsprint.d.ts +6 -0
- package/src/effects/newsprint.js +89 -0
- package/src/effects/optics.d.ts +10 -0
- package/src/effects/optics.js +213 -0
- package/src/effects/options.d.ts +6 -0
- package/src/effects/options.js +106 -0
- package/src/effects/paint-presets.d.ts +10 -0
- package/src/effects/paint-presets.js +47 -0
- package/src/effects/paint.d.ts +49 -0
- package/src/effects/paint.js +66 -0
- package/src/effects/pixels.d.ts +24 -0
- package/src/effects/pixels.js +138 -0
- package/src/effects/polar.d.ts +10 -0
- package/src/effects/polar.js +265 -0
- package/src/effects/region.d.ts +8 -0
- package/src/effects/region.js +136 -0
- package/src/effects/round-border.d.ts +5 -0
- package/src/effects/round-border.js +62 -0
- package/src/effects/screen.d.ts +14 -0
- package/src/effects/screen.js +212 -0
- package/src/effects/sort.d.ts +8 -0
- package/src/effects/sort.js +125 -0
- package/src/effects/symmetry.d.ts +6 -0
- package/src/effects/symmetry.js +74 -0
- package/src/effects/tile.d.ts +7 -0
- package/src/effects/tile.js +55 -0
- package/src/effects/tone.d.ts +16 -0
- package/src/effects/tone.js +117 -0
- package/src/effects/transform.d.ts +35 -0
- package/src/effects/transform.js +127 -0
- package/src/effects/util.d.ts +16 -0
- package/src/effects/util.js +85 -0
- package/src/extract.js +91 -0
- package/src/flatten.js +234 -0
- package/src/fonts.d.ts +55 -0
- package/src/fonts.js +393 -0
- package/src/formats/cut.d.ts +10 -0
- package/src/formats/cut.js +77 -0
- package/src/formats/index.d.ts +6 -0
- package/src/formats/index.js +9 -0
- package/src/formats/presets.d.ts +41 -0
- package/src/formats/presets.js +89 -0
- package/src/formats/print.d.ts +25 -0
- package/src/formats/print.js +117 -0
- package/src/formats/zones.d.ts +10 -0
- package/src/formats/zones.js +64 -0
- package/src/geometry.js +187 -0
- package/src/grid.js +41 -0
- package/src/handmade.js +253 -0
- package/src/icons.js +191 -0
- package/src/index.d.ts +1083 -0
- package/src/index.js +27 -0
- package/src/inter-data.js +27 -0
- package/src/json-recipe.js +616 -0
- package/src/kit/index.d.ts +20 -0
- package/src/kit/index.js +58 -0
- package/src/layer-options.js +56 -0
- package/src/layers/barcode.d.ts +19 -0
- package/src/layers/barcode.js +63 -0
- package/src/layers/code128.d.ts +4 -0
- package/src/layers/code128.js +28 -0
- package/src/layers/expr.d.ts +6 -0
- package/src/layers/expr.js +121 -0
- package/src/layers/geometry.d.ts +6 -0
- package/src/layers/geometry.js +238 -0
- package/src/layers/index.d.ts +18 -0
- package/src/layers/index.js +15 -0
- package/src/layers/perforation.d.ts +14 -0
- package/src/layers/perforation.js +47 -0
- package/src/layers/repeat.d.ts +29 -0
- package/src/layers/repeat.js +90 -0
- package/src/layers/shape.d.ts +46 -0
- package/src/layers/shape.js +115 -0
- package/src/layers/soft.d.ts +5 -0
- package/src/layers/soft.js +43 -0
- package/src/layers/svg.d.ts +23 -0
- package/src/layers/svg.js +109 -0
- package/src/layers/table.d.ts +25 -0
- package/src/layers/table.js +80 -0
- package/src/layers/tear.d.ts +21 -0
- package/src/layers/tear.js +74 -0
- package/src/layers/texture.d.ts +17 -0
- package/src/layers/texture.js +106 -0
- package/src/layers/util.d.ts +8 -0
- package/src/layers/util.js +48 -0
- package/src/layers.js +1056 -0
- package/src/lint/image-size.d.ts +4 -0
- package/src/lint/image-size.js +34 -0
- package/src/lint/index.d.ts +16 -0
- package/src/lint/index.js +127 -0
- package/src/lint/rules.d.ts +5 -0
- package/src/lint/rules.js +19 -0
- package/src/node.d.ts +2 -0
- package/src/node.js +20 -0
- package/src/palette.js +185 -0
- package/src/platform/bytes.d.ts +22 -0
- package/src/platform/bytes.js +103 -0
- package/src/platform/compress.d.ts +12 -0
- package/src/platform/compress.js +81 -0
- package/src/platform/harfbuzz.d.ts +9 -0
- package/src/platform/harfbuzz.js +80 -0
- package/src/platform/index.d.ts +8 -0
- package/src/platform/index.js +13 -0
- package/src/platform/load.d.ts +6 -0
- package/src/platform/load.js +72 -0
- package/src/platform/no-harfbuzz.d.ts +2 -0
- package/src/platform/no-harfbuzz.js +11 -0
- package/src/platform/node.d.ts +26 -0
- package/src/platform/node.js +86 -0
- package/src/platform/settings.d.ts +28 -0
- package/src/platform/settings.js +51 -0
- package/src/png.js +126 -0
- package/src/print.js +659 -0
- package/src/raster/index.d.ts +24 -0
- package/src/raster/index.js +171 -0
- package/src/recipes.js +79 -0
- package/src/render/backdrop.d.ts +6 -0
- package/src/render/backdrop.js +55 -0
- package/src/render/content.d.ts +5 -0
- package/src/render/content.js +58 -0
- package/src/render/context.d.ts +82 -0
- package/src/render/grain.d.ts +7 -0
- package/src/render/grain.js +113 -0
- package/src/render/group.d.ts +23 -0
- package/src/render/group.js +67 -0
- package/src/render/key.d.ts +5 -0
- package/src/render/key.js +91 -0
- package/src/render/report.d.ts +5 -0
- package/src/render/report.js +19 -0
- package/src/render/tiles.d.ts +6 -0
- package/src/render/tiles.js +77 -0
- package/src/render/use.d.ts +27 -0
- package/src/render/use.js +73 -0
- package/src/render/wrap.d.ts +14 -0
- package/src/render/wrap.js +199 -0
- package/src/render.js +553 -0
- package/src/rng.js +95 -0
- package/src/svg.js +69 -0
- package/src/text.d.ts +29 -0
- package/src/text.js +237 -0
- package/src/type/auto-color.d.ts +9 -0
- package/src/type/auto-color.js +44 -0
- package/src/type/fallback.d.ts +11 -0
- package/src/type/fallback.js +92 -0
- package/src/type/glyphs.d.ts +24 -0
- package/src/type/glyphs.js +87 -0
- package/src/type/index.d.ts +5 -0
- package/src/type/index.js +10 -0
- package/src/type/kern.d.ts +4 -0
- package/src/type/kern.js +144 -0
- package/src/type/layout-ref.d.ts +10 -0
- package/src/type/layout-ref.js +74 -0
- package/src/type/line.d.ts +11 -0
- package/src/type/line.js +105 -0
- package/src/type/live.d.ts +6 -0
- package/src/type/live.js +39 -0
- package/src/type/name.d.ts +4 -0
- package/src/type/name.js +32 -0
- package/src/type/path.d.ts +18 -0
- package/src/type/path.js +89 -0
- package/src/type/pixel.d.ts +9 -0
- package/src/type/pixel.js +115 -0
- package/src/type/sfnt.d.ts +20 -0
- package/src/type/sfnt.js +258 -0
- package/src/type/spans.d.ts +33 -0
- package/src/type/spans.js +138 -0
- package/src/type/symbols.d.ts +7 -0
- package/src/type/symbols.js +89 -0
- package/src/type/typeset.d.ts +10 -0
- package/src/type/typeset.js +352 -0
- package/src/type/woff2.d.ts +6 -0
- package/src/type/woff2.js +301 -0
- package/src/version.js +4 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rasterising without sharp: an SVG drawn into a canvas in the browser,
|
|
3
|
+
* encoded as PNG, JPEG or WebP. `renderImage()` stays the Node path (sharp,
|
|
4
|
+
* with its grain and padded-blur handling); this is the browser one.
|
|
5
|
+
*
|
|
6
|
+
* The SVG is decoded by the browser as an `<img>` (so it needs a page:
|
|
7
|
+
* `document` and `Image`), drawn onto an `OffscreenCanvas` when there is
|
|
8
|
+
* one (else a `<canvas>`), and encoded with `convertToBlob` / `toBlob`.
|
|
9
|
+
*
|
|
10
|
+
* @module raster
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MIMES = /** @type {const} */ ({ png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp' });
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Decode an SVG string into a drawable image (browser).
|
|
17
|
+
*
|
|
18
|
+
* @param {string} svg
|
|
19
|
+
* @returns {Promise<HTMLImageElement>}
|
|
20
|
+
*/
|
|
21
|
+
async function decodeSvg(svg) {
|
|
22
|
+
const g = /** @type {any} */ (globalThis);
|
|
23
|
+
if (typeof g.Image !== 'function' || typeof g.document === 'undefined') {
|
|
24
|
+
throw new Error('rasterize() needs a browser page (Image and canvas). In Node use renderImage(); in a Worker return the SVG, or rasterise it with a wasm renderer.');
|
|
25
|
+
}
|
|
26
|
+
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
|
|
27
|
+
try {
|
|
28
|
+
const img = new g.Image();
|
|
29
|
+
img.decoding = 'sync';
|
|
30
|
+
img.src = url;
|
|
31
|
+
await img.decode();
|
|
32
|
+
return img;
|
|
33
|
+
} finally {
|
|
34
|
+
// Revoked after decode: the decoded image keeps its pixels.
|
|
35
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A 2D canvas of a size.
|
|
41
|
+
*
|
|
42
|
+
* @param {number} w @param {number} h
|
|
43
|
+
* @returns {{ canvas: any, ctx: CanvasRenderingContext2D }}
|
|
44
|
+
*/
|
|
45
|
+
function canvasOf(w, h) {
|
|
46
|
+
const g = /** @type {any} */ (globalThis);
|
|
47
|
+
const canvas = typeof g.OffscreenCanvas === 'function' ? new g.OffscreenCanvas(w, h) : Object.assign(g.document.createElement('canvas'), { width: w, height: h });
|
|
48
|
+
return { canvas, ctx: canvas.getContext('2d') };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Rasterise an SVG in the browser.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} svg - An SVG document (from `renderSvg`).
|
|
55
|
+
* @param {{ format?: 'png' | 'jpeg' | 'webp', quality?: number, width?: number, height?: number, scale?: number, background?: string }} [options]
|
|
56
|
+
* @returns {Promise<Blob>}
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* const blob = await rasterize(await renderSvg(cover), { format: 'webp', quality: 0.9 });
|
|
60
|
+
* img.src = URL.createObjectURL(blob);
|
|
61
|
+
*/
|
|
62
|
+
export async function rasterize(svg, options = {}) {
|
|
63
|
+
const format = options.format ?? 'png';
|
|
64
|
+
if (!Object.hasOwn(MIMES, format)) throw new TypeError(`Unknown format "${format}" (png, jpeg, webp)`);
|
|
65
|
+
const vw = Number(/\bwidth="([\d.]+)"/.exec(svg)?.[1] ?? 1200);
|
|
66
|
+
const vh = Number(/\bheight="([\d.]+)"/.exec(svg)?.[1] ?? 1200);
|
|
67
|
+
const scale = options.scale ?? (options.width ? options.width / vw : options.height ? options.height / vh : 1);
|
|
68
|
+
const w = Math.max(1, Math.round(vw * scale));
|
|
69
|
+
const h = Math.max(1, Math.round(vh * scale));
|
|
70
|
+
const img = await decodeSvg(svg);
|
|
71
|
+
const { canvas, ctx } = canvasOf(w, h);
|
|
72
|
+
if (format === 'jpeg' || options.background) {
|
|
73
|
+
ctx.fillStyle = options.background ?? /fill="(#[0-9a-f]{6})"/i.exec(svg)?.[1] ?? '#ffffff';
|
|
74
|
+
ctx.fillRect(0, 0, w, h);
|
|
75
|
+
}
|
|
76
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
77
|
+
const type = MIMES[format];
|
|
78
|
+
const quality = options.quality ?? (format === 'png' ? undefined : 0.9);
|
|
79
|
+
if (typeof canvas.convertToBlob === 'function') return canvas.convertToBlob({ type, quality });
|
|
80
|
+
return new Promise((resolve, reject) => canvas.toBlob((/** @type {Blob | null} */ b) => (b ? resolve(b) : reject(new Error('Canvas encoding failed'))), type, quality));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* RGBA pixels of an SVG at a size: sharp in Node, a canvas in a page.
|
|
85
|
+
* Undefined where neither is available (a Worker).
|
|
86
|
+
*
|
|
87
|
+
* @param {string} svg
|
|
88
|
+
* @param {number} w
|
|
89
|
+
* @param {number} h
|
|
90
|
+
* @param {any} [sharp] - The sharp module, when loaded.
|
|
91
|
+
* @returns {Promise<Uint8Array | undefined>}
|
|
92
|
+
*/
|
|
93
|
+
export async function pixelsOf(svg, w, h, sharp) {
|
|
94
|
+
if (sharp) {
|
|
95
|
+
const out = await sharp(new TextEncoder().encode(svg)).resize(w, h, { fit: 'fill' }).ensureAlpha().raw().toBuffer();
|
|
96
|
+
return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
|
|
97
|
+
}
|
|
98
|
+
const g = /** @type {any} */ (globalThis);
|
|
99
|
+
if (typeof g.Image !== 'function' || typeof g.document === 'undefined') return undefined;
|
|
100
|
+
const img = await decodeSvg(svg);
|
|
101
|
+
const { ctx } = canvasOf(w, h);
|
|
102
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
103
|
+
return new Uint8Array(ctx.getImageData(0, 0, w, h).data.buffer);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Encode RGBA pixels for embedding: JPEG where they're opaque (smaller),
|
|
108
|
+
* PNG where they aren't or where `lossless` (flat colour, crisp cells).
|
|
109
|
+
* sharp in Node, a canvas in a page, else the in-library PNG encoder.
|
|
110
|
+
*
|
|
111
|
+
* @param {Uint8Array | Uint8ClampedArray} data - RGBA, unpremultiplied.
|
|
112
|
+
* @param {number} w
|
|
113
|
+
* @param {number} h
|
|
114
|
+
* @param {{ opaque?: boolean, lossless?: boolean, quality?: number }} [o]
|
|
115
|
+
* @param {any} [sharp]
|
|
116
|
+
* @returns {Promise<{ mime: 'image/jpeg' | 'image/png', bytes: Uint8Array }>}
|
|
117
|
+
*/
|
|
118
|
+
export async function encodePixels(data, w, h, o = {}, sharp) {
|
|
119
|
+
const jpeg = Boolean(o.opaque) && !o.lossless;
|
|
120
|
+
const quality = o.quality ?? 92;
|
|
121
|
+
if (sharp) {
|
|
122
|
+
let img = sharp(data, { raw: { width: w, height: h, channels: 4 } });
|
|
123
|
+
if (o.opaque) img = img.removeAlpha();
|
|
124
|
+
const out = jpeg ? await img.jpeg({ quality, chromaSubsampling: '4:4:4' }).toBuffer() : await img.png({ compressionLevel: 6 }).toBuffer();
|
|
125
|
+
return { mime: jpeg ? 'image/jpeg' : 'image/png', bytes: new Uint8Array(out.buffer, out.byteOffset, out.byteLength) };
|
|
126
|
+
}
|
|
127
|
+
const g = /** @type {any} */ (globalThis);
|
|
128
|
+
if (typeof g.OffscreenCanvas === 'function' || typeof g.document !== 'undefined') {
|
|
129
|
+
const { canvas, ctx } = canvasOf(w, h);
|
|
130
|
+
ctx.putImageData(new g.ImageData(new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), w, h), 0, 0);
|
|
131
|
+
const type = jpeg ? 'image/jpeg' : 'image/png';
|
|
132
|
+
const blob = typeof canvas.convertToBlob === 'function'
|
|
133
|
+
? await canvas.convertToBlob({ type, quality: quality / 100 })
|
|
134
|
+
: await new Promise((resolve, reject) => canvas.toBlob((/** @type {Blob | null} */ b) => (b ? resolve(b) : reject(new Error('Canvas encoding failed'))), type, quality / 100));
|
|
135
|
+
return { mime: type, bytes: new Uint8Array(await blob.arrayBuffer()) };
|
|
136
|
+
}
|
|
137
|
+
const { encodePngAsync } = await import('../png.js');
|
|
138
|
+
return { mime: 'image/png', bytes: await encodePngAsync(new Uint8Array(data.buffer, data.byteOffset, data.byteLength), w, h, 4) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Pixels through a real JPEG encoder and back (compression artefacts):
|
|
143
|
+
* libjpeg through sharp in Node, the browser's own encoder in a page.
|
|
144
|
+
* Undefined where neither is available.
|
|
145
|
+
*
|
|
146
|
+
* @param {Uint8ClampedArray} data - RGBA (alpha ignored).
|
|
147
|
+
* @param {number} w
|
|
148
|
+
* @param {number} h
|
|
149
|
+
* @param {number} quality - 1–100.
|
|
150
|
+
* @param {boolean} subsample - 4:2:0 chroma (blocky colour).
|
|
151
|
+
* @param {any} [sharp]
|
|
152
|
+
* @returns {Promise<Uint8ClampedArray | undefined>} RGBA, opaque.
|
|
153
|
+
*/
|
|
154
|
+
export async function jpegRoundTrip(data, w, h, quality, subsample, sharp) {
|
|
155
|
+
if (sharp) {
|
|
156
|
+
const jpg = await sharp(data, { raw: { width: w, height: h, channels: 4 } }).removeAlpha().jpeg({ quality, chromaSubsampling: subsample ? '4:2:0' : '4:4:4' }).toBuffer();
|
|
157
|
+
const out = await sharp(jpg).ensureAlpha().raw().toBuffer();
|
|
158
|
+
return new Uint8ClampedArray(out.buffer, out.byteOffset, out.byteLength);
|
|
159
|
+
}
|
|
160
|
+
const g = /** @type {any} */ (globalThis);
|
|
161
|
+
if (typeof g.createImageBitmap !== 'function' || (typeof g.OffscreenCanvas !== 'function' && typeof g.document === 'undefined')) return undefined;
|
|
162
|
+
const { canvas, ctx } = canvasOf(w, h);
|
|
163
|
+
ctx.putImageData(new g.ImageData(new Uint8ClampedArray(data), w, h), 0, 0);
|
|
164
|
+
const blob = typeof canvas.convertToBlob === 'function'
|
|
165
|
+
? await canvas.convertToBlob({ type: 'image/jpeg', quality: quality / 100 })
|
|
166
|
+
: await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality / 100));
|
|
167
|
+
const bitmap = await g.createImageBitmap(blob);
|
|
168
|
+
ctx.clearRect(0, 0, w, h);
|
|
169
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
170
|
+
return new Uint8ClampedArray(ctx.getImageData(0, 0, w, h).data.buffer);
|
|
171
|
+
}
|
package/src/recipes.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipes: named layer stacks.
|
|
3
|
+
*
|
|
4
|
+
* A recipe is a function from the cover's options (and the canvas) to a
|
|
5
|
+
* list of layers, each with an `id`. Call it through
|
|
6
|
+
* `cover({ recipe: 'gridTitle', title: '…' })`. Any option named after a
|
|
7
|
+
* layer id customises that layer:
|
|
8
|
+
*
|
|
9
|
+
* - a **string** is the layer's content: `title: 'Hello'`, `icon: 'lucide:feather'`
|
|
10
|
+
* - an **object** is merged into the layer: `mesh: { blur: '16vmax' }`,
|
|
11
|
+
* `title: { content: 'Hello', size: 96 }`
|
|
12
|
+
* - **`false`** removes it: `grain: false`
|
|
13
|
+
*
|
|
14
|
+
* Extra `layers` passed with a recipe are painted on top of its stack.
|
|
15
|
+
*
|
|
16
|
+
* The built-ins are JSON documents (`recipes/<name>.json`) defined with
|
|
17
|
+
* {@link import('./json-recipe.js').defineRecipe}, the same format a theme
|
|
18
|
+
* can write its own styles in.
|
|
19
|
+
*
|
|
20
|
+
* Sizes are written against `u`, the geometric mean of the canvas sides,
|
|
21
|
+
* so a 1200×630 card and a 1200×1200 square get proportionate type.
|
|
22
|
+
*
|
|
23
|
+
* @module recipes
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { defineRecipe } from './json-recipe.js';
|
|
27
|
+
import { BUILTIN_JSON } from './builtins.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a recipe receives besides the options.
|
|
31
|
+
*
|
|
32
|
+
* @typedef {object} RecipeContext
|
|
33
|
+
* @property {number} width - Canvas width.
|
|
34
|
+
* @property {number} height - Canvas height.
|
|
35
|
+
* @property {import('./index.js').Palette} palette - Resolved palette.
|
|
36
|
+
* @property {import('./rng.js').Rng} rng - The cover's random stream (for recipe-level choices).
|
|
37
|
+
* @property {number} u - The design unit: `sqrt(width × height)`.
|
|
38
|
+
* @property {string} seed - The cover's seed (JSON recipes seed each layer's draws from it).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The text content of a string-or-object option.
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} v
|
|
45
|
+
* @returns {string | undefined}
|
|
46
|
+
*/
|
|
47
|
+
export function contentOf(v) {
|
|
48
|
+
if (typeof v === 'string') return v;
|
|
49
|
+
if (v && typeof v === 'object' && 'content' in v) return /** @type {any} */ (v).content;
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The built-in recipe names (each is `recipes/<name>.json`). */
|
|
54
|
+
const BUILT_IN = ['aurora', 'gridTitle', 'iconTile', 'bigType', 'stripes', 'duotone', 'mark', 'diagram', 'cutout', 'riso', 'dither', 'contour', 'waveform', 'bauhaus', 'issue', 'photo', 'brand'];
|
|
55
|
+
|
|
56
|
+
/** Every built-in recipe, by name, defined from its JSON document. */
|
|
57
|
+
export const recipes = Object.freeze(Object.fromEntries(BUILT_IN.map((name) => [
|
|
58
|
+
name,
|
|
59
|
+
// A fresh copy: the documents are shared module data.
|
|
60
|
+
defineRecipe(structuredClone(/** @type {any} */ (BUILTIN_JSON)[name])),
|
|
61
|
+
])));
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Apply per-layer options to a recipe's stack (see the module docs).
|
|
65
|
+
*
|
|
66
|
+
* @param {import('./index.js').Layer[]} layers - The recipe's layers.
|
|
67
|
+
* @param {Record<string, unknown>} options - The cover options.
|
|
68
|
+
* @returns {import('./index.js').Layer[]} The customised stack.
|
|
69
|
+
*/
|
|
70
|
+
export function applyOverrides(layers, options) {
|
|
71
|
+
return layers.flatMap((layer) => {
|
|
72
|
+
if (!layer.id || !Object.hasOwn(options, layer.id)) return [layer];
|
|
73
|
+
const v = options[layer.id];
|
|
74
|
+
if (v === false || v === null) return [];
|
|
75
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Uint8Array)) return [/** @type {any} */ ({ ...layer, ...v, type: layer.type })];
|
|
76
|
+
return [layer];
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The colour under a box, for auto contrast.
|
|
2
|
+
import type { Cover, Layer } from '../index.js';
|
|
3
|
+
import type { Markup } from './context.js';
|
|
4
|
+
|
|
5
|
+
/** A sampler: the average colour (`#rrggbb`) under a box of the parts drawn so far, or undefined where nothing can rasterise. */
|
|
6
|
+
export function backdropSampler(c: Cover, sharp: unknown, documentFor: (c: Cover, parts: Markup[]) => string, loadSharp: (required: boolean) => Promise<unknown>): (box: { x: number; y: number; width: number; height: number }, parts: (Markup & { layer: Layer })[]) => Promise<string | undefined>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What's under a box: the layers drawn so far, rendered small and averaged,
|
|
3
|
+
* for `color: 'auto'`.
|
|
4
|
+
*
|
|
5
|
+
* @module render/backdrop
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { toHex } from '../color.js';
|
|
9
|
+
import { pixelsOf } from '../raster/index.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The average colour under a box, from the layers drawn so far: rendered
|
|
13
|
+
* small (sharp in Node, a canvas in a page) and averaged. Undefined where
|
|
14
|
+
* nothing can rasterise (a Worker): the text keeps to the palette.
|
|
15
|
+
*
|
|
16
|
+
* @param {import('../index.js').Cover} c
|
|
17
|
+
* @param {any} sharp
|
|
18
|
+
* @param {(c: any, parts: any[]) => string} documentFor - Assembles an SVG from rendered parts.
|
|
19
|
+
* @param {(required: boolean) => Promise<any>} loadSharp
|
|
20
|
+
* @returns {(box: { x: number, y: number, width: number, height: number }, parts: any[]) => Promise<string | undefined>}
|
|
21
|
+
*/
|
|
22
|
+
export function backdropSampler(c, sharp, documentFor, loadSharp) {
|
|
23
|
+
return async (box, parts) => {
|
|
24
|
+
if (!parts.length) return undefined;
|
|
25
|
+
const s = Math.min(1, 240 / Math.max(c.width, c.height));
|
|
26
|
+
const w = Math.max(1, Math.round(c.width * s));
|
|
27
|
+
const h = Math.max(1, Math.round(c.height * s));
|
|
28
|
+
let px;
|
|
29
|
+
try {
|
|
30
|
+
px = await pixelsOf(documentFor(/** @type {any} */ ({ ...c, radius: undefined, alt: undefined, print: undefined, overlay: undefined }), parts), w, h, sharp ?? await loadSharp(false));
|
|
31
|
+
} catch {
|
|
32
|
+
px = undefined;
|
|
33
|
+
}
|
|
34
|
+
if (!px) {
|
|
35
|
+
// Analytic fallback: the last full-canvas solid colour.
|
|
36
|
+
const solid = [...parts].reverse().find((p) => p.layer.type === 'solid' && !p.layer.area);
|
|
37
|
+
return solid ? toHex(solid.layer.color ?? c.palette.bg) : undefined;
|
|
38
|
+
}
|
|
39
|
+
const x0 = Math.max(0, Math.floor(box.x * s));
|
|
40
|
+
const y0 = Math.max(0, Math.floor(box.y * s));
|
|
41
|
+
const x1 = Math.min(w, Math.ceil((box.x + box.width) * s));
|
|
42
|
+
const y1 = Math.min(h, Math.ceil((box.y + box.height) * s));
|
|
43
|
+
let r = 0; let g = 0; let b = 0; let n = 0;
|
|
44
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
|
|
45
|
+
const i = (y * w + x) * 4;
|
|
46
|
+
const a = px[i + 3] / 255;
|
|
47
|
+
// Unpainted pixels read as the page colour.
|
|
48
|
+
const bg = c.palette.bg;
|
|
49
|
+
const br = parseInt(bg.slice(1, 3), 16); const bgc = parseInt(bg.slice(3, 5), 16); const bb = parseInt(bg.slice(5, 7), 16);
|
|
50
|
+
r += px[i] * a + br * (1 - a); g += px[i + 1] * a + bgc * (1 - a); b += px[i + 2] * a + bb * (1 - a); n++;
|
|
51
|
+
}
|
|
52
|
+
if (!n) return undefined;
|
|
53
|
+
return '#' + [r, g, b].map((v) => Math.round(v / n).toString(16).padStart(2, '0')).join('');
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Where a layer paints, for content-sized filter regions.
|
|
2
|
+
import type { Markup, RenderContext } from './context.js';
|
|
3
|
+
|
|
4
|
+
/** The box a layer's markup paints, in its units (drawn small with sharp); undefined when it can't be probed. */
|
|
5
|
+
export function contentBox(m: Markup, ctx: RenderContext): Promise<{ x: number; y: number; width: number; height: number } | undefined>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a layer paints: its markup drawn small and the box round every
|
|
3
|
+
* pixel it covers, so filters can work over that box instead of the whole
|
|
4
|
+
* canvas (in `renderImage`, where librsvg's cost is per pixel of region).
|
|
5
|
+
*
|
|
6
|
+
* @module render/content
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { num } from '../svg.js';
|
|
10
|
+
|
|
11
|
+
/** The probe's longer side, px. */
|
|
12
|
+
const PROBE = 256;
|
|
13
|
+
|
|
14
|
+
/** Markup carrying more embedded data than this isn't probed (decoding it would cost more than it saves). */
|
|
15
|
+
const MAX_PROBE_BYTES = 400_000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The box a layer's markup paints, in its units: drawn at most 256 px
|
|
19
|
+
* across over the canvas and half again round it, one probe pixel of slack
|
|
20
|
+
* on every side. Undefined when it can't be probed (no sharp, heavy
|
|
21
|
+
* embedded images) or paints nothing.
|
|
22
|
+
*
|
|
23
|
+
* @param {{ defs: string, body: string }} m
|
|
24
|
+
* @param {any} ctx
|
|
25
|
+
* @returns {Promise<{ x: number, y: number, width: number, height: number } | undefined>}
|
|
26
|
+
*/
|
|
27
|
+
export async function contentBox(m, ctx) {
|
|
28
|
+
if (!ctx.raster || !ctx.sharp || m.body.length + m.defs.length > MAX_PROBE_BYTES) return undefined;
|
|
29
|
+
const ox = -ctx.width / 2;
|
|
30
|
+
const oy = -ctx.height / 2;
|
|
31
|
+
const uw = ctx.width * 2;
|
|
32
|
+
const uh = ctx.height * 2;
|
|
33
|
+
const s = PROBE / Math.max(uw, uh);
|
|
34
|
+
const w = Math.max(1, Math.round(uw * s));
|
|
35
|
+
const h = Math.max(1, Math.round(uh * s));
|
|
36
|
+
const doc = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="${num(ox, 4)} ${num(oy, 4)} ${num(uw, 4)} ${num(uh, 4)}" preserveAspectRatio="none">${m.defs ? `<defs>${m.defs}</defs>` : ''}${m.body}</svg>`;
|
|
37
|
+
let px;
|
|
38
|
+
try {
|
|
39
|
+
px = await ctx.sharp(new TextEncoder().encode(doc)).ensureAlpha().raw().toBuffer();
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
let x0 = w;
|
|
44
|
+
let y0 = h;
|
|
45
|
+
let x1 = -1;
|
|
46
|
+
let y1 = -1;
|
|
47
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
48
|
+
if (!px[(y * w + x) * 4 + 3]) continue;
|
|
49
|
+
if (x < x0) x0 = x;
|
|
50
|
+
if (x > x1) x1 = x;
|
|
51
|
+
if (y < y0) y0 = y;
|
|
52
|
+
if (y > y1) y1 = y;
|
|
53
|
+
}
|
|
54
|
+
if (x1 < 0) return undefined;
|
|
55
|
+
// Anything touching the probe's edge may run further: the whole region then.
|
|
56
|
+
if (x0 === 0 || y0 === 0 || x1 === w - 1 || y1 === h - 1) return undefined;
|
|
57
|
+
return { x: ox + (x0 - 1) / s, y: oy + (y0 - 1) / s, width: (x1 - x0 + 3) / s, height: (y1 - y0 + 3) / s };
|
|
58
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// What a layer renderer receives and returns (types only).
|
|
2
|
+
import type { Palette, Rng, FontSource, Layer } from '../index.js';
|
|
3
|
+
|
|
4
|
+
/** A rendered layer: markup for `<defs>` and for the body. */
|
|
5
|
+
export interface Markup {
|
|
6
|
+
defs: string;
|
|
7
|
+
body: string;
|
|
8
|
+
/** Mesh layers: the blur's 3σ padding, for the padded raster path. */
|
|
9
|
+
pad?: number;
|
|
10
|
+
/** Mesh layers: the finest blur, px. */
|
|
11
|
+
finest?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A text block as drawn, reported to `lint`. */
|
|
15
|
+
export interface DrawnText {
|
|
16
|
+
kind: 'text';
|
|
17
|
+
id?: string;
|
|
18
|
+
content: string;
|
|
19
|
+
/** Type size, canvas px (the first line, stretched). */
|
|
20
|
+
size: number;
|
|
21
|
+
/** The smallest line's capital height, canvas px (stretched, through every group and transform). */
|
|
22
|
+
capHeight: number;
|
|
23
|
+
box: { x: number; y: number; width: number; height: number } | null;
|
|
24
|
+
/** The narrowest horizontal scale a line was set at (justification, stretch). */
|
|
25
|
+
squeeze?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An image as drawn, reported to `lint`. */
|
|
29
|
+
export interface DrawnImage {
|
|
30
|
+
kind: 'image';
|
|
31
|
+
id?: string;
|
|
32
|
+
/** Its box at the cover's size, canvas px. */
|
|
33
|
+
drawn: { width: number; height: number };
|
|
34
|
+
/** The source's pixels. */
|
|
35
|
+
source?: { width: number; height: number };
|
|
36
|
+
/** The pixels embedded. */
|
|
37
|
+
embedded?: { width: number; height: number };
|
|
38
|
+
fit: 'cover' | 'contain' | 'stretch';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Everything a layer renderer needs. */
|
|
42
|
+
export interface RenderContext {
|
|
43
|
+
/** Canvas width, px (a group's grid width inside a group). */
|
|
44
|
+
width: number;
|
|
45
|
+
height: number;
|
|
46
|
+
canvas: { width: number; height: number };
|
|
47
|
+
palette: Palette;
|
|
48
|
+
/** This layer's own seeded stream. */
|
|
49
|
+
rng: Rng;
|
|
50
|
+
/** A unique id prefix for this layer's defs. */
|
|
51
|
+
id: string;
|
|
52
|
+
/** Available fonts, Inter last. */
|
|
53
|
+
fonts: FontSource[];
|
|
54
|
+
/** The sharp module, when loaded (Node). */
|
|
55
|
+
sharp?: unknown;
|
|
56
|
+
/** Print: bleed plus safe margin, px (anchored text and icons default their margin to it). */
|
|
57
|
+
inset?: number;
|
|
58
|
+
/** `'live'` draws text as `<text>`. */
|
|
59
|
+
textRender?: 'outline' | 'live';
|
|
60
|
+
/** Collects what was drawn, for `lint`. */
|
|
61
|
+
report?: (entry: DrawnText | DrawnImage) => void;
|
|
62
|
+
/** Canvas px per unit here, the larger axis (images and raster effects resample for it). */
|
|
63
|
+
res?: number;
|
|
64
|
+
/** This context's units to canvas px. */
|
|
65
|
+
matrix?: [number, number, number, number, number, number];
|
|
66
|
+
/** Drawn for `renderImage` (librsvg): effects may take their faster forms. */
|
|
67
|
+
raster?: boolean;
|
|
68
|
+
/** The print format's trim and die-cut, canvas px. */
|
|
69
|
+
cut?: { x: number; y: number; width: number; height: number; cut?: import('../formats/cut.js').Cut; mm: number };
|
|
70
|
+
/** Where text blocks landed, by layer id, for `{layout.<id>.<field>}`. */
|
|
71
|
+
layout?: Map<string, Record<string, number>>;
|
|
72
|
+
/** Named layers (`defs`) and drawn copies, for `use`. */
|
|
73
|
+
defs?: Map<string, unknown>;
|
|
74
|
+
/** Identity of the document being rendered (one `@font-face` per face per document). */
|
|
75
|
+
doc?: object;
|
|
76
|
+
/** The average colour under a box (`color: 'auto'`), top-level layers only. */
|
|
77
|
+
backdrop?: (box: { x: number; y: number; width: number; height: number }) => Promise<string | undefined>;
|
|
78
|
+
/** Render a nested stack (groups, masks). */
|
|
79
|
+
stack?: (layers: Layer[], sub: { tag: string; width?: number; height?: number; scale?: number; res?: number; matrix?: [number, number, number, number, number, number] }) => Promise<(Markup & { layer: Layer })[]>;
|
|
80
|
+
/** A group's scale to canvas px. */
|
|
81
|
+
scale?: number;
|
|
82
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Raster grain for renderImage.
|
|
2
|
+
import type { GrainLayer, Rng } from '../index.js';
|
|
3
|
+
|
|
4
|
+
/** Seeded film-grain values at full size (one channel, or three with `monochrome: false`). */
|
|
5
|
+
export function noise(sharp: unknown, width: number, height: number, layer: GrainLayer, rng: Rng): Promise<{ data: Uint8Array; channels: 1 | 3 }>;
|
|
6
|
+
/** Blend grain into RGBA pixels in place. */
|
|
7
|
+
export function applyGrain(pixels: Uint8Array, grain: { data: Uint8Array; channels: 1 | 3; width: number; height: number }, layer: GrainLayer): void;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raster grain for `renderImage`: seeded pixel noise blended in JS (librsvg's
|
|
3
|
+
* feTurbulence is ~20× slower at cover sizes).
|
|
4
|
+
*
|
|
5
|
+
* @module render/grain
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { VARY_FLOOR, VARY_PEAK } from '../layers.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Seeded film-grain values, one per pixel (three with `monochrome: false`),
|
|
12
|
+
* at `size` px per grain (upscaled smoothly). Each value is the mean of two
|
|
13
|
+
* uniform draws, a triangular distribution: fewer harsh extremes than flat
|
|
14
|
+
* noise, closer to film.
|
|
15
|
+
*
|
|
16
|
+
* @param {any} sharp - The sharp module.
|
|
17
|
+
* @param {number} width @param {number} height
|
|
18
|
+
* @param {import('../index.js').GrainLayer} layer
|
|
19
|
+
* @param {import('../rng.js').Rng} rng - The layer's stream.
|
|
20
|
+
* @returns {Promise<{ data: Buffer, channels: 1 | 3 }>} Full-size noise.
|
|
21
|
+
*/
|
|
22
|
+
export async function noise(sharp, width, height, layer, rng) {
|
|
23
|
+
const size = Math.max(1, layer.size ?? 1);
|
|
24
|
+
const channels = layer.monochrome === false ? 3 : 1;
|
|
25
|
+
const nw = Math.ceil(width / size);
|
|
26
|
+
const nh = Math.ceil(height / size);
|
|
27
|
+
const data = Buffer.alloc(nw * nh * channels);
|
|
28
|
+
// xorshift32 seeded from the layer's stream: one 32-bit draw gives two
|
|
29
|
+
// values, so 1.4M pixels cost a few milliseconds.
|
|
30
|
+
let x = (rng.next() * 4294967296) >>> 0 || 1;
|
|
31
|
+
for (let i = 0; i < data.length; i++) {
|
|
32
|
+
x ^= x << 13; x ^= x >>> 17; x ^= x << 5;
|
|
33
|
+
data[i] = ((x & 255) + ((x >>> 8) & 255)) >>> 1;
|
|
34
|
+
}
|
|
35
|
+
if (nw === width && nh === height) return { data, channels };
|
|
36
|
+
const out = await sharp(data, { raw: { width: nw, height: nh, channels } })
|
|
37
|
+
.resize(width, height, { kernel: 'cubic', fit: 'fill' })
|
|
38
|
+
.raw()
|
|
39
|
+
.toBuffer();
|
|
40
|
+
return { data: out, channels };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Separable blend modes (W3C Compositing Level 1), channel values 0–1. */
|
|
44
|
+
const BLENDS = /** @type {Record<string, (b: number, s: number) => number>} */ ({
|
|
45
|
+
normal: (_b, s) => s,
|
|
46
|
+
multiply: (b, s) => b * s,
|
|
47
|
+
screen: (b, s) => b + s - b * s,
|
|
48
|
+
overlay: (b, s) => (b <= 0.5 ? 2 * b * s : 1 - 2 * (1 - b) * (1 - s)),
|
|
49
|
+
'soft-light': (b, s) => {
|
|
50
|
+
if (s <= 0.5) return b - (1 - 2 * s) * b * (1 - b);
|
|
51
|
+
const d = b <= 0.25 ? ((16 * b - 12) * b + 4) * b : Math.sqrt(b);
|
|
52
|
+
return b + (2 * s - 1) * (d - b);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A 256×256 lookup (backdrop × noise → result) for one blend and strength:
|
|
58
|
+
* cheaper than the blend maths per pixel.
|
|
59
|
+
*
|
|
60
|
+
* @param {(b: number, s: number) => number} blend
|
|
61
|
+
* @param {number} amount
|
|
62
|
+
* @returns {Uint8Array}
|
|
63
|
+
*/
|
|
64
|
+
function grainTable(blend, amount) {
|
|
65
|
+
const table = new Uint8Array(65536);
|
|
66
|
+
for (let b = 0; b < 256; b++) {
|
|
67
|
+
for (let s = 0; s < 256; s++) {
|
|
68
|
+
const cb = b / 255;
|
|
69
|
+
table[(b << 8) | s] = Math.round((cb + amount * (blend(cb, s / 255) - cb)) * 255);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return table;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Blend grain into RGBA pixels in place, at the layer's `amount`.
|
|
77
|
+
* Done in JS rather than with sharp's `composite` blend modes, whose
|
|
78
|
+
* overlay with a partly transparent layer flattens the noise to almost
|
|
79
|
+
* nothing (measured σ 0.5 against the expected 5.9 on mid grey).
|
|
80
|
+
*
|
|
81
|
+
* @param {Buffer} pixels - RGBA pixels (modified).
|
|
82
|
+
* @param {{ data: Buffer, channels: 1 | 3, width: number, height: number }} grain - Noise from {@link noise}, and the canvas size.
|
|
83
|
+
* @param {import('../index.js').GrainLayer} layer - The grain layer.
|
|
84
|
+
* @returns {void}
|
|
85
|
+
*/
|
|
86
|
+
export function applyGrain(pixels, grain, layer) {
|
|
87
|
+
const amount = Math.max(0, Math.min(1, (layer.amount ?? 0.08) * (layer.opacity ?? 1)));
|
|
88
|
+
const blend = BLENDS[layer.blend ?? 'overlay'];
|
|
89
|
+
if (!blend) throw new TypeError(`Unknown grain blend "${layer.blend}" (${Object.keys(BLENDS).join(', ')})`);
|
|
90
|
+
const table = grainTable(blend, amount);
|
|
91
|
+
const { data, channels } = grain;
|
|
92
|
+
if (layer.vary === 'edges') {
|
|
93
|
+
// Heavier toward the edges: per-pixel strength from the centre out, as in the SVG mask.
|
|
94
|
+
const { width, height } = grain;
|
|
95
|
+
const peak = Math.min(1, amount * VARY_PEAK);
|
|
96
|
+
const tables = Array.from({ length: 17 }, (_, k) => grainTable(blend, peak * (VARY_FLOOR + (1 - VARY_FLOOR) * (k / 16))));
|
|
97
|
+
const half = Math.hypot(width, height) / 2;
|
|
98
|
+
for (let y = 0, i = 0, j = 0; y < height; y++) {
|
|
99
|
+
for (let x = 0; x < width; x++, i += 4, j += channels) {
|
|
100
|
+
const t = tables[Math.min(16, Math.round((Math.hypot(x - width / 2, y - height / 2) / half) * 16))];
|
|
101
|
+
pixels[i] = t[(pixels[i] << 8) | data[j]];
|
|
102
|
+
pixels[i + 1] = t[(pixels[i + 1] << 8) | data[j + (channels === 3 ? 1 : 0)]];
|
|
103
|
+
pixels[i + 2] = t[(pixels[i + 2] << 8) | data[j + (channels === 3 ? 2 : 0)]];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
for (let i = 0, j = 0; i < pixels.length; i += 4, j += channels) {
|
|
109
|
+
pixels[i] = table[(pixels[i] << 8) | data[j]];
|
|
110
|
+
pixels[i + 1] = table[(pixels[i + 1] << 8) | data[j + (channels === 3 ? 1 : 0)]];
|
|
111
|
+
pixels[i + 2] = table[(pixels[i + 2] << 8) | data[j + (channels === 3 ? 2 : 0)]];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The `group` layer.
|
|
2
|
+
import type { Length, Layer, BaseLayer, Anchor } from '../index.js';
|
|
3
|
+
import type { RenderContext, Markup } from './context.js';
|
|
4
|
+
|
|
5
|
+
/** `group`: a stack drawn as one, on its own design grid. */
|
|
6
|
+
export interface GroupLayer extends BaseLayer {
|
|
7
|
+
type: 'group';
|
|
8
|
+
layers: Layer[];
|
|
9
|
+
/** The children's coordinate grid, e.g. `[0, 0, 400, 500]`. */
|
|
10
|
+
viewBox?: [number, number, number, number];
|
|
11
|
+
x?: Length | `${number}%`; y?: Length | `${number}%`; width?: Length | `${number}%`; height?: Length | `${number}%`;
|
|
12
|
+
/** Default `'contain'`. */
|
|
13
|
+
fit?: 'contain' | 'cover' | 'stretch' | 'width' | 'height';
|
|
14
|
+
/** Where a fitted grid sits. Default `'center'`. */
|
|
15
|
+
align?: Anchor;
|
|
16
|
+
clipToBox?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
/** Draw a group: its stack on its grid, fitted into its box. */
|
|
21
|
+
export function renderGroup(layer: GroupLayer, ctx: RenderContext, decorate?: (m: Markup, gctx: RenderContext) => Promise<Markup>): Promise<Markup>;
|
|
22
|
+
/** An anchor as fractions `[x, y]` of the free space. */
|
|
23
|
+
export function alignOf(a?: string): [number, number];
|