@arraypress/covers 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/LICENSE +29 -0
- package/README.md +846 -2
- package/fonts/OFL.txt +93 -0
- package/fonts/inter-latin-400.woff +0 -0
- package/fonts/inter-latin-500.woff +0 -0
- package/fonts/inter-latin-600.woff +0 -0
- package/fonts/inter-latin-700.woff +0 -0
- package/package.json +124 -4
- package/recipes/aurora.json +132 -0
- package/recipes/bauhaus.json +113 -0
- package/recipes/bigType.json +103 -0
- package/recipes/brand.json +1143 -0
- package/recipes/contour.json +109 -0
- package/recipes/cutout.json +191 -0
- package/recipes/diagram.json +92 -0
- package/recipes/dither.json +170 -0
- package/recipes/duotone.json +75 -0
- package/recipes/gridTitle.json +136 -0
- package/recipes/iconTile.json +214 -0
- package/recipes/issue.json +221 -0
- package/recipes/mark.json +170 -0
- package/recipes/photo.json +156 -0
- package/recipes/riso.json +387 -0
- package/recipes/stripes.json +206 -0
- package/recipes/waveform.json +154 -0
- package/schema/recipe.schema.json +8822 -0
- package/src/animate/index.d.ts +19 -0
- package/src/animate/index.js +116 -0
- package/src/astro.d.ts +31 -0
- package/src/astro.js +86 -0
- package/src/builtins.js +4 -0
- package/src/color.js +246 -0
- package/src/diagram.js +248 -0
- package/src/effects/ascii.d.ts +7 -0
- package/src/effects/ascii.js +93 -0
- package/src/effects/buffer.d.ts +60 -0
- package/src/effects/buffer.js +472 -0
- package/src/effects/detail.d.ts +12 -0
- package/src/effects/detail.js +124 -0
- package/src/effects/digital.d.ts +14 -0
- package/src/effects/digital.js +216 -0
- package/src/effects/distort.d.ts +20 -0
- package/src/effects/distort.js +314 -0
- package/src/effects/dither.d.ts +8 -0
- package/src/effects/dither.js +147 -0
- package/src/effects/drop-shadow.d.ts +4 -0
- package/src/effects/drop-shadow.js +45 -0
- package/src/effects/extrude.d.ts +5 -0
- package/src/effects/extrude.js +55 -0
- package/src/effects/film.d.ts +11 -0
- package/src/effects/film.js +204 -0
- package/src/effects/glow.d.ts +8 -0
- package/src/effects/glow.js +56 -0
- package/src/effects/grade.d.ts +30 -0
- package/src/effects/grade.js +274 -0
- package/src/effects/halftone.d.ts +8 -0
- package/src/effects/halftone.js +95 -0
- package/src/effects/index.d.ts +125 -0
- package/src/effects/index.js +277 -0
- package/src/effects/ink.d.ts +12 -0
- package/src/effects/ink.js +70 -0
- package/src/effects/lens.d.ts +14 -0
- package/src/effects/lens.js +277 -0
- package/src/effects/light.d.ts +10 -0
- package/src/effects/light.js +59 -0
- package/src/effects/looks.d.ts +9 -0
- package/src/effects/looks.js +74 -0
- package/src/effects/mask.d.ts +52 -0
- package/src/effects/mask.js +314 -0
- package/src/effects/motion.d.ts +6 -0
- package/src/effects/motion.js +60 -0
- package/src/effects/newsprint.d.ts +6 -0
- package/src/effects/newsprint.js +89 -0
- package/src/effects/optics.d.ts +10 -0
- package/src/effects/optics.js +213 -0
- package/src/effects/options.d.ts +6 -0
- package/src/effects/options.js +106 -0
- package/src/effects/paint-presets.d.ts +10 -0
- package/src/effects/paint-presets.js +47 -0
- package/src/effects/paint.d.ts +49 -0
- package/src/effects/paint.js +66 -0
- package/src/effects/pixels.d.ts +24 -0
- package/src/effects/pixels.js +138 -0
- package/src/effects/polar.d.ts +10 -0
- package/src/effects/polar.js +265 -0
- package/src/effects/region.d.ts +8 -0
- package/src/effects/region.js +136 -0
- package/src/effects/round-border.d.ts +5 -0
- package/src/effects/round-border.js +62 -0
- package/src/effects/screen.d.ts +14 -0
- package/src/effects/screen.js +212 -0
- package/src/effects/sort.d.ts +8 -0
- package/src/effects/sort.js +125 -0
- package/src/effects/symmetry.d.ts +6 -0
- package/src/effects/symmetry.js +74 -0
- package/src/effects/tile.d.ts +7 -0
- package/src/effects/tile.js +55 -0
- package/src/effects/tone.d.ts +16 -0
- package/src/effects/tone.js +117 -0
- package/src/effects/transform.d.ts +35 -0
- package/src/effects/transform.js +127 -0
- package/src/effects/util.d.ts +16 -0
- package/src/effects/util.js +85 -0
- package/src/extract.js +91 -0
- package/src/flatten.js +234 -0
- package/src/fonts.d.ts +55 -0
- package/src/fonts.js +393 -0
- package/src/formats/cut.d.ts +10 -0
- package/src/formats/cut.js +77 -0
- package/src/formats/index.d.ts +6 -0
- package/src/formats/index.js +9 -0
- package/src/formats/presets.d.ts +41 -0
- package/src/formats/presets.js +89 -0
- package/src/formats/print.d.ts +25 -0
- package/src/formats/print.js +117 -0
- package/src/formats/zones.d.ts +10 -0
- package/src/formats/zones.js +64 -0
- package/src/geometry.js +187 -0
- package/src/grid.js +41 -0
- package/src/handmade.js +253 -0
- package/src/icons.js +191 -0
- package/src/index.d.ts +1083 -0
- package/src/index.js +27 -0
- package/src/inter-data.js +27 -0
- package/src/json-recipe.js +616 -0
- package/src/kit/index.d.ts +20 -0
- package/src/kit/index.js +58 -0
- package/src/layer-options.js +56 -0
- package/src/layers/barcode.d.ts +19 -0
- package/src/layers/barcode.js +63 -0
- package/src/layers/code128.d.ts +4 -0
- package/src/layers/code128.js +28 -0
- package/src/layers/expr.d.ts +6 -0
- package/src/layers/expr.js +121 -0
- package/src/layers/geometry.d.ts +6 -0
- package/src/layers/geometry.js +238 -0
- package/src/layers/index.d.ts +18 -0
- package/src/layers/index.js +15 -0
- package/src/layers/perforation.d.ts +14 -0
- package/src/layers/perforation.js +47 -0
- package/src/layers/repeat.d.ts +29 -0
- package/src/layers/repeat.js +90 -0
- package/src/layers/shape.d.ts +46 -0
- package/src/layers/shape.js +115 -0
- package/src/layers/soft.d.ts +5 -0
- package/src/layers/soft.js +43 -0
- package/src/layers/svg.d.ts +23 -0
- package/src/layers/svg.js +109 -0
- package/src/layers/table.d.ts +25 -0
- package/src/layers/table.js +80 -0
- package/src/layers/tear.d.ts +21 -0
- package/src/layers/tear.js +74 -0
- package/src/layers/texture.d.ts +17 -0
- package/src/layers/texture.js +106 -0
- package/src/layers/util.d.ts +8 -0
- package/src/layers/util.js +48 -0
- package/src/layers.js +1056 -0
- package/src/lint/image-size.d.ts +4 -0
- package/src/lint/image-size.js +34 -0
- package/src/lint/index.d.ts +16 -0
- package/src/lint/index.js +127 -0
- package/src/lint/rules.d.ts +5 -0
- package/src/lint/rules.js +19 -0
- package/src/node.d.ts +2 -0
- package/src/node.js +20 -0
- package/src/palette.js +185 -0
- package/src/platform/bytes.d.ts +22 -0
- package/src/platform/bytes.js +103 -0
- package/src/platform/compress.d.ts +12 -0
- package/src/platform/compress.js +81 -0
- package/src/platform/harfbuzz.d.ts +9 -0
- package/src/platform/harfbuzz.js +80 -0
- package/src/platform/index.d.ts +8 -0
- package/src/platform/index.js +13 -0
- package/src/platform/load.d.ts +6 -0
- package/src/platform/load.js +72 -0
- package/src/platform/no-harfbuzz.d.ts +2 -0
- package/src/platform/no-harfbuzz.js +11 -0
- package/src/platform/node.d.ts +26 -0
- package/src/platform/node.js +86 -0
- package/src/platform/settings.d.ts +28 -0
- package/src/platform/settings.js +51 -0
- package/src/png.js +126 -0
- package/src/print.js +659 -0
- package/src/raster/index.d.ts +24 -0
- package/src/raster/index.js +171 -0
- package/src/recipes.js +79 -0
- package/src/render/backdrop.d.ts +6 -0
- package/src/render/backdrop.js +55 -0
- package/src/render/content.d.ts +5 -0
- package/src/render/content.js +58 -0
- package/src/render/context.d.ts +82 -0
- package/src/render/grain.d.ts +7 -0
- package/src/render/grain.js +113 -0
- package/src/render/group.d.ts +23 -0
- package/src/render/group.js +67 -0
- package/src/render/key.d.ts +5 -0
- package/src/render/key.js +91 -0
- package/src/render/report.d.ts +5 -0
- package/src/render/report.js +19 -0
- package/src/render/tiles.d.ts +6 -0
- package/src/render/tiles.js +77 -0
- package/src/render/use.d.ts +27 -0
- package/src/render/use.js +73 -0
- package/src/render/wrap.d.ts +14 -0
- package/src/render/wrap.js +199 -0
- package/src/render.js +553 -0
- package/src/rng.js +95 -0
- package/src/svg.js +69 -0
- package/src/text.d.ts +29 -0
- package/src/text.js +237 -0
- package/src/type/auto-color.d.ts +9 -0
- package/src/type/auto-color.js +44 -0
- package/src/type/fallback.d.ts +11 -0
- package/src/type/fallback.js +92 -0
- package/src/type/glyphs.d.ts +24 -0
- package/src/type/glyphs.js +87 -0
- package/src/type/index.d.ts +5 -0
- package/src/type/index.js +10 -0
- package/src/type/kern.d.ts +4 -0
- package/src/type/kern.js +144 -0
- package/src/type/layout-ref.d.ts +10 -0
- package/src/type/layout-ref.js +74 -0
- package/src/type/line.d.ts +11 -0
- package/src/type/line.js +105 -0
- package/src/type/live.d.ts +6 -0
- package/src/type/live.js +39 -0
- package/src/type/name.d.ts +4 -0
- package/src/type/name.js +32 -0
- package/src/type/path.d.ts +18 -0
- package/src/type/path.js +89 -0
- package/src/type/pixel.d.ts +9 -0
- package/src/type/pixel.js +115 -0
- package/src/type/sfnt.d.ts +20 -0
- package/src/type/sfnt.js +258 -0
- package/src/type/spans.d.ts +33 -0
- package/src/type/spans.js +138 -0
- package/src/type/symbols.d.ts +7 -0
- package/src/type/symbols.js +89 -0
- package/src/type/typeset.d.ts +10 -0
- package/src/type/typeset.js +352 -0
- package/src/type/woff2.d.ts +6 -0
- package/src/type/woff2.js +301 -0
- package/src/version.js +4 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An image's pixel size from its header, without decoding it.
|
|
3
|
+
*
|
|
4
|
+
* @module lint/image-size
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An image's pixel size from its header (PNG, JPEG, WebP, GIF).
|
|
9
|
+
*
|
|
10
|
+
* @param {Uint8Array} b
|
|
11
|
+
* @returns {{ width: number, height: number } | undefined}
|
|
12
|
+
*/
|
|
13
|
+
export function imageSize(b) {
|
|
14
|
+
const v = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
15
|
+
if (b[0] === 0x89 && b[1] === 0x50) return { width: v.getUint32(16), height: v.getUint32(20) };
|
|
16
|
+
if (b[0] === 0x47 && b[1] === 0x49) return { width: v.getUint16(6, true), height: v.getUint16(8, true) };
|
|
17
|
+
if (b[0] === 0xff && b[1] === 0xd8) {
|
|
18
|
+
let i = 2;
|
|
19
|
+
while (i + 9 < b.length) {
|
|
20
|
+
if (b[i] !== 0xff) { i++; continue; }
|
|
21
|
+
const m = b[i + 1];
|
|
22
|
+
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { width: v.getUint16(i + 7), height: v.getUint16(i + 5) };
|
|
23
|
+
i += 2 + v.getUint16(i + 2);
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
if (String.fromCharCode(b[0], b[1], b[2], b[3]) === 'RIFF' && String.fromCharCode(b[8], b[9], b[10], b[11]) === 'WEBP') {
|
|
28
|
+
const kind = String.fromCharCode(b[12], b[13], b[14], b[15]);
|
|
29
|
+
if (kind === 'VP8X') return { width: 1 + (b[24] | (b[25] << 8) | (b[26] << 16)), height: 1 + (b[27] | (b[28] << 8) | (b[29] << 16)) };
|
|
30
|
+
if (kind === 'VP8L') { const x = v.getUint32(21, true); return { width: (x & 0x3fff) + 1, height: ((x >> 14) & 0x3fff) + 1 }; }
|
|
31
|
+
if (kind === 'VP8 ') return { width: v.getUint16(26, true) & 0x3fff, height: v.getUint16(28, true) & 0x3fff };
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Release cover art checks.
|
|
2
|
+
import type { Cover, CoverOptions } from '../index.js';
|
|
3
|
+
export { imageSize } from './image-size.js';
|
|
4
|
+
|
|
5
|
+
/** A problem `lint` found. */
|
|
6
|
+
export interface LintIssue {
|
|
7
|
+
rule: 'aspect' | 'resolution' | 'url' | 'handle' | 'contact' | 'price' | 'promo' | 'platform' | 'date' | 'physical' | 'legibility' | 'metadata' | 'barcode' | 'upscaled';
|
|
8
|
+
severity: 'error' | 'warning';
|
|
9
|
+
message: string;
|
|
10
|
+
layer?: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
/** Check a cover against Spotify's artwork requirements and distributors' content rules. */
|
|
16
|
+
export function lint(input: CoverOptions | Cover, options?: { target?: 'spotify' | 'release'; artist?: string; title?: string; size?: number }): Promise<{ ok: boolean; target: string; issues: LintIssue[] }>;
|
|
@@ -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';
|
package/src/palette.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palettes: the colours a cover draws with.
|
|
3
|
+
*
|
|
4
|
+
* Every layer reads its default colours from one resolved palette:
|
|
5
|
+
*
|
|
6
|
+
* - `bg` the page colour under everything
|
|
7
|
+
* - `colors` the colours of gradients, blobs, stripes and shapes
|
|
8
|
+
* - `text` type (readable on `bg`)
|
|
9
|
+
* - `accent` the one strong colour (icons, labels, spots)
|
|
10
|
+
* - `onAccent` white, or near-black when the accent is light: for marks on the accent
|
|
11
|
+
* - `muted` secondary type (`text` mixed toward `bg`)
|
|
12
|
+
* - `surface` raised things that sit on `bg` (icon tiles, pills)
|
|
13
|
+
* - `mode` `'light'` or `'dark'`, from `bg`
|
|
14
|
+
*
|
|
15
|
+
* A palette can be written as a preset name, an array (`[bg, ...colors]`),
|
|
16
|
+
* a partial object, or derived from a theme's tokens with
|
|
17
|
+
* {@link derivePalette}.
|
|
18
|
+
*
|
|
19
|
+
* @module palette
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { relativeLuminance, contrastRatio, readableOn, readableText } from '@arraypress/color-utils';
|
|
23
|
+
import { toHex, toOklch, fromOklch, mixOklab } from './color.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Curated presets. Each is a full palette; the light ones pair with the
|
|
27
|
+
* dark ones of the same family (`paper`/`ink`, `dawn`/`dusk`, …).
|
|
28
|
+
*
|
|
29
|
+
* @type {Readonly<Record<string, Readonly<import('./index.js').PaletteInput>>>}
|
|
30
|
+
*/
|
|
31
|
+
export const palettes = Object.freeze({
|
|
32
|
+
paper: { bg: '#f3efe7', colors: ['#f0a98a', '#a9bdf2', '#f3d27e', '#b7dcc4'], text: '#1c1a17', accent: '#c9502a' },
|
|
33
|
+
ink: { bg: '#0c0d12', colors: ['#3d5afe', '#ff6b3d', '#9b4dff', '#12b5a0'], text: '#f4f4f6', accent: '#8ea0ff' },
|
|
34
|
+
dawn: { bg: '#fbf1ee', colors: ['#ff9f87', '#ffc7d9', '#c9b6ff', '#ffd9a8'], text: '#2a1714', accent: '#e0533d' },
|
|
35
|
+
dusk: { bg: '#100b1c', colors: ['#6a3df0', '#ff7a59', '#d1408f', '#2b3fd6'], text: '#f7f3ff', accent: '#ff9c7e' },
|
|
36
|
+
ocean: { bg: '#eef4f7', colors: ['#7fb8f0', '#8de0cf', '#b9c9ff', '#d7f0ff'], text: '#0f1d29', accent: '#1f6fd1' },
|
|
37
|
+
ember: { bg: '#140c08', colors: ['#ff5a1f', '#ffb020', '#d6264f', '#7a2b10'], text: '#fff4ea', accent: '#ffb35c' },
|
|
38
|
+
moss: { bg: '#f1f3ea', colors: ['#a8cf8e', '#e6d48a', '#7fbfa8', '#cfe3b5'], text: '#18200f', accent: '#3f7d2c' },
|
|
39
|
+
carbon: { bg: '#0a0a0b', colors: ['#3a3a40', '#5b5b66', '#26262b', '#8a8a96'], text: '#fafafa', accent: '#e4e4e7' },
|
|
40
|
+
mono: { bg: '#f4f4f5', colors: ['#d4d4d8', '#e4e4e7', '#a1a1aa', '#fafafa'], text: '#18181b', accent: '#18181b' },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Derive a full palette from a theme's tokens (or just an accent).
|
|
45
|
+
*
|
|
46
|
+
* Gradient colours are built in OKLCH around the accent's hue: one near the
|
|
47
|
+
* accent, two neighbours (±40°) and a quiet complement, set pastel on light
|
|
48
|
+
* backgrounds and deep on dark ones. Missing `bg` is a faint tint of the
|
|
49
|
+
* accent (light) or a near-black of its hue (dark); missing `text` is a
|
|
50
|
+
* near-black or near-white of the same hue that clears 7:1 on `bg`.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} tokens
|
|
53
|
+
* @param {string} tokens.accent - The theme's accent (hex, `rgb()` or `hsl()`).
|
|
54
|
+
* @param {string} [tokens.bg] - The theme's background.
|
|
55
|
+
* @param {string} [tokens.text] - The theme's text colour.
|
|
56
|
+
* @param {string[]} [tokens.colors] - Gradient colours to use as-is instead of deriving them.
|
|
57
|
+
* @param {'light' | 'dark'} [tokens.mode] - Force a mode (default: from `bg`, else light).
|
|
58
|
+
* @returns {import('./index.js').Palette} A resolved palette.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* derivePalette({ accent: '#2563eb', bg: '#fdfdfd', text: '#1d1d1f' });
|
|
62
|
+
* derivePalette({ accent: 'rgb(209 254 23)', mode: 'dark' });
|
|
63
|
+
*/
|
|
64
|
+
export function derivePalette({ accent, bg, text, colors, mode } = /** @type {any} */ ({})) {
|
|
65
|
+
if (!accent) throw new TypeError('derivePalette() needs an accent');
|
|
66
|
+
const acc = toHex(accent);
|
|
67
|
+
const a = toOklch(acc);
|
|
68
|
+
const resolvedMode = mode ?? (bg ? modeOf(toHex(bg)) : 'light');
|
|
69
|
+
const dark = resolvedMode === 'dark';
|
|
70
|
+
const hue = a.c < 0.02 ? 260 : a.h; // a grey accent has no meaningful hue
|
|
71
|
+
const chroma = Math.min(1, a.c / 0.12); // greys stay grey
|
|
72
|
+
|
|
73
|
+
const base = bg ? toHex(bg) : fromOklch(dark ? { l: 0.16, c: 0.018 * chroma, h: hue } : { l: 0.975, c: 0.014 * chroma, h: hue });
|
|
74
|
+
|
|
75
|
+
const derived = dark
|
|
76
|
+
? [
|
|
77
|
+
{ l: 0.6, c: 0.17, h: hue },
|
|
78
|
+
{ l: 0.48, c: 0.15, h: hue + 40 },
|
|
79
|
+
{ l: 0.64, c: 0.13, h: hue - 40 },
|
|
80
|
+
{ l: 0.36, c: 0.09, h: hue + 180 },
|
|
81
|
+
]
|
|
82
|
+
: [
|
|
83
|
+
{ l: 0.8, c: 0.12, h: hue },
|
|
84
|
+
{ l: 0.87, c: 0.08, h: hue + 40 },
|
|
85
|
+
{ l: 0.83, c: 0.1, h: hue - 40 },
|
|
86
|
+
{ l: 0.92, c: 0.045, h: hue + 180 },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
return finish({
|
|
90
|
+
bg: base,
|
|
91
|
+
colors: colors?.length ? colors.map(toHex) : derived.map((c) => fromOklch({ ...c, c: c.c * chroma })),
|
|
92
|
+
text: text ? toHex(text) : undefined,
|
|
93
|
+
accent: acc,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve any palette input to a full {@link import('./index.js').Palette}.
|
|
99
|
+
*
|
|
100
|
+
* - `undefined` → the `paper` preset
|
|
101
|
+
* - a preset name (`'dusk'`) → that preset
|
|
102
|
+
* - a single colour (`'#2563eb'`) → {@link derivePalette} from it
|
|
103
|
+
* - an array → `[bg, ...colors]` (a one-item array is an accent)
|
|
104
|
+
* - an object → missing fields filled in (only `accent` → derived)
|
|
105
|
+
*
|
|
106
|
+
* @param {import('./index.js').PaletteInput} [input] - The palette.
|
|
107
|
+
* @returns {import('./index.js').Palette} The resolved palette.
|
|
108
|
+
* @throws {TypeError} On an unknown preset name or an unparseable colour.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* resolvePalette('dusk').mode; // 'dark'
|
|
112
|
+
* resolvePalette(['#0b0b0f', '#ff6a3d', '#3d5afe']).text; // a near-white
|
|
113
|
+
*/
|
|
114
|
+
export function resolvePalette(input) {
|
|
115
|
+
if (input == null) return resolvePalette(palettes.paper);
|
|
116
|
+
if (typeof input === 'string') {
|
|
117
|
+
if (Object.hasOwn(palettes, input)) return resolvePalette(palettes[input]);
|
|
118
|
+
try {
|
|
119
|
+
return derivePalette({ accent: input });
|
|
120
|
+
} catch {
|
|
121
|
+
throw new TypeError(`Unknown palette "${input}". Presets: ${Object.keys(palettes).join(', ')}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (Array.isArray(input)) {
|
|
125
|
+
if (input.length === 0) return resolvePalette(undefined);
|
|
126
|
+
if (input.length === 1) return derivePalette({ accent: input[0] });
|
|
127
|
+
const [bg, ...colors] = input.map(toHex);
|
|
128
|
+
return finish({ bg, colors, accent: colors[0] });
|
|
129
|
+
}
|
|
130
|
+
if (typeof input === 'object') {
|
|
131
|
+
if (!input.bg && !input.colors && input.accent) return derivePalette(/** @type {any} */ (input));
|
|
132
|
+
if (!input.colors?.length && input.accent) return derivePalette(/** @type {any} */ (input));
|
|
133
|
+
const bg = toHex(input.bg ?? '#ffffff');
|
|
134
|
+
const colors = (input.colors?.length ? input.colors : [input.accent ?? (modeOf(bg) === 'dark' ? '#ffffff' : '#000000')]).map(toHex);
|
|
135
|
+
return finish({
|
|
136
|
+
bg,
|
|
137
|
+
colors,
|
|
138
|
+
text: input.text ? toHex(input.text) : undefined,
|
|
139
|
+
accent: input.accent ? toHex(input.accent) : colors[0],
|
|
140
|
+
muted: input.muted ? toHex(input.muted) : undefined,
|
|
141
|
+
surface: input.surface ? toHex(input.surface) : undefined,
|
|
142
|
+
onAccent: input.onAccent ? toHex(input.onAccent) : undefined,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
throw new TypeError('A palette is a preset name, a colour, an array of colours or an object');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `'light'` or `'dark'` for a background.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} bg - `#rrggbb`.
|
|
152
|
+
* @returns {'light' | 'dark'} The mode.
|
|
153
|
+
*/
|
|
154
|
+
export function modeOf(bg) {
|
|
155
|
+
// 0.18 is where black and white text have equal contrast on a surface.
|
|
156
|
+
return relativeLuminance(bg) > 0.18 ? 'light' : 'dark';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Fill the derived fields of a partial palette.
|
|
161
|
+
*
|
|
162
|
+
* @param {{ bg: string, colors: string[], accent: string, text?: string, muted?: string, surface?: string, onAccent?: string }} p
|
|
163
|
+
* @returns {import('./index.js').Palette} The palette.
|
|
164
|
+
*/
|
|
165
|
+
function finish(p) {
|
|
166
|
+
const mode = modeOf(p.bg);
|
|
167
|
+
const dark = mode === 'dark';
|
|
168
|
+
const { h, c } = toOklch(p.accent);
|
|
169
|
+
const tint = Math.min(0.02, c * 0.15);
|
|
170
|
+
let text = p.text;
|
|
171
|
+
if (!text) {
|
|
172
|
+
text = fromOklch(dark ? { l: 0.97, c: tint * 0.5, h } : { l: 0.2, c: tint, h });
|
|
173
|
+
if (contrastRatio(text, p.bg) < 7) text = readableOn(text, [p.bg], { toward: dark ? '#ffffff' : '#000000', ratio: 7 });
|
|
174
|
+
}
|
|
175
|
+
return Object.freeze({
|
|
176
|
+
mode,
|
|
177
|
+
bg: p.bg,
|
|
178
|
+
colors: Object.freeze(p.colors.slice()),
|
|
179
|
+
text,
|
|
180
|
+
accent: p.accent,
|
|
181
|
+
onAccent: p.onAccent ?? readableText(p.accent, '#ffffff', '#111111'),
|
|
182
|
+
muted: p.muted ?? mixOklab(text, p.bg, 0.38),
|
|
183
|
+
surface: p.surface ?? (dark ? mixOklab(p.bg, text, 0.07) : mixOklab(p.bg, '#ffffff', 0.75)),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Byte helpers without Buffer.
|
|
2
|
+
|
|
3
|
+
/** Bytes as a Uint8Array view. */
|
|
4
|
+
export function u8(v: Uint8Array | ArrayBuffer | ArrayBufferView): Uint8Array;
|
|
5
|
+
/** Whether a value is bytes. */
|
|
6
|
+
export function isBytes(v: unknown): v is Uint8Array | ArrayBuffer;
|
|
7
|
+
/** Base64-encode bytes. */
|
|
8
|
+
export function toBase64(bytes: Uint8Array | ArrayBuffer): string;
|
|
9
|
+
/** Decode base64. */
|
|
10
|
+
export function fromBase64(s: string): Uint8Array;
|
|
11
|
+
/** Each byte as one character. */
|
|
12
|
+
export function latin1(b: Uint8Array): string;
|
|
13
|
+
/** Decode UTF-8. */
|
|
14
|
+
export function utf8(b: Uint8Array | ArrayBuffer): string;
|
|
15
|
+
/** Encode UTF-8. */
|
|
16
|
+
export function encodeUtf8(s: string): Uint8Array;
|
|
17
|
+
/** Join byte arrays. */
|
|
18
|
+
export function concat(parts: Uint8Array[]): Uint8Array;
|
|
19
|
+
/** A `data:` URI's bytes, or undefined when it isn't one. */
|
|
20
|
+
export function fromDataUri(uri: string): Uint8Array | undefined;
|
|
21
|
+
/** A hex digest with Web Crypto. */
|
|
22
|
+
export function hexDigest(algo: 'SHA-1' | 'SHA-256', data: Uint8Array | string): Promise<string>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte helpers that work the same in Node, browsers and Workers (no
|
|
3
|
+
* `Buffer`). Where Node's `Buffer` is present it's used for speed; the
|
|
4
|
+
* output is identical either way.
|
|
5
|
+
*
|
|
6
|
+
* @module bytes
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const B = /** @type {any} */ (globalThis).Buffer;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Bytes as a `Uint8Array` view.
|
|
13
|
+
*
|
|
14
|
+
* @param {Uint8Array | ArrayBuffer | ArrayBufferView} v
|
|
15
|
+
* @returns {Uint8Array}
|
|
16
|
+
*/
|
|
17
|
+
export function u8(v) {
|
|
18
|
+
if (v instanceof Uint8Array) return v;
|
|
19
|
+
if (v instanceof ArrayBuffer) return new Uint8Array(v);
|
|
20
|
+
if (ArrayBuffer.isView(v)) return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
|
|
21
|
+
throw new TypeError('Expected bytes (Uint8Array or ArrayBuffer)');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @param {unknown} v @returns {v is Uint8Array | ArrayBuffer} */
|
|
25
|
+
export const isBytes = (v) => v instanceof Uint8Array || v instanceof ArrayBuffer || ArrayBuffer.isView(v);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Base64-encode bytes.
|
|
29
|
+
*
|
|
30
|
+
* @param {Uint8Array | ArrayBuffer} bytes
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function toBase64(bytes) {
|
|
34
|
+
const b = u8(bytes);
|
|
35
|
+
if (B) return B.from(b.buffer, b.byteOffset, b.byteLength).toString('base64');
|
|
36
|
+
let out = '';
|
|
37
|
+
for (let i = 0; i < b.length; i += 0x8000) out += String.fromCharCode.apply(null, /** @type {any} */ (b.subarray(i, i + 0x8000)));
|
|
38
|
+
return btoa(out);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decode base64.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} s
|
|
45
|
+
* @returns {Uint8Array}
|
|
46
|
+
*/
|
|
47
|
+
export function fromBase64(s) {
|
|
48
|
+
if (B) return new Uint8Array(B.from(s, 'base64'));
|
|
49
|
+
const bin = atob(s.replace(/\s+/g, ''));
|
|
50
|
+
const out = new Uint8Array(bin.length);
|
|
51
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @param {Uint8Array} b @returns {string} Each byte as one char. */
|
|
56
|
+
export function latin1(b) {
|
|
57
|
+
let s = '';
|
|
58
|
+
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
|
59
|
+
return s;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {Uint8Array | ArrayBuffer} b @returns {string} */
|
|
63
|
+
export const utf8 = (b) => new TextDecoder().decode(u8(b));
|
|
64
|
+
|
|
65
|
+
/** @param {string} s @returns {Uint8Array} */
|
|
66
|
+
export const encodeUtf8 = (s) => new TextEncoder().encode(s);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Join byte arrays.
|
|
70
|
+
*
|
|
71
|
+
* @param {Uint8Array[]} parts
|
|
72
|
+
* @returns {Uint8Array}
|
|
73
|
+
*/
|
|
74
|
+
export function concat(parts) {
|
|
75
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
76
|
+
let o = 0;
|
|
77
|
+
for (const p of parts) { out.set(p, o); o += p.length; }
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Decode a `data:` URI.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} uri
|
|
85
|
+
* @returns {Uint8Array | undefined} Its bytes, or undefined when it isn't one.
|
|
86
|
+
*/
|
|
87
|
+
export function fromDataUri(uri) {
|
|
88
|
+
const m = /^data:([^,]*?),(.*)$/s.exec(uri);
|
|
89
|
+
if (!m) return undefined;
|
|
90
|
+
return /;base64$/i.test(m[1]) ? fromBase64(m[2]) : encodeUtf8(decodeURIComponent(m[2]));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Hex digest with Web Crypto (Node 20+, browsers, Workers).
|
|
95
|
+
*
|
|
96
|
+
* @param {'SHA-1' | 'SHA-256'} algo
|
|
97
|
+
* @param {Uint8Array | string} data
|
|
98
|
+
* @returns {Promise<string>}
|
|
99
|
+
*/
|
|
100
|
+
export async function hexDigest(algo, data) {
|
|
101
|
+
const buf = await globalThis.crypto.subtle.digest(algo, typeof data === 'string' ? encodeUtf8(data) : u8(data));
|
|
102
|
+
return Array.from(new Uint8Array(buf), (x) => x.toString(16).padStart(2, '0')).join('');
|
|
103
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// zlib on every platform.
|
|
2
|
+
|
|
3
|
+
/** zlib-deflate synchronously (Node), else undefined. */
|
|
4
|
+
export function deflateSync(data: Uint8Array): Uint8Array | undefined;
|
|
5
|
+
/** zlib-deflate (Node's zlib, else CompressionStream). */
|
|
6
|
+
export function deflate(data: Uint8Array): Promise<Uint8Array>;
|
|
7
|
+
/** zlib-inflate. */
|
|
8
|
+
export function inflate(data: Uint8Array): Promise<Uint8Array>;
|
|
9
|
+
/** Brotli-decompress synchronously (Node), else undefined. */
|
|
10
|
+
export function unbrotliSync(data: Uint8Array): Uint8Array | undefined;
|
|
11
|
+
/** Brotli-decompress (Node's zlib, else DecompressionStream('brotli') where there is one), else undefined. */
|
|
12
|
+
export function unbrotli(data: Uint8Array): Promise<Uint8Array | undefined>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zlib deflate and inflate: Node's zlib, else `CompressionStream` and
|
|
3
|
+
* `DecompressionStream` (browsers, Workers). For PNG textures and WOFF.
|
|
4
|
+
*
|
|
5
|
+
* @module platform/compress
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { node } from './node.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* zlib-deflate synchronously, when the platform can (Node).
|
|
12
|
+
*
|
|
13
|
+
* @param {Uint8Array} data
|
|
14
|
+
* @returns {Uint8Array | undefined}
|
|
15
|
+
*/
|
|
16
|
+
export function deflateSync(data) {
|
|
17
|
+
const z = node('zlib');
|
|
18
|
+
return z ? z.deflateSync(data, { level: 9 }) : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* zlib-deflate: Node's zlib, else `CompressionStream` (browsers, Workers).
|
|
23
|
+
* The two may differ in bytes but both are valid zlib streams.
|
|
24
|
+
*
|
|
25
|
+
* @param {Uint8Array} data
|
|
26
|
+
* @returns {Promise<Uint8Array>}
|
|
27
|
+
*/
|
|
28
|
+
export async function deflate(data) {
|
|
29
|
+
return deflateSync(data) ?? stream(data, new CompressionStream('deflate'));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* zlib-inflate (WOFF tables).
|
|
34
|
+
*
|
|
35
|
+
* @param {Uint8Array} data
|
|
36
|
+
* @returns {Promise<Uint8Array>}
|
|
37
|
+
*/
|
|
38
|
+
export async function inflate(data) {
|
|
39
|
+
const z = node('zlib');
|
|
40
|
+
return z ? z.inflateSync(data) : stream(data, new DecompressionStream('deflate'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {Uint8Array} data
|
|
45
|
+
* @param {TransformStream} ts
|
|
46
|
+
* @returns {Promise<Uint8Array>}
|
|
47
|
+
*/
|
|
48
|
+
async function stream(data, ts) {
|
|
49
|
+
const out = new Response(new Blob([/** @type {any} */ (data)]).stream().pipeThrough(ts));
|
|
50
|
+
return new Uint8Array(await out.arrayBuffer());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Brotli-decompress synchronously, when the platform can (Node's zlib).
|
|
55
|
+
*
|
|
56
|
+
* @param {Uint8Array} data
|
|
57
|
+
* @returns {Uint8Array | undefined}
|
|
58
|
+
*/
|
|
59
|
+
export function unbrotliSync(data) {
|
|
60
|
+
const z = node('zlib');
|
|
61
|
+
if (!z?.brotliDecompressSync) return undefined;
|
|
62
|
+
const out = z.brotliDecompressSync(data);
|
|
63
|
+
return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Brotli-decompress (WOFF2): Node's zlib, else `DecompressionStream('brotli')`
|
|
68
|
+
* where the runtime has it. Undefined where neither can.
|
|
69
|
+
*
|
|
70
|
+
* @param {Uint8Array} data
|
|
71
|
+
* @returns {Promise<Uint8Array | undefined>}
|
|
72
|
+
*/
|
|
73
|
+
export async function unbrotli(data) {
|
|
74
|
+
const sync = unbrotliSync(data);
|
|
75
|
+
if (sync) return sync;
|
|
76
|
+
try {
|
|
77
|
+
return await stream(data, new DecompressionStream(/** @type {any} */ ('brotli')));
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -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>;
|