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.
Files changed (170) hide show
  1. package/PROMPT.md +461 -0
  2. package/README.md +137 -0
  3. package/START-HERE.md +98 -0
  4. package/assets/README.md +40 -0
  5. package/assets/_icon-contact-sheet.png +0 -0
  6. package/assets/bg-cover-dark.png +0 -0
  7. package/assets/bg-overlay-dark.png +0 -0
  8. package/assets/bg-statement-dark.png +0 -0
  9. package/assets/glyphs/README.md +29 -0
  10. package/assets/glyphs/activity.svg +15 -0
  11. package/assets/glyphs/atom.svg +17 -0
  12. package/assets/glyphs/badge-check.svg +16 -0
  13. package/assets/glyphs/bell.svg +16 -0
  14. package/assets/glyphs/blocks.svg +16 -0
  15. package/assets/glyphs/book-open.svg +16 -0
  16. package/assets/glyphs/bot.svg +20 -0
  17. package/assets/glyphs/boxes.svg +26 -0
  18. package/assets/glyphs/brain.svg +23 -0
  19. package/assets/glyphs/briefcase.svg +16 -0
  20. package/assets/glyphs/building-2.svg +21 -0
  21. package/assets/glyphs/calendar.svg +18 -0
  22. package/assets/glyphs/chart-column.svg +18 -0
  23. package/assets/glyphs/chart-line.svg +16 -0
  24. package/assets/glyphs/chart-pie.svg +16 -0
  25. package/assets/glyphs/circle-check.svg +16 -0
  26. package/assets/glyphs/clipboard-check.svg +17 -0
  27. package/assets/glyphs/clock.svg +16 -0
  28. package/assets/glyphs/cloud.svg +15 -0
  29. package/assets/glyphs/coins.svg +18 -0
  30. package/assets/glyphs/compass.svg +16 -0
  31. package/assets/glyphs/cpu.svg +24 -0
  32. package/assets/glyphs/database.svg +17 -0
  33. package/assets/glyphs/eye.svg +16 -0
  34. package/assets/glyphs/file-check.svg +17 -0
  35. package/assets/glyphs/filter.svg +15 -0
  36. package/assets/glyphs/fingerprint.svg +23 -0
  37. package/assets/glyphs/flag.svg +16 -0
  38. package/assets/glyphs/gauge.svg +16 -0
  39. package/assets/glyphs/gavel.svg +19 -0
  40. package/assets/glyphs/git-branch.svg +18 -0
  41. package/assets/glyphs/globe.svg +17 -0
  42. package/assets/glyphs/graduation-cap.svg +17 -0
  43. package/assets/glyphs/handshake.svg +19 -0
  44. package/assets/glyphs/hexagon.svg +15 -0
  45. package/assets/glyphs/index.json +175 -0
  46. package/assets/glyphs/infinity.svg +15 -0
  47. package/assets/glyphs/key.svg +17 -0
  48. package/assets/glyphs/landmark.svg +20 -0
  49. package/assets/glyphs/layers.svg +17 -0
  50. package/assets/glyphs/lightbulb.svg +17 -0
  51. package/assets/glyphs/link.svg +16 -0
  52. package/assets/glyphs/list-checks.svg +19 -0
  53. package/assets/glyphs/lock.svg +16 -0
  54. package/assets/glyphs/map.svg +17 -0
  55. package/assets/glyphs/megaphone.svg +16 -0
  56. package/assets/glyphs/message-square.svg +15 -0
  57. package/assets/glyphs/milestone.svg +17 -0
  58. package/assets/glyphs/network.svg +19 -0
  59. package/assets/glyphs/orbit.svg +19 -0
  60. package/assets/glyphs/package.svg +18 -0
  61. package/assets/glyphs/plug.svg +18 -0
  62. package/assets/glyphs/puzzle.svg +15 -0
  63. package/assets/glyphs/radar.svg +22 -0
  64. package/assets/glyphs/receipt.svg +17 -0
  65. package/assets/glyphs/refresh-cw.svg +18 -0
  66. package/assets/glyphs/rocket.svg +18 -0
  67. package/assets/glyphs/route.svg +17 -0
  68. package/assets/glyphs/scale.svg +19 -0
  69. package/assets/glyphs/search.svg +16 -0
  70. package/assets/glyphs/server.svg +18 -0
  71. package/assets/glyphs/settings.svg +16 -0
  72. package/assets/glyphs/share-2.svg +19 -0
  73. package/assets/glyphs/shield-check.svg +16 -0
  74. package/assets/glyphs/shuffle.svg +19 -0
  75. package/assets/glyphs/sparkles.svg +19 -0
  76. package/assets/glyphs/square-stack.svg +17 -0
  77. package/assets/glyphs/target.svg +17 -0
  78. package/assets/glyphs/trending-up.svg +16 -0
  79. package/assets/glyphs/triangle-alert.svg +17 -0
  80. package/assets/glyphs/trophy.svg +20 -0
  81. package/assets/glyphs/users.svg +18 -0
  82. package/assets/glyphs/wallet.svg +16 -0
  83. package/assets/glyphs/wand-sparkles.svg +22 -0
  84. package/assets/glyphs/workflow.svg +17 -0
  85. package/assets/glyphs/zap.svg +15 -0
  86. package/assets/icons/icon-11.png +0 -0
  87. package/assets/icons/icon-12.png +0 -0
  88. package/assets/icons/icon-13.png +0 -0
  89. package/assets/icons/icon-14.png +0 -0
  90. package/assets/icons/icon-15.png +0 -0
  91. package/assets/icons/icon-16.png +0 -0
  92. package/assets/icons/icon-17.png +0 -0
  93. package/assets/icons/icon-18.png +0 -0
  94. package/assets/icons/icon-20.png +0 -0
  95. package/assets/icons/icon-23.png +0 -0
  96. package/assets/icons/icon-24.png +0 -0
  97. package/assets/icons/icon-25.png +0 -0
  98. package/assets/icons/icon-27.png +0 -0
  99. package/assets/icons/icon-28.png +0 -0
  100. package/assets/icons/icon-30.png +0 -0
  101. package/assets/icons/icon-31.png +0 -0
  102. package/assets/icons/icon-32.png +0 -0
  103. package/assets/icons/icon-33.png +0 -0
  104. package/assets/icons/icon-34.png +0 -0
  105. package/assets/icons/icon-35.png +0 -0
  106. package/assets/icons/icon-36.png +0 -0
  107. package/assets/icons/icon-37.png +0 -0
  108. package/assets/icons/icon-39.png +0 -0
  109. package/assets/icons/icon-40.png +0 -0
  110. package/assets/icons/icon-41.png +0 -0
  111. package/assets/icons/icon-43.png +0 -0
  112. package/assets/icons/icon-44.png +0 -0
  113. package/assets/icons/icon-45.png +0 -0
  114. package/assets/icons/icon-46.png +0 -0
  115. package/assets/icons/icon-48.png +0 -0
  116. package/assets/icons/icon-49.png +0 -0
  117. package/assets/icons/icon-50.png +0 -0
  118. package/assets/icons/icon-51.png +0 -0
  119. package/assets/icons/icon-52.png +0 -0
  120. package/assets/icons/icon-53.png +0 -0
  121. package/assets/icons/icon-9.png +0 -0
  122. package/assets/mark-dark.png +0 -0
  123. package/assets/mark-white.png +0 -0
  124. package/assets/orb-cover.png +0 -0
  125. package/assets/wordmark-dark.png +0 -0
  126. package/assets/wordmark-white.png +0 -0
  127. package/build.js +192 -0
  128. package/build_example.js +91 -0
  129. package/build_infinite.js +283 -0
  130. package/docs/LAYOUTS.md +86 -0
  131. package/docs/PIPELINE.md +116 -0
  132. package/docs/SPEC.md +89 -0
  133. package/embed_fonts.py +193 -0
  134. package/examples/template.json +164 -0
  135. package/fonts/Geist-Bold.ttf +0 -0
  136. package/fonts/Geist-BoldItalic.ttf +0 -0
  137. package/fonts/Geist-ExtraBold.ttf +0 -0
  138. package/fonts/Geist-ExtraBoldItalic.ttf +0 -0
  139. package/fonts/Geist-Italic.ttf +0 -0
  140. package/fonts/Geist-Light.ttf +0 -0
  141. package/fonts/Geist-LightItalic.ttf +0 -0
  142. package/fonts/Geist-Medium.ttf +0 -0
  143. package/fonts/Geist-MediumItalic.ttf +0 -0
  144. package/fonts/Geist-Regular.ttf +0 -0
  145. package/fonts/Geist-SemiBold.ttf +0 -0
  146. package/fonts/Geist-SemiBoldItalic.ttf +0 -0
  147. package/lib/README.md +23 -0
  148. package/lib/brand.js +51 -0
  149. package/lib/glyphs.js +87 -0
  150. package/lib/layouts_client.js +212 -0
  151. package/lib/layouts_consult.js +301 -0
  152. package/lib/layouts_dense.js +361 -0
  153. package/lib/measure.js +109 -0
  154. package/lib/pdf.js +517 -0
  155. package/lib/plan.js +305 -0
  156. package/lib/render_pdf.js +203 -0
  157. package/lib/schema.js +399 -0
  158. package/lib/shared.js +15 -0
  159. package/lib/trace.js +88 -0
  160. package/lib/ttf.py +167 -0
  161. package/lib/wandxml.py +37 -0
  162. package/package.json +17 -0
  163. package/preview/preview.css +112 -0
  164. package/preview/preview.html +24 -0
  165. package/preview/preview.js +169 -0
  166. package/qa_coverage.py +120 -0
  167. package/requirements.txt +9 -0
  168. package/tools/extract.py +199 -0
  169. package/wand.js +397 -0
  170. package/wand_kit.js +1016 -0
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ extract.py — Phase 0 + Phase 1 in one pass.
4
+
5
+ python tools/extract.py source.pptx -o work/
6
+
7
+ Writes into the output directory:
8
+ visible_dump.txt every verbatim string, one per line — the Phase 4 contract
9
+ all_text.json per-slide, per-frame, per-paragraph structure
10
+ geometry.txt every shape, normalised to the 10" x 5.625" grid
11
+ deck.skeleton.json a spec stub with one entry per slide, layout: "TODO"
12
+
13
+ Why normalisation matters: source decks are usually 13.333" x 7.5" (Google
14
+ Slides / PowerPoint widescreen default). The Wand system is 10" x 5.625". Every
15
+ measurement taken off a source deck must be divided by the scale factor before
16
+ it means anything, and doing that by hand is where errors come from.
17
+ """
18
+
19
+ import argparse, json, os, re, sys, zipfile
20
+
21
+ def _brand_lib():
22
+ """brand/lib in the repo, or lib/ beside the kit once packaged."""
23
+ import os
24
+ here = os.path.dirname(os.path.abspath(__file__))
25
+ for d in filter(None, [os.environ.get("WAND_BRAND_DIR"),
26
+ os.path.join(here, "lib"),
27
+ os.path.join(here, "..", "lib"),
28
+ os.path.join(here, "..", "..", "..", "..", "brand", "lib"),
29
+ os.path.join(here, "..", "..", "..", "brand", "lib")]):
30
+ if os.path.isfile(os.path.join(d, "wandxml.py")):
31
+ return os.path.normpath(d)
32
+ raise SystemExit("brand/lib not found")
33
+
34
+ sys.path.insert(0, _brand_lib())
35
+ import wandxml
36
+
37
+ EMU = 914400.0
38
+ TARGET_W, TARGET_H = 10.0, 5.625
39
+
40
+
41
+ def slide_names(z):
42
+ return sorted(
43
+ (n for n in z.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", n)),
44
+ key=lambda n: int(re.findall(r"\d+", n)[0]),
45
+ )
46
+
47
+
48
+ def canvas(z):
49
+ d = wandxml.parse(z.read("ppt/presentation.xml"))
50
+ sz = d.getElementsByTagName("p:sldSz")[0]
51
+ return int(sz.getAttribute("cx")) / EMU, int(sz.getAttribute("cy")) / EMU
52
+
53
+
54
+ def para_text(p):
55
+ return "".join(t.firstChild.nodeValue for t in p.getElementsByTagName("a:t") if t.firstChild)
56
+
57
+
58
+ def main():
59
+ ap = argparse.ArgumentParser()
60
+ ap.add_argument("pptx")
61
+ ap.add_argument("-o", "--out", default="work")
62
+ a = ap.parse_args()
63
+ os.makedirs(a.out, exist_ok=True)
64
+
65
+ z = zipfile.ZipFile(a.pptx)
66
+ cw, ch = canvas(z)
67
+ scale = TARGET_W / cw
68
+ names = slide_names(z)
69
+
70
+ print(f"source canvas : {cw:.3f}\" x {ch:.3f}\"")
71
+ if abs(scale - 1.0) > 1e-6:
72
+ print(f"scale to grid : x{scale:.4f} (divide every source measurement by {1/scale:.4f})")
73
+ else:
74
+ print("scale to grid : 1.0 — already on the Wand grid")
75
+
76
+ all_text, dump, geo, analysis = {}, [], [], []
77
+ hidden = 0
78
+
79
+ for name in names:
80
+ i = int(re.findall(r"\d+", name)[0])
81
+ d = wandxml.parse(z.read(name))
82
+ is_hidden = d.documentElement.getAttribute("show") == "0"
83
+ if is_hidden:
84
+ hidden += 1
85
+
86
+ frames = []
87
+ for b in d.getElementsByTagName("p:txBody"):
88
+ paras = [para_text(p) for p in b.getElementsByTagName("a:p")]
89
+ paras = [p for p in paras if p.strip()]
90
+ if paras:
91
+ frames.append(paras)
92
+ if not is_hidden:
93
+ dump.extend(p.strip() for p in paras)
94
+ all_text[i] = {"hidden": is_hidden, "frames": frames}
95
+
96
+ shapes = []
97
+ for tag in ("p:sp", "p:pic"):
98
+ for sp in d.getElementsByTagName(tag):
99
+ off = sp.getElementsByTagName("a:off")
100
+ ext = sp.getElementsByTagName("a:ext")
101
+ if not off or not ext:
102
+ continue
103
+ try:
104
+ x = int(off[0].getAttribute("x")) / EMU * scale
105
+ y = int(off[0].getAttribute("y")) / EMU * scale
106
+ w = int(ext[0].getAttribute("cx")) / EMU * scale
107
+ h = int(ext[0].getAttribute("cy")) / EMU * scale
108
+ except ValueError:
109
+ continue # a placeholder inherits its box; nothing to measure here
110
+ prst = sp.getElementsByTagName("a:prstGeom")
111
+ g = prst[0].getAttribute("prst") if prst else ("pic" if tag == "p:pic" else "?")
112
+ t = "".join(para_text(p) for p in sp.getElementsByTagName("a:p"))[:48]
113
+ shapes.append((round(x, 2), round(y, 2), round(w, 2), round(h, 2), g, t.replace("\n", " ")))
114
+ geo.append((i, is_hidden, sorted(shapes, key=lambda s: (s[1], s[0]))))
115
+
116
+ # Phase 2 input: text frames with the geometry and size that make a
117
+ # title a title. Classification without this is guessing from word
118
+ # count, which is how a stat slide becomes a bullet list.
119
+ tf, pics = [], 0
120
+ for sp in d.getElementsByTagName("p:pic"):
121
+ pics += 1
122
+ for sp in d.getElementsByTagName("p:sp"):
123
+ bodies = sp.getElementsByTagName("p:txBody")
124
+ if not bodies:
125
+ continue
126
+ paras = [t for t in (para_text(p) for p in bodies[0].getElementsByTagName("a:p")) if t.strip()]
127
+ if not paras:
128
+ continue
129
+ off = sp.getElementsByTagName("a:off")
130
+ ext = sp.getElementsByTagName("a:ext")
131
+ box = None
132
+ if off and ext:
133
+ try:
134
+ box = [round(int(off[0].getAttribute("x")) / EMU * scale, 2),
135
+ round(int(off[0].getAttribute("y")) / EMU * scale, 2),
136
+ round(int(ext[0].getAttribute("cx")) / EMU * scale, 2),
137
+ round(int(ext[0].getAttribute("cy")) / EMU * scale, 2)]
138
+ except ValueError:
139
+ box = None
140
+ sizes = [int(r.getAttribute("sz")) / 100.0 * scale
141
+ for r in sp.getElementsByTagName("a:rPr") if r.getAttribute("sz")]
142
+ bold = any(r.getAttribute("b") == "1" for r in sp.getElementsByTagName("a:rPr"))
143
+ tf.append({"box": box, "pt": round(max(sizes), 1) if sizes else None,
144
+ "bold": bold, "paras": paras})
145
+ # theme, read rather than guessed: a Wand dark slide is a solid
146
+ # 0F0C26 background, a light one F8F6FB with no art
147
+ bg = None
148
+ for b in d.getElementsByTagName("p:bg"):
149
+ for c in b.getElementsByTagName("a:srgbClr"):
150
+ bg = c.getAttribute("val").upper()
151
+ break
152
+ analysis.append({"slide": i, "hidden": is_hidden, "pics": pics, "bg": bg,
153
+ "shapes": len(shapes), "frames": tf})
154
+
155
+ # de-duplicate the dump while preserving order
156
+ seen, ded = set(), []
157
+ for s in dump:
158
+ if s not in seen:
159
+ seen.add(s)
160
+ ded.append(s)
161
+
162
+ with open(os.path.join(a.out, "visible_dump.txt"), "w", encoding="utf-8") as f:
163
+ f.write("\n".join(ded))
164
+ with open(os.path.join(a.out, "all_text.json"), "w", encoding="utf-8") as f:
165
+ json.dump(all_text, f, indent=1, ensure_ascii=False)
166
+ with open(os.path.join(a.out, "geometry.txt"), "w", encoding="utf-8") as f:
167
+ f.write(f"# normalised to {TARGET_W}\" x {TARGET_H}\" (source {cw:.3f}\" x {ch:.3f}\", scale x{scale:.4f})\n")
168
+ for i, hid, shapes in geo:
169
+ f.write(f"\n--- slide{i}{' [HIDDEN]' if hid else ''} ({len(shapes)} shapes)\n")
170
+ for s in shapes:
171
+ f.write(f" x{s[0]:<6}y{s[1]:<6}w{s[2]:<6}h{s[3]:<6}{s[4]:<12}{s[5]}\n")
172
+
173
+ skeleton = {
174
+ "deck": os.path.splitext(os.path.basename(a.pptx))[0].replace("_", " ").upper(),
175
+ "confidential": "CONFIDENTIAL",
176
+ "slides": [
177
+ {
178
+ "layout": "TODO",
179
+ "_source": i,
180
+ "_shapes": len(sh),
181
+ "_text": [p for fr in all_text[i]["frames"] for p in fr][:6],
182
+ }
183
+ for i, hid, sh in geo if not hid
184
+ ],
185
+ }
186
+ with open(os.path.join(a.out, "deck.skeleton.json"), "w", encoding="utf-8") as f:
187
+ json.dump(skeleton, f, indent=2, ensure_ascii=False)
188
+ with open(os.path.join(a.out, "analysis.json"), "w", encoding="utf-8") as f:
189
+ json.dump({"source": os.path.basename(a.pptx),
190
+ "canvas": [round(cw, 3), round(ch, 3)], "scale": round(scale, 4),
191
+ "slides": analysis}, f, indent=1, ensure_ascii=False)
192
+
193
+ print(f"slides : {len(names)} in source, {len(names) - hidden} visible")
194
+ print(f"strings : {len(ded)} unique verbatim")
195
+ print(f"written : {a.out}/ visible_dump.txt all_text.json geometry.txt analysis.json deck.skeleton.json")
196
+
197
+
198
+ if __name__ == "__main__":
199
+ main()
package/wand.js ADDED
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wand.js - the engine's front door.
4
+ *
5
+ * The kit is meant to arrive as a zip next to a .pptx, in a container that may
6
+ * have nothing installed and will be thrown away afterwards. Everything below
7
+ * assumes that: no global installs, no network, no LibreOffice, and an operator
8
+ * who has read one file.
9
+ *
10
+ * node wand.js doctor what works here, and what does not
11
+ * node wand.js rebuild source.pptx phases 0-3: extract, then the worksheet
12
+ * node wand.js layouts [name] the catalog, with every field
13
+ node wand.js glyphs [search] the named icon set
14
+ * node wand.js validate work/deck.json fields and counts, before the build
15
+ * node wand.js preview work/deck.json browser preview at the 10in grid
16
+ * node wand.js ship work/deck.json build .pptx and .pdf, then every gate
17
+ node wand.js spec deck.pptx read back the spec a built deck carries
18
+ *
19
+ * `rebuild` and `ship` are the two that matter. Everything else is a stage of
20
+ * one of them, exposed so a failure can be re-run on its own.
21
+ *
22
+ * Output is deliberately plain text: this runs as often through a pipe or a
23
+ * transcript as through a terminal, and escape codes only survive one of those.
24
+ */
25
+
26
+ const { execFileSync, spawnSync } = require("child_process");
27
+ const fs = require("fs");
28
+ const path = require("path");
29
+
30
+ const KIT = __dirname;
31
+ const rel = (p) => path.relative(process.cwd(), p) || ".";
32
+ const rule = (s) => `\n== ${s} ${"=".repeat(Math.max(0, 60 - s.length))}`;
33
+
34
+ function die(msg, code = 2) { console.error(`\n${msg}\n`); process.exit(code); }
35
+
36
+ /* ------------------------------------------------------------------ python */
37
+
38
+ /** The first python3 that runs. Phases 0 and 6 need one; nothing else does. */
39
+ function python() {
40
+ for (const c of ["python3", "python"]) {
41
+ const r = spawnSync(c, ["-c", "import sys;print(sys.version_info[0])"], { encoding: "utf8" });
42
+ if (r.status === 0 && r.stdout.trim() === "3") return c;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function runPython(args) {
48
+ const py = python();
49
+ if (!py) die("no python3 on PATH - extract and font embedding both need one.\n" +
50
+ "Everything else (validate, check, preview, build) is pure Node and still works.");
51
+ return spawnSync(py, args, { stdio: "inherit", cwd: KIT }).status;
52
+ }
53
+
54
+ /* ------------------------------------------------------------------ doctor */
55
+
56
+ function doctor() {
57
+ const rows = [];
58
+ const py = python();
59
+ rows.push(["node", process.version, "ok"]);
60
+ rows.push(["python3", py ? execFileSync(py, ["-V"], { encoding: "utf8" }).trim() : "MISSING",
61
+ py ? "ok" : "extract and font embedding unavailable"]);
62
+
63
+ let deps = "ok", where = "bundled";
64
+ try {
65
+ require.resolve("pptxgenjs"); require.resolve("fontkit");
66
+ if (!fs.existsSync(path.join(KIT, "node_modules", "pptxgenjs"))) where = "resolved outside the kit";
67
+ } catch {
68
+ deps = "MISSING - this zip should have shipped with them. `npm install` in " + rel(KIT);
69
+ where = "-";
70
+ }
71
+ rows.push(["dependencies", where, deps]);
72
+
73
+ for (const b of ["fonts", "assets"]) {
74
+ let where = "MISSING", st = "brand payload not found - see lib/brand.js";
75
+ try { where = rel(require("./lib/brand").resolve(b)); st = "ok"; } catch {}
76
+ rows.push([`brand/${b}`, where, st]);
77
+ }
78
+
79
+ const soffice = ["soffice", "libreoffice"].some((c) => spawnSync("which", [c]).status === 0);
80
+ rows.push(["libreoffice", soffice ? "found" : "absent",
81
+ soffice ? "ok" : "no PDF render - use `preview` for the visual gate instead"]);
82
+
83
+ if (py) {
84
+ const opt = spawnSync(py, ["-c",
85
+ "import importlib.util as u;print(','.join(m for m in ('defusedxml','fontTools') if u.find_spec(m)) or 'none')"],
86
+ { encoding: "utf8" });
87
+ const extras = (opt.stdout || "").trim() || "none";
88
+ rows.push(["python extras", extras === "none" ? "none (fine)" : extras, "ok"]);
89
+ }
90
+
91
+ const w = Math.max(...rows.map((r) => r[0].length));
92
+ console.log("");
93
+ for (const [k, v, s] of rows) {
94
+ console.log(`${s === "ok" ? " " : "! "}${k.padEnd(w)} ${String(v).padEnd(26)}${s === "ok" ? "" : s}`);
95
+ }
96
+ const blocked = rows.some((r) => r[2].startsWith("MISSING") || r[2].includes("npm install"));
97
+ console.log(blocked ? "\nBLOCKED - fix the rows marked !\n"
98
+ : "\nready - node wand.js rebuild <source.pptx>\n");
99
+ return blocked ? 1 : 0;
100
+ }
101
+
102
+ /* ----------------------------------------------------------- phases 0 to 2 */
103
+
104
+ function extract(src, out) {
105
+ if (!fs.existsSync(src)) die(`no such file: ${src}`);
106
+ if (runPython([path.join(KIT, "tools", "extract.py"), path.resolve(src), "-o", path.resolve(out)])) {
107
+ die("extract failed");
108
+ }
109
+ }
110
+
111
+ function plan(work) {
112
+ const a = path.join(work, "analysis.json");
113
+ if (!fs.existsSync(a)) die(`no ${rel(a)} - run \`node wand.js extract <source.pptx> -o ${rel(work)}\` first`);
114
+ const { worksheet } = require("./lib/plan");
115
+ const out = path.join(work, "PLAN.md");
116
+ fs.writeFileSync(out, worksheet(JSON.parse(fs.readFileSync(a, "utf8"))));
117
+ console.log(`wrote ${rel(out)}`);
118
+ return out;
119
+ }
120
+
121
+ /* --------------------------------------------------------------- catalogue */
122
+
123
+ function layouts(name) {
124
+ const { LAYOUTS, COMMON } = require("./lib/schema");
125
+ if (name) {
126
+ const d = LAYOUTS[name];
127
+ if (!d) die(`no layout "${name}". Run \`node wand.js layouts\` for the list.`);
128
+ console.log(`\n${name}${d.bleed ? " (full bleed - no footer)" : ""}\n ${d.use}\n`);
129
+ const line = (k, f, ind) => {
130
+ const card = f.min || f.max ? ` [${f.min || 1}-${f.max || "n"}]` : "";
131
+ console.log(`${ind}${(k + (f.required ? "*" : "")).padEnd(16)} ${f.type}${card}${f.note ? " " + f.note : ""}`);
132
+ if (f.of) for (const [k2, f2] of Object.entries(f.of)) line(k2, f2, ind + " ");
133
+ };
134
+ for (const [k, f] of Object.entries(d.fields)) line(k, f, " ");
135
+ console.log(`\n* required. Every layout also takes: ${Object.keys(COMMON).join(", ")}\n`);
136
+ return 0;
137
+ }
138
+ const names = Object.keys(LAYOUTS);
139
+ console.log(`\n${names.length} layouts - \`node wand.js layouts <name>\` for the fields\n`);
140
+ for (const n of names) console.log(` ${n.padEnd(16)} ${LAYOUTS[n].use}`);
141
+ console.log("");
142
+ return 0;
143
+ }
144
+
145
+ /* ---------------------------------------------------------------- validate */
146
+
147
+ function loadSpec(p) {
148
+ if (!fs.existsSync(p)) die(`no such spec: ${p}`);
149
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); }
150
+ catch (e) { die(`${rel(p)} is not valid JSON - ${e.message}`); }
151
+ }
152
+
153
+ function validate(specPath, quiet) {
154
+ const { validate: v } = require("./lib/schema");
155
+ const { errors, warnings } = v(loadSpec(specPath));
156
+ warnings.forEach((w) => console.log(` warn ${w}`));
157
+ errors.forEach((e) => console.log(` ERROR ${e}`));
158
+ if (!quiet || errors.length) {
159
+ console.log(`spec : ${errors.length ? `${errors.length} error(s)` : "valid"}` +
160
+ (warnings.length ? `, ${warnings.length} warning(s)` : ""));
161
+ }
162
+ return errors.length;
163
+ }
164
+
165
+ /* -------------------------------------------------------------------- ship */
166
+
167
+ const node = (args) => spawnSync(process.execPath, args, { stdio: "inherit", cwd: KIT }).status;
168
+ const has = (c) => spawnSync("which", [c]).status === 0;
169
+
170
+ function ship(specPath, outArg) {
171
+ const spec = loadSpec(specPath);
172
+ const work = path.dirname(path.resolve(specPath));
173
+ const base = (spec.deck || "deck").replace(/[^A-Za-z0-9]+/g, "_").replace(/^_|_$/g, "") || "deck";
174
+ const raw = path.join(work, `${base}.raw.pptx`);
175
+ const out = path.resolve(outArg || path.join(work, `${base}.pptx`));
176
+
177
+ console.log(rule("1 spec"));
178
+ if (validate(specPath, true)) die("spec has errors - fix them before building", 1);
179
+ console.log("spec : valid");
180
+
181
+ console.log(rule("2 fit and bounds"));
182
+ const bad = node([path.join(KIT, "build.js"), path.resolve(specPath), "--check"]);
183
+
184
+ console.log(rule("3 build"));
185
+ if (node([path.join(KIT, "build.js"), path.resolve(specPath), raw])) die("build failed", 1);
186
+
187
+ console.log(rule("4 pdf"));
188
+ const pdfOut = out.replace(/\.pptx$/, ".pdf");
189
+ node([path.join(KIT, "build.js"), path.resolve(specPath), pdfOut]);
190
+
191
+ console.log(rule("5 embed fonts"));
192
+ if (runPython([path.join(KIT, "embed_fonts.py"), raw, "-o", out,
193
+ "--spec", path.resolve(specPath)]) !== 0) die("font embedding failed", 1);
194
+ fs.rmSync(raw, { force: true });
195
+
196
+ console.log(rule("6 coverage"));
197
+ const dump = path.join(work, "visible_dump.txt");
198
+ let cov = null;
199
+ if (fs.existsSync(dump)) cov = runPython([path.join(KIT, "qa_coverage.py"), dump, out]);
200
+ else console.log(`no ${rel(dump)} - skipped. An authored deck has no source to cover;\n` +
201
+ "a rebuild without this number is not QA'd.");
202
+
203
+ console.log(rule("7 file"));
204
+ const fileErrs = inspect(out);
205
+
206
+ console.log(rule("8 visual"));
207
+ node([path.join(KIT, "build.js"), path.resolve(specPath), "--preview"]);
208
+ let shots = 0;
209
+ if (has("pdftoppm") && fs.existsSync(pdfOut)) {
210
+ const dir = path.join(work, "slides");
211
+ fs.mkdirSync(dir, { recursive: true });
212
+ spawnSync("pdftoppm", ["-r", "96", "-png", pdfOut, path.join(dir, "slide")], { stdio: "inherit" });
213
+ shots = fs.readdirSync(dir).filter((f) => f.endsWith(".png")).length;
214
+ console.log(`rendered : ${shots} slide(s) to ${rel(dir)}/ - look at every one`);
215
+ } else {
216
+ console.log(`open ${rel(path.join(KIT, "preview", "preview.html"))} and look at every slide.`);
217
+ }
218
+
219
+ const gates = [["fit + bounds", bad === 0], ["coverage", cov === null ? null : cov === 0],
220
+ ["file", fileErrs === 0], ["renders", shots > 0 ? true : null]];
221
+ console.log(rule("gates"));
222
+ for (const [n, ok] of gates) console.log(` ${ok === null ? "skip" : ok ? " ok " : "FAIL"} ${n}`);
223
+ console.log(`\n${rel(out)}${fs.existsSync(pdfOut) ? "\n" + rel(pdfOut) : ""}\n`);
224
+ return gates.some(([, ok]) => ok === false) ? 1 : 0;
225
+ }
226
+
227
+ /**
228
+ * File-level check, in-process. `validate.py` from the pptx skill is not in the
229
+ * zip and LibreOffice may not be installed, so the kit carries its own: the
230
+ * parts a .pptx must have, a relationship target for every r:embed, and the
231
+ * subset flag that decides whether digits garble in an exported PDF.
232
+ */
233
+ function inspect(file) {
234
+ const errs = [];
235
+ const z = readZip(file);
236
+ for (const need of ["[Content_Types].xml", "_rels/.rels", "ppt/presentation.xml"]) {
237
+ if (!z.has(need)) errs.push(`missing part ${need}`);
238
+ }
239
+ const slides = z.names.filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n));
240
+ const media = z.names.filter((n) => n.startsWith("ppt/media/"));
241
+ const fonts = z.names.filter((n) => n.startsWith("ppt/fonts/"));
242
+
243
+ for (const s of slides) {
244
+ const xml = z.text(s);
245
+ const ids = [...new Set([...xml.matchAll(/r:(?:embed|id|link)="([^"]+)"/g)].map((m) => m[1]))];
246
+ if (!ids.length) continue;
247
+ const relsName = s.replace("slides/", "slides/_rels/") + ".rels";
248
+ if (!z.has(relsName)) { errs.push(`${s} references ${ids.length} rel(s) but has no .rels part`); continue; }
249
+ const rels = z.text(relsName);
250
+ for (const id of ids) if (!rels.includes(`Id="${id}"`)) errs.push(`${s} references ${id}, undefined in its .rels`);
251
+ }
252
+ const pres = z.has("ppt/presentation.xml") ? z.text("ppt/presentation.xml") : "";
253
+ const families = (pres.match(/<p:embeddedFont>/g) || []).length;
254
+ if (fonts.length && !/saveSubsetFonts="0"/.test(pres)) {
255
+ errs.push('fonts embedded but saveSubsetFonts is not "0" - digits garble in exported PDFs');
256
+ }
257
+ if (!fonts.length) errs.push("no embedded fonts - this renders as Arial on the client's machine");
258
+ errs.forEach((e) => console.log(` ERROR ${e}`));
259
+ console.log(`file : ${slides.length} slides, ${media.length} media, ${fonts.length} font parts / ` +
260
+ `${families} families` + (errs.length ? ` - ${errs.length} problem(s) above` : " - clean"));
261
+ return errs.length;
262
+ }
263
+
264
+ /** Minimal zip reader, so `inspect` needs no dependency of its own. */
265
+ function readZip(file) {
266
+ const zlib = require("zlib");
267
+ const buf = fs.readFileSync(file);
268
+ let end = buf.length - 22;
269
+ while (end >= 0 && buf.readUInt32LE(end) !== 0x06054b50) end--;
270
+ if (end < 0) die(`${rel(file)} is not a zip - is it really a .pptx?`);
271
+ let off = buf.readUInt32LE(end + 16);
272
+ const count = buf.readUInt16LE(end + 10);
273
+ const entries = new Map();
274
+ for (let i = 0; i < count; i++) {
275
+ const nameLen = buf.readUInt16LE(off + 28);
276
+ const extraLen = buf.readUInt16LE(off + 30);
277
+ const commentLen = buf.readUInt16LE(off + 32);
278
+ entries.set(buf.toString("utf8", off + 46, off + 46 + nameLen), {
279
+ method: buf.readUInt16LE(off + 10), local: buf.readUInt32LE(off + 42), size: buf.readUInt32LE(off + 20),
280
+ });
281
+ off += 46 + nameLen + extraLen + commentLen;
282
+ }
283
+ const read = (name) => {
284
+ const e = entries.get(name);
285
+ const start = e.local + 30 + buf.readUInt16LE(e.local + 26) + buf.readUInt16LE(e.local + 28);
286
+ const raw = buf.subarray(start, start + e.size);
287
+ return e.method === 0 ? raw : zlib.inflateRawSync(raw);
288
+ };
289
+ return { names: [...entries.keys()], has: (n) => entries.has(n), text: (n) => read(n).toString("utf8") };
290
+ }
291
+
292
+ /* ------------------------------------------------------------------ driver */
293
+
294
+ const [, , cmd, ...rest] = process.argv;
295
+ const flag = (name, def) => { const i = rest.indexOf(name); return i < 0 ? def : rest[i + 1]; };
296
+ const positional = rest.filter((a, i) => !a.startsWith("-") && !["-o", "--out"].includes(rest[i - 1]));
297
+
298
+ switch (cmd) {
299
+ case "doctor": process.exit(doctor());
300
+
301
+ case "extract":
302
+ if (!positional[0]) die("usage: node wand.js extract <source.pptx> [-o work/]");
303
+ extract(positional[0], flag("-o", flag("--out", "work")));
304
+ break;
305
+
306
+ case "plan": plan(positional[0] || "work"); break;
307
+
308
+ case "rebuild": {
309
+ if (!positional[0]) die("usage: node wand.js rebuild <source.pptx> [-o work/]");
310
+ const work = flag("-o", flag("--out", "work"));
311
+ console.log(rule("1 normalise and extract (phases 0-1)"));
312
+ extract(positional[0], work);
313
+ console.log(rule("2 classify and map (phases 2-3)"));
314
+ plan(work);
315
+ console.log(`
316
+ NEXT - the part no script does for you:
317
+
318
+ 1. Read ${rel(path.join(work, "PLAN.md"))}. Per slide it has the source's shape,
319
+ a shortlist of layouts with the reason for each, every verbatim string, and
320
+ a stub for the top candidate.
321
+ 2. Choose a layout per slide by section 6 Phase 3. The shortlist is ranked by
322
+ shape and cannot see meaning. Vary them; alternate dark and light.
323
+ 3. Emit the mapping plan, one line per slide, before writing any JSON.
324
+ 4. Write ${rel(path.join(work, "deck.json"))} - verbatim text, no coordinates,
325
+ no colours, no font sizes.
326
+ 5. node wand.js validate ${rel(path.join(work, "deck.json"))}
327
+ 6. node wand.js ship ${rel(path.join(work, "deck.json"))}
328
+
329
+ FAITHFUL MEANS VERBATIM. Reflow to fit; never reword. Paraphrase is invisible to
330
+ every gate except coverage, and it is the failure this pipeline is most exposed
331
+ to.
332
+ `);
333
+ break;
334
+ }
335
+
336
+ case "layouts": process.exit(layouts(positional[0]));
337
+
338
+ case "glyphs": {
339
+ const G = require("./lib/shared").glyphs;
340
+ const idx = G.index();
341
+ if (positional[0]) {
342
+ const q = positional[0].toLowerCase();
343
+ const hits = G.names().filter((n) => n.includes(q));
344
+ console.log(hits.length ? "\n " + hits.join("\n ") + "\n"
345
+ : `\nno glyph matches "${positional[0]}". Closest: ${G.suggest(positional[0]).join(", ")}\n`);
346
+ process.exit(0);
347
+ }
348
+ console.log(`\n${idx.count} glyphs - ${idx.set} ${idx.version}, ${idx.license}. Recoloured per theme, so there is no light/dark variant to pick.\n`);
349
+ for (const [g, list] of Object.entries(idx.groups)) {
350
+ console.log(` ${g}`);
351
+ for (let i = 0; i < list.length; i += 6) console.log(" " + list.slice(i, i + 6).map((n) => n.padEnd(17)).join(""));
352
+ }
353
+ console.log("");
354
+ process.exit(0);
355
+ }
356
+
357
+ case "validate":
358
+ if (!positional[0]) die("usage: node wand.js validate <spec.json>");
359
+ process.exit(validate(positional[0]) ? 1 : 0);
360
+
361
+ case "check":
362
+ case "preview":
363
+ case "build": {
364
+ if (!positional[0]) die(`usage: node wand.js ${cmd} <spec.json>`);
365
+ const a = [path.join(KIT, "build.js"), path.resolve(positional[0])];
366
+ if (cmd === "check") a.push("--check");
367
+ else if (cmd === "preview") a.push("--preview");
368
+ else if (positional[1]) a.push(path.resolve(positional[1]));
369
+ process.exit(node(a) || 0);
370
+ }
371
+
372
+ case "spec": {
373
+ if (!positional[0]) die("usage: node wand.js spec <deck.pptx> [out.json]");
374
+ const z = readZip(path.resolve(positional[0]));
375
+ if (!z.has("wand/spec.json")) {
376
+ die(`${rel(positional[0])} carries no spec.\n` +
377
+ "Only decks this kit built do. For anything else, run `rebuild` on it.");
378
+ }
379
+ const json = z.text("wand/spec.json");
380
+ if (positional[1]) { fs.writeFileSync(positional[1], json); console.log(`wrote ${rel(positional[1])}`); }
381
+ else process.stdout.write(json);
382
+ process.exit(0);
383
+ }
384
+
385
+ case "inspect":
386
+ if (!positional[0]) die("usage: node wand.js inspect <deck.pptx>");
387
+ process.exit(inspect(path.resolve(positional[0])) ? 1 : 0);
388
+
389
+ case "ship":
390
+ if (!positional[0]) die("usage: node wand.js ship <spec.json> [out.pptx]");
391
+ process.exit(ship(positional[0], positional[1]));
392
+
393
+ default:
394
+ console.log(fs.readFileSync(__filename, "utf8").split("*/")[0]
395
+ .split("\n").filter((l) => l.startsWith(" *")).map((l) => l.slice(2).replace(/^ /, "")).join("\n"));
396
+ process.exit(cmd ? 2 : 0);
397
+ }