@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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading bytes from anything: bytes, Blobs and Responses, `data:` URIs,
|
|
3
|
+
* paths (Node), URLs (fetched where allowed), or a `configure({ load })`
|
|
4
|
+
* hook.
|
|
5
|
+
*
|
|
6
|
+
* @module platform/load
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { isBytes, u8, fromDataUri, node, isNode } from '@logomarks/image-effects/platform';
|
|
10
|
+
import { settings } from './settings.js';
|
|
11
|
+
|
|
12
|
+
/** @returns {boolean} */
|
|
13
|
+
const networkAllowed = () => settings.network ?? !isNode();
|
|
14
|
+
|
|
15
|
+
/** @returns {typeof fetch} */
|
|
16
|
+
function fetcher() {
|
|
17
|
+
const f = settings.fetch ?? globalThis.fetch;
|
|
18
|
+
if (!f) throw new Error('No fetch available to load a URL: pass bytes, or configure({ fetch })');
|
|
19
|
+
return f;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whatever a loader handed back, as bytes.
|
|
24
|
+
*
|
|
25
|
+
* @param {any} v
|
|
26
|
+
* @returns {Promise<Uint8Array>}
|
|
27
|
+
*/
|
|
28
|
+
async function asBytes(v) {
|
|
29
|
+
if (isBytes(v)) return u8(v);
|
|
30
|
+
if (typeof v?.arrayBuffer === 'function') {
|
|
31
|
+
if (typeof v.ok === 'boolean' && !v.ok) throw new Error(`Couldn't load ${v.url}: HTTP ${v.status}`);
|
|
32
|
+
return new Uint8Array(await v.arrayBuffer());
|
|
33
|
+
}
|
|
34
|
+
throw new TypeError('A loader must return bytes, an ArrayBuffer, a Blob or a Response');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load bytes: an image, font, audio file or icon set.
|
|
39
|
+
*
|
|
40
|
+
* @param {any} src - Bytes, a Blob, a Response, a `data:` URI, a URL, or a path (Node).
|
|
41
|
+
* @param {'image' | 'font' | 'audio' | 'icons' | 'file'} [kind='file'] - What it is (for errors and the loader).
|
|
42
|
+
* @returns {Promise<Uint8Array>} The bytes (a Node Buffer when read from disk).
|
|
43
|
+
*/
|
|
44
|
+
export async function loadBytes(src, kind = 'file') {
|
|
45
|
+
if (isBytes(src)) return u8(src);
|
|
46
|
+
if (src && typeof src === 'object' && !(src instanceof URL)) return asBytes(src);
|
|
47
|
+
if (settings.load) {
|
|
48
|
+
const r = await settings.load(src, { kind });
|
|
49
|
+
if (r != null) return asBytes(r);
|
|
50
|
+
}
|
|
51
|
+
const s = String(src instanceof URL ? src.href : src);
|
|
52
|
+
const data = fromDataUri(s);
|
|
53
|
+
if (data) return data;
|
|
54
|
+
if (/^https?:/i.test(s)) {
|
|
55
|
+
if (!networkAllowed()) {
|
|
56
|
+
throw new TypeError(kind === 'image'
|
|
57
|
+
? 'Image src must be a local file or bytes: covers never fetch from the network (allow it with configure({ network: true }))'
|
|
58
|
+
: `${kind} "${s}" is a URL, and covers doesn't fetch from the network in Node unless asked: configure({ network: true }) or pass bytes`);
|
|
59
|
+
}
|
|
60
|
+
return asBytes(await fetcher()(s));
|
|
61
|
+
}
|
|
62
|
+
const fsp = node('fs/promises');
|
|
63
|
+
const isFileUrl = s.startsWith('file:');
|
|
64
|
+
if (fsp && (isFileUrl || !/^[a-z][\w+.-]*:/i.test(s))) return fsp.readFile(isFileUrl ? new URL(s) : s);
|
|
65
|
+
// A browser: a relative or absolute URL, against the page.
|
|
66
|
+
const base = /** @type {any} */ (globalThis).document?.baseURI ?? /** @type {any} */ (globalThis).location?.href;
|
|
67
|
+
if (base || /^[a-z][\w+.-]*:/i.test(s)) return asBytes(await fetcher()(new URL(s, base).href));
|
|
68
|
+
throw new Error(`Can't load ${kind} "${s}" here: pass bytes, or give covers a loader with configure({ load })`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export { asBytes };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `#harfbuzz` resolves to in Cloudflare Workers (the `workerd` and
|
|
3
|
+
* `worker` export conditions): harfbuzzjs needs to compile WebAssembly
|
|
4
|
+
* trampolines at runtime, which Workers forbid, and its loader imports
|
|
5
|
+
* `node:module`. Importing this fails, so text falls back to the built-in
|
|
6
|
+
* JavaScript outliner (`sfnt.js`).
|
|
7
|
+
*
|
|
8
|
+
* @module no-harfbuzz
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
throw new Error('HarfBuzz is not available in Cloudflare Workers; covers draws text with its JavaScript outliner there');
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Global settings.
|
|
2
|
+
import type { FontSource } from '../index.js';
|
|
3
|
+
|
|
4
|
+
/** Global settings: how files load, where HarfBuzz comes from, how text is drawn. */
|
|
5
|
+
export interface Settings {
|
|
6
|
+
/** Load a file (an image, font, audio or icon set): return bytes, an ArrayBuffer, a Blob or a Response, or undefined to fall through. */
|
|
7
|
+
load?: (src: unknown, info: { kind: string }) => unknown;
|
|
8
|
+
/** Allow fetching http(s) URLs (default: yes in browsers and Workers, no in Node). */
|
|
9
|
+
network?: boolean;
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
/** HarfBuzz's wasm: a URL, bytes, a compiled `WebAssembly.Module`, a `Response`, or a promise of one. */
|
|
12
|
+
harfbuzz?: unknown;
|
|
13
|
+
/** `'auto'` (HarfBuzz where it runs, else the JavaScript outliner), `'harfbuzz'`, `'js'`. */
|
|
14
|
+
shaper?: 'auto' | 'harfbuzz' | 'js';
|
|
15
|
+
text?: 'outline' | 'live';
|
|
16
|
+
/** Where icon sets load from outside Node: a URL template with `{prefix}`, or a function. */
|
|
17
|
+
icons?: string | ((prefix: string) => string);
|
|
18
|
+
/** Fonts for every cover. */
|
|
19
|
+
fonts?: FontSource[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
/** The current settings. */
|
|
24
|
+
export const settings: Settings;
|
|
25
|
+
/** Fonts registered for every cover. */
|
|
26
|
+
export const registry: FontSource[];
|
|
27
|
+
/** Configure loading, HarfBuzz and text rendering (merges with what's set). */
|
|
28
|
+
export function configure(options: Settings): void;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global settings: how files load, where HarfBuzz comes from, how text is
|
|
3
|
+
* drawn. Set with {@link configure}.
|
|
4
|
+
*
|
|
5
|
+
* @module platform/settings
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {object} Settings
|
|
10
|
+
* @property {((src: any, info: { kind: string }) => any) | undefined} load - A loader hook; return bytes (or a Response, Blob, ArrayBuffer), or undefined to fall through.
|
|
11
|
+
* @property {boolean | undefined} network - Allow fetching http(s) URLs.
|
|
12
|
+
* @property {typeof fetch | undefined} fetch - The fetch to use.
|
|
13
|
+
* @property {any} harfbuzz - HarfBuzz's wasm: a URL, bytes, a WebAssembly.Module, a Response, or a promise of one.
|
|
14
|
+
* @property {'auto' | 'harfbuzz' | 'js'} shaper - How text is shaped and outlined.
|
|
15
|
+
* @property {'outline' | 'live'} text - The default text rendering.
|
|
16
|
+
* @property {((prefix: string) => string) | string | undefined} icons - Where icon sets load from outside Node: a URL template with `{prefix}`, or a function.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @type {Settings} */
|
|
20
|
+
export const settings = {
|
|
21
|
+
load: undefined,
|
|
22
|
+
network: undefined,
|
|
23
|
+
fetch: undefined,
|
|
24
|
+
harfbuzz: undefined,
|
|
25
|
+
shaper: 'auto',
|
|
26
|
+
text: 'outline',
|
|
27
|
+
icons: undefined,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Configure how covers loads files, fetches, shapes text and finds
|
|
32
|
+
* HarfBuzz. Settings are global and merge with what's set already.
|
|
33
|
+
*
|
|
34
|
+
* @param {Partial<Settings> & { fonts?: import('../index.js').FontSource[] }} options
|
|
35
|
+
* @returns {void}
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* // A browser app serving the wasm itself:
|
|
39
|
+
* configure({ harfbuzz: '/wasm/harfbuzz.wasm' });
|
|
40
|
+
* // A Worker: no wasm compilation at runtime, so text uses the JS outliner.
|
|
41
|
+
* configure({ shaper: 'js' });
|
|
42
|
+
*/
|
|
43
|
+
export function configure(options = {}) {
|
|
44
|
+
for (const k of /** @type {(keyof Settings)[]} */ (Object.keys(settings))) {
|
|
45
|
+
if (Object.hasOwn(options, k)) /** @type {any} */ (settings)[k] = /** @type {any} */ (options)[k];
|
|
46
|
+
}
|
|
47
|
+
if (options.fonts) for (const f of options.fonts) registry.push(f);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Fonts registered globally (`configure({ fonts })`, `registerFont()`). @type {import('../index.js').FontSource[]} */
|
|
51
|
+
export const registry = [];
|
package/src/print.js
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
* @module print
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { attrs, num,
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
8
|
+
import { attrs, num, esc } from '@logomarks/image-effects/svg';
|
|
9
|
+
import { layerAttrs } from './layers/util.js';
|
|
10
|
+
import { len } from '@logomarks/image-effects/length';
|
|
11
|
+
import { regionPx } from './geometry.js';
|
|
12
|
+
import { toHex } from '@logomarks/image-effects/color';
|
|
13
|
+
import { createRng } from '@logomarks/image-effects/rng';
|
|
14
|
+
import { encodePngAsync, pngUri } from '@logomarks/image-effects/png';
|
|
13
15
|
import { text as textLayer } from './layers.js';
|
|
14
16
|
|
|
15
17
|
/** Classic risograph inks, by name (`'riso:pink'`). Approximations, not trademarks. */
|
|
@@ -126,7 +128,7 @@ export async function ink(layer, ctx) {
|
|
|
126
128
|
for (let i = 0; i < px.length; i++) px[i] = rng.next() < hole ? 0 : 255;
|
|
127
129
|
// Clump the holes a little (riso misses ink in specks, not single pixels).
|
|
128
130
|
for (let i = 0; i < px.length; i++) if (px[i] === 0 && rng.next() < 0.5) px[(i + 1) % px.length] = 0;
|
|
129
|
-
defs += `<pattern${attrs({ id: `${id}t`, patternUnits: 'userSpaceOnUse', width: size, height: size })}><image${attrs({ width: size, height: size, href: pngUri(
|
|
131
|
+
defs += `<pattern${attrs({ id: `${id}t`, patternUnits: 'userSpaceOnUse', width: size, height: size })}><image${attrs({ width: size, height: size, href: pngUri(await encodePngAsync(px, size, size, 1)) })}/></pattern>`;
|
|
130
132
|
defs += `<mask${attrs({ id: `${id}tm`, maskUnits: 'userSpaceOnUse', ...bleed })}><rect${attrs({ ...bleed, fill: `url(#${id}t)` })}/></mask>`;
|
|
131
133
|
inner = `<g mask="url(#${id}tm)">${inner}</g>`;
|
|
132
134
|
} else if (texture === 'halftone') {
|
|
@@ -153,7 +155,7 @@ export async function ink(layer, ctx) {
|
|
|
153
155
|
px[y * T + x] = Math.round((hit / 4) * 255);
|
|
154
156
|
}
|
|
155
157
|
}
|
|
156
|
-
defs += `<pattern${attrs({ id: `${id}t`, patternUnits: 'userSpaceOnUse', width: T, height: T })}><image${attrs({ width: T, height: T, href: pngUri(
|
|
158
|
+
defs += `<pattern${attrs({ id: `${id}t`, patternUnits: 'userSpaceOnUse', width: T, height: T })}><image${attrs({ width: T, height: T, href: pngUri(await encodePngAsync(px, T, T, 1)) })}/></pattern>`;
|
|
157
159
|
defs += `<mask${attrs({ id: `${id}tm`, maskUnits: 'userSpaceOnUse', ...bleed })}><rect${attrs({ ...bleed, fill: `url(#${id}t)` })}/></mask>`;
|
|
158
160
|
inner = `<g mask="url(#${id}tm)">${inner}</g>`;
|
|
159
161
|
}
|
|
@@ -181,7 +183,7 @@ export async function ink(layer, ctx) {
|
|
|
181
183
|
/**
|
|
182
184
|
* Seeded value noise in 2D, smooth, in [0, 1], with octaves.
|
|
183
185
|
*
|
|
184
|
-
* @param {import('
|
|
186
|
+
* @param {import('@logomarks/image-effects/rng').Rng} rng
|
|
185
187
|
* @param {number} octaves
|
|
186
188
|
* @returns {(x: number, y: number) => number}
|
|
187
189
|
*/
|
|
@@ -242,9 +244,9 @@ const BAYER8 = (() => {
|
|
|
242
244
|
*
|
|
243
245
|
* @param {import('./index.js').DitherLayer} layer
|
|
244
246
|
* @param {import('./layers.js').RenderContext} ctx
|
|
245
|
-
* @returns {import('./layers.js').Markup}
|
|
247
|
+
* @returns {Promise<import('./layers.js').Markup>}
|
|
246
248
|
*/
|
|
247
|
-
export function dither(layer, ctx) {
|
|
249
|
+
export async function dither(layer, ctx) {
|
|
248
250
|
const { width: w, height: h, rng, palette: p } = ctx;
|
|
249
251
|
const cell = Math.max(1, Math.round(len(layer.cell, ctx.canvas, '0.9vmin')));
|
|
250
252
|
const cw = Math.ceil(w / cell);
|
|
@@ -309,7 +311,7 @@ export function dither(layer, ctx) {
|
|
|
309
311
|
px[i] = c[0]; px[i + 1] = c[1]; px[i + 2] = c[2];
|
|
310
312
|
}
|
|
311
313
|
}
|
|
312
|
-
const body = `<image${attrs({ x: 0, y: 0, width: cw * cell, height: chh * cell, preserveAspectRatio: 'none', 'image-rendering': 'optimizeSpeed', style: 'image-rendering:pixelated', href: pngUri(
|
|
314
|
+
const body = `<image${attrs({ x: 0, y: 0, width: cw * cell, height: chh * cell, preserveAspectRatio: 'none', 'image-rendering': 'optimizeSpeed', style: 'image-rendering:pixelated', href: pngUri(await encodePngAsync(px, cw, chh, 3)) })}/>`;
|
|
313
315
|
return { defs: '', body: wrap(layer, body) };
|
|
314
316
|
}
|
|
315
317
|
|
|
@@ -457,7 +459,7 @@ function smoothPath(pts) {
|
|
|
457
459
|
* Seeded demo peaks: a smooth loudness envelope with transients. Not audio:
|
|
458
460
|
* pass `peaks` or `audio` for a real episode.
|
|
459
461
|
*
|
|
460
|
-
* @param {import('
|
|
462
|
+
* @param {import('@logomarks/image-effects/rng').Rng} rng
|
|
461
463
|
* @param {number} n
|
|
462
464
|
* @returns {number[]}
|
|
463
465
|
*/
|
|
@@ -515,7 +517,8 @@ export async function waveform(layer, ctx) {
|
|
|
515
517
|
if (!peaks && layer.audio) {
|
|
516
518
|
let gen;
|
|
517
519
|
try {
|
|
518
|
-
|
|
520
|
+
const name = '@arraypress/waveform-gen';
|
|
521
|
+
gen = await import(/* @vite-ignore */ name);
|
|
519
522
|
} catch {
|
|
520
523
|
throw new Error('Decoding audio needs @arraypress/waveform-gen: npm install @arraypress/waveform-gen (or pass peaks)');
|
|
521
524
|
}
|
package/src/recipes.js
CHANGED
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
* @module recipes
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { readFileSync } from 'node:fs';
|
|
27
26
|
import { defineRecipe } from './json-recipe.js';
|
|
27
|
+
import { BUILTIN_JSON } from './builtins.js';
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* What a recipe receives besides the options.
|
|
@@ -33,7 +33,7 @@ import { defineRecipe } from './json-recipe.js';
|
|
|
33
33
|
* @property {number} width - Canvas width.
|
|
34
34
|
* @property {number} height - Canvas height.
|
|
35
35
|
* @property {import('./index.js').Palette} palette - Resolved palette.
|
|
36
|
-
* @property {import('
|
|
36
|
+
* @property {import('@logomarks/image-effects/rng').Rng} rng - The cover's random stream (for recipe-level choices).
|
|
37
37
|
* @property {number} u - The design unit: `sqrt(width × height)`.
|
|
38
38
|
* @property {string} seed - The cover's seed (JSON recipes seed each layer's draws from it).
|
|
39
39
|
*/
|
|
@@ -51,12 +51,13 @@ export function contentOf(v) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
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'];
|
|
54
|
+
const BUILT_IN = ['aurora', 'gridTitle', 'iconTile', 'bigType', 'stripes', 'duotone', 'mark', 'diagram', 'cutout', 'riso', 'dither', 'contour', 'waveform', 'bauhaus', 'issue', 'photo', 'brand'];
|
|
55
55
|
|
|
56
56
|
/** Every built-in recipe, by name, defined from its JSON document. */
|
|
57
57
|
export const recipes = Object.freeze(Object.fromEntries(BUILT_IN.map((name) => [
|
|
58
58
|
name,
|
|
59
|
-
|
|
59
|
+
// A fresh copy: the documents are shared module data.
|
|
60
|
+
defineRecipe(structuredClone(/** @type {any} */ (BUILTIN_JSON)[name])),
|
|
60
61
|
])));
|
|
61
62
|
|
|
62
63
|
/**
|
|
@@ -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 '@logomarks/image-effects/color';
|
|
9
|
+
import { pixelsOf } from '@logomarks/image-effects/raster';
|
|
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,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('@logomarks/image-effects/rng').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];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `group` layer: a nested stack on its own design grid.
|
|
3
|
+
*
|
|
4
|
+
* @module render/group
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { attrs, esc, num } from '@logomarks/image-effects/svg';
|
|
8
|
+
import { pos } from '../layers/util.js';
|
|
9
|
+
import { scaledReport } from './report.js';
|
|
10
|
+
import { IDENTITY, compose } from './transform.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `group` — a stack of layers drawn as one: in a box (`x`, `y`, `width`,
|
|
14
|
+
* `height`, px), on its own design grid (`viewBox: [x, y, w, h]`, so a
|
|
15
|
+
* flyer drawn on a 400×500 grid keeps its numbers at any output size), and
|
|
16
|
+
* fitted by `fit` (`'contain'`, `'cover'`, `'stretch'`, `'width'`, `'height'`).
|
|
17
|
+
* The group's own `transform`, `effects` and masks apply to the whole
|
|
18
|
+
* (effects and masks in the grid's units, through `decorate`).
|
|
19
|
+
*
|
|
20
|
+
* @param {any} layer
|
|
21
|
+
* @param {any} ctx
|
|
22
|
+
* @param {(m: { defs: string, body: string }, gctx: any) => Promise<{ defs: string, body: string }>} [decorate] - Wraps the children, in the grid's units.
|
|
23
|
+
* @returns {Promise<{ defs: string, body: string }>}
|
|
24
|
+
*/
|
|
25
|
+
export async function renderGroup(layer, ctx, decorate) {
|
|
26
|
+
const bx = pos(layer.x ?? 0, 'x', ctx.canvas);
|
|
27
|
+
const by = pos(layer.y ?? 0, 'y', ctx.canvas);
|
|
28
|
+
const bw = layer.width != null ? pos(layer.width, 'x', ctx.canvas) : ctx.width - bx;
|
|
29
|
+
const bh = layer.height != null ? pos(layer.height, 'y', ctx.canvas) : ctx.height - by;
|
|
30
|
+
const vb = layer.viewBox ? (Array.isArray(layer.viewBox) ? layer.viewBox : String(layer.viewBox).split(/[\s,]+/).map(Number)) : [0, 0, bw, bh];
|
|
31
|
+
const [vx, vy, vw, vh] = vb;
|
|
32
|
+
const fit = layer.fit ?? 'contain';
|
|
33
|
+
let sx = bw / vw;
|
|
34
|
+
let sy = bh / vh;
|
|
35
|
+
if (fit === 'contain') sx = sy = Math.min(sx, sy);
|
|
36
|
+
else if (fit === 'cover') sx = sy = Math.max(sx, sy);
|
|
37
|
+
else if (fit === 'width') sy = sx;
|
|
38
|
+
else if (fit === 'height') sx = sy;
|
|
39
|
+
const [ax, ay] = alignOf(layer.align);
|
|
40
|
+
const tx = bx + (bw - vw * sx) * ax;
|
|
41
|
+
const ty = by + (bh - vh * sy) * ay;
|
|
42
|
+
const k = Math.min(sx, sy);
|
|
43
|
+
/** @type {import('./transform.js').Affine} Grid units to the box. */
|
|
44
|
+
const grid = [sx, 0, 0, sy, tx - vx * sx, ty - vy * sy];
|
|
45
|
+
const parts = await ctx.stack(layer.layers ?? [], { tag: 'g', width: vw, height: vh, scale: k, res: Math.max(sx, sy), matrix: grid });
|
|
46
|
+
let defs = parts.map((/** @type {any} */ p) => p.defs).join('');
|
|
47
|
+
let body = parts.map((/** @type {any} */ p) => p.body).join('');
|
|
48
|
+
if (decorate) {
|
|
49
|
+
const gctx = { ...ctx, width: vw, height: vh, canvas: { width: vw, height: vh }, inset: undefined, scale: (ctx.scale ?? 1) * k, res: (ctx.res ?? 1) * Math.max(sx, sy), report: scaledReport(ctx.report, k), matrix: compose(ctx.matrix ?? IDENTITY, grid) };
|
|
50
|
+
({ defs, body } = await decorate({ defs, body }, gctx));
|
|
51
|
+
}
|
|
52
|
+
const identity = tx === 0 && ty === 0 && sx === 1 && sy === 1 && vx === 0 && vy === 0;
|
|
53
|
+
if (!identity) body = `<g transform="translate(${num(tx, 3)} ${num(ty, 3)}) scale(${num(sx, 6)} ${num(sy, 6)})${vx || vy ? ` translate(${num(-vx, 3)} ${num(-vy, 3)})` : ''}">${body}</g>`;
|
|
54
|
+
if (fit === 'cover' || layer.clipToBox) {
|
|
55
|
+
defs += `<clipPath id="${ctx.id}gc"><rect${attrs({ x: bx, y: by, width: bw, height: bh })}/></clipPath>`;
|
|
56
|
+
body = `<g clip-path="url(#${ctx.id}gc)">${body}</g>`;
|
|
57
|
+
}
|
|
58
|
+
const a = attrs({ opacity: layer.opacity != null && layer.opacity < 1 ? Math.max(0, layer.opacity) : undefined, style: layer.blend && layer.blend !== 'normal' ? `mix-blend-mode:${esc(layer.blend)}` : undefined });
|
|
59
|
+
return { defs, body: a ? `<g${a}>${body}</g>` : body };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {string | undefined} a @returns {[number, number]} */
|
|
63
|
+
export function alignOf(a = 'center') {
|
|
64
|
+
const x = a.includes('left') ? 0 : a.includes('right') ? 1 : 0.5;
|
|
65
|
+
const y = a.includes('top') ? 0 : a.includes('bottom') ? 1 : 0.5;
|
|
66
|
+
return [x, y];
|
|
67
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Content-hash cache keys.
|
|
2
|
+
import type { Cover, CoverOptions, RenderImageOptions } from '../index.js';
|
|
3
|
+
|
|
4
|
+
/** A stable cache key for a cover and its output options (40 hex characters). */
|
|
5
|
+
export function coverKey(input: CoverOptions | Cover, options?: RenderImageOptions): Promise<string>;
|