@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.
Files changed (246) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +18 -524
  3. package/package.json +34 -5
  4. package/recipes/brand.json +1143 -0
  5. package/recipes/flyers/afterglow-motion.json +540 -0
  6. package/recipes/flyers/afterglow.json +70 -0
  7. package/recipes/flyers/arcfield-mirror-motion.json +397 -0
  8. package/recipes/flyers/arcfield-mirror.json +364 -0
  9. package/recipes/flyers/bauhaus-oktav.json +319 -0
  10. package/recipes/flyers/bauhaus-ritmo.json +735 -0
  11. package/recipes/flyers/bauhaus-volta.json +394 -0
  12. package/recipes/flyers/bloomfall.json +99 -0
  13. package/recipes/flyers/drag-solenne-motion.json +444 -0
  14. package/recipes/flyers/drag-solenne.json +426 -0
  15. package/recipes/flyers/fennick-patches.json +874 -0
  16. package/recipes/flyers/ferro-pass.json +398 -0
  17. package/recipes/flyers/ferrova.json +812 -0
  18. package/recipes/flyers/grid-rest-rhythm.json +125 -0
  19. package/recipes/flyers/groovy-honeycomb.json +816 -0
  20. package/recipes/flyers/groovy-marigold.json +479 -0
  21. package/recipes/flyers/groovy-peachfuzz.json +974 -0
  22. package/recipes/flyers/halden-orbit-motion.json +381 -0
  23. package/recipes/flyers/halden-orbit.json +356 -0
  24. package/recipes/flyers/halvard.json +120 -0
  25. package/recipes/flyers/kairo-weekender.json +696 -0
  26. package/recipes/flyers/linger-motion.json +518 -0
  27. package/recipes/flyers/linger.json +67 -0
  28. package/recipes/flyers/little-orchard.json +105 -0
  29. package/recipes/flyers/lofi-hexmoth.json +912 -0
  30. package/recipes/flyers/lofi-lowbit-motion.json +888 -0
  31. package/recipes/flyers/lofi-lowbit.json +880 -0
  32. package/recipes/flyers/lofi-nullpoint.json +3013 -0
  33. package/recipes/flyers/lumio-stack-motion.json +648 -0
  34. package/recipes/flyers/lumio-stack.json +438 -0
  35. package/recipes/flyers/magazine-cover.json +168 -0
  36. package/recipes/flyers/magazine-extended.json +269 -0
  37. package/recipes/flyers/magazine-knockout.json +40 -0
  38. package/recipes/flyers/northfold-varsity.json +618 -0
  39. package/recipes/flyers/open-studio.json +69 -0
  40. package/recipes/flyers/orrin-merch.json +715 -0
  41. package/recipes/flyers/oxyde.json +113 -0
  42. package/recipes/flyers/quiet-fields.json +610 -0
  43. package/recipes/flyers/scrapbook-halcyon.json +948 -0
  44. package/recipes/flyers/solenne-ticket.json +730 -0
  45. package/recipes/flyers/solvane-motion.json +576 -0
  46. package/recipes/flyers/solvane.json +98 -0
  47. package/recipes/flyers/subduct.json +99 -0
  48. package/recipes/flyers/sunvale-lineup.json +584 -0
  49. package/recipes/flyers/tidewell.json +91 -0
  50. package/recipes/flyers/tour-kairo.json +535 -0
  51. package/recipes/flyers/velora-stickers.json +885 -0
  52. package/recipes/flyers/wheatpaste-black.json +313 -0
  53. package/recipes/flyers/wheatpaste-mark.json +198 -0
  54. package/recipes/flyers/wheatpaste-torn.json +114 -0
  55. package/recipes/flyers/wheatpaste-wall.json +984 -0
  56. package/recipes/flyers/wheatpaste-words.json +233 -0
  57. package/recipes/release/aura.json +137 -0
  58. package/recipes/release/chrome.json +433 -0
  59. package/recipes/release/collage.json +372 -0
  60. package/recipes/release/duotone.json +113 -0
  61. package/recipes/release/film-still.json +102 -0
  62. package/recipes/release/headline.json +70 -0
  63. package/recipes/release/instant.json +170 -0
  64. package/recipes/release/label.json +364 -0
  65. package/recipes/release/liquid-glass.json +301 -0
  66. package/recipes/release/odd-tile-dark.json +688 -0
  67. package/recipes/release/odd-tile-dense.json +1056 -0
  68. package/recipes/release/odd-tile-shift.json +541 -0
  69. package/recipes/release/odd-tile-split.json +571 -0
  70. package/recipes/release/odd-tile.json +633 -0
  71. package/recipes/release/off-edge.json +96 -0
  72. package/recipes/release/plinth.json +794 -0
  73. package/recipes/release/riso.json +108 -0
  74. package/recipes/release/series/fluid-label.json +561 -0
  75. package/recipes/release/series/fluid-pixel.json +334 -0
  76. package/recipes/release/series/hairline.json +305 -0
  77. package/recipes/release/series/halo.json +282 -0
  78. package/recipes/release/series/mark-crop.json +135 -0
  79. package/recipes/release/series/mark-window.json +150 -0
  80. package/recipes/release/series/photo-label.json +475 -0
  81. package/recipes/release/series/smear-field.json +345 -0
  82. package/recipes/release/series/smear.json +202 -0
  83. package/recipes/release/series/spin.json +172 -0
  84. package/recipes/release/series/split-ghost.json +230 -0
  85. package/recipes/release/series/zoom.json +168 -0
  86. package/recipes/release/small-print.json +114 -0
  87. package/recipes/release/stack.json +156 -0
  88. package/recipes/release/swiss.json +117 -0
  89. package/schema/recipe.schema.json +7624 -827
  90. package/src/animate/index.d.ts +9 -0
  91. package/src/animate/index.js +11 -0
  92. package/src/animate/keyframes.d.ts +21 -0
  93. package/src/animate/keyframes.js +119 -0
  94. package/src/animate/marquee.d.ts +30 -0
  95. package/src/animate/marquee.js +93 -0
  96. package/src/animate/morph.d.ts +8 -0
  97. package/src/animate/morph.js +99 -0
  98. package/src/animate/motion.d.ts +59 -0
  99. package/src/animate/motion.js +63 -0
  100. package/src/animate/sheet.d.ts +19 -0
  101. package/src/animate/sheet.js +61 -0
  102. package/src/animate/timing.d.ts +28 -0
  103. package/src/animate/timing.js +146 -0
  104. package/src/animate/tracks.d.ts +18 -0
  105. package/src/animate/tracks.js +139 -0
  106. package/src/builtins.js +4 -0
  107. package/src/diagram.js +4 -3
  108. package/src/extract.js +6 -5
  109. package/src/fonts.d.ts +55 -0
  110. package/src/fonts.js +245 -53
  111. package/src/formats/cut.d.ts +10 -0
  112. package/src/formats/cut.js +77 -0
  113. package/src/formats/index.d.ts +6 -0
  114. package/src/formats/index.js +9 -0
  115. package/src/formats/presets.d.ts +41 -0
  116. package/src/formats/presets.js +89 -0
  117. package/src/formats/print.d.ts +25 -0
  118. package/src/formats/print.js +117 -0
  119. package/src/formats/zones.d.ts +10 -0
  120. package/src/formats/zones.js +64 -0
  121. package/src/geometry.js +5 -35
  122. package/src/grid.js +1 -1
  123. package/src/handmade.js +9 -7
  124. package/src/icons.js +42 -9
  125. package/src/index.d.ts +232 -55
  126. package/src/index.js +14 -6
  127. package/src/inter-data.js +27 -0
  128. package/src/json-recipe.js +201 -27
  129. package/src/kit/index.d.ts +20 -0
  130. package/src/kit/index.js +58 -0
  131. package/src/layer-options.js +43 -4
  132. package/src/layers/barcode.d.ts +19 -0
  133. package/src/layers/barcode.js +63 -0
  134. package/src/layers/code128.d.ts +4 -0
  135. package/src/layers/code128.js +28 -0
  136. package/src/layers/expr.d.ts +6 -0
  137. package/src/layers/expr.js +121 -0
  138. package/src/layers/geometry.d.ts +6 -0
  139. package/src/layers/geometry.js +238 -0
  140. package/src/layers/index.d.ts +18 -0
  141. package/src/layers/index.js +15 -0
  142. package/src/layers/perforation.d.ts +14 -0
  143. package/src/layers/perforation.js +47 -0
  144. package/src/layers/repeat.d.ts +29 -0
  145. package/src/layers/repeat.js +90 -0
  146. package/src/layers/shape.d.ts +46 -0
  147. package/src/layers/shape.js +115 -0
  148. package/src/layers/soft.d.ts +5 -0
  149. package/src/layers/soft.js +43 -0
  150. package/src/layers/svg.d.ts +23 -0
  151. package/src/layers/svg.js +108 -0
  152. package/src/layers/table.d.ts +25 -0
  153. package/src/layers/table.js +80 -0
  154. package/src/layers/tear.d.ts +21 -0
  155. package/src/layers/tear.js +74 -0
  156. package/src/layers/texture.d.ts +17 -0
  157. package/src/layers/texture.js +106 -0
  158. package/src/layers/util.d.ts +8 -0
  159. package/src/layers/util.js +61 -0
  160. package/src/layers.js +129 -61
  161. package/src/lint/image-size.d.ts +4 -0
  162. package/src/lint/image-size.js +34 -0
  163. package/src/lint/index.d.ts +16 -0
  164. package/src/lint/index.js +127 -0
  165. package/src/lint/rules.d.ts +5 -0
  166. package/src/lint/rules.js +19 -0
  167. package/src/node.d.ts +2 -0
  168. package/src/node.js +20 -0
  169. package/src/paint/index.d.ts +49 -0
  170. package/src/paint/index.js +66 -0
  171. package/src/paint/presets.d.ts +10 -0
  172. package/src/paint/presets.js +47 -0
  173. package/src/paint/tile.d.ts +7 -0
  174. package/src/paint/tile.js +55 -0
  175. package/src/palette.js +1 -1
  176. package/src/platform/files.d.ts +6 -0
  177. package/src/platform/files.js +41 -0
  178. package/src/platform/harfbuzz.d.ts +9 -0
  179. package/src/platform/harfbuzz.js +80 -0
  180. package/src/platform/index.d.ts +8 -0
  181. package/src/platform/index.js +15 -0
  182. package/src/platform/load.d.ts +6 -0
  183. package/src/platform/load.js +71 -0
  184. package/src/platform/no-harfbuzz.d.ts +2 -0
  185. package/src/platform/no-harfbuzz.js +11 -0
  186. package/src/platform/settings.d.ts +28 -0
  187. package/src/platform/settings.js +51 -0
  188. package/src/print.js +16 -13
  189. package/src/recipes.js +5 -4
  190. package/src/render/backdrop.d.ts +6 -0
  191. package/src/render/backdrop.js +55 -0
  192. package/src/render/context.d.ts +82 -0
  193. package/src/render/grain.d.ts +7 -0
  194. package/src/render/grain.js +113 -0
  195. package/src/render/group.d.ts +23 -0
  196. package/src/render/group.js +67 -0
  197. package/src/render/key.d.ts +5 -0
  198. package/src/render/key.js +90 -0
  199. package/src/render/report.d.ts +5 -0
  200. package/src/render/report.js +19 -0
  201. package/src/render/transform.d.ts +36 -0
  202. package/src/render/transform.js +139 -0
  203. package/src/render/use.d.ts +27 -0
  204. package/src/render/use.js +73 -0
  205. package/src/render/wrap.d.ts +14 -0
  206. package/src/render/wrap.js +144 -0
  207. package/src/render.js +343 -215
  208. package/src/text.d.ts +29 -0
  209. package/src/text.js +23 -16
  210. package/src/type/auto-color.d.ts +9 -0
  211. package/src/type/auto-color.js +44 -0
  212. package/src/type/fallback.d.ts +11 -0
  213. package/src/type/fallback.js +92 -0
  214. package/src/type/glyphs.d.ts +24 -0
  215. package/src/type/glyphs.js +87 -0
  216. package/src/type/index.d.ts +5 -0
  217. package/src/type/index.js +10 -0
  218. package/src/type/kern.d.ts +4 -0
  219. package/src/type/kern.js +144 -0
  220. package/src/type/layout-ref.d.ts +10 -0
  221. package/src/type/layout-ref.js +74 -0
  222. package/src/type/line.d.ts +11 -0
  223. package/src/type/line.js +105 -0
  224. package/src/type/live.d.ts +6 -0
  225. package/src/type/live.js +39 -0
  226. package/src/type/name.d.ts +4 -0
  227. package/src/type/name.js +32 -0
  228. package/src/type/path.d.ts +18 -0
  229. package/src/type/path.js +89 -0
  230. package/src/type/pixel.d.ts +7 -0
  231. package/src/type/pixel.js +85 -0
  232. package/src/type/sfnt.d.ts +20 -0
  233. package/src/type/sfnt.js +258 -0
  234. package/src/type/spans.d.ts +33 -0
  235. package/src/type/spans.js +138 -0
  236. package/src/type/symbols.d.ts +7 -0
  237. package/src/type/symbols.js +89 -0
  238. package/src/type/typeset.d.ts +10 -0
  239. package/src/type/typeset.js +363 -0
  240. package/src/type/woff2.d.ts +6 -0
  241. package/src/type/woff2.js +301 -0
  242. package/src/version.js +4 -0
  243. package/src/color.js +0 -246
  244. package/src/png.js +0 -77
  245. package/src/rng.js +0 -95
  246. 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. Inter (Latin, 400/500/600/700, about 120 KB of WOFF) ships
7
- * in `fonts/` and is always available as the family `Inter`.
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
- * Accepted formats: TrueType/OpenType (`.ttf`/`.otf`) and WOFF (`.woff`).
10
- * WOFF2 is rejected with a clear error: it needs Brotli plus glyph-table
11
- * reconstruction, which isn't worth carrying for a build tool.
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 { readFileSync } from 'node:fs';
17
- import { inflateSync } from 'node:zlib';
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
- /** @type {Promise<typeof import('harfbuzzjs')> | undefined} */
20
- let hbPromise;
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
- hbPromise ??= import('harfbuzzjs');
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, read from disk once per process.
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: readFileSync(new URL(`../fonts/inter-latin-${weight}.woff`, import.meta.url)),
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
- * Turn font bytes into a bare sfnt (TrueType/OpenType) HarfBuzz can read.
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
- * @param {Uint8Array | ArrayBuffer} input - `.ttf`, `.otf` or `.woff` bytes.
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 WOFF2 or bytes that aren't a font.
119
+ * @throws {TypeError} On bytes that aren't a font.
61
120
  */
62
121
  export function decodeFont(input) {
63
- const buf = input instanceof Uint8Array ? input : new Uint8Array(input);
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.forEach((t, i) => {
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 ? inflateSync(src) : src;
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, so the bundled faces and a
137
- * caller's reused buffers are decoded once per process).
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 key = /** @type {object} */ (source.data);
144
- let p = loaded.get(key);
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 hb = await harfbuzz();
148
- const face = new hb.Face(new hb.Blob(decodeFont(source.data)));
149
- const font = new hb.Font(face);
150
- const ext = font.hExtents();
151
- const cap = font.getMetricPosition?.(hb.MetricsTag.CAP_HEIGHT);
152
- const upem = face.upem;
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: source.weight ?? 400,
156
- style: source.style ?? 'normal',
157
- font,
158
- upem,
159
- ascender: ext.ascender,
160
- descender: ext.descender,
161
- capHeight: cap || capFromGlyph(font) || upem * 0.7,
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
- loaded.set(key, p);
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 p.then((f) => ({ ...f, name: source.name, weight: source.weight ?? 400, style: source.style ?? 'normal' }));
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), falling back to Inter.
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
- return pool.reduce((best, s) => (Math.abs((s.weight ?? 400) - weight) < Math.abs((best.weight ?? 400) - weight) ? s : best));
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
+ });