@arraypress/covers 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +296 -11
  3. package/package.json +33 -5
  4. package/recipes/brand.json +1143 -0
  5. package/schema/recipe.schema.json +7300 -770
  6. package/src/animate/index.d.ts +19 -0
  7. package/src/animate/index.js +116 -0
  8. package/src/builtins.js +4 -0
  9. package/src/effects/ascii.d.ts +7 -0
  10. package/src/effects/ascii.js +93 -0
  11. package/src/effects/buffer.d.ts +60 -0
  12. package/src/effects/buffer.js +472 -0
  13. package/src/effects/detail.d.ts +12 -0
  14. package/src/effects/detail.js +124 -0
  15. package/src/effects/digital.d.ts +14 -0
  16. package/src/effects/digital.js +216 -0
  17. package/src/effects/distort.d.ts +20 -0
  18. package/src/effects/distort.js +314 -0
  19. package/src/effects/dither.d.ts +8 -0
  20. package/src/effects/dither.js +147 -0
  21. package/src/effects/drop-shadow.d.ts +4 -0
  22. package/src/effects/drop-shadow.js +45 -0
  23. package/src/effects/extrude.d.ts +5 -0
  24. package/src/effects/extrude.js +55 -0
  25. package/src/effects/film.d.ts +11 -0
  26. package/src/effects/film.js +204 -0
  27. package/src/effects/glow.d.ts +8 -0
  28. package/src/effects/glow.js +56 -0
  29. package/src/effects/grade.d.ts +30 -0
  30. package/src/effects/grade.js +274 -0
  31. package/src/effects/halftone.d.ts +8 -0
  32. package/src/effects/halftone.js +95 -0
  33. package/src/effects/index.d.ts +125 -0
  34. package/src/effects/index.js +277 -0
  35. package/src/effects/ink.d.ts +12 -0
  36. package/src/effects/ink.js +70 -0
  37. package/src/effects/lens.d.ts +14 -0
  38. package/src/effects/lens.js +277 -0
  39. package/src/effects/light.d.ts +10 -0
  40. package/src/effects/light.js +59 -0
  41. package/src/effects/looks.d.ts +9 -0
  42. package/src/effects/looks.js +74 -0
  43. package/src/effects/mask.d.ts +52 -0
  44. package/src/effects/mask.js +314 -0
  45. package/src/effects/motion.d.ts +6 -0
  46. package/src/effects/motion.js +60 -0
  47. package/src/effects/newsprint.d.ts +6 -0
  48. package/src/effects/newsprint.js +89 -0
  49. package/src/effects/optics.d.ts +10 -0
  50. package/src/effects/optics.js +213 -0
  51. package/src/effects/options.d.ts +6 -0
  52. package/src/effects/options.js +106 -0
  53. package/src/effects/paint-presets.d.ts +10 -0
  54. package/src/effects/paint-presets.js +47 -0
  55. package/src/effects/paint.d.ts +49 -0
  56. package/src/effects/paint.js +66 -0
  57. package/src/effects/pixels.d.ts +24 -0
  58. package/src/effects/pixels.js +138 -0
  59. package/src/effects/polar.d.ts +10 -0
  60. package/src/effects/polar.js +265 -0
  61. package/src/effects/region.d.ts +8 -0
  62. package/src/effects/region.js +136 -0
  63. package/src/effects/round-border.d.ts +5 -0
  64. package/src/effects/round-border.js +62 -0
  65. package/src/effects/screen.d.ts +14 -0
  66. package/src/effects/screen.js +212 -0
  67. package/src/effects/sort.d.ts +8 -0
  68. package/src/effects/sort.js +125 -0
  69. package/src/effects/symmetry.d.ts +6 -0
  70. package/src/effects/symmetry.js +74 -0
  71. package/src/effects/tile.d.ts +7 -0
  72. package/src/effects/tile.js +55 -0
  73. package/src/effects/tone.d.ts +16 -0
  74. package/src/effects/tone.js +117 -0
  75. package/src/effects/transform.d.ts +35 -0
  76. package/src/effects/transform.js +127 -0
  77. package/src/effects/util.d.ts +16 -0
  78. package/src/effects/util.js +85 -0
  79. package/src/extract.js +4 -3
  80. package/src/fonts.d.ts +55 -0
  81. package/src/fonts.js +246 -53
  82. package/src/formats/cut.d.ts +10 -0
  83. package/src/formats/cut.js +77 -0
  84. package/src/formats/index.d.ts +6 -0
  85. package/src/formats/index.js +9 -0
  86. package/src/formats/presets.d.ts +41 -0
  87. package/src/formats/presets.js +89 -0
  88. package/src/formats/print.d.ts +25 -0
  89. package/src/formats/print.js +117 -0
  90. package/src/formats/zones.d.ts +10 -0
  91. package/src/formats/zones.js +64 -0
  92. package/src/geometry.js +1 -1
  93. package/src/icons.js +43 -9
  94. package/src/index.d.ts +205 -19
  95. package/src/index.js +12 -4
  96. package/src/inter-data.js +27 -0
  97. package/src/json-recipe.js +181 -23
  98. package/src/kit/index.d.ts +20 -0
  99. package/src/kit/index.js +58 -0
  100. package/src/layer-options.js +23 -4
  101. package/src/layers/barcode.d.ts +19 -0
  102. package/src/layers/barcode.js +63 -0
  103. package/src/layers/code128.d.ts +4 -0
  104. package/src/layers/code128.js +28 -0
  105. package/src/layers/expr.d.ts +6 -0
  106. package/src/layers/expr.js +121 -0
  107. package/src/layers/geometry.d.ts +6 -0
  108. package/src/layers/geometry.js +238 -0
  109. package/src/layers/index.d.ts +18 -0
  110. package/src/layers/index.js +15 -0
  111. package/src/layers/perforation.d.ts +14 -0
  112. package/src/layers/perforation.js +47 -0
  113. package/src/layers/repeat.d.ts +29 -0
  114. package/src/layers/repeat.js +90 -0
  115. package/src/layers/shape.d.ts +46 -0
  116. package/src/layers/shape.js +115 -0
  117. package/src/layers/soft.d.ts +5 -0
  118. package/src/layers/soft.js +43 -0
  119. package/src/layers/svg.d.ts +23 -0
  120. package/src/layers/svg.js +109 -0
  121. package/src/layers/table.d.ts +25 -0
  122. package/src/layers/table.js +80 -0
  123. package/src/layers/tear.d.ts +21 -0
  124. package/src/layers/tear.js +74 -0
  125. package/src/layers/texture.d.ts +17 -0
  126. package/src/layers/texture.js +106 -0
  127. package/src/layers/util.d.ts +8 -0
  128. package/src/layers/util.js +48 -0
  129. package/src/layers.js +125 -41
  130. package/src/lint/image-size.d.ts +4 -0
  131. package/src/lint/image-size.js +34 -0
  132. package/src/lint/index.d.ts +16 -0
  133. package/src/lint/index.js +127 -0
  134. package/src/lint/rules.d.ts +5 -0
  135. package/src/lint/rules.js +19 -0
  136. package/src/node.d.ts +2 -0
  137. package/src/node.js +20 -0
  138. package/src/platform/bytes.d.ts +22 -0
  139. package/src/platform/bytes.js +103 -0
  140. package/src/platform/compress.d.ts +12 -0
  141. package/src/platform/compress.js +81 -0
  142. package/src/platform/harfbuzz.d.ts +9 -0
  143. package/src/platform/harfbuzz.js +80 -0
  144. package/src/platform/index.d.ts +8 -0
  145. package/src/platform/index.js +13 -0
  146. package/src/platform/load.d.ts +6 -0
  147. package/src/platform/load.js +72 -0
  148. package/src/platform/no-harfbuzz.d.ts +2 -0
  149. package/src/platform/no-harfbuzz.js +11 -0
  150. package/src/platform/node.d.ts +26 -0
  151. package/src/platform/node.js +86 -0
  152. package/src/platform/settings.d.ts +28 -0
  153. package/src/platform/settings.js +51 -0
  154. package/src/png.js +77 -28
  155. package/src/print.js +8 -7
  156. package/src/raster/index.d.ts +24 -0
  157. package/src/raster/index.js +171 -0
  158. package/src/recipes.js +4 -3
  159. package/src/render/backdrop.d.ts +6 -0
  160. package/src/render/backdrop.js +55 -0
  161. package/src/render/content.d.ts +5 -0
  162. package/src/render/content.js +58 -0
  163. package/src/render/context.d.ts +82 -0
  164. package/src/render/grain.d.ts +7 -0
  165. package/src/render/grain.js +113 -0
  166. package/src/render/group.d.ts +23 -0
  167. package/src/render/group.js +67 -0
  168. package/src/render/key.d.ts +5 -0
  169. package/src/render/key.js +91 -0
  170. package/src/render/report.d.ts +5 -0
  171. package/src/render/report.js +19 -0
  172. package/src/render/tiles.d.ts +6 -0
  173. package/src/render/tiles.js +77 -0
  174. package/src/render/use.d.ts +27 -0
  175. package/src/render/use.js +73 -0
  176. package/src/render/wrap.d.ts +14 -0
  177. package/src/render/wrap.js +199 -0
  178. package/src/render.js +303 -212
  179. package/src/text.d.ts +29 -0
  180. package/src/text.js +22 -15
  181. package/src/type/auto-color.d.ts +9 -0
  182. package/src/type/auto-color.js +44 -0
  183. package/src/type/fallback.d.ts +11 -0
  184. package/src/type/fallback.js +92 -0
  185. package/src/type/glyphs.d.ts +24 -0
  186. package/src/type/glyphs.js +87 -0
  187. package/src/type/index.d.ts +5 -0
  188. package/src/type/index.js +10 -0
  189. package/src/type/kern.d.ts +4 -0
  190. package/src/type/kern.js +144 -0
  191. package/src/type/layout-ref.d.ts +10 -0
  192. package/src/type/layout-ref.js +74 -0
  193. package/src/type/line.d.ts +11 -0
  194. package/src/type/line.js +105 -0
  195. package/src/type/live.d.ts +6 -0
  196. package/src/type/live.js +39 -0
  197. package/src/type/name.d.ts +4 -0
  198. package/src/type/name.js +32 -0
  199. package/src/type/path.d.ts +18 -0
  200. package/src/type/path.js +89 -0
  201. package/src/type/pixel.d.ts +9 -0
  202. package/src/type/pixel.js +115 -0
  203. package/src/type/sfnt.d.ts +20 -0
  204. package/src/type/sfnt.js +258 -0
  205. package/src/type/spans.d.ts +33 -0
  206. package/src/type/spans.js +138 -0
  207. package/src/type/symbols.d.ts +7 -0
  208. package/src/type/symbols.js +89 -0
  209. package/src/type/typeset.d.ts +10 -0
  210. package/src/type/typeset.js +352 -0
  211. package/src/type/woff2.d.ts +6 -0
  212. package/src/type/woff2.js +301 -0
  213. package/src/version.js +4 -0
package/src/fonts.js CHANGED
@@ -3,31 +3,44 @@
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 } from './platform/index.js';
27
+ import { u8 } from './platform/bytes.js';
28
+ import { parseSfnt } from './type/sfnt.js';
29
+ import { woff2Directory, woff2ToSfnt } from './type/woff2.js';
18
30
 
19
- /** @type {Promise<typeof import('harfbuzzjs')> | undefined} */
20
- let hbPromise;
31
+ /** @param {Uint8Array} b @returns {boolean} Whether bytes are WOFF2. */
32
+ const isWoff2 = (b) => b.length >= 4 && b[0] === 0x77 && b[1] === 0x4f && b[2] === 0x46 && b[3] === 0x32;
21
33
 
22
34
  /**
23
35
  * Load HarfBuzz once (its WebAssembly initialises on import, so it's
24
- * imported lazily: covers with no text never pay for it).
36
+ * imported lazily: covers with no text never pay for it). Undefined where
37
+ * it can't run (Workers), or with `configure({ shaper: 'js' })`: text then
38
+ * uses the JavaScript outliner.
25
39
  *
26
- * @returns {Promise<typeof import('harfbuzzjs')>} The harfbuzzjs module.
40
+ * @returns {Promise<typeof import('harfbuzzjs') | undefined>} The harfbuzzjs module.
27
41
  */
28
42
  export function harfbuzz() {
29
- hbPromise ??= import('harfbuzzjs');
30
- return hbPromise;
43
+ return shaperModule();
31
44
  }
32
45
 
33
46
  /** Bundled weights of Inter. */
@@ -37,34 +50,115 @@ const BUNDLED = [400, 500, 600, 700];
37
50
  let bundled;
38
51
 
39
52
  /**
40
- * The bundled Inter faces, read from disk once per process.
53
+ * The bundled Inter faces: read from disk in Node (once per process),
54
+ * else from an embedded module the first time one is drawn.
41
55
  *
42
56
  * @returns {import('./index.js').FontSource[]} Inter 400/500/600/700.
43
57
  */
44
58
  export function bundledFonts() {
45
- bundled ??= BUNDLED.map((weight) => ({
46
- name: 'Inter',
47
- weight,
48
- style: 'normal',
59
+ bundled ??= BUNDLED.map((weight) => {
49
60
  // 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
- }));
61
+ const data = packageFileSync(`fonts/inter-latin-${weight}.woff`);
62
+ return /** @type {import('./index.js').FontSource} */ (data ? { name: 'Inter', weight, style: 'normal', data } : { name: 'Inter', weight, style: 'normal', src: `covers:inter-${weight}` });
63
+ });
52
64
  return bundled;
53
65
  }
54
66
 
55
67
  /**
56
- * Turn font bytes into a bare sfnt (TrueType/OpenType) HarfBuzz can read.
68
+ * Every font a cover can use: its own, the registered ones, then Inter.
69
+ *
70
+ * @param {import('./index.js').FontSource[]} own
71
+ * @returns {import('./index.js').FontSource[]}
72
+ */
73
+ export function fontPool(own) {
74
+ return [...own, ...registry, ...bundledFonts()];
75
+ }
76
+
77
+ /**
78
+ * Register a font for every cover (by bytes or by `src`).
79
+ *
80
+ * @param {import('./index.js').FontSource} font
81
+ * @returns {void}
57
82
  *
58
- * @param {Uint8Array | ArrayBuffer} input - `.ttf`, `.otf` or `.woff` bytes.
83
+ * @example
84
+ * registerFont({ name: 'Fraunces', src: '/fonts/Fraunces.ttf', weight: [100, 900] });
85
+ */
86
+ export function registerFont(font) {
87
+ if (!font?.name || (font.data == null && font.src == null)) throw new TypeError('registerFont() needs { name, data } or { name, src }');
88
+ registry.push(font);
89
+ }
90
+
91
+ /** @type {Map<string, Promise<Uint8Array>>} */
92
+ const fetched = new Map();
93
+
94
+ /**
95
+ * A font source's bytes (as given; still WOFF if it was).
96
+ *
97
+ * @param {import('./index.js').FontSource} source
98
+ * @returns {Promise<Uint8Array>}
99
+ */
100
+ export function fontBytes(source) {
101
+ if (source.data != null) return Promise.resolve(u8(source.data));
102
+ const src = String(source.src);
103
+ let p = fetched.get(src);
104
+ if (!p) {
105
+ const m = /^covers:inter-(\d+)$/.exec(src);
106
+ p = m
107
+ ? import('./inter-data.js').then((d) => d.inter(Number(m[1])))
108
+ : loadBytes(source.src, 'font');
109
+ fetched.set(src, p);
110
+ }
111
+ return p;
112
+ }
113
+
114
+ /**
115
+ * Turn font bytes into a bare sfnt (TrueType/OpenType) HarfBuzz can read,
116
+ * synchronously (Node, where zlib is synchronous).
117
+ *
118
+ * @param {Uint8Array | ArrayBuffer} input - `.ttf`, `.otf`, `.woff` or `.woff2` bytes.
59
119
  * @returns {Uint8Array} sfnt bytes.
60
- * @throws {TypeError} On WOFF2 or bytes that aren't a font.
120
+ * @throws {TypeError} On bytes that aren't a font.
61
121
  */
62
122
  export function decodeFont(input) {
63
- const buf = input instanceof Uint8Array ? input : new Uint8Array(input);
123
+ const z = node('zlib');
124
+ if (!z) throw new Error('decodeFont() is synchronous only in Node; use decodeFontAsync()');
125
+ const bytes = u8(input);
126
+ if (isWoff2(bytes)) return woff2ToSfnt(bytes, unbrotliSync(woff2Directory(bytes).payload));
127
+ /** @type {Uint8Array | undefined} */
128
+ let out;
129
+ const gen = woffToSfnt(input);
130
+ let step = gen.next();
131
+ while (!step.done) step = gen.next(z.inflateSync(step.value));
132
+ out = step.value;
133
+ return out;
134
+ }
135
+
136
+ /**
137
+ * {@link decodeFont} on any platform (`DecompressionStream` outside Node).
138
+ *
139
+ * @param {Uint8Array | ArrayBuffer} input
140
+ * @returns {Promise<Uint8Array>}
141
+ */
142
+ export async function decodeFontAsync(input) {
143
+ const bytes = u8(input);
144
+ if (isWoff2(bytes)) return woff2ToSfnt(bytes, await unbrotli(woff2Directory(bytes).payload));
145
+ const gen = woffToSfnt(input);
146
+ let step = gen.next();
147
+ while (!step.done) step = gen.next(await inflate(step.value));
148
+ return step.value;
149
+ }
150
+
151
+ /**
152
+ * The WOFF → sfnt conversion, yielding each compressed table to be inflated.
153
+ *
154
+ * @param {Uint8Array | ArrayBuffer} input
155
+ * @returns {Generator<Uint8Array, Uint8Array, Uint8Array>}
156
+ */
157
+ function* woffToSfnt(input) {
158
+ const buf = u8(input);
64
159
  const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
65
160
  if (buf.byteLength < 12) throw new TypeError('Font data is too short to be a font');
66
161
  const tag = view.getUint32(0);
67
- if (tag === 0x774f4632) throw new TypeError('WOFF2 fonts are not supported: pass a .woff, .ttf or .otf');
68
162
  if (tag !== 0x774f4646) {
69
163
  // 0x00010000 TrueType, 'OTTO' CFF, 'true' old Apple TrueType.
70
164
  if (tag === 0x00010000 || tag === 0x4f54544f || tag === 0x74727565) return buf;
@@ -89,7 +183,7 @@ export function decodeFont(input) {
89
183
  while (pow * 2 <= count) { pow *= 2; log++; }
90
184
 
91
185
  const headerSize = 12 + 16 * count;
92
- const pad = (n) => (n + 3) & ~3;
186
+ const pad = (/** @type {number} */ n) => (n + 3) & ~3;
93
187
  const total = tables.reduce((sum, t) => sum + pad(t.origLength), headerSize);
94
188
  const out = new Uint8Array(total);
95
189
  const ov = new DataView(out.buffer);
@@ -100,9 +194,10 @@ export function decodeFont(input) {
100
194
  ov.setUint16(10, count * 16 - pow * 16);
101
195
 
102
196
  let offset = headerSize;
103
- tables.forEach((t, i) => {
197
+ for (let i = 0; i < tables.length; i++) {
198
+ const t = tables[i];
104
199
  const src = buf.subarray(t.offset, t.offset + t.compLength);
105
- const data = t.compLength < t.origLength ? inflateSync(src) : src;
200
+ const data = t.compLength < t.origLength ? yield src : src;
106
201
  out.set(data, offset);
107
202
  const rec = 12 + i * 16;
108
203
  ov.setUint32(rec, t.tag);
@@ -110,10 +205,21 @@ export function decodeFont(input) {
110
205
  ov.setUint32(rec + 8, offset);
111
206
  ov.setUint32(rec + 12, t.origLength);
112
207
  offset += pad(t.origLength);
113
- });
208
+ }
114
209
  return out;
115
210
  }
116
211
 
212
+ /**
213
+ * A shaped glyph, in font units.
214
+ *
215
+ * @typedef {object} RunGlyph
216
+ * @property {number} gid
217
+ * @property {number} cluster
218
+ * @property {number} xAdvance
219
+ * @property {number} xOffset
220
+ * @property {number} yOffset
221
+ */
222
+
117
223
  /**
118
224
  * A loaded face, ready to shape with.
119
225
  *
@@ -121,51 +227,108 @@ export function decodeFont(input) {
121
227
  * @property {string} name - Family name.
122
228
  * @property {number} weight - Weight.
123
229
  * @property {string} style - `'normal'` or `'italic'`.
124
- * @property {any} font - The harfbuzzjs `Font`.
230
+ * @property {any} font - The harfbuzzjs `Font`, or the JS face.
125
231
  * @property {number} upem - Units per em.
126
232
  * @property {number} ascender - In font units.
127
233
  * @property {number} descender - In font units (negative).
128
234
  * @property {number} capHeight - In font units.
129
235
  * @property {Map<number, (string | number)[]>} paths - Glyph outline cache.
236
+ * @property {(text: string) => RunGlyph[]} shapeRun - Shape one line.
237
+ * @property {(gid: number) => string} glyphToPath - Outline, font units, y up.
238
+ * @property {Record<string, { min: number, default: number, max: number }>} axes - The font's variation axes.
239
+ * @property {Record<string, number>} variations - The axis values in use.
240
+ * @property {import('./index.js').FontSource} source - Where it came from.
130
241
  */
131
242
 
132
- /** @type {WeakMap<object, Promise<LoadedFont>>} */
243
+ /** Faces by font bytes (then by variation settings). @type {WeakMap<object, Map<string, Promise<LoadedFont>>>} */
133
244
  const loaded = new WeakMap();
134
245
 
135
246
  /**
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).
247
+ * Load one font source, optionally at a variation (cached per data buffer
248
+ * and settings, so the bundled faces and a caller's reused buffers are
249
+ * decoded once per process).
138
250
  *
139
251
  * @param {import('./index.js').FontSource} source - The font.
252
+ * @param {Record<string, number>} [variations] - Axis values (`{ wght: 650, wdth: 80 }`).
140
253
  * @returns {Promise<LoadedFont>} The loaded face.
141
254
  */
142
- export function loadFont(source) {
143
- const key = /** @type {object} */ (source.data);
144
- let p = loaded.get(key);
255
+ export async function loadFont(source, variations) {
256
+ const bytes = await fontBytes(source);
257
+ const vkey = variations && Object.keys(variations).length ? JSON.stringify(Object.entries(variations).sort()) : '';
258
+ let byVar = loaded.get(bytes);
259
+ if (!byVar) loaded.set(bytes, (byVar = new Map()));
260
+ let p = byVar.get(vkey);
145
261
  if (!p) {
146
262
  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;
263
+ const sfnt = await decodeFontAsync(bytes);
264
+ const hb = await shaperModule();
265
+ if (hb) {
266
+ const face = new hb.Face(new hb.Blob(sfnt));
267
+ const font = new hb.Font(face);
268
+ const axes = vkey && typeof face.getAxisInfos === 'function' ? face.getAxisInfos() : {};
269
+ /** @type {Record<string, number>} */
270
+ const used = {};
271
+ if (vkey && variations) {
272
+ const list = [];
273
+ for (const [tag, value] of Object.entries(variations)) {
274
+ const a = axes[tag];
275
+ if (!a) continue;
276
+ used[tag] = Math.min(a.max, Math.max(a.min, value));
277
+ list.push(new hb.Variation(tag, used[tag]));
278
+ }
279
+ if (list.length) font.setVariations(list);
280
+ }
281
+ const ext = font.hExtents();
282
+ const cap = font.getMetricPosition?.(hb.MetricsTag.CAP_HEIGHT);
283
+ const upem = face.upem;
284
+ return {
285
+ name: source.name,
286
+ weight: 400,
287
+ style: 'normal',
288
+ font,
289
+ upem,
290
+ ascender: ext.ascender,
291
+ descender: ext.descender,
292
+ capHeight: cap || capFromGlyph(font) || upem * 0.7,
293
+ paths: new Map(),
294
+ axes,
295
+ variations: used,
296
+ source,
297
+ glyphToPath: (/** @type {number} */ gid) => font.glyphToPath(gid),
298
+ shapeRun: (/** @type {string} */ text) => {
299
+ const buffer = new hb.Buffer();
300
+ buffer.addText(text);
301
+ buffer.guessSegmentProperties();
302
+ hb.shape(font, buffer);
303
+ const infos = buffer.getGlyphInfos();
304
+ const positions = buffer.getGlyphPositions();
305
+ 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 }));
306
+ },
307
+ };
308
+ }
309
+ const js = parseSfnt(sfnt);
153
310
  return {
154
311
  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,
312
+ weight: 400,
313
+ style: 'normal',
314
+ font: js,
315
+ upem: js.upem,
316
+ ascender: js.ascender,
317
+ descender: js.descender,
318
+ capHeight: js.capHeight || capFromJs(js) || js.upem * 0.7,
162
319
  paths: new Map(),
320
+ axes: js.axes,
321
+ variations: {},
322
+ source,
323
+ glyphToPath: js.glyphToPath,
324
+ shapeRun: (/** @type {string} */ text) => js.shape(text).map((g) => ({ gid: g.gid, cluster: g.cluster, xAdvance: g.advance, xOffset: 0, yOffset: 0 })),
163
325
  };
164
326
  })();
165
- loaded.set(key, p);
327
+ byVar.set(vkey, p);
166
328
  }
329
+ const f = await p;
167
330
  // 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' }));
331
+ return { ...f, name: source.name, weight: Array.isArray(source.weight) ? (variations?.wght ?? 400) : source.weight ?? 400, style: source.style ?? 'normal', source };
169
332
  }
170
333
 
171
334
  /**
@@ -180,9 +343,21 @@ function capFromGlyph(font) {
180
343
  return ext ? ext.yBearing : undefined;
181
344
  }
182
345
 
346
+ /** @param {import('./type/sfnt.js').JsFace} js @returns {number | undefined} */
347
+ function capFromJs(js) {
348
+ const gid = js.shape('H')[0]?.gid;
349
+ if (!gid) return undefined;
350
+ const ys = [...js.glyphToPath(gid).matchAll(/-?[\d.]+,(-?[\d.]+)/g)].map((m) => +m[1]);
351
+ return ys.length ? Math.max(...ys) : undefined;
352
+ }
353
+
354
+ /** @param {import('./index.js').FontSource} s @returns {[number, number]} */
355
+ const weightRange = (s) => (Array.isArray(s.weight) ? [s.weight[0], s.weight[1]] : [s.weight ?? 400, s.weight ?? 400]);
356
+
183
357
  /**
184
358
  * 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.
359
+ * the family (CSS's matching rule, simplified; a variable font's range
360
+ * matches any weight inside it), falling back to Inter.
186
361
  *
187
362
  * @param {import('./index.js').FontSource[]} sources - The available fonts.
188
363
  * @param {string} family - Wanted family.
@@ -196,5 +371,23 @@ export function pickFont(sources, family, weight, style = 'normal') {
196
371
  if (!pool.length) pool = sources;
197
372
  const styled = pool.filter((s) => (s.style ?? 'normal') === style);
198
373
  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));
374
+ const dist = (/** @type {import('./index.js').FontSource} */ s) => {
375
+ const [lo, hi] = weightRange(s);
376
+ return weight < lo ? lo - weight : weight > hi ? weight - hi : 0;
377
+ };
378
+ return pool.reduce((best, s) => (dist(s) < dist(best) ? s : best));
379
+ }
380
+
381
+ /**
382
+ * The variation settings for a layer: its `axes`, with `wght` following
383
+ * the weight on a variable font.
384
+ *
385
+ * @param {import('./index.js').FontSource} source
386
+ * @param {number} weight
387
+ * @param {Record<string, number> | undefined} axes
388
+ * @returns {Record<string, number> | undefined}
389
+ */
390
+ export function variationsFor(source, weight, axes) {
391
+ const v = { ...(Array.isArray(source.weight) ? { wght: weight } : {}), ...(source.axes ?? {}), ...(axes ?? {}) };
392
+ return Object.keys(v).length ? v : undefined;
200
393
  }
@@ -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
+ });