@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,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks for release cover art before it goes to a distributor.
|
|
3
|
+
*
|
|
4
|
+
* Spotify's own requirements (Spotify for Artists, "Cover art
|
|
5
|
+
* requirements"): square (1:1), 640–10000 px on each side, sRGB, and never
|
|
6
|
+
* upscaled. 3000×3000 is the size distributors and the other stores ask
|
|
7
|
+
* for. The content rules come from the distributors that deliver to
|
|
8
|
+
* Spotify (and Apple Music): no URLs, social handles or contact details, no
|
|
9
|
+
* prices or promotional wording ("out now", "free download"), no release
|
|
10
|
+
* dates or other time frames, no store or platform names and logos, no
|
|
11
|
+
* barcodes or QR codes, and text that matches the release's metadata.
|
|
12
|
+
* Those are reported as errors when a distributor would reject the art,
|
|
13
|
+
* warnings when it may.
|
|
14
|
+
*
|
|
15
|
+
* `lint` also renders the cover to measure what it draws, through every
|
|
16
|
+
* group, transform and stretch: text whose smallest capitals are under 4 px
|
|
17
|
+
* tall at a 64 px thumbnail (the size Spotify lists use) is flagged, and so
|
|
18
|
+
* are images drawn larger than their pixels (or embedded smaller than
|
|
19
|
+
* drawn). Store names that are also words ("Tidal Archive") and day
|
|
20
|
+
* names in titles ("Second Sun") need a cue to count; text spelled from
|
|
21
|
+
* the artist and title (a letter per layer, a knocked-out word) matches.
|
|
22
|
+
*
|
|
23
|
+
* @module lint
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { cover, renderReport } from '../render.js';
|
|
27
|
+
import { RULES } from './rules.js';
|
|
28
|
+
import { imageSize } from './image-size.js';
|
|
29
|
+
|
|
30
|
+
export { imageSize };
|
|
31
|
+
|
|
32
|
+
/** Glyphs set narrower than this (justification) read smaller than their cap height. */
|
|
33
|
+
const SQUEEZE = 0.6;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {object} LintIssue
|
|
37
|
+
* @property {string} rule
|
|
38
|
+
* @property {'error' | 'warning'} severity
|
|
39
|
+
* @property {string} message
|
|
40
|
+
* @property {string} [layer] - The layer's id, when it has one.
|
|
41
|
+
* @property {string} [text] - The text that tripped the rule.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check a cover against a target's artwork rules.
|
|
46
|
+
*
|
|
47
|
+
* @param {import('../index.js').CoverOptions | import('../index.js').Cover} input
|
|
48
|
+
* @param {{ target?: 'spotify' | 'release', artist?: string, title?: string, size?: number }} [options] - `artist`, `title`: the release metadata the text should match. `size`: the delivery size (default 3000).
|
|
49
|
+
* @returns {Promise<{ ok: boolean, target: string, issues: LintIssue[] }>}
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* const { ok, issues } = await lint({ recipe: myCover, format: 'release', title: 'Night Garden' }, { target: 'spotify' });
|
|
53
|
+
*/
|
|
54
|
+
export async function lint(input, options = {}) {
|
|
55
|
+
const target = options.target ?? 'spotify';
|
|
56
|
+
if (target !== 'spotify' && target !== 'release') throw new TypeError(`Unknown lint target "${target}" (spotify, release)`);
|
|
57
|
+
const c = cover(input);
|
|
58
|
+
/** @type {LintIssue[]} */
|
|
59
|
+
const issues = [];
|
|
60
|
+
const add = (/** @type {LintIssue} */ i) => issues.push(i);
|
|
61
|
+
|
|
62
|
+
// Spotify's requirements.
|
|
63
|
+
if (c.width !== c.height) add({ rule: 'aspect', severity: 'error', message: `Cover art must be square (1:1); this is ${c.width}×${c.height}.` });
|
|
64
|
+
const side = Math.min(c.width, c.height);
|
|
65
|
+
if (side < 640) add({ rule: 'resolution', severity: 'error', message: `Cover art must be at least 640 px on each side; this is ${c.width}×${c.height}.` });
|
|
66
|
+
else if (side < (options.size ?? 3000)) add({ rule: 'resolution', severity: 'warning', message: `Deliver ${options.size ?? 3000}×${options.size ?? 3000} (stores and distributors ask for it); this is ${c.width}×${c.height}. Render with format: 'release'.` });
|
|
67
|
+
if (Math.max(c.width, c.height) > 10000) add({ rule: 'resolution', severity: 'error', message: 'Cover art must be at most 10000 px on each side.' });
|
|
68
|
+
|
|
69
|
+
// What's drawn (sizes in canvas px, through every group and transform).
|
|
70
|
+
const { svg, drawn } = await renderReport(c);
|
|
71
|
+
const texts = drawn.filter((d) => d.kind === 'text').map((d) => ({ ...d }));
|
|
72
|
+
// Raw SVG text the layer system didn't set.
|
|
73
|
+
for (const m of svg.matchAll(/<text\b[^>]*>([^<]+)<\/text>/g)) texts.push({ kind: 'text', content: m[1], size: undefined, capHeight: undefined });
|
|
74
|
+
|
|
75
|
+
for (const t of texts) {
|
|
76
|
+
const content = String(t.content ?? '');
|
|
77
|
+
for (const [rule, severity, re, what] of RULES) {
|
|
78
|
+
const m = re.exec(content);
|
|
79
|
+
if (m) add({ rule, severity, layer: t.id, text: m[0].trim(), message: `Text contains ${what}: "${m[0].trim()}". Distributors reject cover art with ${what}.` });
|
|
80
|
+
}
|
|
81
|
+
// Legibility at a 64 px thumbnail: the block's smallest capitals as drawn,
|
|
82
|
+
// narrowed by a heavy squeeze (justification scaling glyphs under 60% wide).
|
|
83
|
+
if (t.capHeight != null) {
|
|
84
|
+
const squeeze = (t.squeeze ?? 1) < SQUEEZE ? Math.sqrt(t.squeeze / SQUEEZE) : 1;
|
|
85
|
+
const thumb = (t.capHeight * squeeze * 64) / Math.max(c.width, c.height);
|
|
86
|
+
if (thumb < 4) add({ rule: 'legibility', severity: 'warning', layer: t.id, text: content.slice(0, 40), message: `"${content.slice(0, 40)}${content.length > 40 ? '…' : ''}" is ${thumb.toFixed(1)} px tall (capitals) at a 64 px thumbnail; under 4 px it can't be read.` });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Text that isn't the release's metadata. Text that is part of it (a
|
|
91
|
+
// letter, a word knocked out of a shape) passes; so does anything spelled
|
|
92
|
+
// inside the artist and title.
|
|
93
|
+
if (options.artist || options.title) {
|
|
94
|
+
const meta = `${options.artist ?? ''} ${options.title ?? ''}`.toLowerCase();
|
|
95
|
+
const allowed = new Set(meta.split(/[^\p{L}\p{N}]+/u).filter(Boolean));
|
|
96
|
+
const squashed = meta.replace(/[^\p{L}\p{N}]+/gu, '');
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
for (const t of texts) {
|
|
99
|
+
const text = String(t.content ?? '').toLowerCase();
|
|
100
|
+
const letters = text.replace(/[^\p{L}\p{N}]+/gu, '');
|
|
101
|
+
if (!letters || squashed.includes(letters) || seen.has(text)) continue;
|
|
102
|
+
seen.add(text);
|
|
103
|
+
const extra = text.split(/[^\p{L}\p{N}]+/u).filter((w) => w && !allowed.has(w));
|
|
104
|
+
if (extra.length) add({ rule: 'metadata', severity: 'warning', layer: t.id, text: extra.slice(0, 6).join(' '), message: `Text doesn't match the release metadata (${extra.slice(0, 6).join(', ')}${extra.length > 6 ? ', …' : ''}). Stores ask that cover text matches the artist and title.` });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Barcodes and QR codes.
|
|
109
|
+
const walk = (/** @type {any[]} */ list, /** @type {(l: any) => void} */ fn) => list.forEach((l) => { fn(l); for (const k of ['layers', 'mask', 'knockout', 'clip']) if (Array.isArray(l[k])) walk(l[k], fn); });
|
|
110
|
+
walk(c.layers, (l) => {
|
|
111
|
+
if (l.type === 'barcode') add({ rule: 'barcode', severity: 'error', layer: l.id, message: 'Cover art can\'t carry a barcode or QR code.' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Images drawn bigger than their pixels (at the delivery size), or
|
|
115
|
+
// embedded at fewer pixels than they're drawn at.
|
|
116
|
+
const delivery = (options.size ?? 3000) / Math.max(c.width, c.height);
|
|
117
|
+
for (const im of drawn.filter((d) => d.kind === 'image' && d.source)) {
|
|
118
|
+
const bw = im.drawn.width * delivery;
|
|
119
|
+
const bh = im.drawn.height * delivery;
|
|
120
|
+
// Output pixels per source pixel: contain fits the whole image, cover fills the box.
|
|
121
|
+
const grow = im.fit === 'contain' ? Math.min(bw / im.source.width, bh / im.source.height) : im.fit === 'stretch' ? Math.max(bw / im.source.width, bh / im.source.height) : Math.max(bw / im.source.width, bh / im.source.height);
|
|
122
|
+
if (grow > 1.05) add({ rule: 'upscaled', severity: 'warning', layer: im.id, message: `An image (${im.source.width}×${im.source.height}) is drawn ${grow.toFixed(1)}× its size at ${options.size ?? 3000}×${options.size ?? 3000}: it will look soft. Spotify asks for art that isn't upscaled.` });
|
|
123
|
+
else if (im.embedded && Math.max(im.embedded.width / im.drawn.width, im.embedded.height / im.drawn.height) < 0.95) add({ rule: 'upscaled', severity: 'warning', layer: im.id, message: `An image is embedded at ${im.embedded.width}×${im.embedded.height} but drawn at ${Math.round(im.drawn.width)}×${Math.round(im.drawn.height)}: it was downsampled and will look soft.` });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { ok: !issues.some((i) => i.severity === 'error'), target, issues };
|
|
127
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text rules `lint` applies: what distributors reject on cover art.
|
|
3
|
+
*
|
|
4
|
+
* @module lint/rules
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Text rules: id, severity, pattern, what's wrong. */
|
|
8
|
+
export const RULES = /** @type {[string, 'error' | 'warning', RegExp, string][]} */ ([
|
|
9
|
+
['url', 'error', /\b(?:https?:\/\/|www\.)\S+|\b[a-z0-9][a-z0-9-]*\.(?:com|net|org|io|co|uk|fm|app|dev|me|tv|music|shop|store|info|biz|xyz|fair|art|band|live|link|studio|club|gg|ly|to|de|fr|nl)\b/i, 'a website address'],
|
|
10
|
+
['handle', 'error', /(?:^|[\s(])@[A-Za-z0-9_.]{2,}/, 'a social media handle'],
|
|
11
|
+
['contact', 'error', /[\w.+-]+@[\w-]+\.[\w.]+|(?:\+\d{1,3}[ .-]?)?(?:\(\d{2,5}\)[ .-]?|\d{2,5}[ .-])\d{3,4}[ .-]?\d{3,4}\b/, 'contact details (email or phone)'],
|
|
12
|
+
['price', 'error', /[$£€¥]\s?\d|\b\d+(?:[.,]\d+)?\s?(?:usd|eur|gbp)\b|\bfree(?:\s+(?:download|entry|in|stream))?\b|\bbuy now\b|\bon sale\b/i, 'a price or a sales line'],
|
|
13
|
+
['promo', 'error', /\b(?:out now|coming soon|new (?:single|album|ep|music)|available (?:now|everywhere)|pre-?(?:save|order)|stream (?:it )?now|listen now)\b/i, 'promotional wording'],
|
|
14
|
+
// Brand names that are only ever the platform; "Tidal" is also a word, so it counts with a cue ("on Tidal", "stream Tidal") or on its own.
|
|
15
|
+
['platform', 'error', /\b(?:spotify|apple music|itunes|youtube|bandcamp|soundcloud|deezer|amazon music|tiktok|instagram)\b|\b(?:on|via|stream(?:ing)?(?:\s+on)?|listen(?:\s+on)?|available\s+on|find\s+us\s+on|follow\s+(?:us\s+)?on)\s+tidal\b|^\s*tidal\s*$/im, 'a store or platform name'],
|
|
16
|
+
// Months and short day names count next to a number ("Sat 14", "14 March"); a full day name counts alone; "Second Sun" doesn't.
|
|
17
|
+
['date', 'warning', /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+\d{1,2}(?:st|nd|rd|th)?\b|\b\d{1,2}(?:st|nd|rd|th)?\s+(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\b|\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b|\b(?:mon|tues?|wed|thu(?:rs?)?|fri|sat|sun)\.?,?\s+\d{1,2}\b|\b\d{1,2}(?:st|nd|rd|th)?,?\s+(?:mon|tues?|wed|thu(?:rs?)?|fri|sat|sun)\b|\b(?:mon|tues|wednes|thurs|fri|satur|sun)day\b|\b(?:today|tonight|this (?:week|weekend|friday))\b/i, 'a date or time frame'],
|
|
18
|
+
['physical', 'warning', /\b(?:cd|vinyl|cassette|lp|7")\b/i, 'a physical format'],
|
|
19
|
+
]);
|
package/src/node.d.ts
ADDED
package/src/node.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@arraypress/covers/node`: the same API, with Node's modules installed
|
|
3
|
+
* up front. Needed only on Node older than 20.16 (no
|
|
4
|
+
* `process.getBuiltinModule`), or in a bundle where you want the file
|
|
5
|
+
* system wired in explicitly.
|
|
6
|
+
*
|
|
7
|
+
* @module @arraypress/covers/node
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as fsp from 'node:fs/promises';
|
|
12
|
+
import * as zlib from 'node:zlib';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import * as module from 'node:module';
|
|
15
|
+
import * as url from 'node:url';
|
|
16
|
+
import { installNode } from './platform/index.js';
|
|
17
|
+
|
|
18
|
+
installNode({ fs, fsp, zlib, path, module, url });
|
|
19
|
+
|
|
20
|
+
export * from './index.js';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Paints: a colour, 'none', or a gradient over a shape's bounds.
|
|
2
|
+
import type { Color, Length } from '../index.js';
|
|
3
|
+
|
|
4
|
+
/** A gradient over a shape's bounds. */
|
|
5
|
+
export interface GradientPaint {
|
|
6
|
+
type: 'linear' | 'radial';
|
|
7
|
+
colors: Color[];
|
|
8
|
+
stops?: number[];
|
|
9
|
+
opacities?: number[];
|
|
10
|
+
/** Linear: CSS degrees. Default 180 (top to bottom). */
|
|
11
|
+
angle?: number;
|
|
12
|
+
/** `'reflect'` / `'repeat'` with a `length` run: holographic foil, stripes. */
|
|
13
|
+
spread?: 'pad' | 'reflect' | 'repeat';
|
|
14
|
+
length?: Length;
|
|
15
|
+
cx?: number; cy?: number; r?: Length; fx?: number; fy?: number;
|
|
16
|
+
/** Radial: elliptical radii (lengths, or fractions ≤ 1 of the box). */
|
|
17
|
+
rx?: Length; ry?: Length;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A pattern repeated over a shape. */
|
|
21
|
+
export interface TilePaint {
|
|
22
|
+
type: 'tile';
|
|
23
|
+
kind?: 'checker' | 'stripes' | 'dots' | 'grid' | 'custom';
|
|
24
|
+
/** One cell (or `[width, height]`). Default `'4vmin'`. */
|
|
25
|
+
size?: Length | [Length, Length];
|
|
26
|
+
/** `[ground, ink]`; the ground may be `'none'`. */
|
|
27
|
+
colors?: [Color | 'none', Color];
|
|
28
|
+
/** The stripe, dot or line share of a cell. */
|
|
29
|
+
ratio?: number;
|
|
30
|
+
/** Degrees. */
|
|
31
|
+
angle?: number;
|
|
32
|
+
/** `custom`: the tile's markup (sanitised). */
|
|
33
|
+
markup?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A named paint (`'chrome'`, `'steel'`, `'gold'`): a reflected-horizon gradient with its keyline; fields override it. */
|
|
37
|
+
export interface PresetPaint extends Partial<Omit<GradientPaint, 'type'>> {
|
|
38
|
+
preset: 'chrome' | 'steel' | 'gold';
|
|
39
|
+
type?: 'linear' | 'radial';
|
|
40
|
+
/** The keyline it brings (`false` for none). */
|
|
41
|
+
keyline?: false | { color: Color; width: Length }[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A fill or stroke: a colour, `'none'`, a gradient, a tile or a preset. */
|
|
45
|
+
export type Paint = Color | 'none' | GradientPaint | TilePaint | PresetPaint;
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
/** A paint as an SVG fill value, plus any gradient it needs in `<defs>`. */
|
|
49
|
+
export function paint(v: Paint | undefined, fallback: string, o: { id: string; box: { x: number; y: number; width: number; height: number }; canvas: { width: number; height: number } }): { defs: string; value: string };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paints: a colour, `'none'`, a linear or radial gradient laid over a
|
|
3
|
+
* shape's bounds (reflected foil with `spread` and `length`; elliptical
|
|
4
|
+
* radial fields with `rx`/`ry`), a `tile` pattern (checker, stripes, dots,
|
|
5
|
+
* grid or a custom tile), or a named preset (`'chrome'`).
|
|
6
|
+
*
|
|
7
|
+
* @module paint
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { attrs, num } from '@logomarks/image-effects/svg';
|
|
11
|
+
import { len } from '@logomarks/image-effects/length';
|
|
12
|
+
import { toHex } from '@logomarks/image-effects/color';
|
|
13
|
+
import { PRESETS } from './presets.js';
|
|
14
|
+
import { tile } from './tile.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A fill or stroke: a colour, `'none'`, a gradient object
|
|
18
|
+
* `{ type: 'linear' | 'radial', colors, stops?, opacities?, angle?, spread?: 'pad' | 'reflect' | 'repeat', length?, cx?, cy?, r?, rx?, ry? }`,
|
|
19
|
+
* a tile `{ type: 'tile', kind, size, colors, … }`, or a preset
|
|
20
|
+
* `{ preset: 'chrome', … }` (its fields override the preset's), laid over
|
|
21
|
+
* `box` (the shape's bounds, else the canvas). `length` sets a linear
|
|
22
|
+
* gradient's run in px (with `reflect` it repeats: holographic foil).
|
|
23
|
+
*
|
|
24
|
+
* @param {any} v - The paint.
|
|
25
|
+
* @param {string} fallback - Used when `v` is unset.
|
|
26
|
+
* @param {{ id: string, box: { x: number, y: number, width: number, height: number }, canvas: { width: number, height: number } }} o
|
|
27
|
+
* @returns {{ defs: string, value: string }}
|
|
28
|
+
*/
|
|
29
|
+
export function paint(v, fallback, o) {
|
|
30
|
+
if (v == null || v === '') return { defs: '', value: fallback };
|
|
31
|
+
if (typeof v === 'string') return { defs: '', value: v === 'none' || v === 'currentColor' || v.startsWith('url(') ? v : toHex(v) };
|
|
32
|
+
if (v.preset != null) {
|
|
33
|
+
const base = PRESETS[v.preset];
|
|
34
|
+
if (!base) throw new TypeError(`Unknown paint preset "${v.preset}" (${Object.keys(PRESETS).join(', ')})`);
|
|
35
|
+
const { preset, keyline, ...own } = v;
|
|
36
|
+
return paint({ ...base, ...own }, fallback, o);
|
|
37
|
+
}
|
|
38
|
+
if (v.type === 'tile') return tile(v, o);
|
|
39
|
+
const colors = (v.colors ?? []).map(toHex);
|
|
40
|
+
if (colors.length < 2) throw new TypeError('A gradient paint needs two or more colours');
|
|
41
|
+
const offsets = v.stops ?? colors.map((/** @type {string} */ _, /** @type {number} */ i) => i / (colors.length - 1));
|
|
42
|
+
const stops = colors.map((c, i) => `<stop${attrs({ offset: num(offsets[i], 4), 'stop-color': c, 'stop-opacity': v.opacities?.[i] != null && v.opacities[i] < 1 ? num(v.opacities[i], 3) : undefined })}/>`).join('');
|
|
43
|
+
const { box } = o;
|
|
44
|
+
const spread = v.spread && v.spread !== 'pad' ? v.spread : undefined;
|
|
45
|
+
if (v.type === 'radial') {
|
|
46
|
+
const cx = box.x + (v.cx ?? 0.5) * box.width;
|
|
47
|
+
const cy = box.y + (v.cy ?? 0.5) * box.height;
|
|
48
|
+
// Elliptical: rx and ry (lengths, or fractions ≤ 1 of the box's width and height).
|
|
49
|
+
const radius = (/** @type {any} */ r, /** @type {number} */ side) => (typeof r === 'number' && r <= 1 ? r * side : len(r, o.canvas));
|
|
50
|
+
const rx = v.rx != null ? radius(v.rx, box.width) : undefined;
|
|
51
|
+
const ry = v.ry != null ? radius(v.ry, box.height) : undefined;
|
|
52
|
+
const r = rx ?? ry ?? (v.r != null ? len(v.r, o.canvas) : Math.hypot(box.width, box.height) / 2);
|
|
53
|
+
const squash = rx != null && ry != null && rx > 0 ? ry / rx : 1;
|
|
54
|
+
const gradientTransform = squash !== 1 ? `translate(${num(cx)} ${num(cy)}) scale(1 ${num(squash, 5)}) translate(${num(-cx)} ${num(-cy)})` : undefined;
|
|
55
|
+
return { defs: `<radialGradient${attrs({ id: o.id, gradientUnits: 'userSpaceOnUse', cx, cy, r, fx: v.fx != null ? box.x + v.fx * box.width : undefined, fy: v.fy != null ? box.y + v.fy * box.height : undefined, spreadMethod: spread, gradientTransform })}>${stops}</radialGradient>`, value: `url(#${o.id})` };
|
|
56
|
+
}
|
|
57
|
+
const a = ((v.angle ?? 180) * Math.PI) / 180;
|
|
58
|
+
const cx = box.x + box.width / 2;
|
|
59
|
+
const cy = box.y + box.height / 2;
|
|
60
|
+
const L = v.length != null ? len(v.length, o.canvas) : Math.abs(box.width * Math.sin(a)) + Math.abs(box.height * Math.cos(a));
|
|
61
|
+
const dx = (Math.sin(a) * L) / 2;
|
|
62
|
+
const dy = (-Math.cos(a) * L) / 2;
|
|
63
|
+
const x1 = v.length != null ? box.x : cx - dx;
|
|
64
|
+
const y1 = v.length != null ? box.y : cy - dy;
|
|
65
|
+
return { defs: `<linearGradient${attrs({ id: o.id, gradientUnits: 'userSpaceOnUse', x1, y1, x2: x1 + 2 * dx, y2: y1 + 2 * dy, spreadMethod: spread })}>${stops}</linearGradient>`, value: `url(#${o.id})` };
|
|
66
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Named paints: chrome, steel and gold, painted as a reflected horizon.
|
|
2
|
+
import type { GradientPaint } from './index.js';
|
|
3
|
+
import type { Color, Length } from '../index.js';
|
|
4
|
+
|
|
5
|
+
/** Paint presets, by name. */
|
|
6
|
+
export const PRESETS: Readonly<Record<'chrome' | 'steel' | 'gold', Readonly<GradientPaint>>>;
|
|
7
|
+
/** The keyline metal paints bring: a thin light edge, then a dark one. */
|
|
8
|
+
export const KEYLINE: readonly { readonly color: Color; readonly width: Length }[];
|
|
9
|
+
/** The keyline a fill brings with it (a preset's, unless `keyline: false`). */
|
|
10
|
+
export function presetStrokes(fill: unknown): { color: Color; width: Length }[] | undefined;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named paints. `chrome` is Y2K chrome as it's painted by hand: a
|
|
3
|
+
* reflected horizon, not a lighting filter. Sky at the top, a sharp dark
|
|
4
|
+
* horizon a little past the middle, warm ground below it, and a light
|
|
5
|
+
* bounce at the very bottom. Pair it with a light keyline (`strokes`).
|
|
6
|
+
*
|
|
7
|
+
* @module paint/presets
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Paint presets, by name. A paint `{ preset, …fields }` overrides any of them. */
|
|
11
|
+
export const PRESETS = Object.freeze({
|
|
12
|
+
chrome: Object.freeze({
|
|
13
|
+
type: 'linear',
|
|
14
|
+
angle: 180,
|
|
15
|
+
colors: ['#f4f8ff', '#9fc3ef', '#e8f1ff', '#2b3446', '#7a5a3c', '#d9b48a', '#fff6e6'],
|
|
16
|
+
stops: [0, 0.34, 0.545, 0.555, 0.62, 0.86, 1],
|
|
17
|
+
}),
|
|
18
|
+
/** The same horizon, cooler: steel. */
|
|
19
|
+
steel: Object.freeze({
|
|
20
|
+
type: 'linear',
|
|
21
|
+
angle: 180,
|
|
22
|
+
colors: ['#ffffff', '#b9c4d4', '#eef2f7', '#30363f', '#5d6672', '#c7ced8', '#ffffff'],
|
|
23
|
+
stops: [0, 0.34, 0.545, 0.555, 0.62, 0.86, 1],
|
|
24
|
+
}),
|
|
25
|
+
/** Gold leaf: the horizon in warm metal. */
|
|
26
|
+
gold: Object.freeze({
|
|
27
|
+
type: 'linear',
|
|
28
|
+
angle: 180,
|
|
29
|
+
colors: ['#fff6d6', '#e9c46a', '#fff1c2', '#6b4210', '#a8701f', '#f2cf73', '#fff8e0'],
|
|
30
|
+
stops: [0, 0.34, 0.545, 0.555, 0.62, 0.86, 1],
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** The keyline that goes round metal paints: a thin light edge, then a dark one (widest first, under the fill). */
|
|
35
|
+
export const KEYLINE = Object.freeze([Object.freeze({ color: '#0b0f18', width: '0.9vmin' }), Object.freeze({ color: '#ffffff', width: '0.35vmin' })]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The keyline a fill brings with it: a preset paint's, unless the paint
|
|
39
|
+
* says `keyline: false` (or gives its own `keyline` list).
|
|
40
|
+
*
|
|
41
|
+
* @param {any} fill
|
|
42
|
+
* @returns {{ color: string, width: string | number }[] | undefined}
|
|
43
|
+
*/
|
|
44
|
+
export function presetStrokes(fill) {
|
|
45
|
+
if (!fill || typeof fill !== 'object' || fill.preset == null || fill.keyline === false) return undefined;
|
|
46
|
+
return Array.isArray(fill.keyline) ? fill.keyline : [...KEYLINE];
|
|
47
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Tile paints.
|
|
2
|
+
import type { TilePaint } from './index.js';
|
|
3
|
+
|
|
4
|
+
/** Tile kinds. */
|
|
5
|
+
export const TILES: readonly ('checker' | 'stripes' | 'dots' | 'grid' | 'custom')[];
|
|
6
|
+
/** A tile paint as a `<pattern>` and its fill value. */
|
|
7
|
+
export function tile(v: TilePaint, o: { id: string; box: { x: number; y: number; width: number; height: number }; canvas: { width: number; height: number } }): { defs: string; value: string };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tile paints: a pattern repeated over a shape (checker, stripes, dots,
|
|
3
|
+
* grid), or a custom tile of markup.
|
|
4
|
+
*
|
|
5
|
+
* @module paint/tile
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { attrs, num } from '@logomarks/image-effects/svg';
|
|
9
|
+
import { len } from '@logomarks/image-effects/length';
|
|
10
|
+
import { toHex } from '@logomarks/image-effects/color';
|
|
11
|
+
import { sanitize } from '../layers/svg.js';
|
|
12
|
+
|
|
13
|
+
/** Tile kinds. */
|
|
14
|
+
export const TILES = Object.freeze(['checker', 'stripes', 'dots', 'grid', 'custom']);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A tile paint as a `<pattern>`: `{ type: 'tile', kind, size, colors: [ground, ink], ratio, angle, markup }`.
|
|
18
|
+
* `size` is one cell (a length, default `'4vmin'`); `ratio` the stripe,
|
|
19
|
+
* dot or line share of it; `angle` turns the pattern; a `custom` tile draws
|
|
20
|
+
* `markup` (sanitised) in a `size` square (or `[width, height]`). The
|
|
21
|
+
* pattern starts at the shape's top-left corner. `colors[0]` may be
|
|
22
|
+
* `'none'` for a transparent ground.
|
|
23
|
+
*
|
|
24
|
+
* @param {any} v
|
|
25
|
+
* @param {{ id: string, box: { x: number, y: number, width: number, height: number }, canvas: { width: number, height: number } }} o
|
|
26
|
+
* @returns {{ defs: string, value: string }}
|
|
27
|
+
*/
|
|
28
|
+
export function tile(v, o) {
|
|
29
|
+
const kind = v.kind ?? 'checker';
|
|
30
|
+
if (!TILES.includes(kind)) throw new TypeError(`Unknown tile kind "${kind}" (${TILES.join(', ')})`);
|
|
31
|
+
const [sw, sh] = Array.isArray(v.size) ? v.size.map((/** @type {any} */ s) => len(s, o.canvas)) : [len(v.size, o.canvas, '4vmin'), len(v.size, o.canvas, '4vmin')];
|
|
32
|
+
const [ground, ink] = [v.colors?.[0] ?? '#ffffff', v.colors?.[1] ?? '#111111'].map((c) => (c === 'none' ? 'none' : toHex(c)));
|
|
33
|
+
const ratio = v.ratio ?? (kind === 'dots' ? 0.6 : kind === 'grid' ? 0.08 : 0.5);
|
|
34
|
+
let w = sw;
|
|
35
|
+
let h = sh;
|
|
36
|
+
let body = ground === 'none' ? '' : `<rect${attrs({ width: num(kind === 'checker' ? 2 * sw : sw, 4), height: num(kind === 'checker' ? 2 * sh : sh, 4), fill: ground })}/>`;
|
|
37
|
+
if (kind === 'checker') {
|
|
38
|
+
w = 2 * sw;
|
|
39
|
+
h = 2 * sh;
|
|
40
|
+
body += `<path${attrs({ d: `M0 0h${num(sw, 4)}v${num(sh, 4)}h${num(-sw, 4)}ZM${num(sw, 4)} ${num(sh, 4)}h${num(sw, 4)}v${num(sh, 4)}h${num(-sw, 4)}Z`, fill: ink })}/>`;
|
|
41
|
+
} else if (kind === 'stripes') {
|
|
42
|
+
body += `<rect${attrs({ width: num(sw * ratio, 4), height: num(sh, 4), fill: ink })}/>`;
|
|
43
|
+
} else if (kind === 'dots') {
|
|
44
|
+
body += `<ellipse${attrs({ cx: num(sw / 2, 4), cy: num(sh / 2, 4), rx: num((sw * ratio) / 2, 4), ry: num((sh * ratio) / 2, 4), fill: ink })}/>`;
|
|
45
|
+
} else if (kind === 'grid') {
|
|
46
|
+
body += `<path${attrs({ d: `M0 0H${num(sw, 4)}M0 0V${num(sh, 4)}`, stroke: ink, 'stroke-width': num(Math.min(sw, sh) * ratio * 2, 4), fill: 'none' })}/>`;
|
|
47
|
+
} else {
|
|
48
|
+
body += sanitize(String(v.markup ?? ''));
|
|
49
|
+
}
|
|
50
|
+
const turn = v.angle ? ` rotate(${num(v.angle, 3)})` : '';
|
|
51
|
+
return {
|
|
52
|
+
defs: `<pattern${attrs({ id: o.id, patternUnits: 'userSpaceOnUse', width: num(w, 4), height: num(h, 4), patternTransform: `translate(${num(o.box.x)} ${num(o.box.y)})${turn}` })}>${body}</pattern>`,
|
|
53
|
+
value: `url(#${o.id})`,
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/palette.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { relativeLuminance, contrastRatio, readableOn, readableText } from '@arraypress/color-utils';
|
|
23
|
-
import { toHex, toOklch, fromOklch, mixOklab } from '
|
|
23
|
+
import { toHex, toOklch, fromOklch, mixOklab } from '@logomarks/image-effects/color';
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Curated presets. Each is a full palette; the light ones pair with the
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Files read from disk in Node.
|
|
2
|
+
|
|
3
|
+
/** Read a text file synchronously (Node). */
|
|
4
|
+
export function readTextSync(path: string | URL): string;
|
|
5
|
+
/** A file shipped with the package, from disk (Node), or undefined. */
|
|
6
|
+
export function packageFileSync(rel: string): Uint8Array | undefined;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files read from disk in Node: a recipe's JSON, and the fonts that ship
|
|
3
|
+
* with this package. Node's built-ins come from the image-effects platform
|
|
4
|
+
* adapter (`process.getBuiltinModule`, or what `@arraypress/covers/node`
|
|
5
|
+
* installs).
|
|
6
|
+
*
|
|
7
|
+
* @module platform/files
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { node } from '@logomarks/image-effects/platform';
|
|
11
|
+
|
|
12
|
+
/** This module was loaded from disk (Node, Deno, Bun), not a bundle or a page. */
|
|
13
|
+
const FROM_DISK = String(import.meta.url).startsWith('file:');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read a text file synchronously (Node only; `loadRecipe`).
|
|
17
|
+
*
|
|
18
|
+
* @param {string | URL} path
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function readTextSync(path) {
|
|
22
|
+
const fs = node('fs');
|
|
23
|
+
if (!fs) throw new Error('Reading a file synchronously needs Node. In a browser or Worker, fetch the JSON and pass it to defineRecipe().');
|
|
24
|
+
return fs.readFileSync(path, 'utf8');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read a file that ships with this package (Node, from disk), or undefined.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} rel - Relative to the package root (`'fonts/inter-latin-400.woff'`).
|
|
31
|
+
* @returns {Uint8Array | undefined}
|
|
32
|
+
*/
|
|
33
|
+
export function packageFileSync(rel) {
|
|
34
|
+
const fs = node('fs');
|
|
35
|
+
if (!fs || !FROM_DISK) return undefined;
|
|
36
|
+
try {
|
|
37
|
+
return fs.readFileSync(new URL(`../../${rel}`, import.meta.url));
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// HarfBuzz, loaded once.
|
|
2
|
+
|
|
3
|
+
/** The harfbuzzjs module (its own types need Emscripten's; covers uses a handful of its classes). */
|
|
4
|
+
export type HarfBuzzModule = object;
|
|
5
|
+
|
|
6
|
+
/** harfbuzzjs, with its wasm from `configure({ harfbuzz })` when given. */
|
|
7
|
+
export function harfbuzz(): Promise<HarfBuzzModule>;
|
|
8
|
+
/** harfbuzzjs where it runs (and isn't switched off), else undefined: text then uses the JavaScript outliner. */
|
|
9
|
+
export function shaperModule(): Promise<HarfBuzzModule | undefined>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HarfBuzz, loaded once and lazily, with its WebAssembly from wherever
|
|
3
|
+
* `configure({ harfbuzz })` says. `#harfbuzz` is harfbuzzjs, except under
|
|
4
|
+
* the `workerd` / `worker` conditions, where it's a stub that fails, and
|
|
5
|
+
* text falls back to the JavaScript outliner.
|
|
6
|
+
*
|
|
7
|
+
* @module platform/harfbuzz
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isBytes } from '@logomarks/image-effects/platform';
|
|
11
|
+
import { settings } from './settings.js';
|
|
12
|
+
import { loadBytes, asBytes } from './load.js';
|
|
13
|
+
|
|
14
|
+
/** @type {Promise<any> | undefined} */
|
|
15
|
+
let hbPromise;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Load harfbuzzjs. With `configure({ harfbuzz })` its WebAssembly comes from
|
|
19
|
+
* there (a URL, bytes, a compiled Module or a Response) instead of the file
|
|
20
|
+
* next to harfbuzzjs, which a bundler may not have copied.
|
|
21
|
+
*
|
|
22
|
+
* harfbuzzjs creates small wasm trampolines at runtime (Emscripten's
|
|
23
|
+
* `addFunction`), which Cloudflare Workers forbid; there text falls back to
|
|
24
|
+
* the built-in JavaScript outliner (`fonts/sfnt.js`).
|
|
25
|
+
*
|
|
26
|
+
* @returns {Promise<any>} The harfbuzzjs module.
|
|
27
|
+
*/
|
|
28
|
+
export function harfbuzz() {
|
|
29
|
+
hbPromise ??= (async () => {
|
|
30
|
+
const src = await settings.harfbuzz;
|
|
31
|
+
if (src == null) return import('#harfbuzz');
|
|
32
|
+
const W = /** @type {any} */ (WebAssembly);
|
|
33
|
+
const { instantiate, instantiateStreaming } = W;
|
|
34
|
+
/** @param {any} imports */
|
|
35
|
+
const ours = async (imports) => {
|
|
36
|
+
if (src instanceof WebAssembly.Module) return { module: src, instance: await instantiate(src, imports) };
|
|
37
|
+
const bytes = isBytes(src) || typeof src?.arrayBuffer === 'function' ? await asBytes(src) : await loadBytes(src, 'file');
|
|
38
|
+
return instantiate(bytes, imports);
|
|
39
|
+
};
|
|
40
|
+
let used = false;
|
|
41
|
+
// harfbuzzjs instantiates once, on import: hand it our module then.
|
|
42
|
+
W.instantiateStreaming = (/** @type {any} */ response, /** @type {any} */ imports) => {
|
|
43
|
+
if (used) return instantiateStreaming(response, imports);
|
|
44
|
+
used = true;
|
|
45
|
+
Promise.resolve(response).catch(() => {});
|
|
46
|
+
return ours(imports);
|
|
47
|
+
};
|
|
48
|
+
W.instantiate = (/** @type {any} */ source, /** @type {any} */ imports) => {
|
|
49
|
+
if (used || !(source instanceof Uint8Array || source instanceof ArrayBuffer)) return instantiate(source, imports);
|
|
50
|
+
used = true;
|
|
51
|
+
return ours(imports);
|
|
52
|
+
};
|
|
53
|
+
try {
|
|
54
|
+
return await import('#harfbuzz');
|
|
55
|
+
} finally {
|
|
56
|
+
W.instantiate = instantiate;
|
|
57
|
+
W.instantiateStreaming = instantiateStreaming;
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
return hbPromise;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether outlines come from HarfBuzz: `shaper: 'harfbuzz'`, or `'auto'`
|
|
65
|
+
* where HarfBuzz loads (everywhere but Workers).
|
|
66
|
+
*
|
|
67
|
+
* @returns {Promise<any>} harfbuzzjs, or undefined for the JS outliner.
|
|
68
|
+
*/
|
|
69
|
+
export async function shaperModule() {
|
|
70
|
+
if (settings.shaper === 'js') return undefined;
|
|
71
|
+
try {
|
|
72
|
+
return await harfbuzz();
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (settings.shaper === 'harfbuzz') throw err;
|
|
75
|
+
hbPromise = Promise.reject(err);
|
|
76
|
+
hbPromise.catch(() => {});
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The platform adapter.
|
|
2
|
+
export { node, installNode, isNode, deflateSync, deflate, inflate, unbrotli, unbrotliSync, u8, isBytes, toBase64, fromBase64, latin1, utf8, encodeUtf8, concat, fromDataUri, hexDigest } from '@logomarks/image-effects/platform';
|
|
3
|
+
export type { NodeModules } from '@logomarks/image-effects/platform';
|
|
4
|
+
export { readTextSync, packageFileSync } from './files.js';
|
|
5
|
+
export { settings, configure, registry } from './settings.js';
|
|
6
|
+
export type { Settings } from './settings.js';
|
|
7
|
+
export { loadBytes } from './load.js';
|
|
8
|
+
export { harfbuzz, shaperModule } from './harfbuzz.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform: everything that differs between Node, a browser and a
|
|
3
|
+
* Cloudflare Worker, behind one small adapter. Node's built-ins, zlib and
|
|
4
|
+
* the byte helpers are image-effects' (one registry, so
|
|
5
|
+
* `@arraypress/covers/node` installs for both); settings, loading, files
|
|
6
|
+
* and HarfBuzz are covers' own. Nothing here imports a `node:` module.
|
|
7
|
+
*
|
|
8
|
+
* @module platform
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { node, installNode, isNode, deflateSync, deflate, inflate, unbrotli, unbrotliSync, u8, isBytes, toBase64, fromBase64, latin1, utf8, encodeUtf8, concat, fromDataUri, hexDigest } from '@logomarks/image-effects/platform';
|
|
12
|
+
export { readTextSync, packageFileSync } from './files.js';
|
|
13
|
+
export { settings, configure, registry } from './settings.js';
|
|
14
|
+
export { loadBytes } from './load.js';
|
|
15
|
+
export { harfbuzz, shaperModule } from './harfbuzz.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Loading bytes from anything.
|
|
2
|
+
|
|
3
|
+
/** Load an image, font, audio file or icon set. */
|
|
4
|
+
export function loadBytes(src: unknown, kind?: 'image' | 'font' | 'audio' | 'icons' | 'file'): Promise<Uint8Array>;
|
|
5
|
+
/** Bytes from what a loader returned (bytes, a Blob or a Response). */
|
|
6
|
+
export function asBytes(v: unknown): Promise<Uint8Array>;
|