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.
- package/PROMPT.md +221 -19
- package/SKILL.md +100 -20
- package/START-HERE.md +12 -1
- package/brands/current/tokens.json +10 -2
- package/brands/interim/assets/avatar.svg +1 -0
- package/brands/interim/assets/closing-art.png +0 -0
- package/brands/interim/assets/cover-art.png +0 -0
- package/brands/interim/assets/cover-portrait-art.png +0 -0
- package/brands/interim/assets/divider-art.png +0 -0
- package/brands/interim/assets/glyphs/README.md +29 -0
- package/brands/interim/assets/glyphs/activity.svg +15 -0
- package/brands/interim/assets/glyphs/atom.svg +17 -0
- package/brands/interim/assets/glyphs/badge-check.svg +16 -0
- package/brands/interim/assets/glyphs/bell.svg +16 -0
- package/brands/interim/assets/glyphs/blocks.svg +16 -0
- package/brands/interim/assets/glyphs/book-open.svg +16 -0
- package/brands/interim/assets/glyphs/bot.svg +20 -0
- package/brands/interim/assets/glyphs/boxes.svg +26 -0
- package/brands/interim/assets/glyphs/brain.svg +23 -0
- package/brands/interim/assets/glyphs/briefcase.svg +16 -0
- package/brands/interim/assets/glyphs/building-2.svg +21 -0
- package/brands/interim/assets/glyphs/calendar.svg +18 -0
- package/brands/interim/assets/glyphs/chart-column.svg +18 -0
- package/brands/interim/assets/glyphs/chart-line.svg +16 -0
- package/brands/interim/assets/glyphs/chart-pie.svg +16 -0
- package/brands/interim/assets/glyphs/circle-check.svg +16 -0
- package/brands/interim/assets/glyphs/clipboard-check.svg +17 -0
- package/brands/interim/assets/glyphs/clock.svg +16 -0
- package/brands/interim/assets/glyphs/cloud.svg +15 -0
- package/brands/interim/assets/glyphs/code.svg +15 -0
- package/brands/interim/assets/glyphs/coins.svg +18 -0
- package/brands/interim/assets/glyphs/compass.svg +16 -0
- package/brands/interim/assets/glyphs/cpu.svg +24 -0
- package/brands/interim/assets/glyphs/database.svg +17 -0
- package/brands/interim/assets/glyphs/eye.svg +16 -0
- package/brands/interim/assets/glyphs/file-check.svg +17 -0
- package/brands/interim/assets/glyphs/filter.svg +15 -0
- package/brands/interim/assets/glyphs/fingerprint.svg +23 -0
- package/brands/interim/assets/glyphs/flag.svg +16 -0
- package/brands/interim/assets/glyphs/gauge.svg +16 -0
- package/brands/interim/assets/glyphs/gavel.svg +19 -0
- package/brands/interim/assets/glyphs/git-branch.svg +18 -0
- package/brands/interim/assets/glyphs/globe.svg +17 -0
- package/brands/interim/assets/glyphs/graduation-cap.svg +17 -0
- package/brands/interim/assets/glyphs/handshake.svg +19 -0
- package/brands/interim/assets/glyphs/hexagon.svg +15 -0
- package/brands/interim/assets/glyphs/index.json +177 -0
- package/brands/interim/assets/glyphs/infinity.svg +15 -0
- package/brands/interim/assets/glyphs/key.svg +17 -0
- package/brands/interim/assets/glyphs/landmark.svg +20 -0
- package/brands/interim/assets/glyphs/layers.svg +17 -0
- package/brands/interim/assets/glyphs/lightbulb.svg +17 -0
- package/brands/interim/assets/glyphs/link.svg +16 -0
- package/brands/interim/assets/glyphs/list-checks.svg +19 -0
- package/brands/interim/assets/glyphs/lock.svg +16 -0
- package/brands/interim/assets/glyphs/map.svg +17 -0
- package/brands/interim/assets/glyphs/megaphone.svg +16 -0
- package/brands/interim/assets/glyphs/message-square.svg +15 -0
- package/brands/interim/assets/glyphs/milestone.svg +17 -0
- package/brands/interim/assets/glyphs/network.svg +19 -0
- package/brands/interim/assets/glyphs/orbit.svg +19 -0
- package/brands/interim/assets/glyphs/package.svg +18 -0
- package/brands/interim/assets/glyphs/plug.svg +18 -0
- package/brands/interim/assets/glyphs/puzzle.svg +15 -0
- package/brands/interim/assets/glyphs/radar.svg +22 -0
- package/brands/interim/assets/glyphs/receipt.svg +17 -0
- package/brands/interim/assets/glyphs/refresh-cw.svg +18 -0
- package/brands/interim/assets/glyphs/rocket.svg +18 -0
- package/brands/interim/assets/glyphs/route.svg +17 -0
- package/brands/interim/assets/glyphs/scale.svg +19 -0
- package/brands/interim/assets/glyphs/search.svg +16 -0
- package/brands/interim/assets/glyphs/server.svg +18 -0
- package/brands/interim/assets/glyphs/settings.svg +16 -0
- package/brands/interim/assets/glyphs/share-2.svg +19 -0
- package/brands/interim/assets/glyphs/shield-check.svg +16 -0
- package/brands/interim/assets/glyphs/shuffle.svg +19 -0
- package/brands/interim/assets/glyphs/sparkles.svg +19 -0
- package/brands/interim/assets/glyphs/square-stack.svg +17 -0
- package/brands/interim/assets/glyphs/target.svg +17 -0
- package/brands/interim/assets/glyphs/trending-up.svg +16 -0
- package/brands/interim/assets/glyphs/triangle-alert.svg +17 -0
- package/brands/interim/assets/glyphs/trophy.svg +20 -0
- package/brands/interim/assets/glyphs/users.svg +18 -0
- package/brands/interim/assets/glyphs/wallet.svg +16 -0
- package/brands/interim/assets/glyphs/wand-sparkles.svg +22 -0
- package/brands/interim/assets/glyphs/workflow.svg +17 -0
- package/brands/interim/assets/glyphs/zap.svg +15 -0
- package/brands/interim/assets/mark.svg +1 -0
- package/brands/interim/assets/social/composition.json +6833 -0
- package/brands/interim/assets/spark-lavender.png +0 -0
- package/brands/interim/assets/spark-violet.png +0 -0
- package/brands/interim/assets/statement-art.png +0 -0
- package/brands/interim/assets/story/agent-alpha.png +0 -0
- package/brands/interim/assets/story/agent.png +0 -0
- package/brands/interim/assets/story/economy-alpha.png +0 -0
- package/brands/interim/assets/story/economy.png +0 -0
- package/brands/interim/assets/story/index.json +214 -0
- package/brands/interim/assets/story/learning-alpha.png +0 -0
- package/brands/interim/assets/story/learning.png +0 -0
- package/brands/interim/assets/story/organization-alpha.png +0 -0
- package/brands/interim/assets/story/organization.png +0 -0
- package/brands/interim/assets/story/worker-alpha.png +0 -0
- package/brands/interim/assets/story/worker-light.png +0 -0
- package/brands/interim/assets/story/worker.png +0 -0
- package/brands/interim/assets/wordmark-on-dark.png +0 -0
- package/brands/interim/assets/wordmark-on-dark.svg +1 -0
- package/brands/interim/assets/wordmark-on-light.png +0 -0
- package/brands/interim/assets/wordmark-on-light.svg +1 -0
- package/brands/interim/fonts/Geist-Book.ttf +0 -0
- package/brands/interim/fonts/Geist-Medium.ttf +0 -0
- package/brands/interim/fonts/Geist-Regular.ttf +0 -0
- package/brands/interim/fonts/Geist-SemiBold.ttf +0 -0
- package/brands/interim/fonts/Newsreader-Italic.ttf +0 -0
- package/brands/interim/fonts/Newsreader-Medium.ttf +0 -0
- package/brands/interim/fonts/Newsreader-MediumItalic.ttf +0 -0
- package/brands/interim/fonts/Newsreader-Regular.ttf +0 -0
- package/brands/interim/fonts/OFL-Geist.txt +93 -0
- package/brands/interim/fonts/OFL-Newsreader.txt +93 -0
- package/brands/interim/fonts/faces.json +12 -0
- package/brands/interim/lib/composition.js +169 -0
- package/brands/interim/lib/composition.mjs +101 -0
- package/brands/interim/lib/glyphs.js +89 -0
- package/brands/interim/lib/measure.js +121 -0
- package/brands/interim/lib/pdf.js +597 -0
- package/brands/interim/lib/ttf.py +169 -0
- package/brands/interim/lib/wandxml.py +37 -0
- package/brands/interim/tokens.json +299 -0
- package/brands/next/tokens.json +14 -2
- package/examples/social/social.json +23 -0
- package/lib/audit.js +142 -0
- package/lib/brand.js +12 -7
- package/lib/components.js +42 -10
- package/lib/engine.js +72 -9
- package/lib/library.js +180 -0
- package/lib/pptx.js +1 -1
- package/lib/social.js +366 -0
- package/lib/talk.js +37 -4
- package/library/index.json +629 -0
- package/library/interim/avatar-800x800.png +0 -0
- package/library/interim/icons/apple-touch-icon.png +0 -0
- package/library/interim/icons/favicon-16.png +0 -0
- package/library/interim/icons/favicon-32.png +0 -0
- package/library/interim/icons/favicon-48.png +0 -0
- package/library/interim/icons/favicon.ico +0 -0
- package/library/interim/icons/favicon.svg +1 -0
- package/library/interim/icons/icon-192.png +0 -0
- package/library/interim/icons/icon-512.png +0 -0
- package/library/interim/icons/icon-tags.html +5 -0
- package/library/interim/icons/site.webmanifest +19 -0
- package/library/interim/linkedin-banner-1584x396.png +0 -0
- package/library/interim/linkedin-company-1128x191.png +0 -0
- package/library/interim/open-agent-discovery-share-1200x630.png +0 -0
- package/library/interim/open-agent-discovery-share-head-tags.html +17 -0
- package/library/interim/wand-ai-share-1200x630.png +0 -0
- package/library/interim/wand-ai-share-head-tags.html +17 -0
- package/library/interim/x-header-1500x500.png +0 -0
- package/package.json +2 -2
- package/templates/card.mjs +6 -2
- package/templates/page.mjs +1 -1
- package/templates/slide.mjs +1 -1
- package/templates/storyboard.mjs +1 -1
- 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, "&").replace(/</g, "<");
|
|
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
|
+
};
|