wand-decks-kit 0.8.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 +461 -0
- package/README.md +137 -0
- package/START-HERE.md +98 -0
- package/assets/README.md +40 -0
- package/assets/_icon-contact-sheet.png +0 -0
- package/assets/bg-cover-dark.png +0 -0
- package/assets/bg-overlay-dark.png +0 -0
- package/assets/bg-statement-dark.png +0 -0
- package/assets/glyphs/README.md +29 -0
- package/assets/glyphs/activity.svg +15 -0
- package/assets/glyphs/atom.svg +17 -0
- package/assets/glyphs/badge-check.svg +16 -0
- package/assets/glyphs/bell.svg +16 -0
- package/assets/glyphs/blocks.svg +16 -0
- package/assets/glyphs/book-open.svg +16 -0
- package/assets/glyphs/bot.svg +20 -0
- package/assets/glyphs/boxes.svg +26 -0
- package/assets/glyphs/brain.svg +23 -0
- package/assets/glyphs/briefcase.svg +16 -0
- package/assets/glyphs/building-2.svg +21 -0
- package/assets/glyphs/calendar.svg +18 -0
- package/assets/glyphs/chart-column.svg +18 -0
- package/assets/glyphs/chart-line.svg +16 -0
- package/assets/glyphs/chart-pie.svg +16 -0
- package/assets/glyphs/circle-check.svg +16 -0
- package/assets/glyphs/clipboard-check.svg +17 -0
- package/assets/glyphs/clock.svg +16 -0
- package/assets/glyphs/cloud.svg +15 -0
- package/assets/glyphs/coins.svg +18 -0
- package/assets/glyphs/compass.svg +16 -0
- package/assets/glyphs/cpu.svg +24 -0
- package/assets/glyphs/database.svg +17 -0
- package/assets/glyphs/eye.svg +16 -0
- package/assets/glyphs/file-check.svg +17 -0
- package/assets/glyphs/filter.svg +15 -0
- package/assets/glyphs/fingerprint.svg +23 -0
- package/assets/glyphs/flag.svg +16 -0
- package/assets/glyphs/gauge.svg +16 -0
- package/assets/glyphs/gavel.svg +19 -0
- package/assets/glyphs/git-branch.svg +18 -0
- package/assets/glyphs/globe.svg +17 -0
- package/assets/glyphs/graduation-cap.svg +17 -0
- package/assets/glyphs/handshake.svg +19 -0
- package/assets/glyphs/hexagon.svg +15 -0
- package/assets/glyphs/index.json +175 -0
- package/assets/glyphs/infinity.svg +15 -0
- package/assets/glyphs/key.svg +17 -0
- package/assets/glyphs/landmark.svg +20 -0
- package/assets/glyphs/layers.svg +17 -0
- package/assets/glyphs/lightbulb.svg +17 -0
- package/assets/glyphs/link.svg +16 -0
- package/assets/glyphs/list-checks.svg +19 -0
- package/assets/glyphs/lock.svg +16 -0
- package/assets/glyphs/map.svg +17 -0
- package/assets/glyphs/megaphone.svg +16 -0
- package/assets/glyphs/message-square.svg +15 -0
- package/assets/glyphs/milestone.svg +17 -0
- package/assets/glyphs/network.svg +19 -0
- package/assets/glyphs/orbit.svg +19 -0
- package/assets/glyphs/package.svg +18 -0
- package/assets/glyphs/plug.svg +18 -0
- package/assets/glyphs/puzzle.svg +15 -0
- package/assets/glyphs/radar.svg +22 -0
- package/assets/glyphs/receipt.svg +17 -0
- package/assets/glyphs/refresh-cw.svg +18 -0
- package/assets/glyphs/rocket.svg +18 -0
- package/assets/glyphs/route.svg +17 -0
- package/assets/glyphs/scale.svg +19 -0
- package/assets/glyphs/search.svg +16 -0
- package/assets/glyphs/server.svg +18 -0
- package/assets/glyphs/settings.svg +16 -0
- package/assets/glyphs/share-2.svg +19 -0
- package/assets/glyphs/shield-check.svg +16 -0
- package/assets/glyphs/shuffle.svg +19 -0
- package/assets/glyphs/sparkles.svg +19 -0
- package/assets/glyphs/square-stack.svg +17 -0
- package/assets/glyphs/target.svg +17 -0
- package/assets/glyphs/trending-up.svg +16 -0
- package/assets/glyphs/triangle-alert.svg +17 -0
- package/assets/glyphs/trophy.svg +20 -0
- package/assets/glyphs/users.svg +18 -0
- package/assets/glyphs/wallet.svg +16 -0
- package/assets/glyphs/wand-sparkles.svg +22 -0
- package/assets/glyphs/workflow.svg +17 -0
- package/assets/glyphs/zap.svg +15 -0
- package/assets/icons/icon-11.png +0 -0
- package/assets/icons/icon-12.png +0 -0
- package/assets/icons/icon-13.png +0 -0
- package/assets/icons/icon-14.png +0 -0
- package/assets/icons/icon-15.png +0 -0
- package/assets/icons/icon-16.png +0 -0
- package/assets/icons/icon-17.png +0 -0
- package/assets/icons/icon-18.png +0 -0
- package/assets/icons/icon-20.png +0 -0
- package/assets/icons/icon-23.png +0 -0
- package/assets/icons/icon-24.png +0 -0
- package/assets/icons/icon-25.png +0 -0
- package/assets/icons/icon-27.png +0 -0
- package/assets/icons/icon-28.png +0 -0
- package/assets/icons/icon-30.png +0 -0
- package/assets/icons/icon-31.png +0 -0
- package/assets/icons/icon-32.png +0 -0
- package/assets/icons/icon-33.png +0 -0
- package/assets/icons/icon-34.png +0 -0
- package/assets/icons/icon-35.png +0 -0
- package/assets/icons/icon-36.png +0 -0
- package/assets/icons/icon-37.png +0 -0
- package/assets/icons/icon-39.png +0 -0
- package/assets/icons/icon-40.png +0 -0
- package/assets/icons/icon-41.png +0 -0
- package/assets/icons/icon-43.png +0 -0
- package/assets/icons/icon-44.png +0 -0
- package/assets/icons/icon-45.png +0 -0
- package/assets/icons/icon-46.png +0 -0
- package/assets/icons/icon-48.png +0 -0
- package/assets/icons/icon-49.png +0 -0
- package/assets/icons/icon-50.png +0 -0
- package/assets/icons/icon-51.png +0 -0
- package/assets/icons/icon-52.png +0 -0
- package/assets/icons/icon-53.png +0 -0
- package/assets/icons/icon-9.png +0 -0
- package/assets/mark-dark.png +0 -0
- package/assets/mark-white.png +0 -0
- package/assets/orb-cover.png +0 -0
- package/assets/wordmark-dark.png +0 -0
- package/assets/wordmark-white.png +0 -0
- package/build.js +192 -0
- package/build_example.js +91 -0
- package/build_infinite.js +283 -0
- package/docs/LAYOUTS.md +86 -0
- package/docs/PIPELINE.md +116 -0
- package/docs/SPEC.md +89 -0
- package/embed_fonts.py +193 -0
- package/examples/template.json +164 -0
- package/fonts/Geist-Bold.ttf +0 -0
- package/fonts/Geist-BoldItalic.ttf +0 -0
- package/fonts/Geist-ExtraBold.ttf +0 -0
- package/fonts/Geist-ExtraBoldItalic.ttf +0 -0
- package/fonts/Geist-Italic.ttf +0 -0
- package/fonts/Geist-Light.ttf +0 -0
- package/fonts/Geist-LightItalic.ttf +0 -0
- package/fonts/Geist-Medium.ttf +0 -0
- package/fonts/Geist-MediumItalic.ttf +0 -0
- package/fonts/Geist-Regular.ttf +0 -0
- package/fonts/Geist-SemiBold.ttf +0 -0
- package/fonts/Geist-SemiBoldItalic.ttf +0 -0
- package/lib/README.md +23 -0
- package/lib/brand.js +51 -0
- package/lib/glyphs.js +87 -0
- package/lib/layouts_client.js +212 -0
- package/lib/layouts_consult.js +301 -0
- package/lib/layouts_dense.js +361 -0
- package/lib/measure.js +109 -0
- package/lib/pdf.js +517 -0
- package/lib/plan.js +305 -0
- package/lib/render_pdf.js +203 -0
- package/lib/schema.js +399 -0
- package/lib/shared.js +15 -0
- package/lib/trace.js +88 -0
- package/lib/ttf.py +167 -0
- package/lib/wandxml.py +37 -0
- package/package.json +17 -0
- package/preview/preview.css +112 -0
- package/preview/preview.html +24 -0
- package/preview/preview.js +169 -0
- package/qa_coverage.py +120 -0
- package/requirements.txt +9 -0
- package/tools/extract.py +199 -0
- package/wand.js +397 -0
- package/wand_kit.js +1016 -0
package/lib/plan.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/plan.js — Phase 2 + 3, as a worksheet.
|
|
3
|
+
*
|
|
4
|
+
* The step that actually fails on a rebuild is not the build. It is mapping a
|
|
5
|
+
* source slide onto a layout and then remembering every field that layout
|
|
6
|
+
* needs. `deck.skeleton.json` used to say `"layout": "TODO"` and leave both to
|
|
7
|
+
* memory, which is how a spec ends up paraphrased and three fields short.
|
|
8
|
+
*
|
|
9
|
+
* This reads `analysis.json` and writes, per slide: what the source slide
|
|
10
|
+
* actually is (frame geometry and point sizes, not word counts), ranked layout
|
|
11
|
+
* candidates with the reason for each, the verbatim strings already sorted into
|
|
12
|
+
* tag / title / subtitle / body, and a stub carrying that layout's required
|
|
13
|
+
* fields. Filling it in is then transcription, not recall.
|
|
14
|
+
*
|
|
15
|
+
* The ranking is a suggestion and says so. The operator overrules it; §6
|
|
16
|
+
* Phase 3 is still the rule.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { LAYOUTS, stub } = require("./schema");
|
|
20
|
+
|
|
21
|
+
const round = (n, p = 1) => Math.round(n * 10 ** p) / 10 ** p;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Is a background dark? Testing for Wand's own `0F0C26` was wrong the first time
|
|
25
|
+
* it met a deck built by someone else, whose background was `1A0B3D` - every slide
|
|
26
|
+
* came back light. Measure the luminance instead.
|
|
27
|
+
*/
|
|
28
|
+
function isDark(hex) {
|
|
29
|
+
const n = parseInt(String(hex).replace(/^#/, ""), 16);
|
|
30
|
+
if (!Number.isFinite(n)) return false;
|
|
31
|
+
const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
32
|
+
return (0.2126 * r + 0.7152 * g + 0.0722 * b) < 110;
|
|
33
|
+
}
|
|
34
|
+
const isNumeric = (s) => /[\d%$€£]/.test(s) && s.replace(/[^\w]/g, "").length <= 12;
|
|
35
|
+
// "01" is an ordinal, not a statistic. Without this every numbered-card slide
|
|
36
|
+
// scores as a stat grid, because both are short digit strings in small frames.
|
|
37
|
+
const isOrdinal = (s) => /^0?[1-9]\d?[.)]?$/.test(s.trim());
|
|
38
|
+
|
|
39
|
+
/** 1-D clustering with a tolerance, so "same column" survives a 0.02" nudge. */
|
|
40
|
+
function cluster(values, tol) {
|
|
41
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
42
|
+
const groups = [];
|
|
43
|
+
for (const v of sorted) {
|
|
44
|
+
const g = groups[groups.length - 1];
|
|
45
|
+
if (g && v - g[g.length - 1] <= tol) g.push(v);
|
|
46
|
+
else groups.push([v]);
|
|
47
|
+
}
|
|
48
|
+
return groups;
|
|
49
|
+
}
|
|
50
|
+
const clusterIndex = (groups, v) => groups.findIndex((g) => g.includes(v));
|
|
51
|
+
|
|
52
|
+
/** Sort a slide's text frames into the chrome roles the grid defines (§1). */
|
|
53
|
+
function roles(frames) {
|
|
54
|
+
const withBox = frames.filter((f) => f.box);
|
|
55
|
+
// The footer band is chrome; small type above it is not. Filtering on point
|
|
56
|
+
// size as well dropped nine 6pt content labels on a real deck - "YEAR-END KPI",
|
|
57
|
+
// "REPLACED BY" - which are content, and coverage found them three steps
|
|
58
|
+
// later. Position decides; size never did.
|
|
59
|
+
const content = withBox.filter((f) => f.box[1] < 5.15);
|
|
60
|
+
const tag = content.find((f) => f.box[1] < 0.45 && (f.pt || 99) <= 9);
|
|
61
|
+
const rest = content.filter((f) => f !== tag);
|
|
62
|
+
|
|
63
|
+
// The title is the largest type *in the title band*, not the largest type on
|
|
64
|
+
// the slide: a numbered-card slide sets its ordinals at 30pt and would
|
|
65
|
+
// otherwise steal the role, leaving the real title in the body.
|
|
66
|
+
const band = rest.filter((f) => f.box[1] < 1.6 && (f.pt || 0) >= 16);
|
|
67
|
+
const title = band.sort((a, b) => (b.pt || 0) - (a.pt || 0) || a.box[1] - b.box[1])[0] || null;
|
|
68
|
+
const body = rest.filter((f) => f !== title);
|
|
69
|
+
let subtitle = null;
|
|
70
|
+
for (let i = 0; i < body.length; i++) {
|
|
71
|
+
const f = body[i];
|
|
72
|
+
if (title && f.box[1] > title.box[1] && f.box[1] < title.box[1] + 1.2 &&
|
|
73
|
+
f.box[2] > 5 && (f.pt || 0) >= 10 && (f.pt || 0) < (title.pt || 99)) {
|
|
74
|
+
subtitle = body.splice(i, 1)[0];
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const chrome = withBox.filter((f) => f.box[1] >= 5.15);
|
|
79
|
+
return { tag, title, subtitle, body, chrome };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Card counting, not frame counting. A Wand card is a rounded rect plus a title
|
|
84
|
+
* frame plus a body frame, stacked in one column — so "20 text frames" is four
|
|
85
|
+
* cards, and every count downstream is wrong if you skip this step.
|
|
86
|
+
*
|
|
87
|
+
* Cluster the body frames into columns, then split each column at vertical gaps.
|
|
88
|
+
* Each run is one card. That is the number the layout catalog is indexed on.
|
|
89
|
+
*/
|
|
90
|
+
function signals(slide, index, total) {
|
|
91
|
+
const r = roles(slide.frames);
|
|
92
|
+
const body = r.body;
|
|
93
|
+
|
|
94
|
+
const xs = cluster(body.map((f) => f.box[0]), 0.35);
|
|
95
|
+
const cols = xs.length;
|
|
96
|
+
|
|
97
|
+
let cards = 0;
|
|
98
|
+
const perCol = [];
|
|
99
|
+
for (const col of xs) {
|
|
100
|
+
const inCol = body.filter((f) => col.includes(f.box[0])).sort((a, b) => a.box[1] - b.box[1]);
|
|
101
|
+
let groups = inCol.length ? 1 : 0;
|
|
102
|
+
for (let i = 1; i < inCol.length; i++) {
|
|
103
|
+
const prevBottom = inCol[i - 1].box[1] + inCol[i - 1].box[3];
|
|
104
|
+
if (inCol[i].box[1] - prevBottom > 0.22) groups++;
|
|
105
|
+
}
|
|
106
|
+
perCol.push(groups);
|
|
107
|
+
cards += groups;
|
|
108
|
+
}
|
|
109
|
+
const bands = Math.max(...perCol, 0);
|
|
110
|
+
|
|
111
|
+
// a figure is a short digit-led string sitting alone in its frame — "72%", "$4.2B"
|
|
112
|
+
const ordinals = body.filter((f) => f.paras.length === 1 && isOrdinal(f.paras[0])).length;
|
|
113
|
+
const numeric = body.filter((f) => f.paras.length === 1 && isNumeric(f.paras[0]) && !isOrdinal(f.paras[0])).length;
|
|
114
|
+
const bullets = body.filter((f) => f.paras.length >= 3).length;
|
|
115
|
+
const fullWidth = body.filter((f) => f.box[2] > 8).length;
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
...r,
|
|
119
|
+
cols, bands, cards, numeric, ordinals, bullets, fullWidth,
|
|
120
|
+
first: index === 1, last: index === total,
|
|
121
|
+
words: body.reduce((n, f) => n + f.paras.join(" ").split(/\s+/).length, 0),
|
|
122
|
+
frames: body.length,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Shortlist layouts for one slide from its shape alone.
|
|
128
|
+
*
|
|
129
|
+
* Shape is a real signal — six cards in three columns over two bands is a
|
|
130
|
+
* sixUpIcon and almost nothing else — but it cannot see meaning, and meaning is
|
|
131
|
+
* what §6 Phase 3 actually orders on. So this returns a shortlist with the
|
|
132
|
+
* reason for each entry, never a decision. Read the verbatim text, then choose.
|
|
133
|
+
*
|
|
134
|
+
* Tuned against one 15-slide deck. Treat a confident single answer with
|
|
135
|
+
* suspicion until it has been run over more.
|
|
136
|
+
*/
|
|
137
|
+
function rank(sg) {
|
|
138
|
+
const out = [];
|
|
139
|
+
const add = (layout, score, why) => out.push({ layout, score, why });
|
|
140
|
+
const { cards, cols, bands, numeric, ordinals, bullets, fullWidth: fw, words } = sg;
|
|
141
|
+
|
|
142
|
+
if (sg.first) return [{ layout: "cover", score: 100, why: "slide 1" }];
|
|
143
|
+
if (sg.last && cards <= 2 && words < 60) add("closing", 92, "last slide, little content");
|
|
144
|
+
if (sg.frames <= 2 && (sg.title?.pt || 0) >= 30) add("divider", 90, `one ${sg.title.pt}pt line, nothing else`);
|
|
145
|
+
|
|
146
|
+
// stacked full-width rows — the list shapes
|
|
147
|
+
if (ordinals >= 2) {
|
|
148
|
+
if (fw >= 3) add("numberedList", 80, `${ordinals} ordinals down full-width rows`);
|
|
149
|
+
if (cards >= 2 && cards <= 4) add("numberedCards", 80, `${ordinals} ordinals, ${cards} cards`);
|
|
150
|
+
add("phaseCards", 56, `${ordinals} ordinals — a numbered roadmap?`);
|
|
151
|
+
}
|
|
152
|
+
if (fw >= 4) {
|
|
153
|
+
add("numberedList", 74, `${fw} full-width rows stacked`);
|
|
154
|
+
add("rows", 62, `${fw} full-width rows — a stack / ownership table?`);
|
|
155
|
+
}
|
|
156
|
+
// two columns, one card each: the argument|evidence shapes
|
|
157
|
+
if (cols === 2 && cards <= 3 && fw < 4) {
|
|
158
|
+
add("splitPanel", 72, "two columns, one panel each");
|
|
159
|
+
add("parity", 60, "two columns — list-shaped comparison?");
|
|
160
|
+
add("compareTable", 56, "two columns — a comparison table?");
|
|
161
|
+
}
|
|
162
|
+
// a full grid
|
|
163
|
+
if (cols >= 3 && bands >= 3 && cards >= 9) add("matrixGrid", 78, `${cols} columns x ${bands} deep = ${cards} cells`);
|
|
164
|
+
if (cols === 3 && bands === 2 && cards >= 5 && cards <= 7) add("sixUpIcon", 76, `${cards} cards, 3 across 2 bands`);
|
|
165
|
+
if (cols === 4 && bands === 2 && cards >= 7) add("matrixGrid", 66, `${cards} cells in 4 columns`);
|
|
166
|
+
if (cols >= 3 && bands === 1 && cards === 4) add("fourUpIcon", 68, "4 cards in one band");
|
|
167
|
+
|
|
168
|
+
// bullet-carrying cards
|
|
169
|
+
if (bullets >= 3 && cols >= 4) add("phaseCards", 74, `${cards} columns, each with a bullet list`);
|
|
170
|
+
if (bullets >= 2 && cols <= 3 && cards <= 3) add("colCards", 72, `${cards} columns with prose and bullets`);
|
|
171
|
+
if (bullets >= 2 && cols >= 5) add("productMap", 58, "rail-labelled architecture?");
|
|
172
|
+
|
|
173
|
+
// full-width bands top and bottom with a module row between
|
|
174
|
+
if (fw >= 2 && cols >= 4 && fw < 4) {
|
|
175
|
+
add("archStack", 70, `${fw} full-width bands over a ${cards}-module row`);
|
|
176
|
+
add("productMap", 52, "rail-labelled architecture?");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (numeric >= 3) {
|
|
180
|
+
if (fw >= 2) add("logoBands", 66, `${numeric} figures over full-width name rows`);
|
|
181
|
+
if (cards >= 4 && cards <= 6) add("statGrid", 64, `${numeric} figures, ${cards} cards`);
|
|
182
|
+
if (cards <= 4) add("statQuad", 62, `${numeric} standalone figures`);
|
|
183
|
+
if (cards === 4 || cards === 5) add("heroQuad", 50, "one headline figure over a 2x2?");
|
|
184
|
+
add("barRank", 46, "ranked magnitudes?");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (cards >= 5 && cards <= 8 && cols >= 4) {
|
|
188
|
+
add("problemGrid", 54, "a strip of chips, then the problems?");
|
|
189
|
+
add("compareTable", 48, "reason cards over a comparison?");
|
|
190
|
+
}
|
|
191
|
+
if (cards >= 2 && cards <= 3) {
|
|
192
|
+
add("numberedCards", 52, `${cards} cards — ordinal-led?`);
|
|
193
|
+
add("threeUp", 50, `${cards} comparable cards`);
|
|
194
|
+
}
|
|
195
|
+
if (cards >= 3 && cards <= 5 && cols <= 2) add("rows", 48, `${cards} stacked rows`);
|
|
196
|
+
if (cards <= 2 && words < 45) add("band", 44, "one short assertion");
|
|
197
|
+
|
|
198
|
+
const best = {};
|
|
199
|
+
for (const c of out) if (!best[c.layout] || best[c.layout].score < c.score) best[c.layout] = c;
|
|
200
|
+
return Object.values(best).sort((a, b) => b.score - a.score);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const fence = "```";
|
|
204
|
+
|
|
205
|
+
function worksheet(analysis) {
|
|
206
|
+
const slides = analysis.slides.filter((s) => !s.hidden);
|
|
207
|
+
const total = slides.length;
|
|
208
|
+
const L = [];
|
|
209
|
+
|
|
210
|
+
L.push(`# Mapping worksheet — ${analysis.source}`);
|
|
211
|
+
L.push("");
|
|
212
|
+
L.push(`Source canvas ${analysis.canvas[0]}" × ${analysis.canvas[1]}", scale ×${analysis.scale} onto the Wand grid.`);
|
|
213
|
+
L.push(`${total} visible slides.`);
|
|
214
|
+
L.push("");
|
|
215
|
+
L.push("Candidates are ranked by shape, not by meaning — they are a starting point,");
|
|
216
|
+
L.push("not the decision. §6 Phase 3 is the rule: structural first, then quotes,");
|
|
217
|
+
L.push("timelines, data, comparisons, stacks, assertions, numbered asks, and only");
|
|
218
|
+
L.push("then item count. Vary the layouts and alternate dark/light.");
|
|
219
|
+
L.push("");
|
|
220
|
+
L.push("**Every string below is verbatim. Reflow it to fit; do not reword it.**");
|
|
221
|
+
L.push("");
|
|
222
|
+
|
|
223
|
+
const chosen = [];
|
|
224
|
+
slides.forEach((sl, i) => {
|
|
225
|
+
const sg = signals(sl, i + 1, total);
|
|
226
|
+
const cands = rank(sg).slice(0, 3);
|
|
227
|
+
chosen.push({ slide: sl.slide, cands, sg });
|
|
228
|
+
|
|
229
|
+
L.push(`## S${String(i + 1).padStart(2, "0")} (source slide ${sl.slide})`);
|
|
230
|
+
L.push("");
|
|
231
|
+
L.push(`\`${sl.shapes} shapes · ${sl.pics} images · ${sl.frames.length} text frames · ` +
|
|
232
|
+
`${sg.cols} column${sg.cols === 1 ? "" : "s"} × ${sg.bands} deep` +
|
|
233
|
+
`${sg.repeat ? ` · ${sg.repeat} repeated blocks` : ""}${sg.numeric ? ` · ${sg.numeric} figures` : ""}\``);
|
|
234
|
+
L.push("");
|
|
235
|
+
L.push("| | |");
|
|
236
|
+
L.push("|---|---|");
|
|
237
|
+
L.push(`| tag | ${sg.tag ? esc(sg.tag.paras.join(" ")) : "—"} |`);
|
|
238
|
+
L.push(`| title | ${sg.title ? `${esc(sg.title.paras.join(" \\n "))} *(${sg.title.pt}pt)* ` : "—"} |`);
|
|
239
|
+
L.push(`| subtitle | ${sg.subtitle ? esc(sg.subtitle.paras.join(" ")) : "—"} |`);
|
|
240
|
+
L.push("");
|
|
241
|
+
L.push(`**candidates** — ${cands.map((c) => `\`${c.layout}\` (${c.why})`).join(" · ") || "none scored; choose by §6 Phase 3"}`);
|
|
242
|
+
L.push("");
|
|
243
|
+
if (sg.chrome.length) {
|
|
244
|
+
L.push("<details><summary>footer band — usually chrome, check before dropping</summary>");
|
|
245
|
+
L.push("");
|
|
246
|
+
for (const f of sg.chrome) L.push(`- \`y${f.box[1]}${f.pt ? ` ${f.pt}pt` : ""}\` ${esc(f.paras.join(" "))}`);
|
|
247
|
+
L.push("");
|
|
248
|
+
L.push("</details>");
|
|
249
|
+
L.push("");
|
|
250
|
+
}
|
|
251
|
+
if (sg.body.length) {
|
|
252
|
+
L.push("<details><summary>body frames, verbatim</summary>");
|
|
253
|
+
L.push("");
|
|
254
|
+
for (const f of sg.body) {
|
|
255
|
+
const b = f.box;
|
|
256
|
+
L.push(`- \`x${b[0]} y${b[1]} w${b[2]} h${b[3]}${f.pt ? ` ${f.pt}pt` : ""}\``);
|
|
257
|
+
for (const p of f.paras) L.push(` - ${esc(p)}`);
|
|
258
|
+
}
|
|
259
|
+
L.push("");
|
|
260
|
+
L.push("</details>");
|
|
261
|
+
L.push("");
|
|
262
|
+
}
|
|
263
|
+
if (cands[0]) {
|
|
264
|
+
const st = stub(cands[0].layout) || {};
|
|
265
|
+
if (sg.tag) st.tag = sg.tag.paras.join(" ");
|
|
266
|
+
if (sg.title) st.titleText = sg.title.paras.join("\n");
|
|
267
|
+
if (sg.subtitle && "subtitleText" in st === false && !LAYOUTS[cands[0].layout].bleed) {
|
|
268
|
+
st.subtitleText = sg.subtitle.paras.join(" ");
|
|
269
|
+
}
|
|
270
|
+
// read from the source background where there is one; the fallback
|
|
271
|
+
// alternates, which is what §5 asks for anyway
|
|
272
|
+
st.dark = sl.bg ? isDark(sl.bg) : i % 2 === 0;
|
|
273
|
+
L.push(`<details><summary>stub for <code>${cands[0].layout}</code></summary>`);
|
|
274
|
+
L.push("");
|
|
275
|
+
L.push(fence + "json");
|
|
276
|
+
L.push(JSON.stringify(st, null, 2));
|
|
277
|
+
L.push(fence);
|
|
278
|
+
L.push("");
|
|
279
|
+
L.push("</details>");
|
|
280
|
+
L.push("");
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
L.push("---");
|
|
285
|
+
L.push("");
|
|
286
|
+
L.push("## Mapping plan — emit this before building (§6 Phase 3)");
|
|
287
|
+
L.push("");
|
|
288
|
+
L.push("Shortlist top pick and the source's own theme. Overrule both freely —");
|
|
289
|
+
L.push("this is the line you are supposed to argue with, not copy.");
|
|
290
|
+
L.push("");
|
|
291
|
+
L.push(fence);
|
|
292
|
+
chosen.forEach((c, i) => {
|
|
293
|
+
const th = slides[i].bg ? (isDark(slides[i].bg) ? "D" : "L") : (i % 2 === 0 ? "D" : "L");
|
|
294
|
+
L.push(`S${String(i + 1).padStart(2, "0")} ${String(c.sg.cards).padStart(2)} cards → ` +
|
|
295
|
+
`${(c.cands[0] ? c.cands[0].layout : "?")}-${th}${c.cands[1] ? ` (or ${c.cands.slice(1).map((x) => x.layout).join(" / ")})` : ""}`);
|
|
296
|
+
});
|
|
297
|
+
L.push(fence);
|
|
298
|
+
return L.join("\n") + "\n";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function esc(s) {
|
|
302
|
+
return String(s).replace(/\|/g, "\\|").replace(/\n/g, " ⏎ ");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = { worksheet, signals, rank, roles };
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/render_pdf.js — the same deck, as a PDF.
|
|
3
|
+
*
|
|
4
|
+
* It renders the *trace*, not the spec. `lib/trace.js` already records every
|
|
5
|
+
* draw call the real layout functions make, and the browser preview already
|
|
6
|
+
* proves that recording is complete. So a PDF renderer is a second consumer of
|
|
7
|
+
* that recording, not a second implementation of the layouts — which is the
|
|
8
|
+
* only reason it cannot drift from the .pptx.
|
|
9
|
+
*
|
|
10
|
+
* spec ──▶ layouts ──▶ trace ──┬──▶ pptxgenjs .pptx
|
|
11
|
+
* ├──▶ preview.js the browser
|
|
12
|
+
* └──▶ this file .pdf
|
|
13
|
+
*
|
|
14
|
+
* Two things it buys beyond a second format. Fonts are embedded, so a PDF looks
|
|
15
|
+
* right on a machine that has never heard of Geist. And it makes the visual gate
|
|
16
|
+
* runnable — `pdftoppm` turns it into an image per slide, which is the check the
|
|
17
|
+
* deck engine has never been able to run on itself.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require("fs");
|
|
21
|
+
const path = require("path");
|
|
22
|
+
const { Doc } = require("./shared").pdf;
|
|
23
|
+
const M = require("./shared").measure;
|
|
24
|
+
const brand = require("./brand");
|
|
25
|
+
|
|
26
|
+
/** family + weight + slant -> the file the PDF writer embeds. */
|
|
27
|
+
const FACE = {
|
|
28
|
+
"Geist": { r: "Geist-Regular", b: "Geist-Bold", i: "Geist-Italic", bi: "Geist-BoldItalic" },
|
|
29
|
+
"Geist Medium": { r: "Geist-Medium", b: "Geist-SemiBold", i: "Geist-MediumItalic", bi: "Geist-SemiBoldItalic" },
|
|
30
|
+
"Geist SemiBold": { r: "Geist-SemiBold", b: "Geist-ExtraBold", i: "Geist-SemiBoldItalic", bi: "Geist-ExtraBoldItalic" },
|
|
31
|
+
"Geist ExtraBold": { r: "Geist-ExtraBold", b: "Geist-ExtraBold", i: "Geist-ExtraBoldItalic", bi: "Geist-ExtraBoldItalic" },
|
|
32
|
+
"Geist Light": { r: "Geist-Light", b: "Geist-Medium", i: "Geist-LightItalic", bi: "Geist-MediumItalic" },
|
|
33
|
+
};
|
|
34
|
+
const stemOf = (family, bold, italic) => {
|
|
35
|
+
const set = FACE[family] || FACE["Geist"];
|
|
36
|
+
return set[bold && italic ? "bi" : bold ? "b" : italic ? "i" : "r"];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** A run's style, with the shape-level defaults filled in. */
|
|
40
|
+
function styleOf(sh, run) {
|
|
41
|
+
const o = run.options || {};
|
|
42
|
+
const family = o.fontFace || sh.fontFace || "Geist";
|
|
43
|
+
const bold = o.bold ?? sh.bold ?? false;
|
|
44
|
+
const italic = o.italic ?? sh.italic ?? false;
|
|
45
|
+
return {
|
|
46
|
+
family, bold, italic,
|
|
47
|
+
stem: stemOf(family, bold, italic),
|
|
48
|
+
size: o.fontSize || sh.fontSize || 12,
|
|
49
|
+
color: (o.color || sh.color || "000000").replace(/^#/, ""),
|
|
50
|
+
bullet: !!o.bullet,
|
|
51
|
+
breakLine: !!o.breakLine,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Lay a shape's runs out into positioned lines.
|
|
57
|
+
*
|
|
58
|
+
* pptxgenjs wraps inside the box and centres vertically; this reproduces that
|
|
59
|
+
* closely enough that the two renderings agree, because both are measured
|
|
60
|
+
* against the same real font by `measure.js`.
|
|
61
|
+
*/
|
|
62
|
+
function layoutRuns(sh) {
|
|
63
|
+
const inset = sh.margin === 0 ? 0 : 0.05;
|
|
64
|
+
const boxW = Math.max(0.05, (sh.w || 0) - inset * 2);
|
|
65
|
+
const mult = sh.lineSpacingMultiple || 1.2;
|
|
66
|
+
|
|
67
|
+
const words = [];
|
|
68
|
+
for (const run of sh.runs) {
|
|
69
|
+
const st = styleOf(sh, run);
|
|
70
|
+
const text = String(run.text ?? "");
|
|
71
|
+
const segs = text.split("\n");
|
|
72
|
+
segs.forEach((seg, i) => {
|
|
73
|
+
if (i) words.push({ br: true });
|
|
74
|
+
if (st.bullet) words.push({ w: "▪ ", st, glue: true });
|
|
75
|
+
seg.split(/(\s+)/).filter((t) => t !== "").forEach((t) => words.push({ w: t, st }));
|
|
76
|
+
});
|
|
77
|
+
if (st.breakLine) words.push({ br: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const lines = [];
|
|
81
|
+
let cur = { parts: [], w: 0, size: 0 };
|
|
82
|
+
// A space at a wrap point is discarded, the way every text engine does it.
|
|
83
|
+
// Keeping it made a line measure one space wider than its box and report a
|
|
84
|
+
// 0.005in overflow that is not on the page — and it shifted centred lines.
|
|
85
|
+
const push = () => {
|
|
86
|
+
while (cur.parts.length && /^\s+$/.test(cur.parts[cur.parts.length - 1].text)) {
|
|
87
|
+
cur.w -= cur.parts.pop().w;
|
|
88
|
+
}
|
|
89
|
+
if (cur.parts.length) lines.push(cur);
|
|
90
|
+
cur = { parts: [], w: 0, size: 0 };
|
|
91
|
+
};
|
|
92
|
+
for (const t of words) {
|
|
93
|
+
if (t.br) { push(); continue; }
|
|
94
|
+
const tw = M.widthIn(t.w, { family: t.st.family, fontSize: t.st.size, bold: t.st.bold, italic: t.st.italic });
|
|
95
|
+
const blank = /^\s+$/.test(t.w);
|
|
96
|
+
if (!blank && cur.parts.length && cur.w + tw > boxW) push();
|
|
97
|
+
if (blank && !cur.parts.length) continue;
|
|
98
|
+
cur.parts.push({ text: t.w, st: t.st, w: tw });
|
|
99
|
+
cur.w += tw;
|
|
100
|
+
cur.size = Math.max(cur.size, t.st.size);
|
|
101
|
+
}
|
|
102
|
+
push();
|
|
103
|
+
|
|
104
|
+
const step = (s) => (s * mult) / 72;
|
|
105
|
+
const total = lines.reduce((a, l) => a + step(l.size), 0);
|
|
106
|
+
let y = (sh.y || 0) + inset;
|
|
107
|
+
const h = sh.h || total;
|
|
108
|
+
if (sh.valign === "middle") y += Math.max(0, (h - total) / 2);
|
|
109
|
+
else if (sh.valign === "bottom") y += Math.max(0, h - total);
|
|
110
|
+
|
|
111
|
+
return lines.map((l) => {
|
|
112
|
+
const lineW = l.parts.reduce((a, p) => a + p.w, 0);
|
|
113
|
+
let x = (sh.x || 0) + inset;
|
|
114
|
+
if (sh.align === "center") x += Math.max(0, (boxW - lineW) / 2);
|
|
115
|
+
else if (sh.align === "right") x += Math.max(0, boxW - lineW);
|
|
116
|
+
const baseline = y + (l.size / 72) * 0.80;
|
|
117
|
+
y += step(l.size);
|
|
118
|
+
return { x, baseline, parts: l.parts };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ------------------------------------------------------------------ render */
|
|
123
|
+
|
|
124
|
+
function renderTrace(trace, out, opts = {}) {
|
|
125
|
+
const fontkit = opts.fontkit || require("fontkit");
|
|
126
|
+
const d = new Doc({
|
|
127
|
+
width: trace.canvas ? trace.canvas.w : 10,
|
|
128
|
+
height: trace.canvas ? trace.canvas.h : 5.625,
|
|
129
|
+
fontkit,
|
|
130
|
+
fontsDir: brand.resolve("fonts"),
|
|
131
|
+
});
|
|
132
|
+
const missing = [];
|
|
133
|
+
|
|
134
|
+
for (const slide of trace.slides) {
|
|
135
|
+
d.addPage();
|
|
136
|
+
const bg = (slide.background && slide.background.color) || "FFFFFF";
|
|
137
|
+
d.rect({ x: 0, y: 0, w: d.W, h: d.H, fill: bg.replace(/^#/, "") });
|
|
138
|
+
|
|
139
|
+
for (const sh of slide.shapes) {
|
|
140
|
+
if (sh.kind === "shape") drawShape(d, sh);
|
|
141
|
+
else if (sh.kind === "image") drawImage(d, sh, missing);
|
|
142
|
+
else if (sh.kind === "text") drawText(d, sh);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
fs.writeFileSync(out, d.toBuffer());
|
|
146
|
+
return { pages: trace.slides.length, missing };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function drawShape(d, sh) {
|
|
150
|
+
const fill = sh.fill && sh.fill.color ? sh.fill.color.replace(/^#/, "") : null;
|
|
151
|
+
const opacity = sh.fill && sh.fill.transparency != null ? 1 - sh.fill.transparency / 100 : 1;
|
|
152
|
+
const stroke = sh.line && sh.line.width ? (sh.line.color || "000000").replace(/^#/, "") : null;
|
|
153
|
+
const box = { x: sh.x || 0, y: sh.y || 0, w: sh.w || 0, h: sh.h || 0 };
|
|
154
|
+
if (sh.type === "ellipse") return d.ellipse({ ...box, fill, stroke, lineWidth: (sh.line && sh.line.width) || 0.75 });
|
|
155
|
+
d.rect({
|
|
156
|
+
...box, fill, stroke,
|
|
157
|
+
lineWidth: (sh.line && sh.line.width) || 0.75,
|
|
158
|
+
radius: sh.rectRadius || 0,
|
|
159
|
+
dash: sh.line && sh.line.dashType === "dash" ? 2.4 : 0,
|
|
160
|
+
opacity,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function drawImage(d, sh, missing) {
|
|
165
|
+
const box = { x: sh.x || 0, y: sh.y || 0, w: sh.w || 0, h: sh.h || 0 };
|
|
166
|
+
if (sh.data) { // a recoloured glyph, inline
|
|
167
|
+
const m = String(sh.data).match(/^image\/svg\+xml;base64,(.*)$/);
|
|
168
|
+
if (!m) return;
|
|
169
|
+
const svg = Buffer.from(m[1], "base64").toString("utf8");
|
|
170
|
+
const stroke = (svg.match(/stroke="#?([0-9A-Fa-f]{6})"/) || [])[1] || "000000";
|
|
171
|
+
return d.svg(svg, { x: box.x, y: box.y, size: Math.min(box.w, box.h), color: stroke, strokeWidth: 2 });
|
|
172
|
+
}
|
|
173
|
+
if (!sh.path || !fs.existsSync(sh.path)) { if (sh.path) missing.push(sh.path); return; }
|
|
174
|
+
d.image(sh.path, box);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Draw a laid-out line, merging neighbouring parts that share a style.
|
|
179
|
+
*
|
|
180
|
+
* Showing each word as its own text object positions correctly and extracts as
|
|
181
|
+
* "sixUpIconis forfeaturesor" — the gaps are on the page but no space glyph is
|
|
182
|
+
* ever emitted, so anything reading the PDF back has to guess word boundaries
|
|
183
|
+
* from x gaps, and at 8pt it guesses wrong. Merging emits the spaces.
|
|
184
|
+
*/
|
|
185
|
+
function drawText(d, sh) {
|
|
186
|
+
if (!sh.runs || !sh.runs.length) return;
|
|
187
|
+
for (const line of layoutRuns(sh)) {
|
|
188
|
+
let x = line.x, buf = "", bufX = x, st = null;
|
|
189
|
+
const flush = () => {
|
|
190
|
+
if (buf.trim()) d.text(buf, { x: bufX, y: line.baseline, stem: st.stem, size: st.size, color: st.color });
|
|
191
|
+
buf = "";
|
|
192
|
+
};
|
|
193
|
+
for (const p of line.parts) {
|
|
194
|
+
const same = st && st.stem === p.st.stem && st.size === p.st.size && st.color === p.st.color;
|
|
195
|
+
if (!same) { flush(); st = p.st; bufX = x; }
|
|
196
|
+
buf += p.text;
|
|
197
|
+
x += p.w;
|
|
198
|
+
}
|
|
199
|
+
flush();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = { renderTrace, layoutRuns, stemOf };
|