wand-kit 0.3.2 → 0.5.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 (162) hide show
  1. package/PROMPT.md +221 -19
  2. package/SKILL.md +100 -20
  3. package/START-HERE.md +12 -1
  4. package/brands/current/tokens.json +10 -2
  5. package/brands/interim/assets/avatar.svg +1 -0
  6. package/brands/interim/assets/closing-art.png +0 -0
  7. package/brands/interim/assets/cover-art.png +0 -0
  8. package/brands/interim/assets/cover-portrait-art.png +0 -0
  9. package/brands/interim/assets/divider-art.png +0 -0
  10. package/brands/interim/assets/glyphs/README.md +29 -0
  11. package/brands/interim/assets/glyphs/activity.svg +15 -0
  12. package/brands/interim/assets/glyphs/atom.svg +17 -0
  13. package/brands/interim/assets/glyphs/badge-check.svg +16 -0
  14. package/brands/interim/assets/glyphs/bell.svg +16 -0
  15. package/brands/interim/assets/glyphs/blocks.svg +16 -0
  16. package/brands/interim/assets/glyphs/book-open.svg +16 -0
  17. package/brands/interim/assets/glyphs/bot.svg +20 -0
  18. package/brands/interim/assets/glyphs/boxes.svg +26 -0
  19. package/brands/interim/assets/glyphs/brain.svg +23 -0
  20. package/brands/interim/assets/glyphs/briefcase.svg +16 -0
  21. package/brands/interim/assets/glyphs/building-2.svg +21 -0
  22. package/brands/interim/assets/glyphs/calendar.svg +18 -0
  23. package/brands/interim/assets/glyphs/chart-column.svg +18 -0
  24. package/brands/interim/assets/glyphs/chart-line.svg +16 -0
  25. package/brands/interim/assets/glyphs/chart-pie.svg +16 -0
  26. package/brands/interim/assets/glyphs/circle-check.svg +16 -0
  27. package/brands/interim/assets/glyphs/clipboard-check.svg +17 -0
  28. package/brands/interim/assets/glyphs/clock.svg +16 -0
  29. package/brands/interim/assets/glyphs/cloud.svg +15 -0
  30. package/brands/interim/assets/glyphs/code.svg +15 -0
  31. package/brands/interim/assets/glyphs/coins.svg +18 -0
  32. package/brands/interim/assets/glyphs/compass.svg +16 -0
  33. package/brands/interim/assets/glyphs/cpu.svg +24 -0
  34. package/brands/interim/assets/glyphs/database.svg +17 -0
  35. package/brands/interim/assets/glyphs/eye.svg +16 -0
  36. package/brands/interim/assets/glyphs/file-check.svg +17 -0
  37. package/brands/interim/assets/glyphs/filter.svg +15 -0
  38. package/brands/interim/assets/glyphs/fingerprint.svg +23 -0
  39. package/brands/interim/assets/glyphs/flag.svg +16 -0
  40. package/brands/interim/assets/glyphs/gauge.svg +16 -0
  41. package/brands/interim/assets/glyphs/gavel.svg +19 -0
  42. package/brands/interim/assets/glyphs/git-branch.svg +18 -0
  43. package/brands/interim/assets/glyphs/globe.svg +17 -0
  44. package/brands/interim/assets/glyphs/graduation-cap.svg +17 -0
  45. package/brands/interim/assets/glyphs/handshake.svg +19 -0
  46. package/brands/interim/assets/glyphs/hexagon.svg +15 -0
  47. package/brands/interim/assets/glyphs/index.json +177 -0
  48. package/brands/interim/assets/glyphs/infinity.svg +15 -0
  49. package/brands/interim/assets/glyphs/key.svg +17 -0
  50. package/brands/interim/assets/glyphs/landmark.svg +20 -0
  51. package/brands/interim/assets/glyphs/layers.svg +17 -0
  52. package/brands/interim/assets/glyphs/lightbulb.svg +17 -0
  53. package/brands/interim/assets/glyphs/link.svg +16 -0
  54. package/brands/interim/assets/glyphs/list-checks.svg +19 -0
  55. package/brands/interim/assets/glyphs/lock.svg +16 -0
  56. package/brands/interim/assets/glyphs/map.svg +17 -0
  57. package/brands/interim/assets/glyphs/megaphone.svg +16 -0
  58. package/brands/interim/assets/glyphs/message-square.svg +15 -0
  59. package/brands/interim/assets/glyphs/milestone.svg +17 -0
  60. package/brands/interim/assets/glyphs/network.svg +19 -0
  61. package/brands/interim/assets/glyphs/orbit.svg +19 -0
  62. package/brands/interim/assets/glyphs/package.svg +18 -0
  63. package/brands/interim/assets/glyphs/plug.svg +18 -0
  64. package/brands/interim/assets/glyphs/puzzle.svg +15 -0
  65. package/brands/interim/assets/glyphs/radar.svg +22 -0
  66. package/brands/interim/assets/glyphs/receipt.svg +17 -0
  67. package/brands/interim/assets/glyphs/refresh-cw.svg +18 -0
  68. package/brands/interim/assets/glyphs/rocket.svg +18 -0
  69. package/brands/interim/assets/glyphs/route.svg +17 -0
  70. package/brands/interim/assets/glyphs/scale.svg +19 -0
  71. package/brands/interim/assets/glyphs/search.svg +16 -0
  72. package/brands/interim/assets/glyphs/server.svg +18 -0
  73. package/brands/interim/assets/glyphs/settings.svg +16 -0
  74. package/brands/interim/assets/glyphs/share-2.svg +19 -0
  75. package/brands/interim/assets/glyphs/shield-check.svg +16 -0
  76. package/brands/interim/assets/glyphs/shuffle.svg +19 -0
  77. package/brands/interim/assets/glyphs/sparkles.svg +19 -0
  78. package/brands/interim/assets/glyphs/square-stack.svg +17 -0
  79. package/brands/interim/assets/glyphs/target.svg +17 -0
  80. package/brands/interim/assets/glyphs/trending-up.svg +16 -0
  81. package/brands/interim/assets/glyphs/triangle-alert.svg +17 -0
  82. package/brands/interim/assets/glyphs/trophy.svg +20 -0
  83. package/brands/interim/assets/glyphs/users.svg +18 -0
  84. package/brands/interim/assets/glyphs/wallet.svg +16 -0
  85. package/brands/interim/assets/glyphs/wand-sparkles.svg +22 -0
  86. package/brands/interim/assets/glyphs/workflow.svg +17 -0
  87. package/brands/interim/assets/glyphs/zap.svg +15 -0
  88. package/brands/interim/assets/mark.svg +1 -0
  89. package/brands/interim/assets/social/composition.json +6833 -0
  90. package/brands/interim/assets/spark-lavender.png +0 -0
  91. package/brands/interim/assets/spark-violet.png +0 -0
  92. package/brands/interim/assets/statement-art.png +0 -0
  93. package/brands/interim/assets/story/agent-alpha.png +0 -0
  94. package/brands/interim/assets/story/agent.png +0 -0
  95. package/brands/interim/assets/story/economy-alpha.png +0 -0
  96. package/brands/interim/assets/story/economy.png +0 -0
  97. package/brands/interim/assets/story/index.json +214 -0
  98. package/brands/interim/assets/story/learning-alpha.png +0 -0
  99. package/brands/interim/assets/story/learning.png +0 -0
  100. package/brands/interim/assets/story/organization-alpha.png +0 -0
  101. package/brands/interim/assets/story/organization.png +0 -0
  102. package/brands/interim/assets/story/worker-alpha.png +0 -0
  103. package/brands/interim/assets/story/worker-light.png +0 -0
  104. package/brands/interim/assets/story/worker.png +0 -0
  105. package/brands/interim/assets/wordmark-on-dark.png +0 -0
  106. package/brands/interim/assets/wordmark-on-dark.svg +1 -0
  107. package/brands/interim/assets/wordmark-on-light.png +0 -0
  108. package/brands/interim/assets/wordmark-on-light.svg +1 -0
  109. package/brands/interim/fonts/Geist-Book.ttf +0 -0
  110. package/brands/interim/fonts/Geist-Medium.ttf +0 -0
  111. package/brands/interim/fonts/Geist-Regular.ttf +0 -0
  112. package/brands/interim/fonts/Geist-SemiBold.ttf +0 -0
  113. package/brands/interim/fonts/Newsreader-Italic.ttf +0 -0
  114. package/brands/interim/fonts/Newsreader-Medium.ttf +0 -0
  115. package/brands/interim/fonts/Newsreader-MediumItalic.ttf +0 -0
  116. package/brands/interim/fonts/Newsreader-Regular.ttf +0 -0
  117. package/brands/interim/fonts/OFL-Geist.txt +93 -0
  118. package/brands/interim/fonts/OFL-Newsreader.txt +93 -0
  119. package/brands/interim/fonts/faces.json +12 -0
  120. package/brands/interim/lib/composition.js +169 -0
  121. package/brands/interim/lib/composition.mjs +101 -0
  122. package/brands/interim/lib/glyphs.js +89 -0
  123. package/brands/interim/lib/measure.js +121 -0
  124. package/brands/interim/lib/pdf.js +597 -0
  125. package/brands/interim/lib/ttf.py +169 -0
  126. package/brands/interim/lib/wandxml.py +37 -0
  127. package/brands/interim/tokens.json +299 -0
  128. package/brands/next/tokens.json +14 -2
  129. package/examples/social/social.json +23 -0
  130. package/lib/audit.js +142 -0
  131. package/lib/brand.js +12 -7
  132. package/lib/components.js +42 -10
  133. package/lib/engine.js +72 -9
  134. package/lib/library.js +180 -0
  135. package/lib/pptx.js +1 -1
  136. package/lib/social.js +366 -0
  137. package/lib/talk.js +37 -4
  138. package/library/index.json +629 -0
  139. package/library/interim/avatar-800x800.png +0 -0
  140. package/library/interim/icons/apple-touch-icon.png +0 -0
  141. package/library/interim/icons/favicon-16.png +0 -0
  142. package/library/interim/icons/favicon-32.png +0 -0
  143. package/library/interim/icons/favicon-48.png +0 -0
  144. package/library/interim/icons/favicon.ico +0 -0
  145. package/library/interim/icons/favicon.svg +1 -0
  146. package/library/interim/icons/icon-192.png +0 -0
  147. package/library/interim/icons/icon-512.png +0 -0
  148. package/library/interim/icons/icon-tags.html +5 -0
  149. package/library/interim/icons/site.webmanifest +19 -0
  150. package/library/interim/linkedin-banner-1584x396.png +0 -0
  151. package/library/interim/linkedin-company-1128x191.png +0 -0
  152. package/library/interim/open-agent-discovery-share-1200x630.png +0 -0
  153. package/library/interim/open-agent-discovery-share-head-tags.html +17 -0
  154. package/library/interim/wand-ai-share-1200x630.png +0 -0
  155. package/library/interim/wand-ai-share-head-tags.html +17 -0
  156. package/library/interim/x-header-1500x500.png +0 -0
  157. package/package.json +2 -2
  158. package/templates/card.mjs +6 -2
  159. package/templates/page.mjs +1 -1
  160. package/templates/slide.mjs +1 -1
  161. package/templates/storyboard.mjs +1 -1
  162. package/wand.js +65 -6
@@ -0,0 +1,101 @@
1
+ /**
2
+ * lib/composition.mjs — the approved social composition, ready to draw.
3
+ *
4
+ * import { composition } from "<brand-interim>/lib/composition.mjs";
5
+ * const comp = composition(brandDir);
6
+ * comp.page({ w: 1200, h: 630, ...comp.window({ w: 1200, h: 630 }, { left: 520, right: 3310, centreY: 467 }), mode: "dark" })
7
+ *
8
+ * `page(card)` returns the HTML of one card: the brand's faces, the canvas,
9
+ * `window.CARD` and lib/composition.js, which draws it. Render it at any device
10
+ * scale; the gate it writes into <script id="gate"> says whether the card keeps
11
+ * the rules. Colours come from ../tokens.json through the composition's palette
12
+ * for the card's ground (`mode`), the ring from assets/story, the wordmark's paths
13
+ * from the brand's own SVG; nothing here carries a hex.
14
+ */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ const url = (p) => "file://" + p;
19
+ const NUM = /-?\d+(?:\.\d+)?/g;
20
+
21
+ /** Extents of an SVG path made of M, L, H, V, A and Z commands, from its end points: right for the spark, whose tips are its extremes. */
22
+ function extents(d) {
23
+ const xs = [], ys = [];
24
+ let x = 0, y = 0;
25
+ for (const [, cmd, args] of d.matchAll(/([MLHVAZ])([^MLHVAZ]*)/g)) {
26
+ const v = (args.match(NUM) || []).map(Number);
27
+ if (cmd === "M" || cmd === "L") for (let i = 0; i < v.length; i += 2) { x = v[i]; y = v[i + 1]; xs.push(x); ys.push(y); }
28
+ else if (cmd === "H") for (const n of v) { x = n; xs.push(x); ys.push(y); }
29
+ else if (cmd === "V") for (const n of v) { y = n; xs.push(x); ys.push(y); }
30
+ else if (cmd === "A") for (let i = 0; i < v.length; i += 7) { x = v[i + 5]; y = v[i + 6]; xs.push(x); ys.push(y); }
31
+ }
32
+ return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
33
+ }
34
+
35
+ export function composition(brandDir) {
36
+ const read = (...p) => fs.readFileSync(path.join(brandDir, ...p), "utf8");
37
+ const tokens = JSON.parse(read("tokens.json"));
38
+ const comp = JSON.parse(read("assets", tokens.art.composition));
39
+ const story = JSON.parse(read("assets", "story", "index.json"));
40
+ const sw = tokens.swatch;
41
+
42
+ /** The composition's palette for a ground, swatch names resolved to hex. */
43
+ function palette(mode) {
44
+ const p = comp.palettes[mode];
45
+ if (!p) throw new Error(`composition: no palette for "${mode}" (${Object.keys(comp.palettes).join(", ")})`);
46
+ return Object.fromEntries(Object.entries(p).filter(([k]) => k !== "_").map(([k, v]) => {
47
+ if (typeof v === "number") return [k, v];
48
+ if (!sw[v]) throw new Error(`composition palette ${mode}.${k}: "${v}" is not in the swatch`);
49
+ return [k, sw[v]];
50
+ }));
51
+ }
52
+
53
+ /** The wordmark's letters and spark for a ground, with the extents the lockup is placed by. */
54
+ function paths(mode) {
55
+ const svg = read("assets", tokens.art[mode === "dark" ? "wordmarkSvgOnDark" : "wordmarkSvgOnLight"]);
56
+ const [letters, spark] = [...svg.matchAll(/<path d="([^"]+)"/g)].map((m) => m[1]);
57
+ if (!letters || !spark) throw new Error("wordmark SVG: expected two paths, letters then spark");
58
+ const [lx0, ly0, lx1, ly1] = comp.lockup.wordmark.letters, [, sy0] = extents(spark);
59
+ return { letters, spark, lettersTop: ly0, lettersH: ly1 - ly0, lettersW: lx1 - lx0, sparkTop: sy0 };
60
+ }
61
+
62
+ /** The ring's image for a ground: the alpha capture on dark; on light the same capture with its inner rings tinted (tools/art.mjs). */
63
+ function ringFile(mode) {
64
+ const key = mode === "light" ? comp.ring.stage.replace(/-alpha$/, "-light") : comp.ring.stage;
65
+ const s = story[key];
66
+ if (!s) throw new Error(`composition: story/index.json has no "${key}"${mode === "light" ? " (run tools/art.mjs)" : ""}`);
67
+ return path.join(brandDir, "assets", "story", s.file);
68
+ }
69
+
70
+ /** A wide window: the master x-range [left, right] fills the card's width, centred on master y `centreY`. */
71
+ function window(card, { left, right, centreY }) {
72
+ const s = card.w / (right - left);
73
+ return { s, x0: left, y0: centreY - card.h / s / 2 };
74
+ }
75
+
76
+ const L = comp.lockup;
77
+ const faces = [L.lead, L.phrase].map((f) => {
78
+ const file = tokens.faces.files.find((x) => x.family === f.family && x.weight === f.weight && !!x.italic === !!f.italic);
79
+ if (!file) throw new Error(`composition: no ${f.family} ${f.weight}${f.italic ? " italic" : ""} in tokens.faces.files`);
80
+ return { ...f, file: path.join(brandDir, "fonts", file.file) };
81
+ });
82
+ const FACES = faces.map((f) => `@font-face{font-family:"${f.family}";src:url("${url(f.file)}");font-weight:${f.weight};font-style:${f.italic ? "italic" : "normal"}}`).join("\n");
83
+ const LOADS = faces.map((f) => `${f.italic ? "italic " : ""}${f.weight} ${f.size}px "${f.family}"`);
84
+ const script = read("lib", "composition.js");
85
+
86
+ /** The page for one card: { w, h, s, x0, y0, mode, lockup?: {dx, dy} | false, ring?: {dx, dy}, set?, text?: {lead, phrase}, photo?: path, rules?, id? }. */
87
+ function page(card) {
88
+ const P = palette(card.mode), R = tokens.roles[card.mode];
89
+ const photo = card.photo ? { src: url(path.resolve(card.photo)), low: R.gradeLow, high: R.gradeHigh } : null;
90
+ const cfg = { ...card, photo, composition: comp, palette: P, paths: paths(card.mode), fonts: card.lockup === false ? [] : LOADS, ringSrc: url(card.ringFile || ringFile(card.mode)) };
91
+ const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;");
92
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(card.id || "card")}</title><style>${FACES}
93
+ html,body{margin:0;background:#${P.ground}}canvas{display:block}</style></head><body>
94
+ <canvas id="card"></canvas>
95
+ <script>window.CARD=${JSON.stringify(cfg).replace(/</g, "\\u003c")};</script>
96
+ <script>${script}</script>
97
+ </body></html>`;
98
+ }
99
+
100
+ return { tokens, comp, story, palette, paths, ringFile, window, page, extents };
101
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * lib/glyphs.js — the named icon set.
3
+ *
4
+ * The old library was 36 PNGs named after their extraction order, half baked
5
+ * white and half baked accent. That produced two standing bugs: a white glyph on
6
+ * a light tile is invisible, and nobody could pick an icon by meaning because
7
+ * the names carried none.
8
+ *
9
+ * These are Lucide's single-colour stroke SVGs. `stroke="currentColor"` means
10
+ * the kit substitutes the theme's colour at build time, so polarity cannot be
11
+ * wrong. They go into the deck as vector — pptxgenjs writes the OOXML
12
+ * `asvg:svgBlip` extension with a raster fallback — so nothing is rasterised
13
+ * and the kit keeps needing no native dependency.
14
+ */
15
+
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+
19
+ const DIR = path.join(__dirname, "..", "assets", "glyphs");
20
+ let INDEX = null, BUNDLE = null;
21
+
22
+ function index() {
23
+ if (!INDEX) INDEX = JSON.parse(fs.readFileSync(path.join(DIR, "index.json"), "utf8"));
24
+ return INDEX;
25
+ }
26
+
27
+ /**
28
+ * 75 SVGs is 75 files, and a packaged skill is capped at 200 of them. The
29
+ * packager collapses them into one `glyphs.json`; loose files still win when
30
+ * they are there, so the repo keeps its readable one-file-per-icon layout.
31
+ */
32
+ function bundle() {
33
+ if (BUNDLE === null) {
34
+ const f = path.join(DIR, "glyphs.json");
35
+ BUNDLE = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, "utf8")) : false;
36
+ }
37
+ return BUNDLE;
38
+ }
39
+
40
+ /** The raw SVG source for a glyph. */
41
+ function svg(name) {
42
+ const b = bundle();
43
+ if (b && b[name]) return b[name];
44
+ return fs.readFileSync(path.join(DIR, name + ".svg"), "utf8");
45
+ }
46
+
47
+ /** Every glyph name, for validation and for `wand.js glyphs`. */
48
+ const names = () => index().icons;
49
+ const has = (name) => typeof name === "string" && index().icons.includes(name);
50
+
51
+ const cache = new Map();
52
+
53
+ /**
54
+ * A recoloured glyph as a data URI, ready for `addImage({ data })`.
55
+ * Returns null for anything that isn't a known glyph name, so callers can fall
56
+ * back to the legacy PNG path.
57
+ */
58
+ function dataUri(name, color, { strokeWidth } = {}) {
59
+ if (!has(name)) return null;
60
+ const key = `${name}|${color}|${strokeWidth || ""}`;
61
+ if (cache.has(key)) return cache.get(key);
62
+ let src = svg(name).replace(/currentColor/g, "#" + String(color).replace(/^#/, ""));
63
+ // the site draws its line art at a hairline; Lucide's 2px reads heavy beside it
64
+ if (strokeWidth) src = src.replace(/stroke-width="[\d.]+"/g, `stroke-width="${strokeWidth}"`);
65
+ const uri = "image/svg+xml;base64," + Buffer.from(src, "utf8").toString("base64");
66
+ cache.set(key, uri);
67
+ return uri;
68
+ }
69
+
70
+ /** Nearest names, for a useful error when someone mistypes one. */
71
+ function suggest(name, n = 4) {
72
+ const q = String(name || "").toLowerCase();
73
+ return index().icons
74
+ .map((g) => [g, g.includes(q) || q.includes(g) ? 0 : distance(q, g)])
75
+ .sort((a, b) => a[1] - b[1]).slice(0, n).map((r) => r[0]);
76
+ }
77
+
78
+ function distance(a, b) {
79
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
80
+ for (let j = 0; j <= b.length; j++) d[0][j] = j;
81
+ for (let i = 1; i <= a.length; i++) {
82
+ for (let j = 1; j <= b.length; j++) {
83
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
84
+ }
85
+ }
86
+ return d[a.length][b.length];
87
+ }
88
+
89
+ module.exports = { names, has, svg, dataUri, suggest, index, DIR };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * lib/measure.js — real text metrics against the brand's actual faces.
3
+ *
4
+ * Why this exists: LibreOffice ignores embedded fonts and substitutes DejaVu,
5
+ * so QA renders over-report overflow and lose all weight information. This
6
+ * module measures the real font, so overflow is a build-time error rather than
7
+ * a judgement call about a screenshot.
8
+ *
9
+ * What differs from the Geist-era copy in brand/lib: the face table is data
10
+ * (`fonts/faces.json`), not code, and every family carries its letter-spacing.
11
+ * The website sets headings at -0.07em and body at -0.02em, the kit writes the
12
+ * same spacing into every run, and this module measures with it — so a title
13
+ * is judged at the width it is actually drawn, not at the font's default.
14
+ */
15
+
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+ const FONTS = path.join(__dirname, "..", "fonts");
19
+ const TABLE = JSON.parse(fs.readFileSync(path.join(FONTS, "faces.json"), "utf8"));
20
+
21
+ let FONTKIT = null;
22
+ const cache = new Map();
23
+
24
+ /** The faces.json entry for a family, falling back to the default body face. */
25
+ function familyOf(family) {
26
+ return TABLE.families[family] || TABLE.families[TABLE.default];
27
+ }
28
+
29
+ /** The TTF stem that draws a family in a given style. */
30
+ function stemOf(family, bold = false, italic = false) {
31
+ const set = familyOf(family);
32
+ return set[bold && italic ? "bi" : bold ? "b" : italic ? "i" : "r"];
33
+ }
34
+
35
+ /** Letter-spacing for a family, in em. */
36
+ const trackingEm = (family) => familyOf(family).tracking || 0;
37
+
38
+ /** Letter-spacing in points at a size — the unit PowerPoint's `spc` and PDF's `Tc` take. */
39
+ const trackingPt = (family, fontSize) => +(trackingEm(family) * fontSize).toFixed(2);
40
+
41
+ function face(family, { bold = false, italic = false } = {}) {
42
+ if (!FONTKIT) throw new Error("measure.js was loaded without fontkit — require it through the kit's lib/shared.js");
43
+ const stem = stemOf(family, bold, italic);
44
+ if (!cache.has(stem)) cache.set(stem, FONTKIT.openSync(path.join(FONTS, stem + ".ttf")));
45
+ return cache.get(stem);
46
+ }
47
+
48
+ /** Width of a single line, in inches, tracking included. */
49
+ function widthIn(text, { family, fontSize, bold, italic } = {}) {
50
+ if (!text) return 0;
51
+ const f = face(family, { bold, italic });
52
+ const run = f.layout(String(text));
53
+ const glyphs = run.glyphs.length;
54
+ return (run.advanceWidth / f.unitsPerEm) * (fontSize / 72) + (trackingPt(family, fontSize) * glyphs) / 72;
55
+ }
56
+
57
+ /**
58
+ * Greedy word wrap at a given box width. Returns the lines it would occupy.
59
+ * Honours explicit \n. Matches how PowerPoint breaks closely enough to catch
60
+ * the failures that matter (a line too many, a word that can't fit at all).
61
+ */
62
+ function wrap(text, boxWidthIn, opts = {}) {
63
+ const out = [];
64
+ for (const para of String(text).split("\n")) {
65
+ const words = para.split(/\s+/).filter(Boolean);
66
+ if (!words.length) { out.push(""); continue; }
67
+ let line = words[0];
68
+ for (let i = 1; i < words.length; i++) {
69
+ const test = line + " " + words[i];
70
+ if (widthIn(test, opts) <= boxWidthIn) line = test;
71
+ else { out.push(line); line = words[i]; }
72
+ }
73
+ out.push(line);
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /** Rendered height in inches for text wrapped into a box. */
79
+ function heightIn(text, boxWidthIn, opts = {}) {
80
+ const leading = opts.lineSpacingMultiple || 1.2;
81
+ return wrap(text, boxWidthIn, opts).length * (opts.fontSize / 72) * leading;
82
+ }
83
+
84
+ /** Does this text fit the box? A verdict object, so callers can report why. */
85
+ function fits(text, boxWidthIn, boxHeightIn, opts = {}) {
86
+ const lines = wrap(text, boxWidthIn, opts);
87
+ const h = heightIn(text, boxWidthIn, opts);
88
+ const widest = Math.max(...lines.map((l) => widthIn(l, opts)));
89
+ return {
90
+ ok: h <= boxHeightIn + 1e-6 && widest <= boxWidthIn + 1e-6,
91
+ lines: lines.length,
92
+ heightIn: +h.toFixed(3),
93
+ widestIn: +widest.toFixed(3),
94
+ overflowIn: +Math.max(0, h - boxHeightIn).toFixed(3),
95
+ };
96
+ }
97
+
98
+ /** Largest size at or below `fontSize` that fits, stepping by `step`, never below `min`. */
99
+ function fitSize(text, boxWidthIn, boxHeightIn, opts = {}) {
100
+ const { fontSize, min = 7, step = 0.5 } = opts;
101
+ for (let s = fontSize; s >= min; s -= step) {
102
+ if (fits(text, boxWidthIn, boxHeightIn, { ...opts, fontSize: s }).ok) return s;
103
+ }
104
+ return min;
105
+ }
106
+
107
+ /** Line count only — the common case for "will this title be 2 lines or 3?". */
108
+ const lineCount = (text, boxWidthIn, opts = {}) => wrap(text, boxWidthIn, opts).length;
109
+
110
+ /** Every family the brand knows, for the gates that check a deck stays inside them. */
111
+ const families = () => Object.keys(TABLE.families);
112
+
113
+ /**
114
+ * This module lives in the brand payload, outside any kit's node_modules, so
115
+ * Node cannot resolve `fontkit` from here. The kit that loads it hands its own
116
+ * copy in.
117
+ */
118
+ module.exports = function create(fontkit) {
119
+ FONTKIT = fontkit;
120
+ return { widthIn, wrap, heightIn, fits, fitSize, lineCount, face, stemOf, trackingEm, trackingPt, families, TABLE };
121
+ };