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,116 @@
1
+ # Pipeline
2
+
3
+ The §6 phases, as commands. Two of them do everything; the rest exist so a
4
+ failed stage can be re-run on its own.
5
+
6
+ ```bash
7
+ node wand.js doctor # before anything: what works in this container
8
+ node wand.js rebuild source.pptx # phases 0-3
9
+ # ... you write work/deck.json ...
10
+ node wand.js ship work/deck.json # phases 4-6, every gate, one table at the end
11
+ ```
12
+
13
+ ## 0–1 · Normalize and extract — `rebuild`, stage 1
14
+
15
+ Reports the source canvas and the scale factor onto the 10" grid; most decks are
16
+ 13.333" × 7.5" and need ×0.75. Writes into `work/`:
17
+
18
+ | File | What |
19
+ |---|---|
20
+ | `visible_dump.txt` | every verbatim string — the contract coverage checks against |
21
+ | `all_text.json` | per-slide, per-frame, per-paragraph structure |
22
+ | `geometry.txt` | every shape, normalised to the Wand grid |
23
+ | `analysis.json` | text frames with box, point size and weight, plus the slide background |
24
+ | `deck.skeleton.json` | one entry per visible slide, `layout: "TODO"` |
25
+
26
+ Standalone: `node wand.js extract source.pptx -o work/`
27
+
28
+ ## 2–3 · Classify and map — `rebuild`, stage 2
29
+
30
+ Writes `work/PLAN.md`. Per slide: how many cards in how many columns over how
31
+ many bands, the tag / title / subtitle already identified by point size, a
32
+ shortlist of layouts with the reason for each, every verbatim string grouped by
33
+ frame, and a stub carrying the top candidate's required fields with the theme
34
+ read off the source background.
35
+
36
+ The shortlist is ranked by **shape**. It cannot see meaning, and §6 Phase 3
37
+ orders on meaning — structural first, then quotes, timelines, data, comparisons,
38
+ stacks, assertions, numbered asks, and only then item count. Overrule it freely.
39
+
40
+ Emit the mapping plan before writing any JSON:
41
+
42
+ ```
43
+ S07 (timeline, 6 phases + trajectory band) → phaseCards-D
44
+ ```
45
+
46
+ Standalone: `node wand.js plan work/`
47
+
48
+ ## 4 · Build — `ship`, stages 1–4
49
+
50
+ ```
51
+ 1 spec fields, types, and item counts against the layout catalog
52
+ 2 fit every text box measured in real Geist
53
+ bounds every shape inside the content band
54
+ asset every glyph and image the spec names actually exists
55
+ 3 build .pptx
56
+ 4 embed fonts required (§7). Without it the deck renders as Arial
57
+ ```
58
+
59
+ Iterate in the browser rather than round-tripping the file:
60
+
61
+ ```bash
62
+ node wand.js preview work/deck.json # then open preview/preview.html
63
+ ```
64
+
65
+ The preview renders the same recorded geometry the `.pptx` gets, in real Geist,
66
+ with overflow flagging and the §1 rails as a toggle.
67
+
68
+ `node wand.js layouts <name>` prints any layout's fields when you are writing the
69
+ spec; `node wand.js validate work/deck.json` checks them before you build.
70
+
71
+ ## 5 · Review note
72
+
73
+ Markdown, slide-numbered. Every split or merge, every fallback mapping, anything
74
+ tight after fitting, anything off-brand you couldn't rebrand, **everything
75
+ dropped from the verbatim dump**, and any place a reference deck and the spec
76
+ disagreed.
77
+
78
+ ## 6 · QA — `ship`, stages 5–7
79
+
80
+ ```
81
+ 5 coverage every source string present, and how many were too short to compare
82
+ 6 file parts, relationships, embedded and unsubsetted fonts
83
+ 7 visual the preview — the only gate that sees a slide that is merely ugly
84
+ ```
85
+
86
+ `ship` prints a gate table and exits non-zero if any of them failed.
87
+
88
+ If LibreOffice is installed you can also render:
89
+
90
+ ```bash
91
+ soffice --headless --convert-to pdf work/deck.pptx
92
+ pdftoppm -jpeg -r 150 deck.pdf slide
93
+ ```
94
+
95
+ Plus the subset check — empty output is a pass:
96
+
97
+ ```bash
98
+ python3 -c "import subprocess; t=subprocess.run(['pdftotext','deck.pdf','-'],capture_output=True,text=True).stdout; print([c for c in t if 0x01C9<=ord(c)<=0x024F] or 'clean')"
99
+ ```
100
+
101
+ Deliver the `.pptx` first, then the review note.
102
+
103
+ ## What QA can and can't tell you
104
+
105
+ LibreOffice ignores embedded fonts and substitutes DejaVu. So:
106
+
107
+ - **Don't trust it on fit.** DejaVu is wider than Geist, so renders over-report
108
+ overflow. Use the fit check, which measures the real face.
109
+ - **Don't trust it on weight.** `Geist ExtraBold` renders regular. Verify the
110
+ accent-italic device and any hierarchy question in the XML or the preview.
111
+ - **Do trust it on** geometry, overlap, alignment, gaps, contrast, leftover
112
+ placeholders, orphaned decoration, mispositioned art.
113
+
114
+ And when it isn't installed at all — which is the normal case in a fresh
115
+ container — the browser preview covers everything in that last list, in the real
116
+ faces, which LibreOffice cannot do.
package/docs/SPEC.md ADDED
@@ -0,0 +1,89 @@
1
+ # Deck spec format
2
+
3
+ A deck is data. `build.js` turns a spec into a `.pptx`; `--preview` turns the
4
+ same spec into a browser preview. Neither re-implements the other, so they can't
5
+ disagree.
6
+
7
+ ```bash
8
+ node wand.js validate deck.json # fields, types, item counts. Run this first
9
+ node wand.js layouts numberedCards # what a layout takes, with the caps
10
+ node wand.js check deck.json # fit, bounds and assets. Exit 1 on any
11
+ node wand.js preview deck.json # write preview/trace.js, then open preview/preview.html
12
+ node wand.js ship deck.json # every gate, then the .pptx
13
+ ```
14
+
15
+ `lib/schema.js` is the machine-readable version of this document: every layout's
16
+ fields, their types, and the number of items it can actually draw. `validate`,
17
+ `layouts` and the `plan` worksheet all read it, so they cannot drift from each
18
+ other. If a layout gains a field, it goes there.
19
+
20
+ ## Shape
21
+
22
+ ```json
23
+ {
24
+ "deck": "WAND × ACME",
25
+ "confidential": "CONFIDENTIAL",
26
+ "slides": [
27
+ { "layout": "cover", "eyebrow": "...", "titleLines": ["...", "..."] },
28
+ { "layout": "rows", "dark": true, "tag": "THE FULL STACK", "items": [] }
29
+ ]
30
+ }
31
+ ```
32
+
33
+ `deck` and `confidential` fill the footer and can be overridden per slide.
34
+ Page numbers are computed — never write them into a spec.
35
+
36
+ Full-bleed layouts (`cover`, `divider`, `closing`) get no footer automatically.
37
+
38
+ ## Every slide takes
39
+
40
+ | Key | Meaning |
41
+ |---|---|
42
+ | `layout` | required — a function name exported by `wand_kit.js` |
43
+ | `tag` | the caps label top-left |
44
+ | `dark` | `true` dark theme, `false` light. Alternate across the deck |
45
+ | `titleText` | string, or `["before ", "accent phrase", " after"]` for the accent-italic device |
46
+ | `subtitleText` | the lede under the title |
47
+
48
+ Everything else is layout-specific — see `docs/LAYOUTS.md`, or run
49
+ `node wand.js layouts <name>`, which prints the same thing from the schema and
50
+ marks the required fields.
51
+
52
+ ## Item caps are enforced
53
+
54
+ Every layout slices its items to what it can draw. A 7th item on a `sixUpIcon`
55
+ used to vanish silently and turn up as a coverage miss three steps later;
56
+ `validate` now fails on it, and says how many would have been dropped.
57
+
58
+ ## The accent-italic device
59
+
60
+ ```json
61
+ "titleText": ["Two kinds of colleagues. ", "One set of drives", "."]
62
+ ```
63
+
64
+ Element 1 renders accent + italic. The trailing period stays in ink. One per
65
+ title, never two. A plain string is fine when a title needs no emphasis.
66
+
67
+ ## Line breaks
68
+
69
+ Use `\n` inside a title or caption where the source deck breaks a line. The kit
70
+ splits on it and carries the break explicitly — an embedded newline used to
71
+ push every following run onto its own line.
72
+
73
+ ## What the spec deliberately cannot do
74
+
75
+ No coordinates, no colours, no font sizes. If a slide needs geometry the
76
+ layouts don't provide, add a layout — don't leak positioning into content.
77
+ That separation is what makes a rebuild reviewable: the spec diffs against the
78
+ source's verbatim text, and the layout diffs against the design system.
79
+
80
+ ## Starting from a source deck
81
+
82
+ ```bash
83
+ node wand.js rebuild source.pptx
84
+ ```
85
+
86
+ writes `work/PLAN.md` — the worksheet you fill in. Per slide it carries the
87
+ source's shape, a layout shortlist with reasons, every verbatim string grouped
88
+ by frame, and a stub with the top candidate's required fields already present.
89
+ Choose the layout by §6 Phase 3, transcribe the text, `validate`, `ship`.
package/embed_fonts.py ADDED
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ embed_fonts.py — embed the five Geist families into a .pptx, unsubsetted.
4
+
5
+ python embed_fonts.py deck.pptx [-o out.pptx] [--fonts DIR]
6
+
7
+ Why this exists: a Geist deck without embedded fonts renders as Arial on the
8
+ client's machine. Spec §7.
9
+
10
+ Two things this does that the reference decks get wrong:
11
+ * writes UNCOMPRESSED EOT and sets saveSubsetFonts="0", so digits don't
12
+ garble in exported PDFs (spec §9)
13
+ * ships full faces, not subsets
14
+
15
+ The .fntdata part is an EOT (Embedded OpenType) container — magic 0x504C,
16
+ version 2.1, TTCOMPRESSED flag clear, raw TTF appended.
17
+ """
18
+
19
+ import argparse, os, re, shutil, struct, sys, tempfile, zipfile
20
+
21
+
22
+ def _brand_lib():
23
+ """brand/lib in the repo, or lib/ beside the kit once packaged."""
24
+ import os
25
+ here = os.path.dirname(os.path.abspath(__file__))
26
+ for d in filter(None, [os.environ.get("WAND_BRAND_DIR"),
27
+ os.path.join(here, "lib"),
28
+ os.path.join(here, "..", "lib"),
29
+ os.path.join(here, "..", "..", "..", "..", "brand", "lib"),
30
+ os.path.join(here, "..", "..", "..", "brand", "lib")]):
31
+ if os.path.isfile(os.path.join(d, "wandxml.py")):
32
+ return os.path.normpath(d)
33
+ raise SystemExit("brand/lib not found")
34
+
35
+ sys.path.insert(0, _brand_lib())
36
+ import ttf as _ttf
37
+
38
+ # family -> (regular, bold, italic, boldItalic) source stems
39
+ FAMILIES = {
40
+ "Geist": ("Geist-Regular", "Geist-Bold", "Geist-Italic", "Geist-BoldItalic"),
41
+ "Geist Medium": ("Geist-Medium", "Geist-SemiBold", "Geist-MediumItalic", "Geist-SemiBoldItalic"),
42
+ "Geist SemiBold": ("Geist-SemiBold", "Geist-ExtraBold", "Geist-SemiBoldItalic", "Geist-ExtraBoldItalic"),
43
+ "Geist ExtraBold": ("Geist-ExtraBold", "Geist-ExtraBold", "Geist-ExtraBoldItalic","Geist-ExtraBoldItalic"),
44
+ "Geist Light": ("Geist-Light", "Geist-Medium", "Geist-LightItalic", "Geist-MediumItalic"),
45
+ }
46
+ FACES = ("regular", "bold", "italic", "boldItalic")
47
+
48
+ NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
49
+ REL_FONT = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font"
50
+
51
+
52
+ def retag(ttf_path, family, italic):
53
+ """Rewrite the TTF name table so its family matches the name used in the XML.
54
+
55
+ Weight is carried by the family name in this system, so all five families
56
+ must report distinct names or PowerPoint collapses them onto one face.
57
+ """
58
+ f = _ttf.load(ttf_path)
59
+ sub = "Italic" if italic else "Regular"
60
+ full = f"{family} Italic" if italic else family
61
+ data = f.retagged(family, sub, full, full.replace(" ", ""))
62
+ return data, _ttf.TTF(data)
63
+
64
+
65
+ def eot(ttf_bytes, font, family, italic):
66
+ """Wrap raw TTF bytes in an uncompressed EOT v2.1 container."""
67
+ os2 = font.os2
68
+ panose = os2["panose"]
69
+
70
+ def lpstr(s):
71
+ b = s.encode("utf-16-le")
72
+ return struct.pack("<HH", 0, len(b)) + b # padding + size + string
73
+
74
+ body = b""
75
+ body += panose # FontPANOSE[10]
76
+ body += struct.pack("<BB", 1, 1 if italic else 0) # Charset(DEFAULT), Italic
77
+ body += struct.pack("<I", os2["usWeightClass"]) # Weight
78
+ body += struct.pack("<HH", 0x0000, 0x504C) # fsType(installable), MagicNumber
79
+ body += struct.pack("<IIII", *os2["ulUnicodeRange"])
80
+ body += struct.pack("<II", *os2["ulCodePageRange"])
81
+ body += struct.pack("<I", font.check_sum_adjustment)
82
+ body += struct.pack("<IIII", 0, 0, 0, 0) # Reserved1-4
83
+ body += lpstr(family) # FamilyName
84
+ body += lpstr("Italic" if italic else "Regular") # StyleName
85
+ body += lpstr("Version 1.0") # VersionName
86
+ body += lpstr(f"{family} Italic" if italic else family) # FullName
87
+ body += struct.pack("<HH", 0, 0) # v2.1: RootString (empty)
88
+
89
+ # EOTSize, FontDataSize, Version, Flags(0 = uncompressed, not subsetted)
90
+ prefix = struct.pack("<IIII", 16 + len(body) + len(ttf_bytes),
91
+ len(ttf_bytes), 0x00020001, 0x00000000)
92
+ return prefix + body + ttf_bytes
93
+
94
+
95
+ def brand_dir(name):
96
+ """Locate the shared brand payload. Bundled beside the kit when packaged,
97
+ at the repo root when running from the repo. WAND_BRAND_DIR overrides."""
98
+ here = os.path.dirname(os.path.abspath(__file__))
99
+ for d in filter(None, [os.environ.get("WAND_BRAND_DIR"),
100
+ here,
101
+ os.path.join(here, "..", "..", "..", "brand")]):
102
+ p = os.path.join(d, name)
103
+ # prove it is the brand payload, not a directory that shares its name
104
+ if os.path.isdir(p) and (name != "fonts" or os.path.isfile(os.path.join(p, "Geist-Regular.ttf"))):
105
+ return os.path.normpath(p)
106
+ raise SystemExit(f"brand/{name} not found")
107
+
108
+
109
+ def main():
110
+ ap = argparse.ArgumentParser()
111
+ ap.add_argument("pptx")
112
+ ap.add_argument("-o", "--out")
113
+ ap.add_argument("--fonts", default=None)
114
+ ap.add_argument("--spec", help="carry this spec inside the deck, so the deck can be edited later "
115
+ "without the spec having to travel beside it")
116
+ a = ap.parse_args()
117
+ fonts = a.fonts or brand_dir("fonts")
118
+ out = a.out or a.pptx
119
+
120
+ work = tempfile.mkdtemp()
121
+ with zipfile.ZipFile(a.pptx) as z:
122
+ z.extractall(work)
123
+
124
+ fdir = os.path.join(work, "ppt", "fonts")
125
+ os.makedirs(fdir, exist_ok=True)
126
+
127
+ rels_path = os.path.join(work, "ppt", "_rels", "presentation.xml.rels")
128
+ rels = open(rels_path, encoding="utf-8").read()
129
+ next_id = max([int(m) for m in re.findall(r'Id="rId(\d+)"', rels)] or [0]) + 1
130
+
131
+ new_rels, font_lst, made = [], [], 0
132
+ for family, stems in FAMILIES.items():
133
+ entries = []
134
+ for face, stem in zip(FACES, stems):
135
+ src = os.path.join(fonts, stem + ".ttf")
136
+ if not os.path.exists(src):
137
+ print(f" ! missing {stem}.ttf — skipping {family}/{face}", file=sys.stderr)
138
+ continue
139
+ italic = "italic" in face.lower()
140
+ data, font = retag(src, family, italic)
141
+ name = f"{family.replace(' ', '')}-{face}.fntdata"
142
+ open(os.path.join(fdir, name), "wb").write(eot(data, font, family, italic))
143
+ rid = f"rId{next_id}"; next_id += 1
144
+ new_rels.append(f'<Relationship Id="{rid}" Type="{REL_FONT}" Target="fonts/{name}"/>')
145
+ entries.append(f'<p:{face} r:id="{rid}"/>')
146
+ made += 1
147
+ if entries:
148
+ font_lst.append(
149
+ f'<p:embeddedFont><p:font typeface="{family}" pitchFamily="34" charset="0"/>'
150
+ + "".join(entries) + "</p:embeddedFont>")
151
+
152
+ rels = rels.replace("</Relationships>", "".join(new_rels) + "</Relationships>")
153
+ open(rels_path, "w", encoding="utf-8").write(rels)
154
+
155
+ ct_path = os.path.join(work, "[Content_Types].xml")
156
+ ct = open(ct_path, encoding="utf-8").read()
157
+ if "fntdata" not in ct:
158
+ ct = ct.replace("<Default", '<Default Extension="fntdata" '
159
+ 'ContentType="application/x-fontdata"/><Default', 1)
160
+ open(ct_path, "w", encoding="utf-8").write(ct)
161
+
162
+ p_path = os.path.join(work, "ppt", "presentation.xml")
163
+ p = open(p_path, encoding="utf-8").read()
164
+ p = re.sub(r'\s(embedTrueTypeFonts|saveSubsetFonts)="[^"]*"', "", p)
165
+ p = re.sub(r"(<p:presentation\b)",
166
+ r'\1 embedTrueTypeFonts="1" saveSubsetFonts="0"', p, count=1)
167
+ if "<p:embeddedFontLst" not in p:
168
+ block = "<p:embeddedFontLst>" + "".join(font_lst) + "</p:embeddedFontLst>"
169
+ # schema-fixed position: immediately after <p:notesSz/>
170
+ anchor = re.search(r"<p:notesSz[^/]*/>", p) or re.search(r"<p:sldSz[^/]*/>", p)
171
+ if not anchor:
172
+ sys.exit("no <p:notesSz>/<p:sldSz> anchor in presentation.xml")
173
+ p = p[:anchor.end()] + block + p[anchor.end():]
174
+ open(p_path, "w", encoding="utf-8").write(p)
175
+
176
+ if os.path.exists(out):
177
+ os.remove(out)
178
+ zf = zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED)
179
+ for root, _, files in os.walk(work):
180
+ for fn in files:
181
+ full = os.path.join(root, fn)
182
+ zf.write(full, os.path.relpath(full, work))
183
+ # A deck that carries its own spec can be edited a month later by someone
184
+ # who has only the .pptx. PowerPoint ignores parts it does not know about.
185
+ if a.spec and os.path.exists(a.spec):
186
+ zf.writestr("wand/spec.json", open(a.spec, "rb").read())
187
+ zf.close()
188
+ shutil.rmtree(work)
189
+ print(f"embedded {made} faces across {len(font_lst)} families{' + spec' if a.spec and os.path.exists(a.spec) else ''} -> {out}")
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
@@ -0,0 +1,164 @@
1
+ {
2
+ "deck": "WAND TEMPLATE",
3
+ "confidential": "TEMPLATE",
4
+ "slides": [
5
+ { "layout": "cover", "eyebrow": "REPLACE THIS EYEBROW",
6
+ "titleLines": ["Your headline here.", "One line per line."],
7
+ "body": "The cover takes an eyebrow, up to three title lines, a body paragraph and a kicker. The title shrinks itself if it runs long.",
8
+ "kickerPlain": "Delete what you don't need. ", "kickerBold": "Keep the structure." },
9
+
10
+ { "layout": "divider", "tag": "SECTION", "eyebrow": "Use a divider between sections",
11
+ "titleText": ["One idea. ", "Stated plainly", "."],
12
+ "body": "Dividers carry a tag, an eyebrow, one big line and a paragraph. They have no footer and no page number." },
13
+
14
+ { "layout": "numberedList", "tag": "AGENDA", "dark": true,
15
+ "titleText": ["What we'll ", "cover", "."],
16
+ "subtitleText": "numberedList is the agenda and the ask. Up to six rows, numbered for you.",
17
+ "items": [
18
+ { "title": "Where you are today", "body": "One sentence of context per row. The number is drawn automatically." },
19
+ { "title": "What changes", "body": "Keep rows parallel in length — the layout reads best when they are." },
20
+ { "title": "What it takes", "body": "Six rows is the maximum. Past that, split the slide." },
21
+ { "title": "What happens next", "body": "The closing lines and sign-off below are both optional." }
22
+ ],
23
+ "closing": { "line1": "One line to close.", "line2": "One more if you need it." },
24
+ "signoff": "A sign-off sits at the very bottom." },
25
+
26
+ { "layout": "threeUp", "tag": "THREE THINGS", "dark": false, "arrows": true,
27
+ "titleText": ["Three comparable things. ", "One of them yours", "."],
28
+ "subtitleText": "threeUp is for three options, three stages, or three audiences. Set arrows: true when it is a progression.",
29
+ "items": [
30
+ { "eyebrow": "TODAY", "title": "The status quo", "pill": "where most people are", "body": "Each card takes an eyebrow, a title, a pill and a body." },
31
+ { "eyebrow": "THE SHIFT", "title": "What you propose", "pill": "the recommendation", "emph": true, "body": "Exactly one card per slide can be the emphasis card. Set emph: true on it." },
32
+ { "eyebrow": "THE HORIZON", "title": "Where it leads", "pill": "the end state", "body": "Keep the three bodies roughly the same length." }
33
+ ] },
34
+
35
+ { "layout": "sixUpIcon", "tag": "CAPABILITIES", "dark": true,
36
+ "titleText": ["Six things, ", "evenly weighted", "."],
37
+ "subtitleText": "sixUpIcon is for features or capabilities. Icons are named — run `node wand.js glyphs` for the list.",
38
+ "items": [
39
+ { "icon": "shield-check", "title": "Named icons", "body": "Say icon: \"shield-check\", not a filename. They recolour to the theme.", "note": "75 to choose from" },
40
+ { "icon": "users", "title": "The note line", "body": "Each card can carry a small accent line under the body — a target, a figure, a date.", "note": "Optional" },
41
+ { "icon": "network", "title": "Four to six", "body": "Fewer than four looks sparse; the layout draws at most six." },
42
+ { "icon": "gauge", "title": "Keep bodies short", "body": "Two lines each is the sweet spot. Longer text shrinks to fit, which reads as an accident." },
43
+ { "icon": "workflow", "title": "Don't repeat it", "body": "Three sixUpIcon slides in a row is a failure even if each one is correct." },
44
+ { "icon": "sparkles", "title": "Vary the layouts", "body": "Across fifteen slides, aim for at least eight distinct ones." }
45
+ ] },
46
+
47
+ { "layout": "statGrid", "tag": "THE NUMBERS", "dark": false,
48
+ "titleText": ["Figures, ", "not adjectives", "."],
49
+ "subtitleText": "statGrid holds three to six figures. statQuad does four larger ones; heroQuad leads with a single headline number.",
50
+ "items": [
51
+ { "value": "60%", "label": "The headline figure", "caption": "value, label and caption", "emph": true },
52
+ { "value": "2×", "label": "Second figure", "caption": "One card can be the emphasis card" },
53
+ { "value": "13 wks", "label": "Third figure", "caption": "Keep captions to one line" },
54
+ { "value": "$1.2M", "label": "Fourth figure", "caption": "Currency and units read fine" },
55
+ { "value": "30+", "label": "Fifth figure", "caption": "Six is the maximum" },
56
+ { "value": "24/7", "label": "Sixth figure", "caption": "Cut to four if any caption wraps" }
57
+ ],
58
+ "footnote": "A footnote line sits under the grid. Use it for the source." },
59
+
60
+ { "layout": "colCards", "tag": "THE LAYERS", "dark": true,
61
+ "titleText": ["Two or three things, ", "each with a list", "."],
62
+ "items": [
63
+ { "eyebrow": "Layer 01", "title": "Prose and bullets", "sub": "colCards carries both",
64
+ "body": "Use colCards when each of two or three things needs a paragraph and a list. The body is optional — drop it and the bullets take the room.",
65
+ "bullets": ["Up to five bullets", "Keep them short", "They shrink to fit", "Parallel phrasing reads best"] },
66
+ { "eyebrow": "Layer 02", "title": "Keep them parallel", "sub": "same shape, same length",
67
+ "body": "Three columns of very different lengths is the most common way this layout goes wrong.",
68
+ "bullets": ["Same number of bullets", "Same rough length", "Same grammatical form"] },
69
+ { "eyebrow": "Layer 03", "title": "Three is the maximum", "sub": "past that, use a grid",
70
+ "body": "For four or more categories, matrixGrid or stripGrid will hold them properly.",
71
+ "bullets": ["matrixGrid: 4 x 3 cells", "stripGrid: figures over columns", "compareGrid: 3 columns x 6 rows"] }
72
+ ] },
73
+
74
+ { "layout": "phaseCards", "tag": "ROADMAP", "dark": false,
75
+ "titleText": ["A plan in ", "four steps", "."],
76
+ "subtitleText": "phaseCards is the roadmap layout. Card one is the emphasis card.",
77
+ "phases": [
78
+ { "period": "Weeks 1–4", "title": "Foundation", "bullets": ["A period, a title and up to five bullets", "Keep bullets to one line each"] },
79
+ { "period": "Months 2–6", "title": "First results", "bullets": ["Four cards is the maximum", "Three reads well too"] },
80
+ { "period": "Months 6–12", "title": "Scale", "bullets": ["Name the outcome, not the activity", "Dates beat adjectives"] },
81
+ { "period": "Year 2", "title": "Steady state", "bullets": ["End on what changes for them", "Not on what you deliver"] }
82
+ ] },
83
+
84
+ { "layout": "splitPanel", "tag": "THE ARGUMENT", "dark": true,
85
+ "titleText": ["Half argument, ", "half evidence", "."],
86
+ "subtitleText": "splitPanel puts the claim on the left and the proof on the right.",
87
+ "left": { "eyebrow": "THE CLAIM", "heading": "State it in one line.",
88
+ "body": "The left panel is the emphasis card: an eyebrow, a heading, a paragraph and an optional note. Use it for the argument you want remembered.",
89
+ "note": "The note is italic and accented — good for a consequence or a result." },
90
+ "right": { "eyebrow": "THE EVIDENCE", "heading": "Up to four supporting rows",
91
+ "items": [
92
+ { "label": "Label and body", "body": "render as one line, so the source reads exactly as written." },
93
+ { "label": "Or no label", "body": "Leave it out and the row is a single sentence." },
94
+ { "body": "Like this one — body only, no label." },
95
+ { "label": "Four is the maximum", "body": "A fifth row will be refused by validate rather than silently dropped." }
96
+ ] } },
97
+
98
+ { "layout": "compareGrid", "tag": "BEFORE AND AFTER", "dark": false,
99
+ "titleText": ["A comparison ", "wider than two columns", "."],
100
+ "subtitleText": "compareGrid does up to three headed columns over six labelled rows. The last column is yours and is tinted.",
101
+ "heads": ["TODAY", "WITH YOU", "WHY ONLY YOU"],
102
+ "rows": [
103
+ { "label": "Speed", "cells": [{ "body": "The status quo, stated plainly and without strawmanning it." }, { "lead": "The improvement.", "body": "A short second line if it helps." }, { "body": "The reason nobody else can claim it." }] },
104
+ { "label": "Cost", "cells": [{ "body": "Keep every cell to roughly the same length." }, { "lead": "The number.", "body": "Lead with it." }, { "body": "The structural reason, not the feature." }] },
105
+ { "label": "Risk", "cells": [{ "body": "Six rows is the maximum." }, { "lead": "What changes." }, { "body": "Two columns work too — drop a head and a cell." }] }
106
+ ] },
107
+
108
+ { "layout": "stripGrid", "tag": "THE YEAR", "dark": true,
109
+ "titleText": ["Figures over ", "a column grid", "."],
110
+ "subtitleText": "stripGrid is the densest layout here: a strip of figures, a statement, a column grid and a closing band — all optional except the geometry.",
111
+ "stats": [
112
+ { "value": "8%", "label": "First metric" }, { "value": "12%", "label": "Second" },
113
+ { "value": "8K", "label": "Third" }, { "value": "30%", "label": "Fourth" },
114
+ { "value": "18%", "label": "Fifth" }, { "value": "25%", "label": "Sixth" }
115
+ ],
116
+ "columns": [
117
+ { "label": "WHAT YOU DELIVER", "items": [
118
+ { "body": "Up to six rows per column, up to four columns." },
119
+ { "body": "A row can be a body alone, or a title plus a body." },
120
+ { "body": "Everything measures itself and shrinks to fit." } ] },
121
+ { "label": "WHAT THEY COMMIT", "items": [
122
+ { "body": "Use two columns for a delivers/commits split." },
123
+ { "body": "Three or four for categories." },
124
+ { "body": "One wide column for a single block of prose." } ] }
125
+ ] },
126
+
127
+ { "layout": "archStack", "tag": "ARCHITECTURE", "dark": false,
128
+ "titleText": ["One layer ", "above the rest", "."],
129
+ "subtitleText": "archStack is the platform slide: who sits on top, what you provide in the middle, what it governs below.",
130
+ "top": { "label": "THE PEOPLE ON TOP", "body": "Who uses it, and what they see" },
131
+ "middle": { "label": "YOUR LAYER — what it does",
132
+ "modules": [
133
+ { "title": "Module one", "body": "Up to six modules. The band grows to fit the longest one." },
134
+ { "title": "Module two", "body": "Keep the bodies short and parallel." },
135
+ { "title": "Module three", "body": "Leave modules empty and the band becomes a connector line." },
136
+ { "title": "Module four", "body": "A note line can sit under the row." }
137
+ ] },
138
+ "note": "An optional note line sits between the modules and the systems below.",
139
+ "sources": { "eyebrow": "WHAT IT GOVERNS", "cards": [
140
+ { "title": "System one", "body": "ours: true is a first-party card", "ours": true },
141
+ { "title": "System two", "body": "ours: false gets the dashed treatment", "ours": false } ] },
142
+ "outcome": "THE OUTCOME BAND • ONE LINE • ALL CAPS READS BEST HERE" },
143
+
144
+ { "layout": "logoBands", "tag": "PROOF", "dark": true,
145
+ "titleText": ["Figures over ", "names", "."],
146
+ "subtitleText": "logoBands is the proof slide: a few figures, then rows of customer or investor names.",
147
+ "stats": [
148
+ { "value": "$120M+", "caption": "A figure and\na two-line caption" },
149
+ { "value": ">$1B", "caption": "Up to four\nfigures" },
150
+ { "value": "80+", "caption": "Keep the captions\nparallel" },
151
+ { "value": "3", "caption": "Offices, markets,\nwhatever counts" }
152
+ ],
153
+ "bands": [
154
+ { "label": "CUSTOMERS", "names": "Name • Name • Name • Name • Name • Name",
155
+ "note": "A note under the row is optional." },
156
+ { "label": "INVESTORS", "names": "Name • Name • Name • Name • Name" }
157
+ ] },
158
+
159
+ { "layout": "closing", "eyebrow": "THE CLOSE",
160
+ "line1": "The last thing they read.", "line2": "Make it the ask.",
161
+ "body": "closing is centred and full bleed, with no footer. Two lines, a paragraph and a URL.",
162
+ "url": "wand.ai" }
163
+ ]
164
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/lib/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # Shared libraries
2
+
3
+ Code that is about the *brand payload* rather than about any one output format,
4
+ so decks and docs cannot drift apart on it.
5
+
6
+ | | |
7
+ |---|---|
8
+ | `measure.js` | Real Geist metrics via fontkit. Fit is measured, never guessed |
9
+ | `glyphs.js` | The named Lucide set, recoloured per theme |
10
+ | `pdf.js` | A PDF writer — embedded TrueType, paths, images. Both kits render PDF |
11
+ | `ttf.py` | The 1% of TrueType `embed_fonts.py` needs, without fontTools |
12
+ | `wandxml.py` | XML parsing that works with nothing installed |
13
+
14
+ Each kit keeps its own `lib/brand.js` — that one *is* kit-local, because it
15
+ resolves paths relative to the kit — and reaches these through it:
16
+
17
+ ```js
18
+ const brand = require("./brand");
19
+ const M = require(path.join(brand.resolve("lib"), "measure.js"));
20
+ ```
21
+
22
+ `tools/package.mjs` copies this directory into every zip, so a packaged kit
23
+ still resolves everything locally.