@arraypress/covers 1.0.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -0
- package/README.md +18 -524
- package/package.json +34 -5
- package/recipes/brand.json +1143 -0
- package/recipes/flyers/afterglow-motion.json +540 -0
- package/recipes/flyers/afterglow.json +70 -0
- package/recipes/flyers/arcfield-mirror-motion.json +397 -0
- package/recipes/flyers/arcfield-mirror.json +364 -0
- package/recipes/flyers/bauhaus-oktav.json +319 -0
- package/recipes/flyers/bauhaus-ritmo.json +735 -0
- package/recipes/flyers/bauhaus-volta.json +394 -0
- package/recipes/flyers/bloomfall.json +99 -0
- package/recipes/flyers/drag-solenne-motion.json +444 -0
- package/recipes/flyers/drag-solenne.json +426 -0
- package/recipes/flyers/fennick-patches.json +874 -0
- package/recipes/flyers/ferro-pass.json +398 -0
- package/recipes/flyers/ferrova.json +812 -0
- package/recipes/flyers/grid-rest-rhythm.json +125 -0
- package/recipes/flyers/groovy-honeycomb.json +816 -0
- package/recipes/flyers/groovy-marigold.json +479 -0
- package/recipes/flyers/groovy-peachfuzz.json +974 -0
- package/recipes/flyers/halden-orbit-motion.json +381 -0
- package/recipes/flyers/halden-orbit.json +356 -0
- package/recipes/flyers/halvard.json +120 -0
- package/recipes/flyers/kairo-weekender.json +696 -0
- package/recipes/flyers/linger-motion.json +518 -0
- package/recipes/flyers/linger.json +67 -0
- package/recipes/flyers/little-orchard.json +105 -0
- package/recipes/flyers/lofi-hexmoth.json +912 -0
- package/recipes/flyers/lofi-lowbit-motion.json +888 -0
- package/recipes/flyers/lofi-lowbit.json +880 -0
- package/recipes/flyers/lofi-nullpoint.json +3013 -0
- package/recipes/flyers/lumio-stack-motion.json +648 -0
- package/recipes/flyers/lumio-stack.json +438 -0
- package/recipes/flyers/magazine-cover.json +168 -0
- package/recipes/flyers/magazine-extended.json +269 -0
- package/recipes/flyers/magazine-knockout.json +40 -0
- package/recipes/flyers/northfold-varsity.json +618 -0
- package/recipes/flyers/open-studio.json +69 -0
- package/recipes/flyers/orrin-merch.json +715 -0
- package/recipes/flyers/oxyde.json +113 -0
- package/recipes/flyers/quiet-fields.json +610 -0
- package/recipes/flyers/scrapbook-halcyon.json +948 -0
- package/recipes/flyers/solenne-ticket.json +730 -0
- package/recipes/flyers/solvane-motion.json +576 -0
- package/recipes/flyers/solvane.json +98 -0
- package/recipes/flyers/subduct.json +99 -0
- package/recipes/flyers/sunvale-lineup.json +584 -0
- package/recipes/flyers/tidewell.json +91 -0
- package/recipes/flyers/tour-kairo.json +535 -0
- package/recipes/flyers/velora-stickers.json +885 -0
- package/recipes/flyers/wheatpaste-black.json +313 -0
- package/recipes/flyers/wheatpaste-mark.json +198 -0
- package/recipes/flyers/wheatpaste-torn.json +114 -0
- package/recipes/flyers/wheatpaste-wall.json +984 -0
- package/recipes/flyers/wheatpaste-words.json +233 -0
- package/recipes/release/aura.json +137 -0
- package/recipes/release/chrome.json +433 -0
- package/recipes/release/collage.json +372 -0
- package/recipes/release/duotone.json +113 -0
- package/recipes/release/film-still.json +102 -0
- package/recipes/release/headline.json +70 -0
- package/recipes/release/instant.json +170 -0
- package/recipes/release/label.json +364 -0
- package/recipes/release/liquid-glass.json +301 -0
- package/recipes/release/odd-tile-dark.json +688 -0
- package/recipes/release/odd-tile-dense.json +1056 -0
- package/recipes/release/odd-tile-shift.json +541 -0
- package/recipes/release/odd-tile-split.json +571 -0
- package/recipes/release/odd-tile.json +633 -0
- package/recipes/release/off-edge.json +96 -0
- package/recipes/release/plinth.json +794 -0
- package/recipes/release/riso.json +108 -0
- package/recipes/release/series/fluid-label.json +561 -0
- package/recipes/release/series/fluid-pixel.json +334 -0
- package/recipes/release/series/hairline.json +305 -0
- package/recipes/release/series/halo.json +282 -0
- package/recipes/release/series/mark-crop.json +135 -0
- package/recipes/release/series/mark-window.json +150 -0
- package/recipes/release/series/photo-label.json +475 -0
- package/recipes/release/series/smear-field.json +345 -0
- package/recipes/release/series/smear.json +202 -0
- package/recipes/release/series/spin.json +172 -0
- package/recipes/release/series/split-ghost.json +230 -0
- package/recipes/release/series/zoom.json +168 -0
- package/recipes/release/small-print.json +114 -0
- package/recipes/release/stack.json +156 -0
- package/recipes/release/swiss.json +117 -0
- package/schema/recipe.schema.json +7624 -827
- package/src/animate/index.d.ts +9 -0
- package/src/animate/index.js +11 -0
- package/src/animate/keyframes.d.ts +21 -0
- package/src/animate/keyframes.js +119 -0
- package/src/animate/marquee.d.ts +30 -0
- package/src/animate/marquee.js +93 -0
- package/src/animate/morph.d.ts +8 -0
- package/src/animate/morph.js +99 -0
- package/src/animate/motion.d.ts +59 -0
- package/src/animate/motion.js +63 -0
- package/src/animate/sheet.d.ts +19 -0
- package/src/animate/sheet.js +61 -0
- package/src/animate/timing.d.ts +28 -0
- package/src/animate/timing.js +146 -0
- package/src/animate/tracks.d.ts +18 -0
- package/src/animate/tracks.js +139 -0
- package/src/builtins.js +4 -0
- package/src/diagram.js +4 -3
- package/src/extract.js +6 -5
- package/src/fonts.d.ts +55 -0
- package/src/fonts.js +245 -53
- package/src/formats/cut.d.ts +10 -0
- package/src/formats/cut.js +77 -0
- package/src/formats/index.d.ts +6 -0
- package/src/formats/index.js +9 -0
- package/src/formats/presets.d.ts +41 -0
- package/src/formats/presets.js +89 -0
- package/src/formats/print.d.ts +25 -0
- package/src/formats/print.js +117 -0
- package/src/formats/zones.d.ts +10 -0
- package/src/formats/zones.js +64 -0
- package/src/geometry.js +5 -35
- package/src/grid.js +1 -1
- package/src/handmade.js +9 -7
- package/src/icons.js +42 -9
- package/src/index.d.ts +232 -55
- package/src/index.js +14 -6
- package/src/inter-data.js +27 -0
- package/src/json-recipe.js +201 -27
- package/src/kit/index.d.ts +20 -0
- package/src/kit/index.js +58 -0
- package/src/layer-options.js +43 -4
- package/src/layers/barcode.d.ts +19 -0
- package/src/layers/barcode.js +63 -0
- package/src/layers/code128.d.ts +4 -0
- package/src/layers/code128.js +28 -0
- package/src/layers/expr.d.ts +6 -0
- package/src/layers/expr.js +121 -0
- package/src/layers/geometry.d.ts +6 -0
- package/src/layers/geometry.js +238 -0
- package/src/layers/index.d.ts +18 -0
- package/src/layers/index.js +15 -0
- package/src/layers/perforation.d.ts +14 -0
- package/src/layers/perforation.js +47 -0
- package/src/layers/repeat.d.ts +29 -0
- package/src/layers/repeat.js +90 -0
- package/src/layers/shape.d.ts +46 -0
- package/src/layers/shape.js +115 -0
- package/src/layers/soft.d.ts +5 -0
- package/src/layers/soft.js +43 -0
- package/src/layers/svg.d.ts +23 -0
- package/src/layers/svg.js +108 -0
- package/src/layers/table.d.ts +25 -0
- package/src/layers/table.js +80 -0
- package/src/layers/tear.d.ts +21 -0
- package/src/layers/tear.js +74 -0
- package/src/layers/texture.d.ts +17 -0
- package/src/layers/texture.js +106 -0
- package/src/layers/util.d.ts +8 -0
- package/src/layers/util.js +61 -0
- package/src/layers.js +129 -61
- package/src/lint/image-size.d.ts +4 -0
- package/src/lint/image-size.js +34 -0
- package/src/lint/index.d.ts +16 -0
- package/src/lint/index.js +127 -0
- package/src/lint/rules.d.ts +5 -0
- package/src/lint/rules.js +19 -0
- package/src/node.d.ts +2 -0
- package/src/node.js +20 -0
- package/src/paint/index.d.ts +49 -0
- package/src/paint/index.js +66 -0
- package/src/paint/presets.d.ts +10 -0
- package/src/paint/presets.js +47 -0
- package/src/paint/tile.d.ts +7 -0
- package/src/paint/tile.js +55 -0
- package/src/palette.js +1 -1
- package/src/platform/files.d.ts +6 -0
- package/src/platform/files.js +41 -0
- package/src/platform/harfbuzz.d.ts +9 -0
- package/src/platform/harfbuzz.js +80 -0
- package/src/platform/index.d.ts +8 -0
- package/src/platform/index.js +15 -0
- package/src/platform/load.d.ts +6 -0
- package/src/platform/load.js +71 -0
- package/src/platform/no-harfbuzz.d.ts +2 -0
- package/src/platform/no-harfbuzz.js +11 -0
- package/src/platform/settings.d.ts +28 -0
- package/src/platform/settings.js +51 -0
- package/src/print.js +16 -13
- package/src/recipes.js +5 -4
- package/src/render/backdrop.d.ts +6 -0
- package/src/render/backdrop.js +55 -0
- package/src/render/context.d.ts +82 -0
- package/src/render/grain.d.ts +7 -0
- package/src/render/grain.js +113 -0
- package/src/render/group.d.ts +23 -0
- package/src/render/group.js +67 -0
- package/src/render/key.d.ts +5 -0
- package/src/render/key.js +90 -0
- package/src/render/report.d.ts +5 -0
- package/src/render/report.js +19 -0
- package/src/render/transform.d.ts +36 -0
- package/src/render/transform.js +139 -0
- package/src/render/use.d.ts +27 -0
- package/src/render/use.js +73 -0
- package/src/render/wrap.d.ts +14 -0
- package/src/render/wrap.js +144 -0
- package/src/render.js +343 -215
- package/src/text.d.ts +29 -0
- package/src/text.js +23 -16
- package/src/type/auto-color.d.ts +9 -0
- package/src/type/auto-color.js +44 -0
- package/src/type/fallback.d.ts +11 -0
- package/src/type/fallback.js +92 -0
- package/src/type/glyphs.d.ts +24 -0
- package/src/type/glyphs.js +87 -0
- package/src/type/index.d.ts +5 -0
- package/src/type/index.js +10 -0
- package/src/type/kern.d.ts +4 -0
- package/src/type/kern.js +144 -0
- package/src/type/layout-ref.d.ts +10 -0
- package/src/type/layout-ref.js +74 -0
- package/src/type/line.d.ts +11 -0
- package/src/type/line.js +105 -0
- package/src/type/live.d.ts +6 -0
- package/src/type/live.js +39 -0
- package/src/type/name.d.ts +4 -0
- package/src/type/name.js +32 -0
- package/src/type/path.d.ts +18 -0
- package/src/type/path.js +89 -0
- package/src/type/pixel.d.ts +7 -0
- package/src/type/pixel.js +85 -0
- package/src/type/sfnt.d.ts +20 -0
- package/src/type/sfnt.js +258 -0
- package/src/type/spans.d.ts +33 -0
- package/src/type/spans.js +138 -0
- package/src/type/symbols.d.ts +7 -0
- package/src/type/symbols.js +89 -0
- package/src/type/typeset.d.ts +10 -0
- package/src/type/typeset.js +363 -0
- package/src/type/woff2.d.ts +6 -0
- package/src/type/woff2.js +301 -0
- package/src/version.js +4 -0
- package/src/color.js +0 -246
- package/src/png.js +0 -77
- package/src/rng.js +0 -95
- package/src/svg.js +0 -69
package/src/fonts.js
CHANGED
|
@@ -3,31 +3,43 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Text is shaped by HarfBuzz (`harfbuzzjs`, WebAssembly) and drawn as
|
|
5
5
|
* outlines, so the rendered cover never depends on fonts installed on the
|
|
6
|
-
* build machine.
|
|
7
|
-
*
|
|
6
|
+
* build machine. Where HarfBuzz can't run (Cloudflare Workers) a small
|
|
7
|
+
* JavaScript TrueType reader (`sfnt.js`) draws the outlines instead. Inter
|
|
8
|
+
* (Latin, 400/500/600/700, about 120 KB of WOFF) ships in `fonts/` and is
|
|
9
|
+
* always available as the family `Inter`.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* A font is `{ name, data | src, weight, style }`: bytes, or a `src` loaded
|
|
12
|
+
* like any file (a path in Node, a URL, or through `configure({ load })`).
|
|
13
|
+
* Variable fonts take a weight range (`weight: [100, 900]`) and their axes
|
|
14
|
+
* are set per text layer (`axes: { wdth: 75, SOFT: 100 }`); a variable
|
|
15
|
+
* font's `wght` follows the layer's `weight`.
|
|
16
|
+
*
|
|
17
|
+
* Accepted formats: TrueType/OpenType (`.ttf`/`.otf`), WOFF (`.woff`) and
|
|
18
|
+
* WOFF2 (`.woff2`: Brotli from the platform, Node's zlib or a browser's
|
|
19
|
+
* `DecompressionStream('brotli')`, and the glyph tables rebuilt in
|
|
20
|
+
* JavaScript). Where there's no Brotli (most Workers), convert WOFF2 to TTF
|
|
21
|
+
* first (`scripts/woff2-to-ttf.mjs`).
|
|
12
22
|
*
|
|
13
23
|
* @module fonts
|
|
14
24
|
*/
|
|
15
25
|
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
26
|
+
import { shaperModule, loadBytes, inflate, unbrotli, unbrotliSync, packageFileSync, registry, node, u8 } from './platform/index.js';
|
|
27
|
+
import { parseSfnt } from './type/sfnt.js';
|
|
28
|
+
import { woff2Directory, woff2ToSfnt } from './type/woff2.js';
|
|
18
29
|
|
|
19
|
-
/** @
|
|
20
|
-
|
|
30
|
+
/** @param {Uint8Array} b @returns {boolean} Whether bytes are WOFF2. */
|
|
31
|
+
const isWoff2 = (b) => b.length >= 4 && b[0] === 0x77 && b[1] === 0x4f && b[2] === 0x46 && b[3] === 0x32;
|
|
21
32
|
|
|
22
33
|
/**
|
|
23
34
|
* Load HarfBuzz once (its WebAssembly initialises on import, so it's
|
|
24
|
-
* imported lazily: covers with no text never pay for it).
|
|
35
|
+
* imported lazily: covers with no text never pay for it). Undefined where
|
|
36
|
+
* it can't run (Workers), or with `configure({ shaper: 'js' })`: text then
|
|
37
|
+
* uses the JavaScript outliner.
|
|
25
38
|
*
|
|
26
|
-
* @returns {Promise<typeof import('harfbuzzjs')>} The harfbuzzjs module.
|
|
39
|
+
* @returns {Promise<typeof import('harfbuzzjs') | undefined>} The harfbuzzjs module.
|
|
27
40
|
*/
|
|
28
41
|
export function harfbuzz() {
|
|
29
|
-
|
|
30
|
-
return hbPromise;
|
|
42
|
+
return shaperModule();
|
|
31
43
|
}
|
|
32
44
|
|
|
33
45
|
/** Bundled weights of Inter. */
|
|
@@ -37,34 +49,115 @@ const BUNDLED = [400, 500, 600, 700];
|
|
|
37
49
|
let bundled;
|
|
38
50
|
|
|
39
51
|
/**
|
|
40
|
-
* The bundled Inter faces
|
|
52
|
+
* The bundled Inter faces: read from disk in Node (once per process),
|
|
53
|
+
* else from an embedded module the first time one is drawn.
|
|
41
54
|
*
|
|
42
55
|
* @returns {import('./index.js').FontSource[]} Inter 400/500/600/700.
|
|
43
56
|
*/
|
|
44
57
|
export function bundledFonts() {
|
|
45
|
-
bundled ??= BUNDLED.map((weight) =>
|
|
46
|
-
name: 'Inter',
|
|
47
|
-
weight,
|
|
48
|
-
style: 'normal',
|
|
58
|
+
bundled ??= BUNDLED.map((weight) => {
|
|
49
59
|
// A URL, not `.pathname`: a path with a space would be percent-encoded.
|
|
50
|
-
data
|
|
51
|
-
|
|
60
|
+
const data = packageFileSync(`fonts/inter-latin-${weight}.woff`);
|
|
61
|
+
return /** @type {import('./index.js').FontSource} */ (data ? { name: 'Inter', weight, style: 'normal', data } : { name: 'Inter', weight, style: 'normal', src: `covers:inter-${weight}` });
|
|
62
|
+
});
|
|
52
63
|
return bundled;
|
|
53
64
|
}
|
|
54
65
|
|
|
55
66
|
/**
|
|
56
|
-
*
|
|
67
|
+
* Every font a cover can use: its own, the registered ones, then Inter.
|
|
68
|
+
*
|
|
69
|
+
* @param {import('./index.js').FontSource[]} own
|
|
70
|
+
* @returns {import('./index.js').FontSource[]}
|
|
71
|
+
*/
|
|
72
|
+
export function fontPool(own) {
|
|
73
|
+
return [...own, ...registry, ...bundledFonts()];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Register a font for every cover (by bytes or by `src`).
|
|
78
|
+
*
|
|
79
|
+
* @param {import('./index.js').FontSource} font
|
|
80
|
+
* @returns {void}
|
|
57
81
|
*
|
|
58
|
-
* @
|
|
82
|
+
* @example
|
|
83
|
+
* registerFont({ name: 'Fraunces', src: '/fonts/Fraunces.ttf', weight: [100, 900] });
|
|
84
|
+
*/
|
|
85
|
+
export function registerFont(font) {
|
|
86
|
+
if (!font?.name || (font.data == null && font.src == null)) throw new TypeError('registerFont() needs { name, data } or { name, src }');
|
|
87
|
+
registry.push(font);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @type {Map<string, Promise<Uint8Array>>} */
|
|
91
|
+
const fetched = new Map();
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A font source's bytes (as given; still WOFF if it was).
|
|
95
|
+
*
|
|
96
|
+
* @param {import('./index.js').FontSource} source
|
|
97
|
+
* @returns {Promise<Uint8Array>}
|
|
98
|
+
*/
|
|
99
|
+
export function fontBytes(source) {
|
|
100
|
+
if (source.data != null) return Promise.resolve(u8(source.data));
|
|
101
|
+
const src = String(source.src);
|
|
102
|
+
let p = fetched.get(src);
|
|
103
|
+
if (!p) {
|
|
104
|
+
const m = /^covers:inter-(\d+)$/.exec(src);
|
|
105
|
+
p = m
|
|
106
|
+
? import('./inter-data.js').then((d) => d.inter(Number(m[1])))
|
|
107
|
+
: loadBytes(source.src, 'font');
|
|
108
|
+
fetched.set(src, p);
|
|
109
|
+
}
|
|
110
|
+
return p;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Turn font bytes into a bare sfnt (TrueType/OpenType) HarfBuzz can read,
|
|
115
|
+
* synchronously (Node, where zlib is synchronous).
|
|
116
|
+
*
|
|
117
|
+
* @param {Uint8Array | ArrayBuffer} input - `.ttf`, `.otf`, `.woff` or `.woff2` bytes.
|
|
59
118
|
* @returns {Uint8Array} sfnt bytes.
|
|
60
|
-
* @throws {TypeError} On
|
|
119
|
+
* @throws {TypeError} On bytes that aren't a font.
|
|
61
120
|
*/
|
|
62
121
|
export function decodeFont(input) {
|
|
63
|
-
const
|
|
122
|
+
const z = node('zlib');
|
|
123
|
+
if (!z) throw new Error('decodeFont() is synchronous only in Node; use decodeFontAsync()');
|
|
124
|
+
const bytes = u8(input);
|
|
125
|
+
if (isWoff2(bytes)) return woff2ToSfnt(bytes, unbrotliSync(woff2Directory(bytes).payload));
|
|
126
|
+
/** @type {Uint8Array | undefined} */
|
|
127
|
+
let out;
|
|
128
|
+
const gen = woffToSfnt(input);
|
|
129
|
+
let step = gen.next();
|
|
130
|
+
while (!step.done) step = gen.next(z.inflateSync(step.value));
|
|
131
|
+
out = step.value;
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* {@link decodeFont} on any platform (`DecompressionStream` outside Node).
|
|
137
|
+
*
|
|
138
|
+
* @param {Uint8Array | ArrayBuffer} input
|
|
139
|
+
* @returns {Promise<Uint8Array>}
|
|
140
|
+
*/
|
|
141
|
+
export async function decodeFontAsync(input) {
|
|
142
|
+
const bytes = u8(input);
|
|
143
|
+
if (isWoff2(bytes)) return woff2ToSfnt(bytes, await unbrotli(woff2Directory(bytes).payload));
|
|
144
|
+
const gen = woffToSfnt(input);
|
|
145
|
+
let step = gen.next();
|
|
146
|
+
while (!step.done) step = gen.next(await inflate(step.value));
|
|
147
|
+
return step.value;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The WOFF → sfnt conversion, yielding each compressed table to be inflated.
|
|
152
|
+
*
|
|
153
|
+
* @param {Uint8Array | ArrayBuffer} input
|
|
154
|
+
* @returns {Generator<Uint8Array, Uint8Array, Uint8Array>}
|
|
155
|
+
*/
|
|
156
|
+
function* woffToSfnt(input) {
|
|
157
|
+
const buf = u8(input);
|
|
64
158
|
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
65
159
|
if (buf.byteLength < 12) throw new TypeError('Font data is too short to be a font');
|
|
66
160
|
const tag = view.getUint32(0);
|
|
67
|
-
if (tag === 0x774f4632) throw new TypeError('WOFF2 fonts are not supported: pass a .woff, .ttf or .otf');
|
|
68
161
|
if (tag !== 0x774f4646) {
|
|
69
162
|
// 0x00010000 TrueType, 'OTTO' CFF, 'true' old Apple TrueType.
|
|
70
163
|
if (tag === 0x00010000 || tag === 0x4f54544f || tag === 0x74727565) return buf;
|
|
@@ -89,7 +182,7 @@ export function decodeFont(input) {
|
|
|
89
182
|
while (pow * 2 <= count) { pow *= 2; log++; }
|
|
90
183
|
|
|
91
184
|
const headerSize = 12 + 16 * count;
|
|
92
|
-
const pad = (n) => (n + 3) & ~3;
|
|
185
|
+
const pad = (/** @type {number} */ n) => (n + 3) & ~3;
|
|
93
186
|
const total = tables.reduce((sum, t) => sum + pad(t.origLength), headerSize);
|
|
94
187
|
const out = new Uint8Array(total);
|
|
95
188
|
const ov = new DataView(out.buffer);
|
|
@@ -100,9 +193,10 @@ export function decodeFont(input) {
|
|
|
100
193
|
ov.setUint16(10, count * 16 - pow * 16);
|
|
101
194
|
|
|
102
195
|
let offset = headerSize;
|
|
103
|
-
tables.
|
|
196
|
+
for (let i = 0; i < tables.length; i++) {
|
|
197
|
+
const t = tables[i];
|
|
104
198
|
const src = buf.subarray(t.offset, t.offset + t.compLength);
|
|
105
|
-
const data = t.compLength < t.origLength ?
|
|
199
|
+
const data = t.compLength < t.origLength ? yield src : src;
|
|
106
200
|
out.set(data, offset);
|
|
107
201
|
const rec = 12 + i * 16;
|
|
108
202
|
ov.setUint32(rec, t.tag);
|
|
@@ -110,10 +204,21 @@ export function decodeFont(input) {
|
|
|
110
204
|
ov.setUint32(rec + 8, offset);
|
|
111
205
|
ov.setUint32(rec + 12, t.origLength);
|
|
112
206
|
offset += pad(t.origLength);
|
|
113
|
-
}
|
|
207
|
+
}
|
|
114
208
|
return out;
|
|
115
209
|
}
|
|
116
210
|
|
|
211
|
+
/**
|
|
212
|
+
* A shaped glyph, in font units.
|
|
213
|
+
*
|
|
214
|
+
* @typedef {object} RunGlyph
|
|
215
|
+
* @property {number} gid
|
|
216
|
+
* @property {number} cluster
|
|
217
|
+
* @property {number} xAdvance
|
|
218
|
+
* @property {number} xOffset
|
|
219
|
+
* @property {number} yOffset
|
|
220
|
+
*/
|
|
221
|
+
|
|
117
222
|
/**
|
|
118
223
|
* A loaded face, ready to shape with.
|
|
119
224
|
*
|
|
@@ -121,51 +226,108 @@ export function decodeFont(input) {
|
|
|
121
226
|
* @property {string} name - Family name.
|
|
122
227
|
* @property {number} weight - Weight.
|
|
123
228
|
* @property {string} style - `'normal'` or `'italic'`.
|
|
124
|
-
* @property {any} font - The harfbuzzjs `Font
|
|
229
|
+
* @property {any} font - The harfbuzzjs `Font`, or the JS face.
|
|
125
230
|
* @property {number} upem - Units per em.
|
|
126
231
|
* @property {number} ascender - In font units.
|
|
127
232
|
* @property {number} descender - In font units (negative).
|
|
128
233
|
* @property {number} capHeight - In font units.
|
|
129
234
|
* @property {Map<number, (string | number)[]>} paths - Glyph outline cache.
|
|
235
|
+
* @property {(text: string) => RunGlyph[]} shapeRun - Shape one line.
|
|
236
|
+
* @property {(gid: number) => string} glyphToPath - Outline, font units, y up.
|
|
237
|
+
* @property {Record<string, { min: number, default: number, max: number }>} axes - The font's variation axes.
|
|
238
|
+
* @property {Record<string, number>} variations - The axis values in use.
|
|
239
|
+
* @property {import('./index.js').FontSource} source - Where it came from.
|
|
130
240
|
*/
|
|
131
241
|
|
|
132
|
-
/** @type {WeakMap<object, Promise<LoadedFont
|
|
242
|
+
/** Faces by font bytes (then by variation settings). @type {WeakMap<object, Map<string, Promise<LoadedFont>>>} */
|
|
133
243
|
const loaded = new WeakMap();
|
|
134
244
|
|
|
135
245
|
/**
|
|
136
|
-
* Load one font source (cached per data buffer
|
|
137
|
-
* caller's reused buffers are
|
|
246
|
+
* Load one font source, optionally at a variation (cached per data buffer
|
|
247
|
+
* and settings, so the bundled faces and a caller's reused buffers are
|
|
248
|
+
* decoded once per process).
|
|
138
249
|
*
|
|
139
250
|
* @param {import('./index.js').FontSource} source - The font.
|
|
251
|
+
* @param {Record<string, number>} [variations] - Axis values (`{ wght: 650, wdth: 80 }`).
|
|
140
252
|
* @returns {Promise<LoadedFont>} The loaded face.
|
|
141
253
|
*/
|
|
142
|
-
export function loadFont(source) {
|
|
143
|
-
const
|
|
144
|
-
|
|
254
|
+
export async function loadFont(source, variations) {
|
|
255
|
+
const bytes = await fontBytes(source);
|
|
256
|
+
const vkey = variations && Object.keys(variations).length ? JSON.stringify(Object.entries(variations).sort()) : '';
|
|
257
|
+
let byVar = loaded.get(bytes);
|
|
258
|
+
if (!byVar) loaded.set(bytes, (byVar = new Map()));
|
|
259
|
+
let p = byVar.get(vkey);
|
|
145
260
|
if (!p) {
|
|
146
261
|
p = (async () => {
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
262
|
+
const sfnt = await decodeFontAsync(bytes);
|
|
263
|
+
const hb = await shaperModule();
|
|
264
|
+
if (hb) {
|
|
265
|
+
const face = new hb.Face(new hb.Blob(sfnt));
|
|
266
|
+
const font = new hb.Font(face);
|
|
267
|
+
const axes = vkey && typeof face.getAxisInfos === 'function' ? face.getAxisInfos() : {};
|
|
268
|
+
/** @type {Record<string, number>} */
|
|
269
|
+
const used = {};
|
|
270
|
+
if (vkey && variations) {
|
|
271
|
+
const list = [];
|
|
272
|
+
for (const [tag, value] of Object.entries(variations)) {
|
|
273
|
+
const a = axes[tag];
|
|
274
|
+
if (!a) continue;
|
|
275
|
+
used[tag] = Math.min(a.max, Math.max(a.min, value));
|
|
276
|
+
list.push(new hb.Variation(tag, used[tag]));
|
|
277
|
+
}
|
|
278
|
+
if (list.length) font.setVariations(list);
|
|
279
|
+
}
|
|
280
|
+
const ext = font.hExtents();
|
|
281
|
+
const cap = font.getMetricPosition?.(hb.MetricsTag.CAP_HEIGHT);
|
|
282
|
+
const upem = face.upem;
|
|
283
|
+
return {
|
|
284
|
+
name: source.name,
|
|
285
|
+
weight: 400,
|
|
286
|
+
style: 'normal',
|
|
287
|
+
font,
|
|
288
|
+
upem,
|
|
289
|
+
ascender: ext.ascender,
|
|
290
|
+
descender: ext.descender,
|
|
291
|
+
capHeight: cap || capFromGlyph(font) || upem * 0.7,
|
|
292
|
+
paths: new Map(),
|
|
293
|
+
axes,
|
|
294
|
+
variations: used,
|
|
295
|
+
source,
|
|
296
|
+
glyphToPath: (/** @type {number} */ gid) => font.glyphToPath(gid),
|
|
297
|
+
shapeRun: (/** @type {string} */ text) => {
|
|
298
|
+
const buffer = new hb.Buffer();
|
|
299
|
+
buffer.addText(text);
|
|
300
|
+
buffer.guessSegmentProperties();
|
|
301
|
+
hb.shape(font, buffer);
|
|
302
|
+
const infos = buffer.getGlyphInfos();
|
|
303
|
+
const positions = buffer.getGlyphPositions();
|
|
304
|
+
return infos.map((/** @type {any} */ info, /** @type {number} */ i) => ({ gid: info.codepoint, cluster: info.cluster, xAdvance: positions[i].xAdvance, xOffset: positions[i].xOffset, yOffset: positions[i].yOffset }));
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
const js = parseSfnt(sfnt);
|
|
153
309
|
return {
|
|
154
310
|
name: source.name,
|
|
155
|
-
weight:
|
|
156
|
-
style:
|
|
157
|
-
font,
|
|
158
|
-
upem,
|
|
159
|
-
ascender:
|
|
160
|
-
descender:
|
|
161
|
-
capHeight:
|
|
311
|
+
weight: 400,
|
|
312
|
+
style: 'normal',
|
|
313
|
+
font: js,
|
|
314
|
+
upem: js.upem,
|
|
315
|
+
ascender: js.ascender,
|
|
316
|
+
descender: js.descender,
|
|
317
|
+
capHeight: js.capHeight || capFromJs(js) || js.upem * 0.7,
|
|
162
318
|
paths: new Map(),
|
|
319
|
+
axes: js.axes,
|
|
320
|
+
variations: {},
|
|
321
|
+
source,
|
|
322
|
+
glyphToPath: js.glyphToPath,
|
|
323
|
+
shapeRun: (/** @type {string} */ text) => js.shape(text).map((g) => ({ gid: g.gid, cluster: g.cluster, xAdvance: g.advance, xOffset: 0, yOffset: 0 })),
|
|
163
324
|
};
|
|
164
325
|
})();
|
|
165
|
-
|
|
326
|
+
byVar.set(vkey, p);
|
|
166
327
|
}
|
|
328
|
+
const f = await p;
|
|
167
329
|
// The same bytes can be registered under another name or weight.
|
|
168
|
-
return
|
|
330
|
+
return { ...f, name: source.name, weight: Array.isArray(source.weight) ? (variations?.wght ?? 400) : source.weight ?? 400, style: source.style ?? 'normal', source };
|
|
169
331
|
}
|
|
170
332
|
|
|
171
333
|
/**
|
|
@@ -180,9 +342,21 @@ function capFromGlyph(font) {
|
|
|
180
342
|
return ext ? ext.yBearing : undefined;
|
|
181
343
|
}
|
|
182
344
|
|
|
345
|
+
/** @param {import('./type/sfnt.js').JsFace} js @returns {number | undefined} */
|
|
346
|
+
function capFromJs(js) {
|
|
347
|
+
const gid = js.shape('H')[0]?.gid;
|
|
348
|
+
if (!gid) return undefined;
|
|
349
|
+
const ys = [...js.glyphToPath(gid).matchAll(/-?[\d.]+,(-?[\d.]+)/g)].map((m) => +m[1]);
|
|
350
|
+
return ys.length ? Math.max(...ys) : undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** @param {import('./index.js').FontSource} s @returns {[number, number]} */
|
|
354
|
+
const weightRange = (s) => (Array.isArray(s.weight) ? [s.weight[0], s.weight[1]] : [s.weight ?? 400, s.weight ?? 400]);
|
|
355
|
+
|
|
183
356
|
/**
|
|
184
357
|
* Pick the face for a family, weight and style: the closest weight within
|
|
185
|
-
* the family (CSS's matching rule, simplified
|
|
358
|
+
* the family (CSS's matching rule, simplified; a variable font's range
|
|
359
|
+
* matches any weight inside it), falling back to Inter.
|
|
186
360
|
*
|
|
187
361
|
* @param {import('./index.js').FontSource[]} sources - The available fonts.
|
|
188
362
|
* @param {string} family - Wanted family.
|
|
@@ -196,5 +370,23 @@ export function pickFont(sources, family, weight, style = 'normal') {
|
|
|
196
370
|
if (!pool.length) pool = sources;
|
|
197
371
|
const styled = pool.filter((s) => (s.style ?? 'normal') === style);
|
|
198
372
|
if (styled.length) pool = styled;
|
|
199
|
-
|
|
373
|
+
const dist = (/** @type {import('./index.js').FontSource} */ s) => {
|
|
374
|
+
const [lo, hi] = weightRange(s);
|
|
375
|
+
return weight < lo ? lo - weight : weight > hi ? weight - hi : 0;
|
|
376
|
+
};
|
|
377
|
+
return pool.reduce((best, s) => (dist(s) < dist(best) ? s : best));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The variation settings for a layer: its `axes`, with `wght` following
|
|
382
|
+
* the weight on a variable font.
|
|
383
|
+
*
|
|
384
|
+
* @param {import('./index.js').FontSource} source
|
|
385
|
+
* @param {number} weight
|
|
386
|
+
* @param {Record<string, number> | undefined} axes
|
|
387
|
+
* @returns {Record<string, number> | undefined}
|
|
388
|
+
*/
|
|
389
|
+
export function variationsFor(source, weight, axes) {
|
|
390
|
+
const v = { ...(Array.isArray(source.weight) ? { wght: weight } : {}), ...(source.axes ?? {}), ...(axes ?? {}) };
|
|
391
|
+
return Object.keys(v).length ? v : undefined;
|
|
200
392
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Die-cut outlines for print formats.
|
|
2
|
+
|
|
3
|
+
/** A die-cut: a plain trim, 3 mm corners, a circle, corners in mm, or a path drawn from its grid into the trim. */
|
|
4
|
+
export type Cut = 'rect' | 'rounded' | 'circle' | { radius: number } | { d: string; viewBox?: [number, number, number, number] };
|
|
5
|
+
|
|
6
|
+
/** One path segment, canvas px. */
|
|
7
|
+
export interface Segment { cmd: 'M' | 'L' | 'C' | 'Z'; pts: number[] }
|
|
8
|
+
|
|
9
|
+
/** The cut outline round a box (px per mm for corner radii; `outset` grows the radii with the box). */
|
|
10
|
+
export function cutPath(cut: Cut | undefined, box: { x: number; y: number; width: number; height: number }, mm: number, outset?: number): Segment[];
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Die-cut outlines for print formats: a plain trim, rounded corners, a
|
|
3
|
+
* circle, or any path, as Bézier segments in canvas px (so a layer can
|
|
4
|
+
* draw or clip to it in its own units).
|
|
5
|
+
*
|
|
6
|
+
* @module formats/cut
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { flattenPath } from '../flatten.js';
|
|
10
|
+
|
|
11
|
+
/** A circle's quarter as a cubic Bézier (control-point distance per radius). */
|
|
12
|
+
const K = 0.5522847498;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {{ cmd: 'M' | 'L' | 'C' | 'Z', pts: number[] }} Segment
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The cut outline round a box.
|
|
20
|
+
*
|
|
21
|
+
* `cut`: `'rect'` (or none), `'rounded'` (3 mm corners), `'circle'` (an
|
|
22
|
+
* ellipse in the box), `{ radius }` (corners in mm), or `{ d, viewBox }`
|
|
23
|
+
* (a die path drawn from its grid into the box).
|
|
24
|
+
*
|
|
25
|
+
* @param {any} cut
|
|
26
|
+
* @param {{ x: number, y: number, width: number, height: number }} box - The trim (grown by any outset), px.
|
|
27
|
+
* @param {number} mm - px per millimetre.
|
|
28
|
+
* @param {number} [outset=0] - How far the box was grown (corner radii grow with it).
|
|
29
|
+
* @returns {Segment[]}
|
|
30
|
+
*/
|
|
31
|
+
export function cutPath(cut, box, mm, outset = 0) {
|
|
32
|
+
const { x, y, width: w, height: h } = box;
|
|
33
|
+
if (cut === 'circle') return ellipse(x + w / 2, y + h / 2, w / 2, h / 2);
|
|
34
|
+
if (cut && typeof cut === 'object' && cut.d) return custom(cut, box);
|
|
35
|
+
const r = cut === 'rounded' ? 3 * mm + outset : cut && typeof cut === 'object' && cut.radius != null ? cut.radius * mm + outset : 0;
|
|
36
|
+
return rounded(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @param {number} x @param {number} y @param {number} w @param {number} h @param {number} r @returns {Segment[]} */
|
|
40
|
+
function rounded(x, y, w, h, r) {
|
|
41
|
+
if (r <= 0) return [{ cmd: 'M', pts: [x, y] }, { cmd: 'L', pts: [x + w, y] }, { cmd: 'L', pts: [x + w, y + h] }, { cmd: 'L', pts: [x, y + h] }, { cmd: 'Z', pts: [] }];
|
|
42
|
+
const k = r * (1 - K);
|
|
43
|
+
return [
|
|
44
|
+
{ cmd: 'M', pts: [x + r, y] }, { cmd: 'L', pts: [x + w - r, y] }, { cmd: 'C', pts: [x + w - k, y, x + w, y + k, x + w, y + r] },
|
|
45
|
+
{ cmd: 'L', pts: [x + w, y + h - r] }, { cmd: 'C', pts: [x + w, y + h - k, x + w - k, y + h, x + w - r, y + h] },
|
|
46
|
+
{ cmd: 'L', pts: [x + r, y + h] }, { cmd: 'C', pts: [x + k, y + h, x, y + h - k, x, y + h - r] },
|
|
47
|
+
{ cmd: 'L', pts: [x, y + r] }, { cmd: 'C', pts: [x, y + k, x + k, y, x + r, y] }, { cmd: 'Z', pts: [] },
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {number} cx @param {number} cy @param {number} rx @param {number} ry @returns {Segment[]} */
|
|
52
|
+
function ellipse(cx, cy, rx, ry) {
|
|
53
|
+
const kx = rx * K;
|
|
54
|
+
const ky = ry * K;
|
|
55
|
+
return [
|
|
56
|
+
{ cmd: 'M', pts: [cx, cy - ry] },
|
|
57
|
+
{ cmd: 'C', pts: [cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy] },
|
|
58
|
+
{ cmd: 'C', pts: [cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry] },
|
|
59
|
+
{ cmd: 'C', pts: [cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy] },
|
|
60
|
+
{ cmd: 'C', pts: [cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry] },
|
|
61
|
+
{ cmd: 'Z', pts: [] },
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @param {{ d: string, viewBox?: number[] }} cut @param {{ x: number, y: number, width: number, height: number }} box @returns {Segment[]} */
|
|
66
|
+
function custom(cut, box) {
|
|
67
|
+
const [vx, vy, vw, vh] = cut.viewBox ?? [0, 0, box.width, box.height];
|
|
68
|
+
const sx = box.width / vw;
|
|
69
|
+
const sy = box.height / vh;
|
|
70
|
+
/** @type {Segment[]} */
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const sub of flattenPath(String(cut.d), Math.min(vw, vh) / 400)) {
|
|
73
|
+
sub.points.forEach(([px, py], i) => out.push({ cmd: i ? 'L' : 'M', pts: [box.x + (px - vx) * sx, box.y + (py - vy) * sy] }));
|
|
74
|
+
if (sub.closed) out.push({ cmd: 'Z', pts: [] });
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Formats: named sizes, safe zones, print set-up.
|
|
2
|
+
export { FORMATS, avatarZones } from './presets.js';
|
|
3
|
+
export type { Zones, Format, FormatName } from './presets.js';
|
|
4
|
+
export { zonesOverlay, scaleZones, safeBox } from './zones.js';
|
|
5
|
+
export { resolveFormat, printMarks, toPx } from './print.js';
|
|
6
|
+
export type { PrintInfo } from './print.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formats: named sizes, safe zones, print set-up.
|
|
3
|
+
*
|
|
4
|
+
* @module formats
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { FORMATS, avatarZones } from './presets.js';
|
|
8
|
+
export { zonesOverlay, scaleZones, safeBox } from './zones.js';
|
|
9
|
+
export { resolveFormat, printMarks, toPx } from './print.js';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The named formats, as data.
|
|
2
|
+
|
|
3
|
+
/** Where a platform crops or covers an image, px of the canvas. */
|
|
4
|
+
export interface Zones {
|
|
5
|
+
/** `[x, y, w, h]`: anchored text and icons are laid out inside it. */
|
|
6
|
+
safe: [number, number, number, number];
|
|
7
|
+
avoid?: { name: string; rect?: [number, number, number, number]; circle?: [number, number, number] }[];
|
|
8
|
+
crops?: { name: string; rect: [number, number, number, number] }[];
|
|
9
|
+
/** Where the numbers come from. */
|
|
10
|
+
source?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
/** The named formats. */
|
|
15
|
+
export type FormatName =
|
|
16
|
+
| 'release' | 'spotify-canvas' | 'bandcamp-header'
|
|
17
|
+
| 'instagram-post' | 'instagram-square' | 'instagram-story' | 'instagram-reel' | 'youtube-thumbnail' | 'youtube-banner' | 'og'
|
|
18
|
+
| 'avatar' | 'x-avatar' | 'youtube-avatar' | 'x-header' | 'linkedin-banner' | 'linkedin-company' | 'facebook-cover'
|
|
19
|
+
| 'twitch-banner' | 'discord-banner' | 'discord-profile' | 'bluesky-banner' | 'pinterest' | 'tiktok-cover'
|
|
20
|
+
| 'flyer' | 'a6' | 'a5' | 'a4' | 'a3' | 'a2'
|
|
21
|
+
| 'business-card' | 'business-card-us' | 'letterhead' | 'letterhead-us' | 'compliment-slip'
|
|
22
|
+
| 'sticker' | 'sticker-square' | 'ticket' | 'badge' | 'lanyard-pass';
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
/** A format: a size in a unit, with print defaults or safe zones. */
|
|
26
|
+
export interface Format {
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
unit: 'px' | 'mm' | 'in';
|
|
30
|
+
label: string;
|
|
31
|
+
bleed?: number;
|
|
32
|
+
safe?: number;
|
|
33
|
+
cut?: import('./cut.js').Cut;
|
|
34
|
+
zones?: Zones;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
/** The named formats. */
|
|
39
|
+
export const FORMATS: Readonly<Record<FormatName, Readonly<Format>>>;
|
|
40
|
+
/** The zones of a circle-cropped avatar of side `n`. */
|
|
41
|
+
export function avatarZones(n: number): Zones;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The named formats, as data: release covers, social posts and banners
|
|
3
|
+
* with their safe zones, flyers and posters, and print stationery.
|
|
4
|
+
*
|
|
5
|
+
* Zone figures are official where a platform publishes them (YouTube Help,
|
|
6
|
+
* Meta, LinkedIn); otherwise they're community measurements, and each
|
|
7
|
+
* format's `zones.source` says which.
|
|
8
|
+
*
|
|
9
|
+
* @module formats/presets
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} Format
|
|
14
|
+
* @property {number} width - In `unit`.
|
|
15
|
+
* @property {number} height - In `unit`.
|
|
16
|
+
* @property {'px' | 'mm' | 'in'} unit
|
|
17
|
+
* @property {string} label - What it is.
|
|
18
|
+
* @property {number} [bleed] - Default bleed, in mm (print).
|
|
19
|
+
* @property {number} [safe] - Default safe margin inside the trim, in mm (print).
|
|
20
|
+
* @property {string} [cut] - A die-cut shape: `'circle'` or `'rounded'`.
|
|
21
|
+
* @property {import('./index.js').Zones} [zones] - Where the platform crops or covers the image (screen formats, px).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Safe zones, in px of the format's canvas.
|
|
26
|
+
*
|
|
27
|
+
* @typedef {object} Zones
|
|
28
|
+
* @property {[number, number, number, number]} safe - `[x, y, w, h]`: where type and marks are seen on every device; anchored text and icons are laid out inside it.
|
|
29
|
+
* @property {{ name: string, rect?: [number, number, number, number], circle?: [number, number, number] }[]} [avoid] - Areas covered by the platform's UI or the avatar.
|
|
30
|
+
* @property {{ name: string, rect: [number, number, number, number] }[]} [crops] - What one device shows.
|
|
31
|
+
* @property {string} source - Where the numbers come from (official, or a community measurement).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** A circle crop: the visible circle and the square inside it (with a little air). @param {number} n @returns {import('./index.js').Zones} */
|
|
35
|
+
export const avatarZones = (n) => {
|
|
36
|
+
const side = Math.round((n / Math.SQRT2) * 0.94);
|
|
37
|
+
const o = Math.round((n - side) / 2);
|
|
38
|
+
return { safe: [o, o, side, side], avoid: [], crops: [{ name: 'circle', rect: [0, 0, n, n] }], source: 'Every listed platform crops avatars to a circle (X, Instagram, Threads, LinkedIn, YouTube, TikTok, Bluesky); the safe square is the one inscribed in it.' };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** @type {Readonly<Record<string, Readonly<Format>>>} */
|
|
42
|
+
export const FORMATS = Object.freeze({
|
|
43
|
+
// Release and streaming
|
|
44
|
+
release: { width: 3000, height: 3000, unit: 'px', label: 'Release cover art (Spotify, Apple Music, Bandcamp; 3000×3000)' },
|
|
45
|
+
'spotify-canvas': { width: 1080, height: 1920, unit: 'px', label: 'Spotify Canvas (9:16; a still frame or the poster for one)', zones: { safe: [60, 320, 960, 1040], avoid: [{ name: 'track info and controls', rect: [0, 1360, 1080, 560] }, { name: 'header', rect: [0, 0, 1080, 320] }], source: 'Spotify asks that Canvas carries no text and keeps its focus central; the player UI covers the bottom third (measured).' } },
|
|
46
|
+
'bandcamp-header': { width: 975, height: 180, unit: 'px', label: 'Bandcamp custom header (975 wide, 40–180 tall)' },
|
|
47
|
+
// Social
|
|
48
|
+
'instagram-post': { width: 1080, height: 1350, unit: 'px', label: 'Instagram post, 4:5 (1080×1350)', zones: { safe: [54, 135, 972, 1080], crops: [{ name: 'profile grid (3:4)', rect: [0, 0, 1080, 1350] }, { name: 'square', rect: [0, 135, 1080, 1080] }], source: '1080×1350 is Instagram\'s portrait size; older grid and some previews crop to the central square, so key type stays in it.' } },
|
|
49
|
+
'instagram-square': { width: 1080, height: 1080, unit: 'px', label: 'Instagram post, 1:1 (1080×1080)', zones: { safe: [54, 54, 972, 972], source: '1080×1080 is Instagram\'s square size.' } },
|
|
50
|
+
'instagram-story': { width: 1080, height: 1920, unit: 'px', label: 'Instagram story, 9:16 (1080×1920)', zones: { safe: [65, 250, 950, 1420], avoid: [{ name: 'profile and progress', rect: [0, 0, 1080, 250] }, { name: 'reply bar', rect: [0, 1670, 1080, 250] }], source: 'Meta: keep about 14% (250 px) at the top and bottom of a story free of text and logos.' } },
|
|
51
|
+
'instagram-reel': { width: 1080, height: 1920, unit: 'px', label: 'Instagram reel or reel cover, 9:16 (1080×1920)', zones: { safe: [65, 269, 950, 979], avoid: [{ name: 'top UI', rect: [0, 0, 1080, 269] }, { name: 'caption and buttons', rect: [0, 1248, 1080, 672] }, { name: 'action buttons', rect: [1015, 900, 65, 1020] }], crops: [{ name: 'profile grid (3:4)', rect: [0, 240, 1080, 1440] }], source: 'Meta: keep 14% at the top, 35% at the bottom and 6% at the sides of a reel free of text and logos.' } },
|
|
52
|
+
'youtube-thumbnail': { width: 1280, height: 720, unit: 'px', label: 'YouTube thumbnail, 16:9 (1280×720)', zones: { safe: [64, 48, 1152, 600], avoid: [{ name: 'duration', rect: [1100, 640, 180, 80] }], source: 'YouTube Help: 1280×720 (16:9). The duration badge over the bottom right is measured.' } },
|
|
53
|
+
og: { width: 1200, height: 630, unit: 'px', label: 'Open Graph card (link previews)', zones: { safe: [60, 40, 1080, 550], source: 'Open Graph cards show whole at 1.91:1; some clients crop to a square in the middle, so key type sits centrally.' } },
|
|
54
|
+
// Avatars: every platform crops to a circle.
|
|
55
|
+
avatar: { width: 1080, height: 1080, unit: 'px', label: 'Avatar, circle-cropped (X, Instagram, Threads, LinkedIn, YouTube, TikTok, Bluesky)', zones: avatarZones(1080) },
|
|
56
|
+
'x-avatar': { width: 400, height: 400, unit: 'px', label: 'X profile photo (400×400, shown as a circle)', zones: avatarZones(400) },
|
|
57
|
+
'youtube-avatar': { width: 800, height: 800, unit: 'px', label: 'YouTube profile picture (800×800, circle)', zones: avatarZones(800) },
|
|
58
|
+
// Banners and headers
|
|
59
|
+
'x-header': { width: 1500, height: 500, unit: 'px', label: 'X (Twitter) header (1500×500)', zones: { safe: [420, 60, 930, 380], avoid: [{ name: 'avatar', circle: [215, 470, 175] }], crops: [{ name: 'mobile', rect: [150, 50, 1200, 400] }], source: '1500×500 is X\'s upload size. The avatar overlapping the bottom left and the ~60 px trimmed top and bottom (and the narrower mobile view) are community measurements; X publishes no safe zone.' } },
|
|
60
|
+
'linkedin-banner': { width: 1584, height: 396, unit: 'px', label: 'LinkedIn personal background (1584×396)', zones: { safe: [600, 40, 940, 316], avoid: [{ name: 'avatar', circle: [300, 380, 230] }], source: '1584×396 is LinkedIn\'s recommended size; the profile photo over the bottom left is measured from the desktop profile.' } },
|
|
61
|
+
'linkedin-company': { width: 1128, height: 191, unit: 'px', label: 'LinkedIn company page cover (1128×191)', zones: { safe: [40, 24, 1048, 143], source: '1128×191 is LinkedIn\'s recommended size; the logo sits below the cover, not on it.' } },
|
|
62
|
+
'youtube-banner': { width: 2560, height: 1440, unit: 'px', label: 'YouTube channel art (2560×1440; the 1546×423 centre shows on every device)', zones: { safe: [507, 508, 1546, 423], crops: [{ name: 'tv', rect: [0, 0, 2560, 1440] }, { name: 'desktop', rect: [0, 508, 2560, 423] }, { name: 'tablet', rect: [352, 508, 1855, 423] }, { name: 'mobile', rect: [507, 508, 1546, 423] }], source: 'YouTube Help: upload at least 2048×1152 and keep text and logos in the 1235×338 centre (scaled here to 2560×1440: 1546×423).' } },
|
|
63
|
+
'facebook-cover': { width: 1640, height: 924, unit: 'px', label: 'Facebook page cover (16:9 upload: phones show it all, desktop a 2.63:1 band)', zones: { safe: [80, 150, 1480, 624], crops: [{ name: 'desktop', rect: [0, 150, 1640, 624] }, { name: 'mobile', rect: [0, 0, 1640, 924] }], avoid: [{ name: 'desktop profile picture', circle: [220, 774, 170] }], source: 'Meta Help: the cover shows at 820×312 on desktop and 640×360 on phones; this canvas is the 16:9 shape at 2×, with the desktop band in the middle.' } },
|
|
64
|
+
'twitch-banner': { width: 1200, height: 480, unit: 'px', label: 'Twitch profile banner (1200×480)', zones: { safe: [60, 40, 1080, 400], source: '1200×480 is Twitch\'s recommended size.' } },
|
|
65
|
+
'discord-banner': { width: 960, height: 540, unit: 'px', label: 'Discord server banner (960×540)', zones: { safe: [48, 40, 864, 460], source: '960×540 (16:9) is Discord\'s recommended size.' } },
|
|
66
|
+
'discord-profile': { width: 680, height: 240, unit: 'px', label: 'Discord profile banner (680×240)', zones: { safe: [34, 20, 612, 150], avoid: [{ name: 'avatar', circle: [110, 240, 80] }], source: '680×240 is Discord\'s recommended size; the avatar overlapping the bottom left is measured.' } },
|
|
67
|
+
'bluesky-banner': { width: 3000, height: 1000, unit: 'px', label: 'Bluesky profile banner (3000×1000)', zones: { safe: [600, 80, 2250, 760], avoid: [{ name: 'avatar', circle: [330, 960, 300] }], source: '3000×1000 is Bluesky\'s banner size; the avatar over the bottom left is measured.' } },
|
|
68
|
+
// Posts
|
|
69
|
+
pinterest: { width: 1000, height: 1500, unit: 'px', label: 'Pinterest pin (2:3, 1000×1500)', zones: { safe: [60, 60, 880, 1300], avoid: [{ name: 'save button', rect: [780, 1360, 220, 140] }], source: 'Pinterest recommends 1000×1500 (2:3). The UI over the lower right is measured.' } },
|
|
70
|
+
'tiktok-cover': { width: 1080, height: 1920, unit: 'px', label: 'TikTok video cover (1080×1920; the profile grid crops it to 3:4)', zones: { safe: [60, 240, 900, 1080], avoid: [{ name: 'top UI', rect: [0, 0, 1080, 240] }, { name: 'caption and sound', rect: [0, 1320, 1080, 600] }, { name: 'action buttons', rect: [960, 760, 120, 1160] }], crops: [{ name: 'profile grid', rect: [0, 240, 1080, 1440] }], source: 'TikTok publishes its safe zones as template files; these margins are the commonly measured ones (240 px top, ~600 px bottom, 120 px right), with the 3:4 grid crop.' } },
|
|
71
|
+
// Flyers and posters
|
|
72
|
+
flyer: { width: 1600, height: 2000, unit: 'px', label: 'Digital flyer, 4:5' },
|
|
73
|
+
a6: { width: 105, height: 148, unit: 'mm', label: 'A6 flyer', bleed: 3, safe: 4 },
|
|
74
|
+
a5: { width: 148, height: 210, unit: 'mm', label: 'A5 flyer', bleed: 3, safe: 5 },
|
|
75
|
+
a4: { width: 210, height: 297, unit: 'mm', label: 'A4 flyer or poster', bleed: 3, safe: 5 },
|
|
76
|
+
a3: { width: 297, height: 420, unit: 'mm', label: 'A3 poster', bleed: 3, safe: 8 },
|
|
77
|
+
a2: { width: 420, height: 594, unit: 'mm', label: 'A2 poster', bleed: 3, safe: 10 },
|
|
78
|
+
// Stationery
|
|
79
|
+
'business-card': { width: 85, height: 55, unit: 'mm', label: 'Business card (85×55 mm)', bleed: 3, safe: 3 },
|
|
80
|
+
'business-card-us': { width: 3.5, height: 2, unit: 'in', label: 'Business card (3.5×2 in)', bleed: 3, safe: 3 },
|
|
81
|
+
letterhead: { width: 210, height: 297, unit: 'mm', label: 'Letterhead (A4)', bleed: 3, safe: 15 },
|
|
82
|
+
'letterhead-us': { width: 8.5, height: 11, unit: 'in', label: 'Letterhead (US Letter)', bleed: 3, safe: 15 },
|
|
83
|
+
'compliment-slip': { width: 210, height: 99, unit: 'mm', label: 'Compliment slip (⅓ A4)', bleed: 3, safe: 8 },
|
|
84
|
+
sticker: { width: 60, height: 60, unit: 'mm', label: 'Sticker, die-cut round (60 mm)', bleed: 2, safe: 3, cut: 'circle' },
|
|
85
|
+
'sticker-square': { width: 60, height: 60, unit: 'mm', label: 'Sticker, die-cut rounded square (60 mm)', bleed: 2, safe: 3, cut: 'rounded' },
|
|
86
|
+
ticket: { width: 5.5, height: 2, unit: 'in', label: 'Event ticket (5.5×2 in), 3 mm corners', bleed: 3, safe: 3, cut: { radius: 3 } },
|
|
87
|
+
badge: { width: 54, height: 85.6, unit: 'mm', label: 'ID badge, CR80 portrait (54×85.6 mm)', bleed: 2, safe: 3, cut: 'rounded' },
|
|
88
|
+
'lanyard-pass': { width: 105, height: 148, unit: 'mm', label: 'Lanyard pass (A6 portrait), 4 mm corners', bleed: 3, safe: 5, cut: { radius: 4 } },
|
|
89
|
+
});
|