@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
@@ -0,0 +1,33 @@
1
+ // Mixed runs in one text layer.
2
+ import type { LoadedFont } from '../fonts.js';
3
+ import type { Shaped } from '../text.js';
4
+ import type { RenderContext } from '../render/context.js';
5
+ import type { Length, Color } from '../index.js';
6
+
7
+ /** A span as written in a text layer's `spans`. */
8
+ export interface TextSpan {
9
+ content: string;
10
+ font?: string; weight?: number; style?: 'normal' | 'italic'; axes?: Record<string, number>;
11
+ /** Size relative to the layer's (default 1). */
12
+ scale?: number;
13
+ /** Size outright. */
14
+ size?: Length;
15
+ color?: Color; fill?: Color;
16
+ /** Em. */
17
+ letterSpacing?: number;
18
+ /** Baseline shift (down is positive). */
19
+ dy?: Length;
20
+ transform?: 'uppercase' | 'lowercase' | 'none';
21
+ }
22
+
23
+ /** A resolved span. */
24
+ export interface Span { text: string; face: LoadedFont; scale: number; tracking: number; paint: string | undefined; dy: number }
25
+
26
+ /** Resolve a layer's spans (faces loaded, sizes relative to `size`). */
27
+ export function loadSpans(layer: { spans: (TextSpan | string)[]; font?: string; weight?: number; style?: string; axes?: Record<string, number>; letterSpacing?: number; transform?: string }, ctx: RenderContext, size: number): Promise<Span[]>;
28
+ /** Spans as lines (split at `\n`, wrapped at `maxWidth`). */
29
+ export function spanLines(spans: Span[], size: number, maxWidth: number, fallbacks: LoadedFont[]): Span[][];
30
+ /** Shape a line of spans as one run. */
31
+ export function shapeSpans(line: Span[], size: number, extra: number, base: LoadedFont | undefined, fallbacks: LoadedFont[]): Shaped;
32
+ /** A line's text. */
33
+ export function spanText(line: Span[]): string;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Mixed runs in one text layer: `spans`, each with its own font, weight,
3
+ * style, axes, size, colour, tracking and baseline shift, set one after
4
+ * another on the same lines (a `\n` in a span starts a new line).
5
+ *
6
+ * `spans: [{ content, font, weight, style, axes, scale, size, color, letterSpacing, dy }]`;
7
+ * anything a span leaves out comes from the layer. `scale` sizes a span
8
+ * relative to the layer (`size` gives it outright).
9
+ *
10
+ * @module type/spans
11
+ */
12
+
13
+ import { len } from '@logomarks/image-effects/length';
14
+ import { toHex } from '@logomarks/image-effects/color';
15
+ import { faceFor } from './line.js';
16
+ import { shapeText } from './fallback.js';
17
+
18
+ /**
19
+ * @typedef {object} Span
20
+ * @property {string} text
21
+ * @property {import('../fonts.js').LoadedFont} face
22
+ * @property {number} scale - Size relative to the layer's.
23
+ * @property {number} tracking - Em.
24
+ * @property {string | undefined} paint - A colour, or the layer's fill.
25
+ * @property {number} dy - Baseline shift, px at the layer's size (scales with it).
26
+ */
27
+
28
+ /**
29
+ * Resolve a layer's spans: faces loaded, sizes as multiples of the layer's.
30
+ *
31
+ * @param {any} layer
32
+ * @param {import('../layers.js').RenderContext} ctx
33
+ * @param {number} size - The layer's size, px.
34
+ * @returns {Promise<Span[]>}
35
+ */
36
+ export async function loadSpans(layer, ctx, size) {
37
+ const out = [];
38
+ for (const given of layer.spans) {
39
+ const s = typeof given === 'string' ? { content: given } : given;
40
+ const face = await faceFor(ctx, { font: s.font ?? layer.font, weight: s.weight ?? layer.weight, style: s.style ?? layer.style, axes: { ...layer.axes, ...s.axes } });
41
+ const kase = s.transform ?? layer.transform;
42
+ const raw = String(s.content ?? '').replace(/[\t\r ]+/g, ' ');
43
+ out.push({
44
+ text: kase === 'uppercase' ? raw.toUpperCase() : kase === 'lowercase' ? raw.toLowerCase() : raw,
45
+ face,
46
+ scale: s.size != null ? len(s.size, ctx.canvas) / size : s.scale ?? 1,
47
+ tracking: s.letterSpacing ?? layer.letterSpacing ?? -0.02,
48
+ paint: s.color ?? (typeof s.fill === 'string' ? s.fill : undefined) ? toHex(s.color ?? s.fill) : undefined,
49
+ dy: len(s.dy, ctx.canvas, 0) / size,
50
+ });
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * Spans as lines: split at `\n`, then (with a finite `maxWidth`) wrapped at
57
+ * spaces, greedily, at `size`.
58
+ *
59
+ * @param {Span[]} spans
60
+ * @param {number} size
61
+ * @param {number} maxWidth
62
+ * @param {import('../fonts.js').LoadedFont[]} fallbacks
63
+ * @returns {Span[][]}
64
+ */
65
+ export function spanLines(spans, size, maxWidth, fallbacks) {
66
+ /** @type {Span[][]} */
67
+ const lines = [[]];
68
+ for (const s of spans) {
69
+ s.text.split('\n').forEach((part, i) => {
70
+ if (i) lines.push([]);
71
+ if (part) lines[lines.length - 1].push({ ...s, text: part });
72
+ });
73
+ }
74
+ if (!Number.isFinite(maxWidth)) return lines;
75
+ /** @type {Span[][]} */
76
+ const out = [];
77
+ for (const line of lines) {
78
+ /** @type {Span[]} */
79
+ let cur = [];
80
+ let width = 0;
81
+ for (const s of line) {
82
+ for (const word of s.text.split(/(?<= )/)) {
83
+ const piece = { ...s, text: word };
84
+ const w = shapeSpans([piece], size, 0, undefined, fallbacks).width;
85
+ if (cur.length && width + w - trailing(piece, size) > maxWidth) {
86
+ out.push(trim(cur));
87
+ cur = [];
88
+ width = 0;
89
+ }
90
+ cur.push(piece);
91
+ width += w;
92
+ }
93
+ }
94
+ out.push(trim(cur));
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** @param {Span} s @param {number} size @returns {number} The width of a trailing space, px (it may hang past the line). */
100
+ const trailing = (s, size) => (s.text.endsWith(' ') ? size * s.scale * 0.25 : 0);
101
+
102
+ /** @param {Span[]} line @returns {Span[]} The line with its ends' spaces trimmed. */
103
+ function trim(line) {
104
+ if (!line.length) return line;
105
+ const out = line.map((s) => ({ ...s }));
106
+ out[0].text = out[0].text.replace(/^ +/, '');
107
+ out[out.length - 1].text = out[out.length - 1].text.replace(/ +$/, '');
108
+ return out.filter((s) => s.text);
109
+ }
110
+
111
+ /**
112
+ * Shape a line of spans as one run: each span in its face and size, its
113
+ * glyphs carrying them (and their colour as a paint key).
114
+ *
115
+ * @param {Span[]} line
116
+ * @param {number} size - The layer's size, px.
117
+ * @param {number} extra - Tracking added everywhere, px (justification).
118
+ * @param {import('../fonts.js').LoadedFont | undefined} base - The layer's face (its glyphs needn't carry it).
119
+ * @param {import('../fonts.js').LoadedFont[]} fallbacks
120
+ * @returns {import('../text.js').Shaped}
121
+ */
122
+ export function shapeSpans(line, size, extra, base, fallbacks) {
123
+ /** @type {import('../text.js').Shaped['glyphs']} */
124
+ const glyphs = [];
125
+ let x = 0;
126
+ let at = 0;
127
+ line.forEach((s, i) => {
128
+ const sz = size * s.scale;
129
+ const run = shapeText(s.face, s.text, sz, s.tracking * sz + extra, fallbacks);
130
+ for (const g of run.glyphs) glyphs.push({ ...g, x: g.x + x, y: g.y - s.dy * size, face: g.face ?? s.face, size: sz, paint: s.paint, cluster: (g.cluster ?? 0) + at });
131
+ x += run.width + (i < line.length - 1 ? s.tracking * sz + extra : 0);
132
+ at += s.text.length;
133
+ });
134
+ return { glyphs, width: x };
135
+ }
136
+
137
+ /** @param {Span[]} line @returns {string} The line's text. */
138
+ export const spanText = (line) => line.map((s) => s.text).join('');
@@ -0,0 +1,7 @@
1
+ // Built-in symbols, drawn as shapes (the last glyph fallback).
2
+ import type { LoadedFont } from '../fonts.js';
3
+
4
+ /** The symbol face: ✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪. */
5
+ export function symbolFace(): LoadedFont;
6
+ /** The characters it draws. */
7
+ export const SYMBOLS: string;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * A built-in face of a few symbols type often wants and fonts often lack
3
+ * (✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪), drawn as shapes. The last fallback when a
4
+ * text layer's font has no glyph for a character.
5
+ *
6
+ * @module type/symbols
7
+ */
8
+
9
+ /** Advance, font units (1000 per em). */
10
+ const ADV = 820;
11
+ /** Centre of a symbol: half the advance across, about the middle of a capital up. */
12
+ const CX = ADV / 2;
13
+ const CY = 340;
14
+ const R = 360;
15
+
16
+ /** @param {[number, number][]} pts @returns {string} A closed polygon, font units (y up). */
17
+ const poly = (pts) => `M${pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join('L')}Z`;
18
+
19
+ /** @param {number} n @param {number} inner @param {number} [turn=0] @returns {[number, number][]} A star's points (clockwise from the top). */
20
+ function star(n, inner, turn = 0) {
21
+ return Array.from({ length: n * 2 }, (_, i) => {
22
+ const a = (i / (n * 2)) * Math.PI * 2 + turn;
23
+ const r = i % 2 ? R * inner : R;
24
+ return /** @type {[number, number]} */ ([CX + r * Math.sin(a), CY + r * Math.cos(a)]);
25
+ });
26
+ }
27
+
28
+ /** @param {number} k - Size, 0–1. @returns {[number, number][]} A heart, clockwise. */
29
+ function heart(k) {
30
+ return Array.from({ length: 72 }, (_, i) => {
31
+ const t = (i / 72) * Math.PI * 2;
32
+ const x = 16 * Math.sin(t) ** 3;
33
+ const y = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t);
34
+ return /** @type {[number, number]} */ ([CX + x * 22 * k, CY + 30 + y * 22 * k]);
35
+ });
36
+ }
37
+
38
+ /** @param {number} r @param {number} [n=48] @returns {[number, number][]} */
39
+ const circle = (r, n = 48) => Array.from({ length: n }, (_, i) => /** @type {[number, number]} */ ([CX + r * Math.sin((i / n) * Math.PI * 2), CY + r * Math.cos((i / n) * Math.PI * 2)]));
40
+
41
+ /** Outlines by character. An inner contour runs the other way (a hole). */
42
+ const SHAPES = /** @type {Record<string, string>} */ ({
43
+ '✺': poly(star(16, 0.5)),
44
+ '✦': poly(star(4, 0.32)),
45
+ '✶': poly(star(6, 0.45)),
46
+ '★': poly(star(5, 0.4)),
47
+ '☆': poly(star(5, 0.4)) + poly(star(5, 0.4).map(([x, y]) => /** @type {[number, number]} */ ([CX + (x - CX) * 0.7, CY + (y - CY) * 0.7])).reverse()),
48
+ '♥': poly(heart(1)),
49
+ '♡': poly(heart(1)) + poly(heart(0.72).map(([x, y]) => /** @type {[number, number]} */ ([x, y + 28])).reverse()),
50
+ '▶': poly([[CX - R * 0.8, CY + R], [CX + R, CY], [CX - R * 0.8, CY - R]]),
51
+ '◀': poly([[CX + R * 0.8, CY + R], [CX + R * 0.8, CY - R], [CX - R, CY]]),
52
+ '●': poly(circle(R * 0.8)),
53
+ '◆': poly([[CX, CY + R], [CX + R * 0.8, CY], [CX, CY - R], [CX - R * 0.8, CY]]),
54
+ '■': poly([[CX - R * 0.75, CY + R * 0.75], [CX + R * 0.75, CY + R * 0.75], [CX + R * 0.75, CY - R * 0.75], [CX - R * 0.75, CY - R * 0.75]]),
55
+ '♪': poly([[CX - 40, CY + R], [CX + 220, CY + R * 0.7], [CX + 220, CY + R * 0.45], [CX + 20, CY + R * 0.72], [CX + 20, CY - R * 0.55], [CX - 40, CY - R * 0.55]]) + poly(circle(110).map(([x, y]) => /** @type {[number, number]} */ ([x - 120, y - R * 0.6]))),
56
+ });
57
+
58
+ const CHARS = Object.keys(SHAPES);
59
+
60
+ /** @type {any} */
61
+ let face;
62
+
63
+ /**
64
+ * The symbol face (shaped and outlined in JavaScript).
65
+ *
66
+ * @returns {import('../fonts.js').LoadedFont}
67
+ */
68
+ export function symbolFace() {
69
+ face ??= {
70
+ name: 'symbols',
71
+ weight: 400,
72
+ style: 'normal',
73
+ font: null,
74
+ upem: 1000,
75
+ ascender: 800,
76
+ descender: -200,
77
+ capHeight: 700,
78
+ paths: new Map(),
79
+ axes: {},
80
+ variations: {},
81
+ source: { name: 'symbols', src: 'covers:symbols', weight: 400 },
82
+ glyphToPath: (/** @type {number} */ gid) => SHAPES[CHARS[gid - 1]] ?? '',
83
+ shapeRun: (/** @type {string} */ text) => Array.from(text).map((ch, i) => ({ gid: CHARS.indexOf(ch) + 1, cluster: i, xAdvance: ADV, xOffset: 0, yOffset: 0 })),
84
+ };
85
+ return face;
86
+ }
87
+
88
+ /** The characters the symbol face draws. */
89
+ export const SYMBOLS = Object.freeze(CHARS.join(''));
@@ -0,0 +1,10 @@
1
+ // The text layer with its 1.1 options.
2
+ import type { TextLayer } from '../index.js';
3
+ import type { RenderContext, Markup } from '../render/context.js';
4
+
5
+ /** The options that switch a text layer onto the poster typesetter. */
6
+ export const TYPE_OPTIONS: readonly (keyof TextLayer)[];
7
+ /** Whether a text layer uses any of them. */
8
+ export function wantsType(layer: TextLayer): boolean;
9
+ /** Draw a text layer with its 1.1 options. */
10
+ export function typeset(layer: TextLayer, ctx: RenderContext): Promise<Markup>;
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Typesetting for posters and flyers: what the `text` layer does when it's
3
+ * given any of the 1.1 options (the 1.0 path is untouched, so 1.0 covers
4
+ * render byte-identically).
5
+ *
6
+ * - **Baseline placement**: `x`, `y` put the first baseline where SVG
7
+ * `<text x y>` would, with `align` acting as `text-anchor`.
8
+ * - **Justified to a width**: `width` sets each line to exactly that width,
9
+ * like `textLength` (`lengthAdjust: 'spacingAndGlyphs'` scales the glyphs,
10
+ * the default; `'spacing'` opens the letter-spacing instead).
11
+ * - **Fitting**: `fit: 'width'` sizes the block so its widest line fills
12
+ * `width` (or `maxWidth`); `fit: 'lines'` sizes every line on its own to
13
+ * fill it (the stacked full-width headline); `fit: 'shrink'` shrinks to
14
+ * `minSize` before wrapping further.
15
+ * - **Stretch and slant**: `stretch: [sx, sy]` about the first baseline.
16
+ * - **Variable fonts**: `axes: { wdth: 50, SOFT: 100, WONK: 1 }`.
17
+ * - **Strokes**: `stroke`, `strokeWidth`, `fill: 'none'` (outline type), or
18
+ * `strokes: [{ color, width }]`, drawn widest first under the fill, for
19
+ * keylines.
20
+ * - **Text on a path**: `path` (`circle`, `arc`, `ellipse`, `wave`, or any
21
+ * `d`), `startOffset`, `fitPath` to fill a ring, `dy`.
22
+ * - **Auto contrast**: `color: 'auto'` picks white or near-black (or your
23
+ * `autoColors`) by contrast against whatever is drawn underneath.
24
+ * - **Live text**: `render: 'live'` writes `<text>` with the font embedded
25
+ * by `@font-face` (or linked by URL), laid out the same way, for in-browser
26
+ * speed and selectable text.
27
+ * - **Mixed runs**: `spans`, each with its own font, weight, size, colour.
28
+ * - **Per-glyph control**: `glyphs: { scale, dx, dy, rotate, color }` arrays.
29
+ * - **Fallback**: characters the font lacks come from `fallback`, fonts
30
+ * registered with `fallback: true`, or the built-in symbols.
31
+ * - **Pixel type**: `font: 'pixel'`, a built-in 5×7 face.
32
+ * - **Layout from data**: `fit: 'words'` (a word a line), `fit: 'wrap'`
33
+ * (wrap, then fill the width), `split: 'words'`, and the block's box for
34
+ * later layers as `{layout.<id>.bottom}` and the like.
35
+ *
36
+ * @module type/typeset
37
+ */
38
+
39
+ import { attrs, esc, num } from '@logomarks/image-effects/svg';
40
+ import { len } from '@logomarks/image-effects/length';
41
+ import { place, anchorAlign, unit } from '../geometry.js';
42
+ import { toHex } from '@logomarks/image-effects/color';
43
+ import { layout, caseText } from '../text.js';
44
+ import { paint } from '../paint/index.js';
45
+ import { pos, wrap } from '../layers/util.js';
46
+ import { presetStrokes } from '../paint/presets.js';
47
+ import { extrude } from '@logomarks/image-effects/effects/extrude';
48
+ import { dashArray } from '../layers/shape.js';
49
+ import { faceFor, mappedPath, setLine } from './line.js';
50
+ import { textPathD, pathSampler } from './path.js';
51
+ import { fontFace } from './live.js';
52
+ import { autoColor } from './auto-color.js';
53
+ import { fallbackFaces, shapeText } from './fallback.js';
54
+ import { controlGlyphs, glyphCount } from './glyphs.js';
55
+ import { loadSpans, spanLines, shapeSpans, spanText } from './spans.js';
56
+ import { recordLayout } from './layout-ref.js';
57
+ import { marquee } from '../animate/marquee.js';
58
+
59
+ /** Options that switch the text layer onto this typesetter. */
60
+ export const TYPE_OPTIONS = ['x', 'y', 'width', 'justify', 'lengthAdjust', 'fit', 'stretch', 'axes', 'stroke', 'strokeWidth', 'strokes', 'fill', 'path', 'startOffset', 'fitPath', 'dy', 'render', 'autoColors', 'lines', 'spans', 'glyphs', 'fallback', 'split', 'dash', 'extrude', 'maxHeight', 'autoThreshold'];
61
+
62
+ /**
63
+ * Whether a text layer needs this typesetter.
64
+ *
65
+ * @param {any} layer
66
+ * @returns {boolean}
67
+ */
68
+ export function wantsType(layer) {
69
+ return TYPE_OPTIONS.some((k) => layer[k] !== undefined) || layer.color === 'auto' || Array.isArray(layer.content) || layer.font === 'pixel';
70
+ }
71
+
72
+ /**
73
+ * A line to set: its text, size, and (with `spans`) its runs.
74
+ *
75
+ * @typedef {{ text: string, size: number, spans?: import('./spans.js').Span[] }} Line
76
+ */
77
+
78
+ /**
79
+ * The `text` layer with 1.1 options.
80
+ *
81
+ * @param {any} layer
82
+ * @param {import('../layers.js').RenderContext} ctx
83
+ * @returns {Promise<import('../layers.js').Markup>}
84
+ */
85
+ export async function typeset(layer, ctx) {
86
+ const rawContent = Array.isArray(layer.content) ? layer.content.join('\n') : layer.content && typeof layer.content === 'object' ? layer.content.content : layer.content;
87
+ const kase = layer.transform === 'uppercase' || layer.transform === 'lowercase' ? layer.transform : undefined;
88
+ let content = layer.spans ? '' : caseText(String(rawContent ?? ''), kase);
89
+ if (!layer.spans && (layer.split === 'words' || layer.fit === 'words')) content = content.split(/\s+/).filter(Boolean).join('\n');
90
+ const p = ctx.palette;
91
+ const u = unit(ctx.canvas);
92
+ const face = await faceFor(ctx, layer);
93
+ const fallbacks = await fallbackFaces(ctx, layer);
94
+ let size = len(layer.size, ctx.canvas, u * 0.06);
95
+ const ls = layer.letterSpacing ?? -0.02;
96
+ const lh = layer.lineHeight ?? 1.12;
97
+ const baseline = layer.x != null || layer.y != null;
98
+ const anchor = layer.anchor ?? 'bottom-left';
99
+ const align = layer.align ?? (baseline ? 'left' : anchorAlign(anchor));
100
+ const margin = len(layer.margin, ctx.canvas, /** @type {any} */ (ctx).inset ?? u * 0.07);
101
+ const target = layer.width != null ? len(layer.width, ctx.canvas) : undefined;
102
+ const maxWidth = layer.maxWidth != null ? len(layer.maxWidth, ctx.canvas) : baseline ? Infinity : ctx.width - 2 * margin;
103
+ const justifyTo = target ?? (layer.justify ? maxWidth : undefined);
104
+ const adjust = layer.lengthAdjust ?? 'spacingAndGlyphs';
105
+ const id = ctx.id;
106
+ const spans = layer.spans ? await loadSpans(layer, ctx, size) : undefined;
107
+ if (spans) content = spans.map((s) => s.text).join('');
108
+ if (!content.trim()) return { defs: '', body: '' };
109
+ let defs = '';
110
+
111
+ /** Shape a line at a tracking (px), through fallback, spans and per-glyph control. @type {(l: Line, tracking: number, start: number) => import('../text.js').Shaped} */
112
+ const shapeLine = (l, tracking, start) => {
113
+ const run = l.spans ? shapeSpans(l.spans, l.size, tracking - ls * l.size, face, fallbacks) : shapeText(face, l.text, l.size, tracking, fallbacks);
114
+ return controlGlyphs(run, layer.glyphs, { start, size: l.size, capHeight: (face.capHeight / face.upem) * l.size, canvas: ctx.canvas, text: l.text }).run;
115
+ };
116
+ /** @param {Line} l @returns {number} */
117
+ const natural = (l) => shapeLine(l, ls * l.size, 0).width;
118
+
119
+ // ---- Lines and sizes ----
120
+ /** @type {Line[]} */
121
+ let lines;
122
+ if (layer.path) {
123
+ lines = [{ text: content.replace(/\n/g, ' '), size, spans: spans ? spanLines(spans, size, Infinity, fallbacks).flat() : undefined }];
124
+ } else if (spans) {
125
+ const fitting = layer.fit === 'width' || layer.fit === 'lines' || layer.fit === 'words';
126
+ const rows = spanLines(spans, size, fitting ? Infinity : layer.fit === 'wrap' ? justifyTo ?? maxWidth : maxWidth, fallbacks);
127
+ lines = rows.map((r) => ({ text: spanText(r), size, spans: r }));
128
+ lines = fitLines(lines, layer.fit, justifyTo ?? maxWidth, natural);
129
+ } else if (layer.fit === 'width' || layer.fit === 'lines' || layer.fit === 'words') {
130
+ lines = fitLines(content.split('\n').map((t) => ({ text: t, size })), layer.fit, justifyTo ?? maxWidth, natural);
131
+ } else {
132
+ const fitTo = justifyTo ?? maxWidth;
133
+ const set = layout({
134
+ face,
135
+ text: content,
136
+ size,
137
+ minSize: layer.minSize != null ? len(layer.minSize, ctx.canvas) : layer.fit === 'shrink' ? size * 0.5 : undefined,
138
+ letterSpacing: ls,
139
+ // Only `fit: 'wrap'` wraps at the justification width; otherwise `width` justifies what `maxWidth` wrapped.
140
+ maxWidth: layer.fit === 'wrap' ? fitTo : maxWidth,
141
+ maxLines: layer.maxLines ?? (baseline || layer.fit === 'wrap' ? Infinity : 3),
142
+ ellipsis: layer.ellipsis,
143
+ balance: layer.balance ?? !baseline,
144
+ });
145
+ size = set.size;
146
+ lines = set.lines.map((l) => ({ text: l.text, size }));
147
+ // Wrap, then fill: every line at one size, the widest filling the width.
148
+ if (layer.fit === 'wrap') lines = fitLines(lines, 'width', fitTo, natural);
149
+ }
150
+ // A block taller than `maxHeight` shrinks to it.
151
+ if (layer.maxHeight != null && !layer.path) {
152
+ const cap = face.capHeight / face.upem;
153
+ const tall = lines[0].size * cap + lines.slice(1).reduce((h, l) => h + l.size * lh, 0);
154
+ const k = len(layer.maxHeight, ctx.canvas) / tall;
155
+ if (k < 1) lines = lines.map((l) => ({ ...l, size: l.size * k }));
156
+ }
157
+
158
+ // ---- Paint ----
159
+ const strokes = layer.strokes ?? (layer.stroke ? [{ color: layer.stroke, width: layer.strokeWidth ?? '0.3vmin', dash: layer.dash }] : presetStrokes(layer.fill) ?? []);
160
+ const live = (layer.render ?? /** @type {any} */ (ctx).textRender) === 'live';
161
+
162
+ // ---- Text on a path ----
163
+ if (layer.path) {
164
+ const d = textPathD(layer.path, ctx.canvas);
165
+ const sampler = pathSampler(d);
166
+ const line = lines[0];
167
+ let run = shapeLine(line, ls * size, 0);
168
+ if (layer.fitPath) {
169
+ const gaps = Math.max(1, run.glyphs.length);
170
+ const gap = len(layer.fitPath === true ? 0 : layer.fitPath, ctx.canvas);
171
+ run = shapeLine(line, ls * size + (sampler.length - gap - run.width) / gaps, 0);
172
+ }
173
+ const so = layer.startOffset ?? (layer.fitPath ? 0 : 0.5);
174
+ const offset = typeof so === 'number' && so <= 1 && so >= 0 ? so * sampler.length : pos(so, 'x', ctx.canvas);
175
+ let start = layer.fitPath ? offset : align === 'center' || layer.align == null ? offset - run.width / 2 : align === 'right' ? offset - run.width : offset;
176
+ const dy = len(layer.dy, ctx.canvas, 0);
177
+ const adv = (/** @type {number} */ i) => (i + 1 < run.glyphs.length ? run.glyphs[i + 1].x : run.width) - run.glyphs[i].x;
178
+ const mids = run.glyphs.map((g, i) => g.x + adv(i) / 2);
179
+ const index = new Map(run.glyphs.map((g, i) => [g, i]));
180
+ const along = (/** @type {(g: any) => boolean} */ only) => mappedPath(face, run, size, (gx, gy, g) => {
181
+ const i = /** @type {number} */ (index.get(/** @type {any} */ (g)));
182
+ const at = sampler.at(start + mids[i]);
183
+ const a = gx - mids[i];
184
+ const across = gy + dy;
185
+ return [at.x + at.tx * a - at.ty * across, at.y + at.ty * a + at.tx * across];
186
+ }, only);
187
+ const whole = { x: 0, y: 0, width: ctx.width, height: ctx.height };
188
+ const fill = layer.fill === 'none' ? { defs: '', value: 'none' } : layer.color === 'auto' ? { defs: '', value: await autoColor(layer, ctx, whole) } : paint(layer.fill ?? layer.color, p.text, { id: `${id}f`, box: whole, canvas: ctx.canvas });
189
+ defs += fill.defs;
190
+ // A marquee (motion): the still moves the start; playing, each glyph is drawn once in its own frame and placed by copies.
191
+ const running = /** @type {any} */ (ctx).marquee && marquee(/** @type {any} */ (ctx).marquee, {
192
+ run, mids, start, sampler, id,
193
+ glyph: (i) => drawBuckets({ glyphs: [run.glyphs[i]] }, (only) => mappedPath(face, run, size, (gx, gy) => [gx - mids[i], gy + dy], (g) => g === run.glyphs[i] && only(g)), fill.value, strokes, ctx),
194
+ });
195
+ if (running) {
196
+ start += running.shift;
197
+ defs += running.defs;
198
+ }
199
+ report(ctx, layer, content, size, face, null, { capHeight: (face.capHeight / face.upem) * size });
200
+ const drawn = running?.body ?? drawBuckets(run, along, fill.value, strokes, ctx);
201
+ recordLayout(ctx, layer.id, whole, { x: 0, y: 0, baseline: 0, size, lines: 1 });
202
+ return { defs, body: wrap(layer, finish(drawn, layer, ctx)) };
203
+ }
204
+
205
+ // ---- Measure, then place ----
206
+ // `width` justifies every line, except under a fit that sizes the block to
207
+ // its widest line (`width`, `words`, `wrap`): there the widest fills the
208
+ // width and the others keep their natural width.
209
+ const widestOnly = justifyTo != null && (layer.fit === 'width' || layer.fit === 'words' || layer.fit === 'wrap');
210
+ const naturals = widestOnly ? lines.map(natural) : [];
211
+ const widest = widestOnly ? naturals.indexOf(Math.max(...naturals)) : -1;
212
+ const runs = lines.map((l, i) => {
213
+ const fill = justifyTo != null && (!widestOnly || i === widest);
214
+ const w = fill ? /** @type {number} */ (justifyTo) : widestOnly ? naturals[i] : natural(l);
215
+ return { ...l, w, fill, cap: (face.capHeight / face.upem) * l.size };
216
+ });
217
+ const lineStep = (/** @type {number} */ i) => runs[i].size * lh;
218
+ let x0;
219
+ let y0;
220
+ const blockW = Math.max(...runs.map((r) => r.w));
221
+ if (baseline) {
222
+ x0 = pos(layer.x ?? 0, 'x', ctx.canvas);
223
+ y0 = pos(layer.y ?? 0, 'y', ctx.canvas);
224
+ } else {
225
+ let h = runs[0].cap;
226
+ for (let i = 1; i < runs.length; i++) h += lineStep(i);
227
+ const at = place({ width: blockW, height: h, canvas: ctx.canvas, anchor, margin, position: layer.position, offset: layer.offset });
228
+ x0 = align === 'center' ? at.x + blockW / 2 : align === 'right' ? at.x + blockW : at.x;
229
+ y0 = at.y + runs[0].cap;
230
+ }
231
+ const baselines = [y0];
232
+ for (let i = 1; i < runs.length; i++) baselines.push(baselines[i - 1] + lineStep(i));
233
+ const top = y0 - runs[0].cap;
234
+ const left = align === 'center' ? x0 - blockW / 2 : align === 'right' ? x0 - blockW : x0;
235
+ const box = { x: left, y: top, width: blockW, height: baselines[baselines.length - 1] - top };
236
+ const [sx, sy] = layer.stretch ? (Array.isArray(layer.stretch) ? layer.stretch : [1, layer.stretch]) : [1, 1];
237
+ // The block as drawn (stretched about the first baseline), for lint, `color: 'auto'` and later layers.
238
+ const drawnBox = { x: Math.min(x0 + (box.x - x0) * sx, x0 + (box.x + box.width - x0) * sx), y: Math.min(y0 + (box.y - y0) * sy, y0 + (box.y + box.height - y0) * sy), width: box.width * Math.abs(sx), height: box.height * Math.abs(sy) };
239
+
240
+ const color = layer.color === 'auto' ? await autoColor(layer, ctx, drawnBox) : undefined;
241
+ const fillPaint = layer.fill === 'none' ? { defs: '', value: 'none' } : paint(color ?? layer.fill ?? layer.color, p.text, { id: `${id}f`, box, canvas: ctx.canvas });
242
+ defs += fillPaint.defs;
243
+ recordLayout(ctx, layer.id, drawnBox, { x: x0, y: y0, baseline: y0 + (baselines[baselines.length - 1] - y0) * sy, size: runs[0].size, lines: runs.length });
244
+
245
+ let body;
246
+ /** Narrowest horizontal scale a line was set at (justification squeezes glyphs). */
247
+ let squeeze = 1;
248
+ if (live) {
249
+ const ff = await fontFace(face, ctx);
250
+ if (ff.css) defs += `<style>${ff.css}</style>`;
251
+ const fv = Object.entries(face.variations ?? {}).filter(([t]) => t !== 'wght').map(([t, v]) => `'${t}' ${num(v, 2)}`).join(',');
252
+ const anchorAttr = align === 'center' ? 'middle' : align === 'right' ? 'end' : undefined;
253
+ const strokeAttrs = strokes.length ? { stroke: toHex(strokes[0].color), 'stroke-width': len(strokes[0].width, ctx.canvas), 'paint-order': 'stroke', 'stroke-linejoin': 'round' } : {};
254
+ body = runs.map((r, i) => `<text${attrs({
255
+ x: x0, y: baselines[i], 'font-family': ff.family, 'font-size': r.size,
256
+ 'font-weight': layer.weight ?? 600, 'font-style': layer.style && layer.style !== 'normal' ? layer.style : undefined,
257
+ 'letter-spacing': ls ? num(ls * r.size, 3) : undefined,
258
+ 'text-anchor': anchorAttr,
259
+ textLength: r.fill ? justifyTo : undefined, lengthAdjust: r.fill ? adjust : undefined,
260
+ style: fv ? `font-variation-settings:${fv}` : undefined,
261
+ fill: fillPaint.value, ...strokeAttrs,
262
+ })}>${esc(r.text)}</text>`).join('');
263
+ } else {
264
+ // Each line once; then its glyphs drawn per paint (spans and per-glyph colours).
265
+ let start = 0;
266
+ const set = runs.map((r, i) => {
267
+ const first = start;
268
+ start += glyphCount(r.text);
269
+ const line = setLine({ face, text: r.text, size: r.size, tracking: ls * r.size, x: x0, y: baselines[i], align, width: r.fill ? justifyTo : undefined, adjust, shape: (t) => shapeLine(r, t, first), only: () => false });
270
+ squeeze = Math.min(squeeze, line.scaleX);
271
+ return { ...line, baseline: baselines[i], size: r.size };
272
+ });
273
+ const draw = (/** @type {(g: any) => boolean} */ only) => set.map((l) => mappedPath(face, l.run, l.size, (gx, gy) => [l.left + gx * l.scaleX, l.baseline + gy], only)).join('');
274
+ body = finish(drawBuckets({ glyphs: set.flatMap((l) => l.run.glyphs) }, draw, fillPaint.value, strokes, ctx), layer, ctx);
275
+ }
276
+ // Mirrored (negative) stretch is still that tall.
277
+ report(ctx, layer, content, runs[0].size * Math.abs(sy), face, drawnBox, {
278
+ capHeight: Math.min(...runs.map((r) => r.cap)) * Math.abs(sy),
279
+ squeeze: squeeze * Math.abs(sx),
280
+ });
281
+
282
+ if (layer.stretch) body = `<g transform="translate(${num(x0)} ${num(y0)}) scale(${num(sx, 4)} ${num(sy, 4)}) translate(${num(-x0)} ${num(-y0)})">${body}</g>`;
283
+ // Opacity and blend (renderWrapped has already taken them off a wrapped layer).
284
+ return { defs, body: wrap(layer, body) };
285
+ }
286
+
287
+ /**
288
+ * Lines sized to a width: `'width'` (one size, the widest line fills it),
289
+ * `'lines'` and `'words'`… `'lines'` sizes each line on its own; `'words'`
290
+ * (a word a line) is sized like `'width'`.
291
+ *
292
+ * @param {Line[]} lines @param {string} mode @param {number} fitTo
293
+ * @param {(l: Line) => number} natural - A line's width at its size.
294
+ * @returns {Line[]}
295
+ */
296
+ function fitLines(lines, mode, fitTo, natural) {
297
+ if (mode !== 'width' && mode !== 'lines' && mode !== 'words') return lines;
298
+ if (!Number.isFinite(fitTo)) throw new TypeError('fit needs a width (width or maxWidth)');
299
+ const nat = lines.map(natural);
300
+ if (mode === 'lines') return lines.map((l, i) => ({ ...l, size: (l.size * fitTo) / Math.max(nat[i], 1e-6) }));
301
+ const k = fitTo / Math.max(...nat, 1e-6);
302
+ return lines.map((l) => ({ ...l, size: l.size * k }));
303
+ }
304
+
305
+ /**
306
+ * Draw glyphs by paint: strokes under everything (widest first), then the
307
+ * fill, then each colour bucket (spans, per-glyph colours) on its own path.
308
+ *
309
+ * @param {{ glyphs: any[] }} run - Every glyph drawn (for its paint keys).
310
+ * @param {(only: (g: any) => boolean) => string} draw - Path data for the glyphs a filter keeps.
311
+ * @param {string} fill
312
+ * @param {any[]} strokes
313
+ * @param {import('../layers.js').RenderContext} ctx
314
+ * @returns {string}
315
+ */
316
+ function drawBuckets(run, draw, fill, strokes, ctx) {
317
+ const paints = [...new Set(run.glyphs.map((g) => g.paint).filter(Boolean))];
318
+ if (!paints.length) return drawPaths([draw(() => true)], fill, strokes, ctx);
319
+ let out = strokes.length ? drawPaths([draw(() => true)], 'none', strokes, ctx) : '';
320
+ const plain = draw((g) => !g.paint);
321
+ if (plain && fill !== 'none') out += `<path${attrs({ d: plain, fill })}/>`;
322
+ for (const c of paints) out += `<path${attrs({ d: draw((g) => g.paint === c), fill: c })}/>`;
323
+ return out;
324
+ }
325
+
326
+ /**
327
+ * The text's extrusion (`extrude: { depth, angle, color, to, steps }` or a depth).
328
+ *
329
+ * @param {string} body @param {any} layer @param {import('../layers.js').RenderContext} ctx
330
+ * @returns {string}
331
+ */
332
+ function finish(body, layer, ctx) {
333
+ if (!layer.extrude) return body;
334
+ const fx = typeof layer.extrude === 'object' ? layer.extrude : { depth: layer.extrude };
335
+ const out = extrude({ kind: 'extrude', ...fx }, { defs: '', body }, ctx, `${ctx.id}t`);
336
+ return `<defs>${out.defs}</defs>${out.body}`;
337
+ }
338
+
339
+ /**
340
+ * Stroke copies widest first, then the fill.
341
+ *
342
+ * @param {string[]} ds @param {string} fill @param {any[]} strokes @param {import('../layers.js').RenderContext} ctx
343
+ * @returns {string}
344
+ */
345
+ function drawPaths(ds, fill, strokes, ctx) {
346
+ const d = ds.join('');
347
+ const sorted = [...strokes].sort((a, b) => len(b.width, ctx.canvas) - len(a.width, ctx.canvas));
348
+ let out = sorted.map((s) => `<path${attrs({ d, fill: s.fill ? toHex(s.fill) : 'none', stroke: toHex(s.color), 'stroke-width': len(s.width, ctx.canvas), 'stroke-linejoin': 'round', 'stroke-linecap': 'round', 'stroke-dasharray': s.dash ? dashArray(s.dash, len(s.width, ctx.canvas), ctx) : undefined })}/>`).join('');
349
+ if (fill !== 'none' || !strokes.length) out += `<path${attrs({ d, fill })}/>`;
350
+ return out;
351
+ }
352
+
353
+ /**
354
+ * Note a text block for `lint`: the whole block as drawn (every line,
355
+ * stretched), its smallest capitals, how far justification squeezed it.
356
+ *
357
+ * @param {import('../layers.js').RenderContext} ctx @param {any} layer @param {string} content @param {number} size
358
+ * @param {import('../fonts.js').LoadedFont} face @param {{ x: number, y: number, width: number, height: number } | null} box
359
+ * @param {{ capHeight: number, squeeze?: number }} drawn
360
+ */
361
+ function report(ctx, layer, content, size, face, box, drawn) {
362
+ /** @type {any} */ (ctx).report?.({ kind: 'text', id: layer.id, content, size, capHeight: drawn.capHeight, box, squeeze: drawn.squeeze ?? 1 });
363
+ }
@@ -0,0 +1,6 @@
1
+ // WOFF2 to sfnt.
2
+
3
+ /** A WOFF2 file's flavour, table directory and Brotli payload. */
4
+ export function woff2Directory(buf: Uint8Array): { flavor: number; tables: { tag: string; origLength: number; length: number; transformed: boolean; data: Uint8Array }[]; payload: Uint8Array };
5
+ /** WOFF2 bytes as sfnt, given its decompressed stream (glyf, loca and hmtx rebuilt). */
6
+ export function woff2ToSfnt(buf: Uint8Array, stream: Uint8Array | undefined): Uint8Array;