wand-decks-kit 0.9.0 → 0.11.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 +42 -0
- package/SKILL.md +58 -12
- package/assets/divider-art.png +0 -0
- package/build.js +13 -2
- package/docs/LAYOUTS.md +7 -3
- package/examples/session.json +420 -0
- package/examples/template.json +100 -0
- package/lib/layouts_consult.js +1 -1
- package/lib/pdf.js +31 -0
- package/lib/render_pdf.js +64 -0
- package/lib/schema.js +51 -3
- package/lib/trace.js +9 -4
- package/package.json +1 -1
- package/preview/preview.css +2 -0
- package/preview/preview.js +49 -1
- package/qa_coverage.py +7 -0
- package/tools/brandlint.py +3 -1
- package/wand.js +116 -2
- package/wand_kit.js +238 -37
package/examples/template.json
CHANGED
|
@@ -665,6 +665,106 @@
|
|
|
665
665
|
}
|
|
666
666
|
]
|
|
667
667
|
},
|
|
668
|
+
{
|
|
669
|
+
"layout": "flowCompare",
|
|
670
|
+
"tag": "Why three years of AI spend has not moved the operating line",
|
|
671
|
+
"titleText": "Every AI tool you own ends at a person.",
|
|
672
|
+
"lanes": [
|
|
673
|
+
{
|
|
674
|
+
"label": "Today — AI as a tool",
|
|
675
|
+
"caption": "Capacity = your people. Four of five steps wait on a person; that person is the ceiling.",
|
|
676
|
+
"steps": [
|
|
677
|
+
{
|
|
678
|
+
"who": "Person",
|
|
679
|
+
"step": "Notices",
|
|
680
|
+
"human": true
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
"who": "Person",
|
|
684
|
+
"step": "Gathers context",
|
|
685
|
+
"human": true
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
"who": "Person",
|
|
689
|
+
"step": "Instructs",
|
|
690
|
+
"human": true
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
"who": "AI",
|
|
694
|
+
"step": "Drafts"
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
"who": "Person",
|
|
698
|
+
"step": "Checks",
|
|
699
|
+
"human": true
|
|
700
|
+
}
|
|
701
|
+
]
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
"label": "With Wand — AI as labor",
|
|
705
|
+
"caption": "Capacity = people + agents. The person moves from every step to the one step that needs judgment.",
|
|
706
|
+
"steps": [
|
|
707
|
+
{
|
|
708
|
+
"who": "Agent",
|
|
709
|
+
"step": "Notices"
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
"who": "Agent",
|
|
713
|
+
"step": "Plans"
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
"who": "Agent",
|
|
717
|
+
"step": "Executes"
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
"who": "Agent",
|
|
721
|
+
"step": "Grades"
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
"who": "Manager",
|
|
725
|
+
"step": "Approves",
|
|
726
|
+
"human": true
|
|
727
|
+
}
|
|
728
|
+
]
|
|
729
|
+
}
|
|
730
|
+
]
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
"layout": "timeline",
|
|
734
|
+
"tag": "Transformation journey",
|
|
735
|
+
"titleText": "The transformation timeline.",
|
|
736
|
+
"subtitleText": "From the starting line, month over month, compounding.",
|
|
737
|
+
"phases": [
|
|
738
|
+
{
|
|
739
|
+
"when": "3–6 months",
|
|
740
|
+
"label": "Foundation",
|
|
741
|
+
"bullets": [
|
|
742
|
+
"A core transformation team of 7–10 people",
|
|
743
|
+
"One registry covering people and agents",
|
|
744
|
+
"5–10 core processes rebuilt for the dual workforce",
|
|
745
|
+
"Dozens of agents in production with named managers"
|
|
746
|
+
]
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
"when": "6–12 months",
|
|
750
|
+
"label": "Scale",
|
|
751
|
+
"bullets": [
|
|
752
|
+
"Agents across dozens of core functions",
|
|
753
|
+
"Management structure rebalanced; spans widened",
|
|
754
|
+
"Hundreds of agents in production"
|
|
755
|
+
]
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
"when": "12–24 months",
|
|
759
|
+
"label": "Accelerate",
|
|
760
|
+
"bullets": [
|
|
761
|
+
"Most processes designed for a dual workforce from the start",
|
|
762
|
+
"Whole functions run by agent teams",
|
|
763
|
+
"Cost per unit of work structurally below the market"
|
|
764
|
+
]
|
|
765
|
+
}
|
|
766
|
+
]
|
|
767
|
+
},
|
|
668
768
|
{
|
|
669
769
|
"layout": "closing",
|
|
670
770
|
"eyebrow": "THE CLOSE",
|
package/lib/layouts_consult.js
CHANGED
|
@@ -215,7 +215,7 @@ module.exports = function attach(W) {
|
|
|
215
215
|
});
|
|
216
216
|
s.addShape("roundRect", {
|
|
217
217
|
x: L + LAB, y: ry, w: COL, h: trh, rectRadius: radius(COL, trh),
|
|
218
|
-
fill: { color: dark ? T.cardMuted : T.surface }, line: {
|
|
218
|
+
fill: { color: dark ? T.cardMuted : T.surface }, line: { type: "none" },
|
|
219
219
|
});
|
|
220
220
|
s.addText(row.left, {
|
|
221
221
|
x: L + LAB + 0.18, y: ry, w: COL - 0.36, h: trh, margin: 0, isTextBox: true,
|
package/lib/pdf.js
CHANGED
|
@@ -112,6 +112,37 @@ class Doc {
|
|
|
112
112
|
return this;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* A filled triangle in its box, pointing up — or right when rotated 90°, the
|
|
117
|
+
* way a flow connector is drawn. Matches pptxgenjs's "triangle" + rotate.
|
|
118
|
+
*/
|
|
119
|
+
triangle({ x, y, w, h, fill, rotate = 0 }) {
|
|
120
|
+
const o = this.page.ops;
|
|
121
|
+
const pts = rotate === 90
|
|
122
|
+
? [[x, y], [x + w, y + h / 2], [x, y + h]]
|
|
123
|
+
: [[x, y + h], [x + w / 2, y], [x + w, y + h]];
|
|
124
|
+
o.push(`${rgb(fill || "000000")} rg`,
|
|
125
|
+
pts.map(([px, py], i) => `${num(px * PT)} ${num(this._y(py))} ${i ? "l" : "m"}`).join(" ") + " h f");
|
|
126
|
+
return this;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A filled polygon, optionally translucent — an area chart's fill. */
|
|
130
|
+
poly({ points, fill, opacity }) {
|
|
131
|
+
const o = this.page.ops;
|
|
132
|
+
const soft = opacity != null && opacity < 1;
|
|
133
|
+
if (soft) o.push("q", `/${this._alpha(opacity)} gs`);
|
|
134
|
+
o.push(`${rgb(fill || "000000")} rg`,
|
|
135
|
+
points.map(([px, py], i) => `${num(px * PT)} ${num(this._y(py))} ${i ? "l" : "m"}`).join(" ") + " h f");
|
|
136
|
+
if (soft) o.push("Q");
|
|
137
|
+
return this;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Width of a string in inches, for centring a label without drawing it. */
|
|
141
|
+
measure(str, { stem, size }) {
|
|
142
|
+
const F = this._font(stem);
|
|
143
|
+
return (F.font.layout(String(str)).advanceWidth / F.font.unitsPerEm) * size / 72;
|
|
144
|
+
}
|
|
145
|
+
|
|
115
146
|
line({ x1, y1, x2, y2, color = "000000", width = 0.75 }) {
|
|
116
147
|
this.page.ops.push(`${rgb(color)} RG`, `${num(width)} w`,
|
|
117
148
|
`${num(x1 * PT)} ${num(this._y(y1))} m ${num(x2 * PT)} ${num(this._y(y2))} l S`);
|
package/lib/render_pdf.js
CHANGED
|
@@ -140,6 +140,7 @@ function renderTrace(trace, out, opts = {}) {
|
|
|
140
140
|
if (sh.kind === "shape") drawShape(d, sh);
|
|
141
141
|
else if (sh.kind === "image") drawImage(d, sh, missing);
|
|
142
142
|
else if (sh.kind === "text") drawText(d, sh);
|
|
143
|
+
else if (sh.kind === "chart") drawChart(d, sh);
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
fs.writeFileSync(out, d.toBuffer());
|
|
@@ -152,6 +153,7 @@ function drawShape(d, sh) {
|
|
|
152
153
|
const stroke = sh.line && sh.line.width ? (sh.line.color || "000000").replace(/^#/, "") : null;
|
|
153
154
|
const box = { x: sh.x || 0, y: sh.y || 0, w: sh.w || 0, h: sh.h || 0 };
|
|
154
155
|
if (sh.type === "ellipse") return d.ellipse({ ...box, fill, stroke, lineWidth: (sh.line && sh.line.width) || 0.75 });
|
|
156
|
+
if (sh.type === "triangle") return d.triangle({ ...box, fill, rotate: sh.rotate || 0 });
|
|
155
157
|
d.rect({
|
|
156
158
|
...box, fill, stroke,
|
|
157
159
|
lineWidth: (sh.line && sh.line.width) || 0.75,
|
|
@@ -161,6 +163,68 @@ function drawShape(d, sh) {
|
|
|
161
163
|
});
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
/** Round up to a gridline-friendly maximum: 87 -> 100, 3.2 -> 4, 460 -> 500. */
|
|
167
|
+
function niceMax(v) {
|
|
168
|
+
if (!(v > 0)) return 1;
|
|
169
|
+
const p = Math.pow(10, Math.floor(Math.log10(v))), m = v / p;
|
|
170
|
+
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * p;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The chart as PowerPoint draws it: legend on top, four gridlines, category
|
|
175
|
+
* labels under the plot. Not pixel-identical to PowerPoint's own layout — close
|
|
176
|
+
* enough that a slide which reads here reads there.
|
|
177
|
+
*/
|
|
178
|
+
function drawChart(d, sh) {
|
|
179
|
+
const c = sh.chart || {}, labels = c.labels || [], series = c.series || [];
|
|
180
|
+
const stem = "Geist-Regular", sz = 7;
|
|
181
|
+
const legendH = c.legend ? 0.26 : 0, padL = 0.40, padB = 0.26;
|
|
182
|
+
const px = sh.x + padL, py = sh.y + legendH + 0.06, pw = sh.w - padL - 0.08, ph = sh.h - legendH - padB - 0.06;
|
|
183
|
+
const max = c.yMax || niceMax(Math.max(0, ...series.flatMap((x) => x.values || [])));
|
|
184
|
+
const fmt = (v) => (Math.round(v * 100) / 100).toString();
|
|
185
|
+
for (let k = 0; k <= 4; k++) {
|
|
186
|
+
const gy = py + ph - (ph * k) / 4;
|
|
187
|
+
d.line({ x1: px, y1: gy, x2: px + pw, y2: gy, color: c.grid || "5B4BB0", width: 0.5 });
|
|
188
|
+
const lab = fmt((max * k) / 4);
|
|
189
|
+
d.text(lab, { x: px - 0.08 - d.measure(lab, { stem, size: sz }), y: gy + 0.03, stem, size: sz, color: c.ink || "BBBBBB" });
|
|
190
|
+
}
|
|
191
|
+
const n = labels.length, bar = c.kind === "bar";
|
|
192
|
+
const xAt = (i) => bar ? px + (pw / n) * (i + 0.5) : px + (n > 1 ? (pw * i) / (n - 1) : pw / 2);
|
|
193
|
+
const yAt = (v) => py + ph - (ph * Math.min(v, max)) / max;
|
|
194
|
+
labels.forEach((l, i) => {
|
|
195
|
+
const w = d.measure(l, { stem, size: sz });
|
|
196
|
+
const lx = Math.min(Math.max(xAt(i) - w / 2, px), px + pw - w);
|
|
197
|
+
d.text(l, { x: lx, y: py + ph + 0.17, stem, size: sz, color: c.ink || "BBBBBB" });
|
|
198
|
+
});
|
|
199
|
+
series.forEach((ser, si) => {
|
|
200
|
+
const col = (c.colors && c.colors[si]) || "8B7CF6", vals = ser.values || [];
|
|
201
|
+
if (bar) {
|
|
202
|
+
const slot = pw / n, group = slot * 0.62, bw = group / series.length;
|
|
203
|
+
vals.forEach((v, i) => {
|
|
204
|
+
const bx = px + slot * i + (slot - group) / 2 + bw * si;
|
|
205
|
+
d.rect({ x: bx, y: yAt(v), w: bw * 0.92, h: py + ph - yAt(v), fill: col });
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (c.kind === "area") {
|
|
210
|
+
d.poly({ points: [[xAt(0), py + ph], ...vals.map((v, i) => [xAt(i), yAt(v)]), [xAt(vals.length - 1), py + ph]],
|
|
211
|
+
fill: col, opacity: 0.45 });
|
|
212
|
+
}
|
|
213
|
+
if (c.kind === "area") return; // PowerPoint's area chart has no outline
|
|
214
|
+
for (let i = 1; i < vals.length; i++) {
|
|
215
|
+
d.line({ x1: xAt(i - 1), y1: yAt(vals[i - 1]), x2: xAt(i), y2: yAt(vals[i]), color: col, width: ser.emph ? 2.5 : 2 });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
if (c.legend) {
|
|
219
|
+
let lx = px;
|
|
220
|
+
series.forEach((ser, si) => {
|
|
221
|
+
d.rect({ x: lx, y: sh.y + 0.08, w: 0.1, h: 0.1, fill: (c.colors && c.colors[si]) || "8B7CF6" });
|
|
222
|
+
d.text(ser.name, { x: lx + 0.15, y: sh.y + 0.17, stem, size: 8, color: c.ink || "BBBBBB" });
|
|
223
|
+
lx += 0.15 + d.measure(ser.name, { stem, size: 8 }) + 0.3;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
164
228
|
function drawImage(d, sh, missing) {
|
|
165
229
|
const box = { x: sh.x || 0, y: sh.y || 0, w: sh.w || 0, h: sh.h || 0 };
|
|
166
230
|
if (sh.data) { // a recoloured glyph, inline
|
package/lib/schema.js
CHANGED
|
@@ -27,6 +27,8 @@ const COMMON = {
|
|
|
27
27
|
subtitleText: str({ note: "the lede under the title" }),
|
|
28
28
|
deck: str({ note: "overrides the deck-level footer" }),
|
|
29
29
|
confidential: str(),
|
|
30
|
+
notes: s("notes", { note: 'speaker notes — a string, or { time: "00:21 to 00:25", core: true, text, watch }' }),
|
|
31
|
+
backup: s("boolean", { note: "a hidden slide after the closing; its footer says BACKUP" }),
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
const L = {
|
|
@@ -43,7 +45,8 @@ const L = {
|
|
|
43
45
|
divider: {
|
|
44
46
|
use: "Section breaks. Always dark, keeps the tag, drops the footer. Prose goes in `body`",
|
|
45
47
|
bleed: true,
|
|
46
|
-
fields: { eyebrow: str(), titleText: s("title", req()), body: str()
|
|
48
|
+
fields: { eyebrow: str(), titleText: s("title", req()), body: str(),
|
|
49
|
+
part: str({ note: '"Part II" — set before the body as "Part II · <body>"' }) },
|
|
47
50
|
},
|
|
48
51
|
closing: {
|
|
49
52
|
use: "Last slide. Always dark, centred, full bleed",
|
|
@@ -54,7 +57,7 @@ const L = {
|
|
|
54
57
|
/* ----------------------------------------------------- guideline (0.9) */
|
|
55
58
|
agenda: {
|
|
56
59
|
use: "What we'll cover — 2–5 numbered cards in a row",
|
|
57
|
-
fields: { items: s("object[]", req({ min: 2, max: 5, of: { title: str(req()), body: str() } })) },
|
|
60
|
+
fields: { items: s("object[]", req({ min: 2, max: 5, of: { title: str(req()), body: str(), meta: str({ note: '"15 min"' }) } })) },
|
|
58
61
|
},
|
|
59
62
|
team: {
|
|
60
63
|
use: "Who's in the room — 2–4 people, each with a headshot slot the user fills",
|
|
@@ -70,7 +73,33 @@ const L = {
|
|
|
70
73
|
},
|
|
71
74
|
coDelivery: {
|
|
72
75
|
use: "Up to six numbered rows in two columns — a co-delivery model, operating principles",
|
|
73
|
-
fields: { items: s("object[]", req({ min: 2, max: 6, of: { title: str(req()), body: str(req()) } })) },
|
|
76
|
+
fields: { items: s("object[]", req({ min: 2, max: 6, of: { title: str(req()), body: str(req()), meta: str({ note: '"15 min" — makes it the session agenda' }) } })) },
|
|
77
|
+
},
|
|
78
|
+
flowCompare: {
|
|
79
|
+
use: "Two lanes of steps — today vs with Wand. Mark the steps a person does `human: true`",
|
|
80
|
+
fields: { lanes: s("object[]", req({ min: 2, max: 2, of: {
|
|
81
|
+
label: str(req()), caption: str(),
|
|
82
|
+
steps: s("object[]", req({ min: 3, max: 5, of: { who: str(), step: str(req()), human: s("boolean") } })),
|
|
83
|
+
} })) },
|
|
84
|
+
},
|
|
85
|
+
chart: {
|
|
86
|
+
use: "A native chart the seller can edit in PowerPoint — line, area or bar — with up to two argument blocks beside or below it",
|
|
87
|
+
fields: {
|
|
88
|
+
kind: str({ note: '"line" (default), "area" or "bar"' }),
|
|
89
|
+
labels: s("string[]", req({ min: 2, max: 12, note: "the categories along the bottom" })),
|
|
90
|
+
series: s("object[]", req({ min: 1, max: 3, of: {
|
|
91
|
+
name: str(req()), values: s("number[]", req()), emph: s("boolean"),
|
|
92
|
+
} })),
|
|
93
|
+
yMax: s("number"), yLabel: str(),
|
|
94
|
+
side: s("object[]", { max: 2, of: { title: str(req()), body: str(req()) } }),
|
|
95
|
+
sidePlace: str({ note: '"right" (default) or "below"' }),
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
timeline: {
|
|
99
|
+
use: "A journey in 2–4 phases on a line — months, a label, bullets",
|
|
100
|
+
fields: { phases: s("object[]", req({ min: 2, max: 4, of: {
|
|
101
|
+
when: str(), label: str(req()), bullets: s("string[]", { max: 6 }),
|
|
102
|
+
} })) },
|
|
74
103
|
},
|
|
75
104
|
|
|
76
105
|
/* --------------------------------------------------------------- content */
|
|
@@ -320,8 +349,10 @@ const TYPEOF = {
|
|
|
320
349
|
boolean: (v) => typeof v === "boolean",
|
|
321
350
|
title: (v) => typeof v === "string" || (Array.isArray(v) && v.every((x) => typeof x === "string")),
|
|
322
351
|
"string[]": (v) => Array.isArray(v) && v.every((x) => typeof x === "string"),
|
|
352
|
+
"number[]": (v) => Array.isArray(v) && v.every((x) => typeof x === "number" && Number.isFinite(x)),
|
|
323
353
|
"object[]": (v) => Array.isArray(v) && v.every((x) => x && typeof x === "object" && !Array.isArray(x)),
|
|
324
354
|
object: (v) => v && typeof v === "object" && !Array.isArray(v),
|
|
355
|
+
notes: (v) => typeof v === "string" || (v && typeof v === "object" && !Array.isArray(v)),
|
|
325
356
|
};
|
|
326
357
|
|
|
327
358
|
function checkFields(defs, value, path, errs, warns) {
|
|
@@ -395,6 +426,23 @@ function validate(spec) {
|
|
|
395
426
|
seen.push(sl.layout);
|
|
396
427
|
});
|
|
397
428
|
|
|
429
|
+
// a chart whose series do not match its labels draws a line that ends early
|
|
430
|
+
spec.slides.forEach((x, i) => {
|
|
431
|
+
if (x.layout !== "chart" || !Array.isArray(x.labels) || !Array.isArray(x.series)) return;
|
|
432
|
+
x.series.forEach((ser) => {
|
|
433
|
+
if (Array.isArray(ser.values) && ser.values.length !== x.labels.length)
|
|
434
|
+
errs.push(`S${i + 1} chart series "${ser.name}" has ${ser.values.length} values for ${x.labels.length} labels`);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// backups live after the close — hidden, so they never interrupt the room
|
|
439
|
+
const closeAt = spec.slides.findIndex((x) => x.layout === "closing");
|
|
440
|
+
spec.slides.forEach((x, i) => {
|
|
441
|
+
if (x.backup && closeAt >= 0 && i < closeAt) warns.push(`S${i + 1} is a backup slide before the closing — move it after S${closeAt + 1}`);
|
|
442
|
+
if (x.notes && typeof x.notes === "object" && x.notes.time && !/\d\d:\d\d/.test(x.notes.time))
|
|
443
|
+
warns.push(`S${i + 1} notes.time "${x.notes.time}" is not a time window — use "00:21 to 00:25"`);
|
|
444
|
+
});
|
|
445
|
+
|
|
398
446
|
// §5: vary the layouts, alternate the themes
|
|
399
447
|
const distinct = new Set(seen).size;
|
|
400
448
|
if (spec.slides.length >= 10 && distinct < 8) {
|
package/lib/trace.js
CHANGED
|
@@ -18,7 +18,12 @@ function fakeSlide() {
|
|
|
18
18
|
addImage(o) { shapes.push({ kind: "image", ...o }); return api; },
|
|
19
19
|
addText(t, o) { shapes.push({ kind: "text", runs: normaliseRuns(t), ...o }); return api; },
|
|
20
20
|
addNotes() { return api; },
|
|
21
|
-
|
|
21
|
+
// a chart is recorded by what it shows, so the PDF and preview can draw
|
|
22
|
+
// the same thing PowerPoint will
|
|
23
|
+
addChart(type, data, o = {}) {
|
|
24
|
+
shapes.push({ kind: "chart", type, x: o.x, y: o.y, w: o.w, h: o.h, chart: o._wand || { kind: type, series: data } });
|
|
25
|
+
return api;
|
|
26
|
+
},
|
|
22
27
|
};
|
|
23
28
|
Object.defineProperty(api, "background", {
|
|
24
29
|
get() { return api._bg; },
|
|
@@ -52,7 +57,7 @@ function fakePres() {
|
|
|
52
57
|
function trace(W, spec) {
|
|
53
58
|
const pres = fakePres();
|
|
54
59
|
pres.layout = "LAYOUT_16x9";
|
|
55
|
-
const total = spec.slides.length;
|
|
60
|
+
const total = spec.slides.filter((x) => !x.backup).length;
|
|
56
61
|
let n = 0;
|
|
57
62
|
const errors = [];
|
|
58
63
|
|
|
@@ -63,12 +68,12 @@ function trace(W, spec) {
|
|
|
63
68
|
return;
|
|
64
69
|
}
|
|
65
70
|
const fullBleed = ["cover", "divider", "closing"].includes(sl.layout);
|
|
66
|
-
n
|
|
67
|
-
const args = { ...sl, n, total };
|
|
71
|
+
const args = { ...sl, n: sl.backup ? null : ++n, total };
|
|
68
72
|
if (!fullBleed) {
|
|
69
73
|
args.deck = sl.deck || spec.deck;
|
|
70
74
|
args.confidential = sl.confidential || spec.confidential;
|
|
71
75
|
}
|
|
76
|
+
W.context({ confidential: args.confidential || "STRICTLY CONFIDENTIAL" });
|
|
72
77
|
try { fn(pres, args); }
|
|
73
78
|
catch (e) { errors.push(`slide ${i + 1} (${sl.layout}): ${e.message}`); }
|
|
74
79
|
});
|
package/package.json
CHANGED
package/preview/preview.css
CHANGED
|
@@ -90,6 +90,8 @@ header input[type="range"] { width: 120px; accent-color: var(--accent); }
|
|
|
90
90
|
|
|
91
91
|
.sh.rect { border-radius: 0; }
|
|
92
92
|
.sh.roundRect { border-radius: calc(var(--in) * .10); }
|
|
93
|
+
/* flow connectors: pptxgenjs triangle rotated 90 points right */
|
|
94
|
+
.sh.triangle { clip-path: polygon(0 0, 100% 50%, 0 100%); }
|
|
93
95
|
.sh.ellipse { border-radius: 50%; }
|
|
94
96
|
|
|
95
97
|
.sh.image { object-fit: contain; }
|
package/preview/preview.js
CHANGED
|
@@ -95,7 +95,10 @@
|
|
|
95
95
|
|
|
96
96
|
const s = document.createElement("div");
|
|
97
97
|
const bg = sl.background && sl.background.color;
|
|
98
|
-
|
|
98
|
+
// by luminance, not by one hex: the dark ground moved from 0F0C26 to
|
|
99
|
+
// 240F48 in 0.9 and every dark slide previewed as light
|
|
100
|
+
const lum = bg ? (parseInt(bg.slice(0, 2), 16) * 299 + parseInt(bg.slice(2, 4), 16) * 587 + parseInt(bg.slice(4, 6), 16) * 114) / 1000 : 255;
|
|
101
|
+
s.className = "slide " + (lum < 90 ? "dark" : "light");
|
|
99
102
|
if (flag.checked) s.classList.add("flag");
|
|
100
103
|
if (grid.checked) s.classList.add("grid");
|
|
101
104
|
s.title = sl.layout;
|
|
@@ -109,6 +112,8 @@
|
|
|
109
112
|
el.src = sh.data ? "data:" + sh.data
|
|
110
113
|
: sh.path ? (T.assetsBase || "../assets/") + sh.path.split(/[\\/]assets[\\/]/).pop()
|
|
111
114
|
: "";
|
|
115
|
+
} else if (sh.kind === "chart") {
|
|
116
|
+
el = chartEl(sh);
|
|
112
117
|
} else if (sh.kind === "text") {
|
|
113
118
|
el = textEl(sh);
|
|
114
119
|
} else {
|
|
@@ -145,6 +150,49 @@
|
|
|
145
150
|
});
|
|
146
151
|
}
|
|
147
152
|
|
|
153
|
+
/** The same chart the PDF draws, as SVG in inches. */
|
|
154
|
+
function chartEl(sh) {
|
|
155
|
+
const c = sh.chart || {}, labels = c.labels || [], series = c.series || [];
|
|
156
|
+
const NS = "http://www.w3.org/2000/svg", W = sh.w, H = sh.h;
|
|
157
|
+
const svg = document.createElementNS(NS, "svg");
|
|
158
|
+
svg.setAttribute("class", "sh chart"); svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
|
159
|
+
const add = (tag, a, txt) => { const e = document.createElementNS(NS, tag); for (const k in a) e.setAttribute(k, a[k]); if (txt != null) e.textContent = txt; svg.appendChild(e); return e; };
|
|
160
|
+
const legendH = c.legend ? 0.26 : 0, padL = 0.40, padB = 0.26;
|
|
161
|
+
const px = padL, py = legendH + 0.06, pw = W - padL - 0.08, ph = H - legendH - padB - 0.06;
|
|
162
|
+
const all = series.flatMap((x) => x.values || []);
|
|
163
|
+
const nice = (v) => { if (!(v > 0)) return 1; const p = Math.pow(10, Math.floor(Math.log10(v))), m = v / p; return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * p; };
|
|
164
|
+
const max = c.yMax || nice(Math.max(0, ...all)), ink = "#" + (c.ink || "BBBBBB"), grid = "#" + (c.grid || "5B4BB0");
|
|
165
|
+
for (let k = 0; k <= 4; k++) {
|
|
166
|
+
const gy = py + ph - (ph * k) / 4;
|
|
167
|
+
add("line", { x1: px, y1: gy, x2: px + pw, y2: gy, stroke: grid, "stroke-width": 0.007 });
|
|
168
|
+
add("text", { x: px - 0.08, y: gy + 0.03, "text-anchor": "end", "font-size": 0.1, fill: ink, "font-family": "Geist" }, String(Math.round((max * k) / 4 * 100) / 100));
|
|
169
|
+
}
|
|
170
|
+
const n = labels.length, bar = c.kind === "bar";
|
|
171
|
+
const xAt = (i) => bar ? px + (pw / n) * (i + 0.5) : px + (n > 1 ? (pw * i) / (n - 1) : pw / 2);
|
|
172
|
+
const yAt = (v) => py + ph - (ph * Math.min(v, max)) / max;
|
|
173
|
+
labels.forEach((l, i) => add("text", { x: xAt(i), y: py + ph + 0.17, "text-anchor": "middle", "font-size": 0.1, fill: ink, "font-family": "Geist" }, l));
|
|
174
|
+
series.forEach((ser, si) => {
|
|
175
|
+
const col = "#" + ((c.colors && c.colors[si]) || "8B7CF6"), vals = ser.values || [];
|
|
176
|
+
if (bar) {
|
|
177
|
+
const slot = pw / n, group = slot * 0.62, bw = group / series.length;
|
|
178
|
+
vals.forEach((v, i) => add("rect", { x: px + slot * i + (slot - group) / 2 + bw * si, y: yAt(v), width: bw * 0.92, height: py + ph - yAt(v), fill: col }));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const pts = vals.map((v, i) => `${xAt(i)},${yAt(v)}`).join(" ");
|
|
182
|
+
if (c.kind === "area") add("polygon", { points: `${xAt(0)},${py + ph} ${pts} ${xAt(vals.length - 1)},${py + ph}`, fill: col, "fill-opacity": 0.45 });
|
|
183
|
+
if (c.kind !== "area") add("polyline", { points: pts, fill: "none", stroke: col, "stroke-width": ser.emph ? 0.035 : 0.028 });
|
|
184
|
+
});
|
|
185
|
+
if (c.legend) {
|
|
186
|
+
let lx = px;
|
|
187
|
+
series.forEach((ser, si) => {
|
|
188
|
+
add("rect", { x: lx, y: 0.08, width: 0.1, height: 0.1, fill: "#" + ((c.colors && c.colors[si]) || "8B7CF6") });
|
|
189
|
+
add("text", { x: lx + 0.15, y: 0.17, "font-size": 0.11, fill: ink, "font-family": "Geist" }, ser.name);
|
|
190
|
+
lx += 0.15 + ser.name.length * 0.065 + 0.3;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return svg;
|
|
194
|
+
}
|
|
195
|
+
|
|
148
196
|
/** Browser-side overflow detection: does the laid-out text exceed its box? */
|
|
149
197
|
function markOverflow(slideEl) {
|
|
150
198
|
slideEl.querySelectorAll(".sh.text").forEach((el) => {
|
package/qa_coverage.py
CHANGED
|
@@ -62,6 +62,13 @@ def strings_from_pptx(path):
|
|
|
62
62
|
paras.append("".join(runs))
|
|
63
63
|
if paras:
|
|
64
64
|
out.append((i, " ".join(paras)))
|
|
65
|
+
# Chart series names and category labels live in ppt/charts, not on the
|
|
66
|
+
# slide. A rebuilt chart that drops a series name must still fail here.
|
|
67
|
+
for n in sorted(x for x in z.namelist() if re.fullmatch(r"ppt/charts/chart\d+\.xml", x)):
|
|
68
|
+
vals = re.findall(r"<c:v>([^<]*)</c:v>|<a:t>([^<]*)</a:t>", z.read(n).decode("utf8", "ignore"))
|
|
69
|
+
words = [a or b for a, b in vals if (a or b).strip() and not re.fullmatch(r"[-\d.,%]+", a or b)]
|
|
70
|
+
if words:
|
|
71
|
+
out.append((0, " ".join(words)))
|
|
65
72
|
return out
|
|
66
73
|
|
|
67
74
|
|
package/tools/brandlint.py
CHANGED
|
@@ -23,7 +23,9 @@ z = zipfile.ZipFile(deck)
|
|
|
23
23
|
names = sorted((n for n in z.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", n)),
|
|
24
24
|
key=lambda n: int(re.findall(r"\d+", n)[0]))
|
|
25
25
|
pres = z.read("ppt/presentation.xml").decode()
|
|
26
|
-
|
|
26
|
+
# attribute order varies by writer — the Kearney deck writes cy before cx
|
|
27
|
+
tag = re.search(r"<p:sldSz[^>]*>", pres).group(0)
|
|
28
|
+
cx, cy = (int(re.search(rf'{k}="(\d+)"', tag).group(1)) for k in ("cx", "cy"))
|
|
27
29
|
canvas_ok = abs(cx / 914400 - 10) < 0.01 and abs(cy / 914400 - 5.625) < 0.01
|
|
28
30
|
|
|
29
31
|
bad = 0
|
package/wand.js
CHANGED
|
@@ -10,11 +10,14 @@
|
|
|
10
10
|
* node wand.js doctor what works here, and what does not
|
|
11
11
|
* node wand.js rebuild source.pptx phases 0-3: extract, then the worksheet
|
|
12
12
|
* node wand.js layouts [name] the catalog, with every field
|
|
13
|
-
|
|
13
|
+
* node wand.js glyphs [search] the named icon set
|
|
14
14
|
* node wand.js validate work/deck.json fields and counts, before the build
|
|
15
15
|
* node wand.js preview work/deck.json browser preview at the 10in grid
|
|
16
16
|
* node wand.js ship work/deck.json build .pptx and .pdf, then every gate
|
|
17
|
-
|
|
17
|
+
* node wand.js show deck.pptx [n] one line per slide, or slide n's fields
|
|
18
|
+
* node wand.js edit deck.pptx --set 4.titleText="..." patch a kit-built deck, rebuild, gate
|
|
19
|
+
* node wand.js audit any.pptx every spec-free gate, incl. brand, on any deck
|
|
20
|
+
* node wand.js spec deck.pptx read back the spec a built deck carries
|
|
18
21
|
*
|
|
19
22
|
* `rebuild` and `ship` are the two that matter. Everything else is a stage of
|
|
20
23
|
* one of them, exposed so a failure can be re-run on its own.
|
|
@@ -254,6 +257,100 @@ function ship(specPath, outArg) {
|
|
|
254
257
|
return gates.some(([, ok]) => ok === false) ? 1 : 0;
|
|
255
258
|
}
|
|
256
259
|
|
|
260
|
+
/* --------------------------------------------------------------- show/edit */
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Why these exist: a salesperson's edits are small — a date, a name, a title —
|
|
264
|
+
* and the old path cost two full copies of the spec in the context (read it,
|
|
265
|
+
* write it back) plus a rebuild. A 40 KB spec is ~10K tokens each way.
|
|
266
|
+
* `show` prints one line per slide, or one slide's fields; `edit` patches the
|
|
267
|
+
* spec the deck carries inside itself and ships. An edit costs a command line.
|
|
268
|
+
*/
|
|
269
|
+
function specOf(file) {
|
|
270
|
+
const f = path.resolve(file);
|
|
271
|
+
if (f.endsWith(".json")) return JSON.parse(fs.readFileSync(f, "utf8"));
|
|
272
|
+
const z = readZip(f);
|
|
273
|
+
if (!z.has("wand/spec.json")) die(`${rel(f)} carries no spec — it was not built by this kit.\n` +
|
|
274
|
+
"Edit it directly with the pptx tools, then run `node wand.js audit` on it (SKILL.md, \"Edit a deck they attached\").");
|
|
275
|
+
return JSON.parse(z.text("wand/spec.json"));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function oneLine(sl) {
|
|
279
|
+
// titleText arrays are runs of one line; titleLines are separate lines
|
|
280
|
+
const t = sl.titleText || sl.titleLines || sl.line1 || "";
|
|
281
|
+
const title = (Array.isArray(t) ? t.join(sl.titleLines && !sl.titleText ? " " : "") : String(t)).replace(/\s+/g, " ");
|
|
282
|
+
const bits = [sl.backup ? "BACKUP" : null, sl.notes ? "notes" : null, sl.dark === false ? "light" : null].filter(Boolean);
|
|
283
|
+
return `${sl.layout.padEnd(15)} ${title.slice(0, 64)}${bits.length ? " [" + bits.join(", ") + "]" : ""}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function show(file, which) {
|
|
287
|
+
const spec = specOf(file);
|
|
288
|
+
if (which == null) {
|
|
289
|
+
console.log(`${spec.deck || "(untitled)"} — ${spec.slides.length} slides`);
|
|
290
|
+
spec.slides.forEach((sl, i) => console.log(` ${String(i + 1).padStart(2)} ${oneLine(sl)}`));
|
|
291
|
+
console.log("\nnode wand.js show <deck> <n> for one slide's fields");
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
const sl = spec.slides[Number(which) - 1];
|
|
295
|
+
if (!sl) die(`no slide ${which} — the deck has ${spec.slides.length}`);
|
|
296
|
+
console.log(JSON.stringify(sl, null, 1));
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** "4.items.2.body" sets slide 4, item 2, body. 1-based, the way the room counts. */
|
|
301
|
+
function setPath(spec, dotted, value) {
|
|
302
|
+
const [n, ...keys] = dotted.split(".");
|
|
303
|
+
let node = spec.slides[Number(n) - 1];
|
|
304
|
+
if (!node) die(`--set ${dotted}: no slide ${n}`);
|
|
305
|
+
if (!keys.length) die(`--set ${dotted}: name a field, e.g. ${n}.titleText`);
|
|
306
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
307
|
+
const k = /^\d+$/.test(keys[i]) ? Number(keys[i]) - 1 : keys[i];
|
|
308
|
+
if (node[k] == null) node[k] = /^\d+$/.test(keys[i + 1]) ? [] : {};
|
|
309
|
+
node = node[k];
|
|
310
|
+
}
|
|
311
|
+
const last = keys[keys.length - 1];
|
|
312
|
+
node[/^\d+$/.test(last) ? Number(last) - 1 : last] = value;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Deck_v2.pptx -> Deck_v3.pptx; Deck.pptx -> Deck_v2.pptx. Never overwrite the input. */
|
|
316
|
+
function nextVersion(file) {
|
|
317
|
+
const ext = path.extname(file), base = file.slice(0, -ext.length);
|
|
318
|
+
const m = /_v(\d+)$/.exec(base);
|
|
319
|
+
return m ? `${base.slice(0, m.index)}_v${Number(m[1]) + 1}${ext}` : `${base}_v2${ext}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function edit(file, args) {
|
|
323
|
+
const spec = specOf(file);
|
|
324
|
+
const changed = [];
|
|
325
|
+
for (let i = 0; i < args.length; i++) {
|
|
326
|
+
const a = args[i];
|
|
327
|
+
if (a === "--set") {
|
|
328
|
+
const arg = args[++i] || "", eq = arg.indexOf("=");
|
|
329
|
+
if (eq < 1) die(`--set wants N.path=value, got "${arg}"`);
|
|
330
|
+
const key = arg.slice(0, eq), raw = arg.slice(eq + 1);
|
|
331
|
+
let val; try { val = JSON.parse(raw); } catch { val = raw; } // arrays, numbers, booleans parse; prose stays prose
|
|
332
|
+
setPath(spec, key, val); changed.push(`set ${key}`);
|
|
333
|
+
} else if (a === "--del") {
|
|
334
|
+
const n = Number(args[++i]); spec.slides.splice(n - 1, 1); changed.push(`deleted S${n}`);
|
|
335
|
+
} else if (a === "--move") {
|
|
336
|
+
const from = Number(args[++i]), to = Number(args[++i]);
|
|
337
|
+
const [sl] = spec.slides.splice(from - 1, 1); spec.slides.splice(to - 1, 0, sl); changed.push(`moved S${from} to ${to}`);
|
|
338
|
+
} else if (a === "--add") {
|
|
339
|
+
const at = Number(args[++i]); spec.slides.splice(at - 1, 0, JSON.parse(args[++i])); changed.push(`added S${at}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (!changed.length) die("nothing to do — pass --set, --del, --move or --add");
|
|
343
|
+
console.log(changed.map((c) => " " + c).join("\n"));
|
|
344
|
+
const work = path.join(path.dirname(path.resolve(file)), "work");
|
|
345
|
+
fs.mkdirSync(work, { recursive: true });
|
|
346
|
+
const specPath = path.join(work, "deck.json");
|
|
347
|
+
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
|
|
348
|
+
if (args.includes("--dry")) { show(specPath); return validate(specPath) ? 1 : 0; }
|
|
349
|
+
const oi = args.indexOf("-o");
|
|
350
|
+
const out = oi >= 0 ? path.resolve(args[oi + 1]) : nextVersion(path.resolve(file));
|
|
351
|
+
return ship(specPath, out);
|
|
352
|
+
}
|
|
353
|
+
|
|
257
354
|
/**
|
|
258
355
|
* Every gate that does not need a spec, on any .pptx.
|
|
259
356
|
*
|
|
@@ -359,6 +456,13 @@ function inspect(file) {
|
|
|
359
456
|
errs.push('fonts embedded but saveSubsetFonts is not "0" - digits garble in exported PDFs');
|
|
360
457
|
}
|
|
361
458
|
if (!fonts.length) errs.push("no embedded fonts - this renders as Arial on the client's machine");
|
|
459
|
+
// pptxgenjs writes its default outline — 1pt #333333 — whenever a shape asks
|
|
460
|
+
// for `line: { width: 0 }`. No Wand token is 333333, so this is always a leak.
|
|
461
|
+
let leaks = 0;
|
|
462
|
+
for (const name of z.names.filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n))) {
|
|
463
|
+
leaks += (z.text(name).match(/<a:ln w="12700"><a:solidFill><a:srgbClr val="333333"\/>/g) || []).length;
|
|
464
|
+
}
|
|
465
|
+
if (leaks) errs.push(`${leaks} shape(s) carry pptxgenjs's default 1pt #333333 outline — a layout used line:{width:0}; use line:{type:"none"}`);
|
|
362
466
|
errs.forEach((e) => console.log(` ERROR ${e}`));
|
|
363
467
|
console.log(`file : ${slides.length} slides, ${media.length} media, ${fonts.length} font parts / ` +
|
|
364
468
|
`${families} families` + (errs.length ? ` - ${errs.length} problem(s) above` : " - clean"));
|
|
@@ -486,6 +590,16 @@ to.
|
|
|
486
590
|
process.exit(0);
|
|
487
591
|
}
|
|
488
592
|
|
|
593
|
+
case "show": {
|
|
594
|
+
if (!positional[0]) die("usage: node wand.js show <deck.pptx|deck.json> [slide]");
|
|
595
|
+
process.exit(show(positional[0], positional[1]));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
case "edit": {
|
|
599
|
+
if (!positional[0]) die("usage: node wand.js edit <deck.pptx> --set N.path=value [--del N] [--move N M] [--add N '<json>'] [-o out.pptx] [--dry]");
|
|
600
|
+
process.exit(edit(positional[0], rest));
|
|
601
|
+
}
|
|
602
|
+
|
|
489
603
|
case "inspect":
|
|
490
604
|
if (!positional[0]) die("usage: node wand.js inspect <deck.pptx>");
|
|
491
605
|
process.exit(inspect(path.resolve(positional[0])) ? 1 : 0);
|