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/tools/extract.py
ADDED
|
@@ -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
|
+
}
|