wand-kit 0.0.0-stage → 0.2.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/.gitkeep +0 -0
- package/PROMPT.md +255 -0
- package/SKILL.md +117 -0
- package/START-HERE.md +16 -0
- package/brands/current/assets/bg-cover-dark.png +0 -0
- package/brands/current/assets/bg-overlay-dark.png +0 -0
- package/brands/current/assets/bg-statement-dark.png +0 -0
- package/brands/current/assets/closing-art.png +0 -0
- package/brands/current/assets/cover-art.png +0 -0
- package/brands/current/assets/divider-art.png +0 -0
- package/brands/current/assets/glyphs/README.md +29 -0
- package/brands/current/assets/glyphs/activity.svg +15 -0
- package/brands/current/assets/glyphs/atom.svg +17 -0
- package/brands/current/assets/glyphs/badge-check.svg +16 -0
- package/brands/current/assets/glyphs/bell.svg +16 -0
- package/brands/current/assets/glyphs/blocks.svg +16 -0
- package/brands/current/assets/glyphs/book-open.svg +16 -0
- package/brands/current/assets/glyphs/bot.svg +20 -0
- package/brands/current/assets/glyphs/boxes.svg +26 -0
- package/brands/current/assets/glyphs/brain.svg +23 -0
- package/brands/current/assets/glyphs/briefcase.svg +16 -0
- package/brands/current/assets/glyphs/building-2.svg +21 -0
- package/brands/current/assets/glyphs/calendar.svg +18 -0
- package/brands/current/assets/glyphs/chart-column.svg +18 -0
- package/brands/current/assets/glyphs/chart-line.svg +16 -0
- package/brands/current/assets/glyphs/chart-pie.svg +16 -0
- package/brands/current/assets/glyphs/circle-check.svg +16 -0
- package/brands/current/assets/glyphs/clipboard-check.svg +17 -0
- package/brands/current/assets/glyphs/clock.svg +16 -0
- package/brands/current/assets/glyphs/cloud.svg +15 -0
- package/brands/current/assets/glyphs/code.svg +15 -0
- package/brands/current/assets/glyphs/coins.svg +18 -0
- package/brands/current/assets/glyphs/compass.svg +16 -0
- package/brands/current/assets/glyphs/cpu.svg +24 -0
- package/brands/current/assets/glyphs/database.svg +17 -0
- package/brands/current/assets/glyphs/eye.svg +16 -0
- package/brands/current/assets/glyphs/file-check.svg +17 -0
- package/brands/current/assets/glyphs/filter.svg +15 -0
- package/brands/current/assets/glyphs/fingerprint.svg +23 -0
- package/brands/current/assets/glyphs/flag.svg +16 -0
- package/brands/current/assets/glyphs/gauge.svg +16 -0
- package/brands/current/assets/glyphs/gavel.svg +19 -0
- package/brands/current/assets/glyphs/git-branch.svg +18 -0
- package/brands/current/assets/glyphs/globe.svg +17 -0
- package/brands/current/assets/glyphs/graduation-cap.svg +17 -0
- package/brands/current/assets/glyphs/handshake.svg +19 -0
- package/brands/current/assets/glyphs/hexagon.svg +15 -0
- package/brands/current/assets/glyphs/index.json +177 -0
- package/brands/current/assets/glyphs/infinity.svg +15 -0
- package/brands/current/assets/glyphs/key.svg +17 -0
- package/brands/current/assets/glyphs/landmark.svg +20 -0
- package/brands/current/assets/glyphs/layers.svg +17 -0
- package/brands/current/assets/glyphs/lightbulb.svg +17 -0
- package/brands/current/assets/glyphs/link.svg +16 -0
- package/brands/current/assets/glyphs/list-checks.svg +19 -0
- package/brands/current/assets/glyphs/lock.svg +16 -0
- package/brands/current/assets/glyphs/map.svg +17 -0
- package/brands/current/assets/glyphs/megaphone.svg +16 -0
- package/brands/current/assets/glyphs/message-square.svg +15 -0
- package/brands/current/assets/glyphs/milestone.svg +17 -0
- package/brands/current/assets/glyphs/network.svg +19 -0
- package/brands/current/assets/glyphs/orbit.svg +19 -0
- package/brands/current/assets/glyphs/package.svg +18 -0
- package/brands/current/assets/glyphs/plug.svg +18 -0
- package/brands/current/assets/glyphs/puzzle.svg +15 -0
- package/brands/current/assets/glyphs/radar.svg +22 -0
- package/brands/current/assets/glyphs/receipt.svg +17 -0
- package/brands/current/assets/glyphs/refresh-cw.svg +18 -0
- package/brands/current/assets/glyphs/rocket.svg +18 -0
- package/brands/current/assets/glyphs/route.svg +17 -0
- package/brands/current/assets/glyphs/scale.svg +19 -0
- package/brands/current/assets/glyphs/search.svg +16 -0
- package/brands/current/assets/glyphs/server.svg +18 -0
- package/brands/current/assets/glyphs/settings.svg +16 -0
- package/brands/current/assets/glyphs/share-2.svg +19 -0
- package/brands/current/assets/glyphs/shield-check.svg +16 -0
- package/brands/current/assets/glyphs/shuffle.svg +19 -0
- package/brands/current/assets/glyphs/sparkles.svg +19 -0
- package/brands/current/assets/glyphs/square-stack.svg +17 -0
- package/brands/current/assets/glyphs/target.svg +17 -0
- package/brands/current/assets/glyphs/trending-up.svg +16 -0
- package/brands/current/assets/glyphs/triangle-alert.svg +17 -0
- package/brands/current/assets/glyphs/trophy.svg +20 -0
- package/brands/current/assets/glyphs/users.svg +18 -0
- package/brands/current/assets/glyphs/wallet.svg +16 -0
- package/brands/current/assets/glyphs/wand-sparkles.svg +22 -0
- package/brands/current/assets/glyphs/workflow.svg +17 -0
- package/brands/current/assets/glyphs/zap.svg +15 -0
- package/brands/current/assets/mark-dark.png +0 -0
- package/brands/current/assets/mark-white.png +0 -0
- package/brands/current/assets/orb-cover.png +0 -0
- package/brands/current/assets/orb-glow.png +0 -0
- package/brands/current/assets/wordmark-dark.png +0 -0
- package/brands/current/assets/wordmark-white.png +0 -0
- package/brands/current/fonts/Geist-Bold.ttf +0 -0
- package/brands/current/fonts/Geist-BoldItalic.ttf +0 -0
- package/brands/current/fonts/Geist-ExtraBold.ttf +0 -0
- package/brands/current/fonts/Geist-ExtraBoldItalic.ttf +0 -0
- package/brands/current/fonts/Geist-Italic.ttf +0 -0
- package/brands/current/fonts/Geist-Light.ttf +0 -0
- package/brands/current/fonts/Geist-LightItalic.ttf +0 -0
- package/brands/current/fonts/Geist-Medium.ttf +0 -0
- package/brands/current/fonts/Geist-MediumItalic.ttf +0 -0
- package/brands/current/fonts/Geist-Regular.ttf +0 -0
- package/brands/current/fonts/Geist-SemiBold.ttf +0 -0
- package/brands/current/fonts/Geist-SemiBoldItalic.ttf +0 -0
- package/brands/current/lib/glyphs.js +87 -0
- package/brands/current/lib/measure.js +115 -0
- package/brands/current/lib/pdf.js +603 -0
- package/brands/current/lib/ttf.py +167 -0
- package/brands/current/lib/wandxml.py +37 -0
- package/brands/current/tokens.json +206 -0
- package/brands/next/assets/closing-art.png +0 -0
- package/brands/next/assets/cover-art.png +0 -0
- package/brands/next/assets/divider-art.png +0 -0
- package/brands/next/assets/glyphs/README.md +29 -0
- package/brands/next/assets/glyphs/activity.svg +15 -0
- package/brands/next/assets/glyphs/atom.svg +17 -0
- package/brands/next/assets/glyphs/badge-check.svg +16 -0
- package/brands/next/assets/glyphs/bell.svg +16 -0
- package/brands/next/assets/glyphs/blocks.svg +16 -0
- package/brands/next/assets/glyphs/book-open.svg +16 -0
- package/brands/next/assets/glyphs/bot.svg +20 -0
- package/brands/next/assets/glyphs/boxes.svg +26 -0
- package/brands/next/assets/glyphs/brain.svg +23 -0
- package/brands/next/assets/glyphs/briefcase.svg +16 -0
- package/brands/next/assets/glyphs/building-2.svg +21 -0
- package/brands/next/assets/glyphs/calendar.svg +18 -0
- package/brands/next/assets/glyphs/chart-column.svg +18 -0
- package/brands/next/assets/glyphs/chart-line.svg +16 -0
- package/brands/next/assets/glyphs/chart-pie.svg +16 -0
- package/brands/next/assets/glyphs/circle-check.svg +16 -0
- package/brands/next/assets/glyphs/clipboard-check.svg +17 -0
- package/brands/next/assets/glyphs/clock.svg +16 -0
- package/brands/next/assets/glyphs/cloud.svg +15 -0
- package/brands/next/assets/glyphs/code.svg +15 -0
- package/brands/next/assets/glyphs/coins.svg +18 -0
- package/brands/next/assets/glyphs/compass.svg +16 -0
- package/brands/next/assets/glyphs/cpu.svg +24 -0
- package/brands/next/assets/glyphs/database.svg +17 -0
- package/brands/next/assets/glyphs/eye.svg +16 -0
- package/brands/next/assets/glyphs/file-check.svg +17 -0
- package/brands/next/assets/glyphs/filter.svg +15 -0
- package/brands/next/assets/glyphs/fingerprint.svg +23 -0
- package/brands/next/assets/glyphs/flag.svg +16 -0
- package/brands/next/assets/glyphs/gauge.svg +16 -0
- package/brands/next/assets/glyphs/gavel.svg +19 -0
- package/brands/next/assets/glyphs/git-branch.svg +18 -0
- package/brands/next/assets/glyphs/globe.svg +17 -0
- package/brands/next/assets/glyphs/graduation-cap.svg +17 -0
- package/brands/next/assets/glyphs/handshake.svg +19 -0
- package/brands/next/assets/glyphs/hexagon.svg +15 -0
- package/brands/next/assets/glyphs/index.json +177 -0
- package/brands/next/assets/glyphs/infinity.svg +15 -0
- package/brands/next/assets/glyphs/key.svg +17 -0
- package/brands/next/assets/glyphs/landmark.svg +20 -0
- package/brands/next/assets/glyphs/layers.svg +17 -0
- package/brands/next/assets/glyphs/lightbulb.svg +17 -0
- package/brands/next/assets/glyphs/link.svg +16 -0
- package/brands/next/assets/glyphs/list-checks.svg +19 -0
- package/brands/next/assets/glyphs/lock.svg +16 -0
- package/brands/next/assets/glyphs/map.svg +17 -0
- package/brands/next/assets/glyphs/megaphone.svg +16 -0
- package/brands/next/assets/glyphs/message-square.svg +15 -0
- package/brands/next/assets/glyphs/milestone.svg +17 -0
- package/brands/next/assets/glyphs/network.svg +19 -0
- package/brands/next/assets/glyphs/orbit.svg +19 -0
- package/brands/next/assets/glyphs/package.svg +18 -0
- package/brands/next/assets/glyphs/plug.svg +18 -0
- package/brands/next/assets/glyphs/puzzle.svg +15 -0
- package/brands/next/assets/glyphs/radar.svg +22 -0
- package/brands/next/assets/glyphs/receipt.svg +17 -0
- package/brands/next/assets/glyphs/refresh-cw.svg +18 -0
- package/brands/next/assets/glyphs/rocket.svg +18 -0
- package/brands/next/assets/glyphs/route.svg +17 -0
- package/brands/next/assets/glyphs/scale.svg +19 -0
- package/brands/next/assets/glyphs/search.svg +16 -0
- package/brands/next/assets/glyphs/server.svg +18 -0
- package/brands/next/assets/glyphs/settings.svg +16 -0
- package/brands/next/assets/glyphs/share-2.svg +19 -0
- package/brands/next/assets/glyphs/shield-check.svg +16 -0
- package/brands/next/assets/glyphs/shuffle.svg +19 -0
- package/brands/next/assets/glyphs/sparkles.svg +19 -0
- package/brands/next/assets/glyphs/square-stack.svg +17 -0
- package/brands/next/assets/glyphs/target.svg +17 -0
- package/brands/next/assets/glyphs/trending-up.svg +16 -0
- package/brands/next/assets/glyphs/triangle-alert.svg +17 -0
- package/brands/next/assets/glyphs/trophy.svg +20 -0
- package/brands/next/assets/glyphs/users.svg +18 -0
- package/brands/next/assets/glyphs/wallet.svg +16 -0
- package/brands/next/assets/glyphs/wand-sparkles.svg +22 -0
- package/brands/next/assets/glyphs/workflow.svg +17 -0
- package/brands/next/assets/glyphs/zap.svg +15 -0
- package/brands/next/assets/layers-dark.png +0 -0
- package/brands/next/assets/layers-light.png +0 -0
- package/brands/next/assets/ornaments/burst-dark.png +0 -0
- package/brands/next/assets/ornaments/burst-light.png +0 -0
- package/brands/next/assets/ornaments/chain-dark.png +0 -0
- package/brands/next/assets/ornaments/chain-light.png +0 -0
- package/brands/next/assets/ornaments/coil-dark.png +0 -0
- package/brands/next/assets/ornaments/coil-light.png +0 -0
- package/brands/next/assets/ornaments/cone-dark.png +0 -0
- package/brands/next/assets/ornaments/cone-light.png +0 -0
- package/brands/next/assets/ornaments/diamond-dark.png +0 -0
- package/brands/next/assets/ornaments/diamond-light.png +0 -0
- package/brands/next/assets/ornaments/fan-dark.png +0 -0
- package/brands/next/assets/ornaments/fan-light.png +0 -0
- package/brands/next/assets/ornaments/globe-dark.png +0 -0
- package/brands/next/assets/ornaments/globe-light.png +0 -0
- package/brands/next/assets/ornaments/orbit-dark.png +0 -0
- package/brands/next/assets/ornaments/orbit-light.png +0 -0
- package/brands/next/assets/ornaments/rings-dark.png +0 -0
- package/brands/next/assets/ornaments/rings-light.png +0 -0
- package/brands/next/assets/ornaments/sphere-dark.png +0 -0
- package/brands/next/assets/ornaments/sphere-light.png +0 -0
- package/brands/next/assets/ornaments/sun-dark.png +0 -0
- package/brands/next/assets/ornaments/sun-light.png +0 -0
- package/brands/next/assets/photos/atrium.jpg +0 -0
- package/brands/next/assets/photos/cantilever.jpg +0 -0
- package/brands/next/assets/photos/corridor.jpg +0 -0
- package/brands/next/assets/photos/dome.jpg +0 -0
- package/brands/next/assets/photos/facade.jpg +0 -0
- package/brands/next/assets/photos/glass.jpg +0 -0
- package/brands/next/assets/photos/stairs.jpg +0 -0
- package/brands/next/assets/photos/tower.jpg +0 -0
- package/brands/next/assets/photos/walk.jpg +0 -0
- package/brands/next/assets/spark-gold.png +0 -0
- package/brands/next/assets/statement-art.png +0 -0
- package/brands/next/assets/wordmark-dark.png +0 -0
- package/brands/next/assets/wordmark-light.png +0 -0
- package/brands/next/assets/wordmark.svg +7 -0
- package/brands/next/fonts/FKRomanStandard-Light.ttf +0 -0
- package/brands/next/fonts/OFL-OverusedGrotesk.txt +93 -0
- package/brands/next/fonts/OverusedGrotesk-Italic.ttf +0 -0
- package/brands/next/fonts/OverusedGrotesk-Medium.ttf +0 -0
- package/brands/next/fonts/OverusedGrotesk-MediumItalic.ttf +0 -0
- package/brands/next/fonts/OverusedGrotesk-Roman.ttf +0 -0
- package/brands/next/fonts/OverusedGrotesk-SemiBold.ttf +0 -0
- package/brands/next/fonts/OverusedGrotesk-SemiBoldItalic.ttf +0 -0
- package/brands/next/fonts/faces.json +27 -0
- package/brands/next/lib/glyphs.js +89 -0
- package/brands/next/lib/measure.js +121 -0
- package/brands/next/lib/pdf.js +597 -0
- package/brands/next/lib/ttf.py +169 -0
- package/brands/next/lib/wandxml.py +37 -0
- package/brands/next/tokens.json +174 -0
- package/examples/gallery/job.mjs +83 -0
- package/examples/overview/job.mjs +99 -0
- package/lib/brand.js +135 -0
- package/lib/components.js +313 -0
- package/lib/content.js +66 -0
- package/lib/engine.js +280 -0
- package/lib/html_export.js +71 -0
- package/lib/pptx.js +446 -0
- package/lib/surfaces.js +62 -0
- package/package.json +17 -4
- package/templates/card.mjs +30 -0
- package/templates/page.mjs +52 -0
- package/templates/slide.mjs +46 -0
- package/tools/embed_fonts.py +154 -0
- package/wand.js +179 -0
- package/README.md +0 -3
package/lib/pptx.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/pptx.js: the rendered pages as a .pptx, one slide per page.
|
|
3
|
+
*
|
|
4
|
+
* Reads Chromium's own rendering of the engine's HTML, the render the PDF
|
|
5
|
+
* came from, and writes each page as two layers:
|
|
6
|
+
* - the plate: everything that is not text (ground, art, cards, rules,
|
|
7
|
+
* glyphs, the mark) as the slide background, shot at 2x with the text
|
|
8
|
+
* made transparent so the layout is untouched;
|
|
9
|
+
* - the text: every line as a live text box in the brand's face, at the
|
|
10
|
+
* size, weight, colour, tracking and position Chrome set it, read
|
|
11
|
+
* character by character; the lines of one block grouped, so a title or
|
|
12
|
+
* a paragraph moves as one.
|
|
13
|
+
* Links to http(s) addresses carry over, a page's `notes` become speaker
|
|
14
|
+
* notes, and the faces used are embedded, one clean family per face, by
|
|
15
|
+
* tools/embed_fonts.py when a python is on PATH (without one the .pptx still
|
|
16
|
+
* ships, with a warning).
|
|
17
|
+
*
|
|
18
|
+
* Ported from runs/2026-10-07-bofa-agentic-age-html/pptx (capture.mjs,
|
|
19
|
+
* build.mjs, fonts.py), generalised over brands and surfaces. A run's CSS
|
|
20
|
+
* (family, weight, italic) resolves to the nearest file in the brand's
|
|
21
|
+
* `faces.files` the way Chrome resolves it; that file's `pptx` name carries
|
|
22
|
+
* the weight, so every run is written b="0", and its own OS/2 usWin metrics
|
|
23
|
+
* give the line box, so no face is measured with another's numbers.
|
|
24
|
+
*
|
|
25
|
+
* Vertical placement: each line is a single-spaced text box centred on
|
|
26
|
+
* Chrome's baseline. A renderer centres a single line by its own metrics,
|
|
27
|
+
* which puts the baseline (ascent minus descent) / 2 below the box's middle
|
|
28
|
+
* with Office's usWin values, and 0.40 em below it with LibreOffice's
|
|
29
|
+
* font-independent 1.2-em lines. The box is centred on the baseline less the
|
|
30
|
+
* mean of the two, so either lands within a hundredth of an em.
|
|
31
|
+
*/
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
35
|
+
import { createRequire } from "node:module";
|
|
36
|
+
import { spawnSync } from "node:child_process";
|
|
37
|
+
import { loadBrand } from "./brand.js";
|
|
38
|
+
|
|
39
|
+
const require = createRequire(import.meta.url);
|
|
40
|
+
const KIT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
41
|
+
|
|
42
|
+
// the pages with their text made transparent, layout intact: the plate
|
|
43
|
+
const PLATE_CSS = `
|
|
44
|
+
.page, .page * { -webkit-text-fill-color: transparent !important; text-shadow: none !important; }
|
|
45
|
+
.page *::before, .page *::after { -webkit-text-fill-color: currentColor !important; }
|
|
46
|
+
.page svg text, .page svg tspan { fill: transparent !important; stroke: none !important; }
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
const xml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
50
|
+
const unxml = (s) => s.replace(/"/g, '"').replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&");
|
|
51
|
+
const label = (line) => line.runs.map((r) => r.text).join("");
|
|
52
|
+
const short = (t, n = 48) => (t.length > n ? t.slice(0, n - 1) + "…" : t);
|
|
53
|
+
const nn = (n) => String(n).padStart(2, "0");
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write `outPath` from the engine's HTML at `htmlPath`. Returns { ok, msg,
|
|
57
|
+
* warn }: one line for the gate table, and warn when the file shipped
|
|
58
|
+
* without embedded fonts or with a face the brand does not carry.
|
|
59
|
+
*/
|
|
60
|
+
export async function exportPptx({ htmlPath, outPath, brand: brandName, surface, notes = [], name = "Wand" }) {
|
|
61
|
+
const PptxGenJS = require("pptxgenjs");
|
|
62
|
+
const JSZip = require("jszip");
|
|
63
|
+
const fontkit = require("fontkit");
|
|
64
|
+
const brand = loadBrand(brandName);
|
|
65
|
+
const work = path.join(path.dirname(outPath), "work", "pptx");
|
|
66
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
67
|
+
fs.mkdirSync(work, { recursive: true });
|
|
68
|
+
|
|
69
|
+
/* ------------------------------------------------------------- units */
|
|
70
|
+
// a page keeps its paper size; a slide or a card is 10 in wide
|
|
71
|
+
const W = surface.kind === "page" ? surface.w / 96 : 10;
|
|
72
|
+
const H = surface.kind === "page" ? surface.h / 96 : (10 * surface.h) / surface.w;
|
|
73
|
+
const PPI = surface.w / W; // canvas px per inch
|
|
74
|
+
const IN = (px) => px / PPI; // pptxgenjs takes inches
|
|
75
|
+
const PT100 = (px) => Math.round((px * 7200) / PPI); // hundredths of a point
|
|
76
|
+
|
|
77
|
+
/* ------------------------------------------------------------- faces */
|
|
78
|
+
// the brand's files with what the capture and the boxes need from each:
|
|
79
|
+
// hhea ascender (Chrome's glyph box top is the baseline less round(asc * size)),
|
|
80
|
+
// and the usWin line box Office centres by
|
|
81
|
+
const faces = brand.faceFiles.map((f, i) => {
|
|
82
|
+
const font = fontkit.openSync(f.path);
|
|
83
|
+
const upm = font.unitsPerEm, os2 = font["OS/2"];
|
|
84
|
+
const wa = os2.winAscent, wd = os2.winDescent;
|
|
85
|
+
return { i, family: f.family, weight: f.weight, italic: !!f.italic, file: f.file, pptx: f.pptx,
|
|
86
|
+
asc: font.ascent / upm, mid: ((wa - wd) / upm + 0.8) / 4, line: (wa + wd) / upm };
|
|
87
|
+
});
|
|
88
|
+
const fallback = brand.tokens.faces.body.family;
|
|
89
|
+
|
|
90
|
+
/* ----------------------------------------------------------- capture */
|
|
91
|
+
const { browser } = await import("./engine.js");
|
|
92
|
+
const b = await browser();
|
|
93
|
+
const page = await b.newPage({ viewport: { width: surface.w, height: surface.h }, deviceScaleFactor: 2 });
|
|
94
|
+
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "load" });
|
|
95
|
+
await page.evaluate(() => document.fonts.ready);
|
|
96
|
+
await page.evaluate(() => Promise.all([...document.images].map((i) => (i.complete ? null : new Promise((r) => { i.onload = i.onerror = r; })))));
|
|
97
|
+
const count = await page.evaluate(() => document.querySelectorAll(".page").length);
|
|
98
|
+
const pages = [];
|
|
99
|
+
const subs = new Set();
|
|
100
|
+
for (let i = 0; i < count; i++) {
|
|
101
|
+
const d = await page.evaluate(extract, { index: i, faces: faces.map(({ i, family, weight, italic, asc }) => ({ i, family, weight, italic, asc })), fallback });
|
|
102
|
+
d.subs.forEach((s) => subs.add(s));
|
|
103
|
+
pages.push(d);
|
|
104
|
+
}
|
|
105
|
+
await page.addStyleTag({ content: PLATE_CSS });
|
|
106
|
+
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
|
107
|
+
const els = await page.$$(".page");
|
|
108
|
+
for (const pg of pages) {
|
|
109
|
+
pg.plate = path.join(work, `plate-${nn(pg.n)}.jpg`);
|
|
110
|
+
await els[pg.n - 1].screenshot({ path: pg.plate, type: "jpeg", quality: 92 });
|
|
111
|
+
}
|
|
112
|
+
await page.close();
|
|
113
|
+
fs.writeFileSync(path.join(work, "capture.json"), JSON.stringify({ name, surface: surface.name, brand: brand.name, pages }, null, 1));
|
|
114
|
+
|
|
115
|
+
/* ---------------------------------------------------------- text XML */
|
|
116
|
+
const used = new Map(); // typeface -> { regular, italic } files
|
|
117
|
+
const face = (run) => {
|
|
118
|
+
const f = faces[run.face];
|
|
119
|
+
if (!used.has(f.pptx)) used.set(f.pptx, {});
|
|
120
|
+
used.get(f.pptx)[f.italic ? "italic" : "regular"] = f.file;
|
|
121
|
+
return f;
|
|
122
|
+
};
|
|
123
|
+
// weight rides on the typeface name, so b is never set; i picks the italic slot
|
|
124
|
+
const rPr = (run, tag = "a:rPr") => {
|
|
125
|
+
const f = face(run);
|
|
126
|
+
const fill = `<a:solidFill><a:srgbClr val="${run.color}">${run.alpha < 0.999 ? `<a:alpha val="${Math.round(run.alpha * 100000)}"/>` : ""}</a:srgbClr></a:solidFill>`;
|
|
127
|
+
const spc = PT100(run.ls);
|
|
128
|
+
return `<${tag} lang="en-US" sz="${PT100(run.size)}" b="0" i="${run.italic ? 1 : 0}"${spc ? ` spc="${spc}"` : ""} kern="100" dirty="0">` +
|
|
129
|
+
`${fill}<a:latin typeface="${xml(f.pptx)}"/><a:ea typeface="${xml(f.pptx)}"/><a:cs typeface="${xml(f.pptx)}"/></${tag}>`;
|
|
130
|
+
};
|
|
131
|
+
const txBody = (line) => {
|
|
132
|
+
const runs = line.runs.map((r) => `<a:r>${rPr(r)}<a:t>${xml(r.text)}</a:t></a:r>`).join("");
|
|
133
|
+
return `<p:txBody><a:bodyPr wrap="none" lIns="0" tIns="0" rIns="0" bIns="0" rtlCol="0" anchor="ctr" anchorCtr="0"><a:noAutofit/></a:bodyPr><a:lstStyle/>` +
|
|
134
|
+
`<a:p><a:pPr marL="0" indent="0" algn="${line.align}"><a:lnSpc><a:spcPct val="100000"/></a:lnSpc><a:spcBef><a:spcPts val="0"/></a:spcBef><a:spcAft><a:spcPts val="0"/></a:spcAft><a:buNone/></a:pPr>` +
|
|
135
|
+
`${runs}${rPr(line.runs[line.runs.length - 1], "a:endParaRPr")}</a:p></p:txBody>`;
|
|
136
|
+
};
|
|
137
|
+
/** A line's box in canvas px: anchored the way the HTML aligns it, with room to spare, sized by its largest run's face. */
|
|
138
|
+
const boxOf = (line) => {
|
|
139
|
+
const big = line.runs.reduce((a, r) => (r.size > a.size ? r : a));
|
|
140
|
+
const { mid, line: lh } = faces[big.face];
|
|
141
|
+
// Chrome counts the last character's tracking into the line; a slide
|
|
142
|
+
// renderer aligns the glyphs, so a centred or right-set line loses it
|
|
143
|
+
const x1 = line.align === "l" ? line.x1 : line.x1 - (line.runs[line.runs.length - 1].ls || 0);
|
|
144
|
+
const w = x1 - line.x0, slack = Math.max(0.3 * line.size, 0.04 * w);
|
|
145
|
+
const L = lh * line.size, top = line.base - mid * line.size - L / 2;
|
|
146
|
+
if (line.align === "ctr") return { x: line.x0 - slack, y: top, w: w + 2 * slack, h: L };
|
|
147
|
+
if (line.align === "r") return { x: x1 - w - slack, y: top, w: w + slack, h: L };
|
|
148
|
+
return { x: line.x0, y: top, w: w + slack, h: L };
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/* ------------------------------------------------------------ slides */
|
|
152
|
+
const pptx = new PptxGenJS();
|
|
153
|
+
pptx.defineLayout({ name: "WAND", width: W, height: H });
|
|
154
|
+
pptx.layout = "WAND";
|
|
155
|
+
pptx.title = name;
|
|
156
|
+
pptx.author = "Wand";
|
|
157
|
+
pptx.company = "Wand";
|
|
158
|
+
const texts = new Map(); // "page|name" -> line
|
|
159
|
+
for (const pg of pages) {
|
|
160
|
+
const sl = pptx.addSlide();
|
|
161
|
+
sl.background = { path: pg.plate };
|
|
162
|
+
// links first, so the text sits over them; the text boxes inside carry the same link
|
|
163
|
+
pg.links.forEach((l, k) => {
|
|
164
|
+
const color = pg.lines.find((ln) => ln.link)?.runs[0].color || "FFFFFF";
|
|
165
|
+
sl.addShape(pptx.ShapeType.rect, { x: IN(l.x), y: IN(l.y), w: IN(l.w), h: IN(l.h), objectName: `pp-link-${k}`,
|
|
166
|
+
fill: { color, transparency: 100 }, line: { type: "none" }, hyperlink: { url: l.url, tooltip: l.text } });
|
|
167
|
+
});
|
|
168
|
+
pg.lines.forEach((line, k) => {
|
|
169
|
+
const bx = boxOf(line), id = `pp-t-${k}`;
|
|
170
|
+
sl.addText("x", { x: IN(bx.x), y: IN(bx.y), w: IN(bx.w), h: IN(bx.h), margin: 0, isTextBox: true, objectName: id });
|
|
171
|
+
texts.set(`${pg.n}|${id}`, line);
|
|
172
|
+
});
|
|
173
|
+
const nt = String(notes[pg.n - 1] || "").trim();
|
|
174
|
+
if (nt) sl.addNotes(nt);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ------------------------------------------------- write, then finish */
|
|
178
|
+
const zip = await JSZip.loadAsync(await pptx.write({ outputType: "nodebuffer" }));
|
|
179
|
+
for (const f of Object.keys(zip.files).filter((f) => /^ppt\/slides\/slide\d+\.xml$/.test(f))) {
|
|
180
|
+
const n = +f.match(/slide(\d+)\.xml/)[1];
|
|
181
|
+
const pg = pages[n - 1];
|
|
182
|
+
let x = await zip.file(f).async("string");
|
|
183
|
+
// the rIds the link shapes were given, by address
|
|
184
|
+
const linkRid = new Map();
|
|
185
|
+
for (const m of x.matchAll(/<p:cNvPr id="\d+" name="pp-link-(\d+)"><a:hlinkClick r:id="(rId\d+)"/g)) linkRid.set(pg.links[+m[1]].url, m[2]);
|
|
186
|
+
// text: the measured body in place of the placeholder; the link, if any; a readable name
|
|
187
|
+
x = x.replace(/<p:sp>(?:(?!<\/p:sp>)[\s\S])*?name="(pp-t-\d+)"[\s\S]*?<\/p:sp>/g, (sp, id) => {
|
|
188
|
+
const line = texts.get(`${n}|${id}`);
|
|
189
|
+
const rid = line.link && linkRid.get(line.link.url);
|
|
190
|
+
return sp
|
|
191
|
+
.replace(/name="pp-t-\d+">/, () => `name="${xml(`Text: ${short(label(line).trim())}`)}">${rid ? `<a:hlinkClick r:id="${rid}"/>` : ""}`)
|
|
192
|
+
.replace(/<p:txBody>[\s\S]*<\/p:txBody>/, () => txBody(line))
|
|
193
|
+
.replace(/<p:sp>/, () => `<p:sp><!--block:${line.block}-->`);
|
|
194
|
+
});
|
|
195
|
+
x = x.replace(/name="pp-link-(\d+)"/g, (_, k) => `name="${xml(`Link: ${short(pg.links[+k].text)}`)}"`);
|
|
196
|
+
x = groupBlocks(x).replace(/<!--block:\d+-->/g, "");
|
|
197
|
+
x = x.replace(/<a:ln><\/a:ln>/g, "<a:ln><a:noFill/></a:ln>");
|
|
198
|
+
zip.file(f, x);
|
|
199
|
+
}
|
|
200
|
+
// speaker notes: one paragraph per line of the page's notes
|
|
201
|
+
for (const f of Object.keys(zip.files).filter((f) => /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(f))) {
|
|
202
|
+
const x = await zip.file(f).async("string");
|
|
203
|
+
zip.file(f, x.replace(/<a:p><a:r><a:rPr lang="en-US" dirty="0"\/><a:t>([\s\S]*?)<\/a:t><\/a:r>(?:<a:endParaRPr[^>]*\/>)?<\/a:p>/, (_, t) =>
|
|
204
|
+
t.split(/\r?\n/).map((p) => `<a:p><a:r><a:rPr lang="en-US" dirty="0"/><a:t>${p}</a:t></a:r></a:p>`).join("")));
|
|
205
|
+
}
|
|
206
|
+
const raw = path.join(work, "raw.pptx");
|
|
207
|
+
fs.writeFileSync(raw, await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE", compressionOptions: { level: 6 } }));
|
|
208
|
+
|
|
209
|
+
// every line read back from the XML, verbatim
|
|
210
|
+
const paras = new Set();
|
|
211
|
+
for (const f of Object.keys(zip.files).filter((f) => /^ppt\/slides\/slide\d+\.xml$/.test(f))) {
|
|
212
|
+
const x = await zip.file(f).async("string");
|
|
213
|
+
for (const m of x.matchAll(/<a:p>([\s\S]*?)<\/a:p>/g)) paras.add(unxml([...m[1].matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)].map((t) => t[1]).join("")));
|
|
214
|
+
}
|
|
215
|
+
const lines = pages.reduce((a, p) => a + p.lines.length, 0);
|
|
216
|
+
const links = pages.reduce((a, p) => a + p.links.length, 0);
|
|
217
|
+
const missing = pages.flatMap((p) => p.lines.filter((ln) => !paras.has(label(ln))).map((ln) => `p${p.n} "${short(label(ln), 32)}"`));
|
|
218
|
+
|
|
219
|
+
/* ------------------------------------------------------------- fonts */
|
|
220
|
+
// one family per typeface: its regular file, and the italic file when one was used
|
|
221
|
+
const table = {};
|
|
222
|
+
for (const [tf, slots] of used) {
|
|
223
|
+
table[tf] = { regular: slots.regular || faces.find((f) => f.pptx === tf && !f.italic)?.file || slots.italic };
|
|
224
|
+
if (slots.italic) table[tf].italic = slots.italic;
|
|
225
|
+
}
|
|
226
|
+
const facesPath = path.join(work, "faces.json");
|
|
227
|
+
fs.writeFileSync(facesPath, JSON.stringify(table, null, 1));
|
|
228
|
+
const py = ["python3", "python"].find((c) => spawnSync(c, ["-c", "import sys;print(sys.version_info[0])"], { encoding: "utf8" }).stdout?.trim() === "3");
|
|
229
|
+
let fonts, warn = false;
|
|
230
|
+
if (!py) { fonts = "fonts not embedded (no python3 on PATH)"; warn = true; }
|
|
231
|
+
else {
|
|
232
|
+
const r = spawnSync(py, [path.join(KIT, "tools", "embed_fonts.py"), "--faces", facesPath, "--fonts", brand.fonts, "--lib", brand.lib, raw, outPath], { encoding: "utf8" });
|
|
233
|
+
if (r.status === 0 && fs.existsSync(outPath)) fonts = `fonts embedded (${Object.keys(table).length} faces: ${Object.keys(table).join(", ")})`;
|
|
234
|
+
else { fonts = `fonts not embedded (${(r.stderr || r.stdout || String(r.error || "embed_fonts.py failed")).trim().split("\n").pop()})`; warn = true; }
|
|
235
|
+
}
|
|
236
|
+
if (warn) fs.copyFileSync(raw, outPath);
|
|
237
|
+
if (subs.size) { warn = true; fonts += `; not a brand face, set in ${fallback}: ${[...subs].join(", ")}`; }
|
|
238
|
+
|
|
239
|
+
const msg = `${pages.length} slide${pages.length === 1 ? "" : "s"}, ${lines} text lines${links ? `, ${links} links` : ""}, ${fonts}`;
|
|
240
|
+
if (missing.length) return { ok: false, warn: false, msg: `${missing.length} of ${lines} lines not verbatim in the XML: ${missing.slice(0, 3).join("; ")}` };
|
|
241
|
+
return { ok: true, warn, msg };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A block of several lines (a title, a paragraph) moves as one: a group. */
|
|
245
|
+
function groupBlocks(x) {
|
|
246
|
+
let id = Math.max(...[...x.matchAll(/<p:cNvPr id="(\d+)"/g)].map((m) => +m[1])) + 1;
|
|
247
|
+
const sps = [...x.matchAll(/<p:sp><!--block:(\d+)-->[\s\S]*?<\/p:sp>/g)];
|
|
248
|
+
const runs = [];
|
|
249
|
+
for (const m of sps) {
|
|
250
|
+
const last = runs[runs.length - 1];
|
|
251
|
+
if (last && last.block === m[1] && last.end === m.index) { last.items.push(m); last.end = m.index + m[0].length; }
|
|
252
|
+
else runs.push({ block: m[1], items: [m], start: m.index, end: m.index + m[0].length });
|
|
253
|
+
}
|
|
254
|
+
let out = "", at = 0;
|
|
255
|
+
for (const r of runs) {
|
|
256
|
+
if (r.items.length < 2) continue;
|
|
257
|
+
const xs = r.items.map((m) => {
|
|
258
|
+
const o = m[0].match(/<a:off x="(-?\d+)" y="(-?\d+)"\/><a:ext cx="(\d+)" cy="(\d+)"\/>/);
|
|
259
|
+
return { x: +o[1], y: +o[2], w: +o[3], h: +o[4] };
|
|
260
|
+
});
|
|
261
|
+
const gx = Math.min(...xs.map((b) => b.x)), gy = Math.min(...xs.map((b) => b.y));
|
|
262
|
+
const gw = Math.max(...xs.map((b) => b.x + b.w)) - gx, gh = Math.max(...xs.map((b) => b.y + b.h)) - gy;
|
|
263
|
+
const name = xml(`Group: ${short(r.items.map((m) => (m[0].match(/name="Text: ([^"]*)"/) || [])[1] || "").join(" "))}`);
|
|
264
|
+
out += x.slice(at, r.start) +
|
|
265
|
+
`<p:grpSp><p:nvGrpSpPr><p:cNvPr id="${id++}" name="${name}"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>` +
|
|
266
|
+
`<p:grpSpPr><a:xfrm><a:off x="${gx}" y="${gy}"/><a:ext cx="${gw}" cy="${gh}"/><a:chOff x="${gx}" y="${gy}"/><a:chExt cx="${gw}" cy="${gh}"/></a:xfrm></p:grpSpPr>` +
|
|
267
|
+
x.slice(r.start, r.end) + "</p:grpSp>";
|
|
268
|
+
at = r.end;
|
|
269
|
+
}
|
|
270
|
+
return out + x.slice(at);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* ------------------------------------------------------------------ in page */
|
|
274
|
+
/**
|
|
275
|
+
* Runs inside the page: one section's text, character by character, so each
|
|
276
|
+
* line is exactly where Chrome set it, hand breaks and soft wraps alike;
|
|
277
|
+
* SVG text; the links. Coordinates are canvas px relative to the section.
|
|
278
|
+
*/
|
|
279
|
+
function extract({ index, faces, fallback }) {
|
|
280
|
+
const pg = document.querySelectorAll(".page")[index];
|
|
281
|
+
const sr = pg.getBoundingClientRect();
|
|
282
|
+
const X = (x) => x - sr.left, Y = (y) => y - sr.top;
|
|
283
|
+
const subs = new Set();
|
|
284
|
+
|
|
285
|
+
// CSS font matching over the brand's files: the family, its style, then the
|
|
286
|
+
// nearest weight the way Chrome picks it; a family the brand lacks is set
|
|
287
|
+
// in the body face and reported
|
|
288
|
+
const picked = new Map();
|
|
289
|
+
const pick = (family, weight, italic) => {
|
|
290
|
+
const k = `${family}|${weight}|${italic}`;
|
|
291
|
+
if (picked.has(k)) return picked.get(k);
|
|
292
|
+
let pool = faces.filter((f) => f.family === family);
|
|
293
|
+
if (!pool.length) { subs.add(family); pool = faces.filter((f) => f.family === fallback); }
|
|
294
|
+
const styled = pool.filter((f) => f.italic === italic);
|
|
295
|
+
if (styled.length) pool = styled;
|
|
296
|
+
const ws = [...new Set(pool.map((f) => f.weight))].sort((a, b) => a - b);
|
|
297
|
+
let w = weight;
|
|
298
|
+
if (!ws.includes(w)) {
|
|
299
|
+
const up = ws.filter((v) => v > w), down = ws.filter((v) => v < w).reverse();
|
|
300
|
+
if (w >= 400 && w <= 500) { const mid = up.filter((v) => v <= 500); w = mid[0] ?? down[0] ?? up[0]; }
|
|
301
|
+
else w = w < 400 ? (down[0] ?? up[0]) : (up[0] ?? down[0]);
|
|
302
|
+
}
|
|
303
|
+
const i = pool.find((f) => f.weight === w).i;
|
|
304
|
+
picked.set(k, i);
|
|
305
|
+
return i;
|
|
306
|
+
};
|
|
307
|
+
const familyOf = (s) => s.split(",")[0].replace(/["']/g, "").trim();
|
|
308
|
+
|
|
309
|
+
const rgba = (c) => {
|
|
310
|
+
const m = String(c).match(/rgba?\(([^)]+)\)/);
|
|
311
|
+
if (!m) return null;
|
|
312
|
+
const p = m[1].split(/[ ,/]+/).filter(Boolean).map(Number);
|
|
313
|
+
return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 };
|
|
314
|
+
};
|
|
315
|
+
const hex = (c) => [c.r, c.g, c.b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
316
|
+
const opacityOf = (el) => { let o = 1; for (let e = el; e && e !== pg.parentElement; e = e.parentElement) o *= +getComputedStyle(e).opacity; return o; };
|
|
317
|
+
const blockOf = (el) => { for (let e = el; e; e = e.parentElement) { const d = getComputedStyle(e).display; if (d !== "inline" && d !== "contents") return e; } return pg; };
|
|
318
|
+
// ancestors that clip, the page itself included: a box outside them is not on the page
|
|
319
|
+
const clipped = (el, r) => {
|
|
320
|
+
for (let e = el; e && e !== pg.parentElement; e = e.parentElement) {
|
|
321
|
+
const cs = getComputedStyle(e);
|
|
322
|
+
if (cs.overflow !== "visible" || cs.clip !== "auto") {
|
|
323
|
+
const b = e.getBoundingClientRect();
|
|
324
|
+
if (r.right <= b.left + 0.5 || r.left >= b.right - 0.5 || r.bottom <= b.top + 0.5 || r.top >= b.bottom - 0.5) return true;
|
|
325
|
+
if (b.width <= 1 || b.height <= 1) return true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
};
|
|
330
|
+
const linkOf = (el) => {
|
|
331
|
+
const a = el.closest("a[href]");
|
|
332
|
+
if (!a || !pg.contains(a)) return null;
|
|
333
|
+
const h = a.getAttribute("href") || "";
|
|
334
|
+
return /^https?:/.test(h) ? { url: h } : null;
|
|
335
|
+
};
|
|
336
|
+
const alignOf = (cs) => ({ center: "ctr", right: "r", end: "r" })[cs.textAlign] || "l";
|
|
337
|
+
|
|
338
|
+
/* HTML text, character by character */
|
|
339
|
+
const chars = [];
|
|
340
|
+
const rg = document.createRange();
|
|
341
|
+
const tw = document.createTreeWalker(pg, NodeFilter.SHOW_TEXT);
|
|
342
|
+
let order = 0;
|
|
343
|
+
for (let n; (n = tw.nextNode());) {
|
|
344
|
+
const el = n.parentElement;
|
|
345
|
+
if (!el || el.closest("svg, script, style, template, .deco")) continue;
|
|
346
|
+
const cs = getComputedStyle(el);
|
|
347
|
+
if (cs.visibility !== "visible") continue;
|
|
348
|
+
const col = rgba(cs.color);
|
|
349
|
+
const alpha = opacityOf(el) * (col ? col.a : 1);
|
|
350
|
+
if (alpha < 0.02) continue;
|
|
351
|
+
const block = blockOf(el);
|
|
352
|
+
const bcs = getComputedStyle(block);
|
|
353
|
+
const size = parseFloat(cs.fontSize);
|
|
354
|
+
const italic = cs.fontStyle !== "normal";
|
|
355
|
+
const face = pick(familyOf(cs.fontFamily), +cs.fontWeight || 400, italic);
|
|
356
|
+
const style = { face, italic, color: col ? hex(col) : "000000", alpha: +alpha.toFixed(3), ls: cs.letterSpacing === "normal" ? 0 : parseFloat(cs.letterSpacing) };
|
|
357
|
+
const upper = cs.textTransform === "uppercase";
|
|
358
|
+
const asc = faces[face].asc;
|
|
359
|
+
const t = n.data;
|
|
360
|
+
for (let i = 0; i < t.length; i++) {
|
|
361
|
+
rg.setStart(n, i); rg.setEnd(n, i + 1);
|
|
362
|
+
const rs = [...rg.getClientRects()].filter((r) => r.width > 0 || r.height > 0);
|
|
363
|
+
if (!rs.length) continue;
|
|
364
|
+
const r = rs[0];
|
|
365
|
+
let ch = t[i];
|
|
366
|
+
if (/\s/.test(ch)) { if (r.width < 0.01) continue; ch = " "; }
|
|
367
|
+
if (clipped(el, r)) continue;
|
|
368
|
+
chars.push({ ch: upper ? ch.toUpperCase() : ch, block, order: order++,
|
|
369
|
+
x0: X(r.left), x1: X(r.right), base: Y(r.top + Math.round(asc * size)), size, style, align: alignOf(bcs), link: linkOf(el) });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/* lines: consecutive characters of one block on one baseline, moving forward */
|
|
374
|
+
const lines = [];
|
|
375
|
+
let cur = null;
|
|
376
|
+
const flush = () => {
|
|
377
|
+
if (!cur) return;
|
|
378
|
+
while (cur.chars.length && cur.chars[0].ch === " ") cur.chars.shift();
|
|
379
|
+
while (cur.chars.length && cur.chars[cur.chars.length - 1].ch === " ") cur.chars.pop();
|
|
380
|
+
if (cur.chars.length) lines.push(cur);
|
|
381
|
+
cur = null;
|
|
382
|
+
};
|
|
383
|
+
for (const c of chars) {
|
|
384
|
+
const p = cur && cur.chars[cur.chars.length - 1];
|
|
385
|
+
const same = cur && c.block === cur.block &&
|
|
386
|
+
Math.abs(c.base - cur.base) < 0.35 * Math.min(c.size, cur.size) && c.x0 > p.x1 - Math.max(2, 0.3 * c.size);
|
|
387
|
+
if (!same) { flush(); cur = { block: c.block, base: c.base, chars: [] }; }
|
|
388
|
+
cur.chars.push(c);
|
|
389
|
+
if (c.size > (cur.size || 0)) { cur.size = c.size; cur.base = c.base; }
|
|
390
|
+
}
|
|
391
|
+
flush();
|
|
392
|
+
|
|
393
|
+
const blocks = new Map();
|
|
394
|
+
const out = lines.map((ln) => {
|
|
395
|
+
if (!blocks.has(ln.block)) blocks.set(ln.block, blocks.size);
|
|
396
|
+
// runs of one style; a character followed by space the text itself does
|
|
397
|
+
// not account for (an inline element's padding or margin) is a run of its
|
|
398
|
+
// own, its tracking widened by that gap
|
|
399
|
+
const runs = [];
|
|
400
|
+
ln.chars.forEach((c, i) => {
|
|
401
|
+
const nx = ln.chars[i + 1];
|
|
402
|
+
const gap = nx ? nx.x0 - c.x1 : 0;
|
|
403
|
+
const extra = gap > 0.5 ? gap : 0;
|
|
404
|
+
const k = JSON.stringify(c.style) + c.size;
|
|
405
|
+
const last = runs[runs.length - 1];
|
|
406
|
+
if (last && last.k === k && !last.extra && !extra) last.text += c.ch;
|
|
407
|
+
else runs.push({ k, text: c.ch, ...c.style, size: c.size, ls: c.style.ls + extra, extra });
|
|
408
|
+
});
|
|
409
|
+
runs.forEach((r) => { delete r.k; if (!r.extra) delete r.extra; });
|
|
410
|
+
const first = ln.chars[0], last = ln.chars[ln.chars.length - 1];
|
|
411
|
+
return { block: blocks.get(ln.block), kind: "html", x0: +first.x0.toFixed(2), x1: +last.x1.toFixed(2), base: +ln.base.toFixed(2), size: +ln.size.toFixed(3),
|
|
412
|
+
align: first.align, link: first.link, runs };
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
/* SVG text */
|
|
416
|
+
for (const t of pg.querySelectorAll("svg text")) {
|
|
417
|
+
if (t.closest(".deco")) continue;
|
|
418
|
+
const cs = getComputedStyle(t);
|
|
419
|
+
if (cs.visibility !== "visible" || cs.display === "none") continue;
|
|
420
|
+
const ctm = t.getScreenCTM();
|
|
421
|
+
if (!ctm) continue;
|
|
422
|
+
const k = Math.hypot(ctm.a, ctm.b);
|
|
423
|
+
const fill = rgba(cs.fill);
|
|
424
|
+
const alpha = opacityOf(t) * (fill ? fill.a : 1) * +cs.fillOpacity;
|
|
425
|
+
if (alpha < 0.02 || !t.textContent.trim()) continue;
|
|
426
|
+
const bb = t.getBoundingClientRect();
|
|
427
|
+
const base = new DOMPoint(t.x.baseVal[0]?.value || 0, t.y.baseVal[0]?.value || 0).matrixTransform(ctm);
|
|
428
|
+
const size = parseFloat(cs.fontSize) * k;
|
|
429
|
+
const anchor = cs.textAnchor;
|
|
430
|
+
const italic = cs.fontStyle !== "normal";
|
|
431
|
+
out.push({ block: blocks.size + out.length, kind: "svg", x0: +X(bb.left).toFixed(2), x1: +X(bb.right).toFixed(2), base: +Y(base.y).toFixed(2), size: +size.toFixed(3),
|
|
432
|
+
align: anchor === "middle" ? "ctr" : anchor === "end" ? "r" : "l", link: null,
|
|
433
|
+
runs: [{ text: t.textContent, size, face: pick(familyOf(cs.fontFamily), +cs.fontWeight || 400, italic), italic, color: fill ? hex(fill) : "000000", alpha: +alpha.toFixed(3), ls: 0 }] });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/* links */
|
|
437
|
+
const links = [];
|
|
438
|
+
for (const a of pg.querySelectorAll("a[href]")) {
|
|
439
|
+
const to = linkOf(a);
|
|
440
|
+
const r = a.getBoundingClientRect();
|
|
441
|
+
if (!to || !r.width || getComputedStyle(a).visibility !== "visible") continue;
|
|
442
|
+
links.push({ ...to, x: X(r.left), y: Y(r.top), w: r.width, h: r.height, text: a.textContent.trim().replace(/\s+/g, " ") });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return { n: index + 1, lines: out, links, subs: [...subs] };
|
|
446
|
+
}
|
package/lib/surfaces.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/surfaces.js — the canvases: a slide, a printed page, a social card.
|
|
3
|
+
*
|
|
4
|
+
* A surface fixes what must not drift per output type — the frame, the
|
|
5
|
+
* margins, where the chrome sits, the type scale — and nothing else. The same
|
|
6
|
+
* components draw on every surface; they take their sizes from the custom
|
|
7
|
+
* properties written here, so a card on a slide and a card on a Letter page
|
|
8
|
+
* are one piece of CSS at two scales.
|
|
9
|
+
*
|
|
10
|
+
* The letter scale is the Franklin Templeton letter's (Oct 2026), the slide
|
|
11
|
+
* scale its deck's: both reviewed, both shipped.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const LETTER = {
|
|
15
|
+
w: 816, h: 1056, x: 60, top: 92, bottom: 74, base: 11.6, min: 7, chars: 95,
|
|
16
|
+
chrome: { eyebrowTop: 44, eyebrowSize: 8.5, markTop: 36, markW: 30, markH: 28, footBottom: 34, footSize: 8.5, footRule: 1 },
|
|
17
|
+
t: { display: 56, h1: 40, h1md: 32, h2: 25, h3: 16.5, h4: 14.5, h5: 12, lead: 14.6, lede: 16, body: 11.6, sm: 10.6,
|
|
18
|
+
note: 8.8, tag: 7.8, label: 17, num: 10, stat: 34, hero: 40, quote: 11.6, name: 12, role: 10.5, meta: 10 },
|
|
19
|
+
m: { gap: 13, cardPad: "16px 18px", cardPadSm: "13px 15px", radius: 14, colGap: 14, secTop: 15, secGap: 9, chip: 18, arrow: 8, stroke: 1 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const SURFACES = {
|
|
23
|
+
letter: { ...LETTER, name: "letter", kind: "page", paper: "US Letter" },
|
|
24
|
+
a4: { ...LETTER, name: "a4", kind: "page", paper: "A4", w: 794, h: 1123 },
|
|
25
|
+
slide: {
|
|
26
|
+
name: "slide", kind: "slide", w: 1920, h: 1080, x: 72, top: 178, bottom: 112, base: 21, min: 13, chars: 95,
|
|
27
|
+
chrome: { eyebrowTop: 82, eyebrowSize: 16, markTop: 64, markW: 56, markH: 52, footBottom: 44, footSize: 14, footRule: 0 },
|
|
28
|
+
t: { display: 88, h1: 68, h1md: 56, h2: 38, h3: 28, h4: 26, h5: 21, lead: 26, lede: 28, body: 21, sm: 18.5,
|
|
29
|
+
note: 15.5, tag: 15, label: 30, num: 16, stat: 74, hero: 128, quote: 23, name: 22, role: 18.5, meta: 18 },
|
|
30
|
+
m: { gap: 30, cardPad: "36px 40px", cardPadSm: "30px 30px", radius: 24, colGap: 28, secTop: 0, secGap: 14, chip: 28, arrow: 13, stroke: 1.5 },
|
|
31
|
+
},
|
|
32
|
+
square: {
|
|
33
|
+
name: "square", kind: "card", w: 1080, h: 1080, x: 72, top: 150, bottom: 120, base: 22, min: 14, chars: 60,
|
|
34
|
+
chrome: { eyebrowTop: 72, eyebrowSize: 16, markTop: 56, markW: 56, markH: 52, footBottom: 56, footSize: 15, footRule: 0 },
|
|
35
|
+
t: { display: 84, h1: 64, h1md: 52, h2: 40, h3: 30, h4: 26, h5: 22, lead: 28, lede: 30, body: 22, sm: 19,
|
|
36
|
+
note: 16, tag: 15, label: 28, num: 16, stat: 96, hero: 140, quote: 26, name: 22, role: 19, meta: 18 },
|
|
37
|
+
m: { gap: 28, cardPad: "32px 36px", cardPadSm: "26px 28px", radius: 24, colGap: 24, secTop: 0, secGap: 12, chip: 28, arrow: 12, stroke: 1.5 },
|
|
38
|
+
},
|
|
39
|
+
og: {
|
|
40
|
+
name: "og", kind: "card", w: 1200, h: 630, x: 64, top: 120, bottom: 90, base: 20, min: 13, chars: 70,
|
|
41
|
+
chrome: { eyebrowTop: 56, eyebrowSize: 14, markTop: 44, markW: 48, markH: 44, footBottom: 40, footSize: 14, footRule: 0 },
|
|
42
|
+
t: { display: 72, h1: 56, h1md: 46, h2: 34, h3: 26, h4: 24, h5: 20, lead: 24, lede: 26, body: 20, sm: 18,
|
|
43
|
+
note: 15, tag: 14, label: 24, num: 14, stat: 80, hero: 110, quote: 22, name: 20, role: 17, meta: 16 },
|
|
44
|
+
m: { gap: 24, cardPad: "28px 32px", cardPadSm: "22px 24px", radius: 20, colGap: 22, secTop: 0, secGap: 10, chip: 26, arrow: 11, stroke: 1.5 },
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const surface = (name) => {
|
|
49
|
+
const s = SURFACES[name];
|
|
50
|
+
if (!s) throw new Error(`unknown surface "${name}" — one of ${Object.keys(SURFACES).join(", ")}`);
|
|
51
|
+
return s;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The surface as custom properties on `.page`, plus the page box itself. */
|
|
55
|
+
export function surfaceCss(s) {
|
|
56
|
+
const t = Object.entries(s.t).map(([k, v]) => `--t-${k}:${v}px`).join(";");
|
|
57
|
+
const kebab = (k) => k.replace(/[A-Z]/g, (x) => "-" + x.toLowerCase());
|
|
58
|
+
const m = Object.entries(s.m).map(([k, v]) => `--m-${kebab(k)}:${typeof v === "number" ? v + "px" : v}`).join(";");
|
|
59
|
+
const c = Object.entries(s.chrome).map(([k, v]) => `--c-${kebab(k)}:${v}px`).join(";");
|
|
60
|
+
return `@page{size:${s.w}px ${s.h}px;margin:0}
|
|
61
|
+
.page{width:${s.w}px;height:${s.h}px;--x:${s.x}px;--top:${s.top}px;--bottom:${s.bottom}px;--base:${s.base}px;font-size:${s.base}px;${t};${m};${c}}`;
|
|
62
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wand-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "One HTML design surface for every sales and marketing material, on either Wand brand: decks, documents, one-pagers, case studies, social cards. Every export (PDF, PNG, PowerPoint, one-file HTML) is read from the page that was reviewed.",
|
|
5
|
+
"license": "SEE LICENSE IN PROMPT.md",
|
|
6
|
+
"bin": {
|
|
7
|
+
"wand-kit": "./wand.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./wand.js",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"fontkit": "^2.0.4",
|
|
12
|
+
"jszip": "^3.10.1",
|
|
13
|
+
"playwright-core": "^1.47.2",
|
|
14
|
+
"pptxgenjs": "^3.12.0"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"doctor": "node wand.js doctor"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* __NAME__ — social cards on the Wand studio (square 1080 × 1080, or og 1200 × 630).
|
|
3
|
+
*
|
|
4
|
+
* One `c.page()` per card. Each page becomes a PNG in out/pages/; the PDF
|
|
5
|
+
* carries them all. `css` may only use the brand's custom properties.
|
|
6
|
+
*/
|
|
7
|
+
export default {
|
|
8
|
+
name: "__NAME__",
|
|
9
|
+
brand: "__BRAND__", // current | next
|
|
10
|
+
surface: "__SURFACE__", // square | og
|
|
11
|
+
tag: "Wand",
|
|
12
|
+
footer: "wand.ai",
|
|
13
|
+
css: `
|
|
14
|
+
.quote-card .content{justify-content:center;gap:1.2em}
|
|
15
|
+
.quote-card .lede{font-size:var(--t-h2);line-height:1.3;max-width:100%}
|
|
16
|
+
`,
|
|
17
|
+
pages: (c) => [
|
|
18
|
+
c.page({ dark: true, art: "cover", eyebrow: "Announcement", cls: "quote-card", body: `
|
|
19
|
+
${c.tag("Customer story")}
|
|
20
|
+
<p class="lede">Agentic AI, out of the lab and into <em>enterprise-scale production</em>.</p>
|
|
21
|
+
${c.who("Wand × Franklin Templeton", "Multi-year strategic partnership")}
|
|
22
|
+
` }),
|
|
23
|
+
|
|
24
|
+
c.page({ eyebrow: "By the numbers", cls: "quote-card", body: `
|
|
25
|
+
${c.tag("Benchmark trajectory")}
|
|
26
|
+
${c.stats([{ v: "70%", k: "Agentic GDP at Evolution" }, { v: "95%", k: "process penetration" }])}
|
|
27
|
+
${c.note("Illustrative Wand Dual Workforce framework benchmarks.")}
|
|
28
|
+
` }),
|
|
29
|
+
],
|
|
30
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* __NAME__ — a printed document on the Wand studio.
|
|
3
|
+
*
|
|
4
|
+
* `pages(c)` composes with the components (node wand.js components) and may
|
|
5
|
+
* write raw markup where none fits. `css` is this job's own; it may only use
|
|
6
|
+
* the brand's custom properties (--ink, --accent, --t-h2 …), never a hex.
|
|
7
|
+
*
|
|
8
|
+
* The words come from content.txt by line number (c.L(12)) when rebuilding a
|
|
9
|
+
* source, so coverage can read every line back off the page. Authored copy
|
|
10
|
+
* goes straight into the markup; then leave `content` out.
|
|
11
|
+
*/
|
|
12
|
+
export default {
|
|
13
|
+
name: "__NAME__",
|
|
14
|
+
brand: "__BRAND__", // current | next
|
|
15
|
+
surface: "__SURFACE__", // letter | a4
|
|
16
|
+
tag: "Wand", // the running tag, left of the eyebrow
|
|
17
|
+
footer: "Wand AI · Confidential",
|
|
18
|
+
content: "content.txt", // delete this line when authoring from a brief
|
|
19
|
+
edits: {}, // { 12: [["needle", "replacement"]] } — every departure from the source, declared
|
|
20
|
+
cut: [], // lines left out on purpose
|
|
21
|
+
denylist: [], // names that must not appear, e.g. a previous client's
|
|
22
|
+
css: `
|
|
23
|
+
.cover .top{margin-top:8%}
|
|
24
|
+
`,
|
|
25
|
+
pages: (c) => [
|
|
26
|
+
c.cover({
|
|
27
|
+
label: "Wand × Client",
|
|
28
|
+
title: `A title with <em>one accent phrase</em>`,
|
|
29
|
+
lede: "One sentence that says what this document argues.",
|
|
30
|
+
meta: "Prepared for … · October 2026 · Strictly confidential",
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
c.page({ eyebrow: "The starting point", body: `
|
|
34
|
+
${c.sec("01", `Where growth outruns <em>the organisation</em>`)}
|
|
35
|
+
${c.lead("The claim this page carries, in one sentence.")}
|
|
36
|
+
${c.body("Running text. Keep a measure near 75 characters; let the grid, hairlines and type do the separating.")}
|
|
37
|
+
${c.cols(3, [
|
|
38
|
+
c.card({ tag: "Layer 01", title: "Real agents", body: ["Persistent identity, memory, objectives and a full action history."] }),
|
|
39
|
+
c.card({ tag: "Layer 02", title: "An agentic workforce", body: ["Hired, onboarded, reviewed and retired like people, in one registry."] }),
|
|
40
|
+
c.card({ tag: "Layer 03", title: "Wand OS", emph: true, body: ["The control plane: policy, identity, audit and approval."] }),
|
|
41
|
+
])}
|
|
42
|
+
${c.sec("02", `Three <em>steps</em>`)}
|
|
43
|
+
${c.steps([
|
|
44
|
+
{ title: "Working session", when: "Within 3 weeks", body: "Two hours with the functions that own the shortlist." },
|
|
45
|
+
{ title: "Deployment posture", when: "Weeks 3–5", body: "Environment, policies and approval gates agreed." },
|
|
46
|
+
{ title: "First agents live", when: "Within 90 days", body: "Real work on real data, measured against the baseline.", emph: true },
|
|
47
|
+
])}
|
|
48
|
+
` }),
|
|
49
|
+
|
|
50
|
+
c.closing({ pair: "Wand × Client", title: "The Agentic Workforce", url: "www.wand.ai" }),
|
|
51
|
+
],
|
|
52
|
+
};
|