@litfamily/litopencode 1.0.31 → 1.0.33

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 (282) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README-Ko-KR.md +6 -6
  3. package/README.md +6 -6
  4. package/TRADEMARK.md +12 -0
  5. package/dist/activation-primary-prompts.js +5 -1
  6. package/dist/activation-probe.js +1 -0
  7. package/dist/activation-prompt-utils.d.ts +1 -1
  8. package/dist/activation-reentry.d.ts +20 -0
  9. package/dist/activation-reentry.js +110 -1
  10. package/dist/activation-routing.d.ts +2 -1
  11. package/dist/activation-routing.js +100 -11
  12. package/dist/activation-workflow-prompts.d.ts +4 -0
  13. package/dist/activation-workflow-prompts.js +30 -0
  14. package/dist/activation.d.ts +1 -1
  15. package/dist/activation.js +44 -5
  16. package/dist/adhd-next.d.ts +51 -0
  17. package/dist/adhd-next.js +150 -0
  18. package/dist/agents/defaults.js +1 -1
  19. package/dist/cli/managed-skill-assets.d.ts +1 -1
  20. package/dist/cli/managed-skill-assets.js +7 -1
  21. package/dist/cli/native-skill-integrity.js +1 -1
  22. package/dist/cli.js +7 -1
  23. package/dist/commands.d.ts +28 -1
  24. package/dist/commands.js +58 -5
  25. package/dist/durable-plan.d.ts +5 -1
  26. package/dist/durable-plan.js +14 -2
  27. package/dist/features.d.ts +80 -1
  28. package/dist/features.js +105 -1
  29. package/dist/hooks.js +3 -0
  30. package/dist/ledger.js +12 -1
  31. package/dist/plan-scope-roots.d.ts +23 -0
  32. package/dist/plan-scope-roots.js +116 -0
  33. package/dist/restart-notice.d.ts +7 -0
  34. package/dist/restart-notice.js +40 -0
  35. package/dist/secret-shapes.js +5 -5
  36. package/dist/server.js +4 -0
  37. package/dist/session-liveness.d.ts +20 -0
  38. package/dist/session-liveness.js +99 -0
  39. package/dist/skills.d.ts +22 -1
  40. package/dist/skills.js +36 -0
  41. package/dist/start-work-typed.d.ts +34 -0
  42. package/dist/start-work-typed.js +167 -0
  43. package/dist/state.d.ts +3 -0
  44. package/dist/state.js +3 -0
  45. package/dist/tools.js +22 -2
  46. package/dist/tui/mission-band.d.ts +22 -1
  47. package/dist/tui/mission-band.js +75 -16
  48. package/dist/tui/mission-looks.d.ts +38 -0
  49. package/dist/tui/mission-looks.js +73 -0
  50. package/dist/tui/mission-model.d.ts +2 -0
  51. package/dist/tui/mission-model.js +3 -1
  52. package/dist/tui/mission-rainbow.d.ts +33 -0
  53. package/dist/tui/mission-rainbow.js +90 -0
  54. package/dist/tui/mission-source.d.ts +49 -0
  55. package/dist/tui/mission-source.js +217 -2
  56. package/dist/tui/mission-text.d.ts +2 -0
  57. package/dist/tui/mission-text.js +9 -1
  58. package/dist/tui/mission-threads.d.ts +58 -0
  59. package/dist/tui/mission-threads.js +128 -0
  60. package/dist/tui.js +112 -7
  61. package/dist/uiux-visual-catalog.js +8 -1
  62. package/dist/v2/events.js +3 -0
  63. package/dist/v2/session-hooks.js +38 -5
  64. package/dist/workflow-stack.d.ts +53 -0
  65. package/dist/workflow-stack.js +196 -0
  66. package/docs/assets/readme/badge-version.svg +1 -1
  67. package/docs/assets/skills/lit-schematic.webp +0 -0
  68. package/docs/reference-Ko-KR.md +48 -3
  69. package/docs/reference.md +54 -3
  70. package/package.json +1 -1
  71. package/skills/frontend-ui-ux/SKILL.md +3 -2
  72. package/skills/frontend-ui-ux/kit/components.css +414 -0
  73. package/skills/frontend-ui-ux/kit/fonts.json +22 -0
  74. package/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  75. package/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  76. package/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  77. package/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  78. package/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  79. package/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  80. package/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  81. package/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  82. package/skills/frontend-ui-ux/kit/ui.js +226 -0
  83. package/skills/frontend-ui-ux/references/brand-and-imagery.md +2 -2
  84. package/skills/frontend-ui-ux/references/complete-contract.md +2 -1
  85. package/skills/frontend-ui-ux/references/craft-floor.md +6 -2
  86. package/skills/frontend-ui-ux/references/default-editorial-pixel.json +3 -0
  87. package/skills/frontend-ui-ux/references/house-styles.md +236 -0
  88. package/skills/frontend-ui-ux/references/motion-guide.md +2 -2
  89. package/skills/frontend-ui-ux/references/production.md +3 -1
  90. package/skills/frontend-ui-ux/references/slop-register.md +4 -4
  91. package/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  92. package/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
  93. package/skills/frontend-ui-ux/scripts/probe-lock.mjs +235 -0
  94. package/skills/frontend-ui-ux/scripts/probe-page.mjs +34 -12
  95. package/skills/frontend-ui-ux/scripts/probe-static.mjs +16 -3
  96. package/skills/frontend-ui-ux/scripts/probe-thresholds.json +8 -2
  97. package/skills/frontend-ui-ux/scripts/probe.mjs +35 -7
  98. package/skills/lit-adhd/SKILL.md +209 -0
  99. package/skills/lit-adhd/references/brief-format.md +46 -0
  100. package/skills/lit-adhd/references/card-examples.md +103 -0
  101. package/skills/lit-adhd/references/status-taxonomy.md +34 -0
  102. package/skills/lit-adhd/scripts/rewind.mjs +933 -0
  103. package/skills/lit-diagram-drawer/SKILL.md +4 -3
  104. package/skills/lit-diagram-drawer/references/style-guide.md +20 -11
  105. package/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
  106. package/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
  107. package/skills/lit-diagram-drawer/references/type-tree.md +3 -3
  108. package/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
  109. package/skills/lit-diagram-drawer/references/verifier-guide.md +1 -0
  110. package/skills/lit-diagram-drawer/scripts/craft-thresholds.json +1 -2
  111. package/skills/lit-diagram-drawer/scripts/visual-quality.mjs +1 -2
  112. package/skills/lit-docx/SKILL.md +2 -1
  113. package/skills/lit-handoff/SKILL.md +2 -0
  114. package/skills/lit-hijack/SKILL.md +422 -0
  115. package/skills/lit-hijack/references/capture-and-provenance.md +87 -0
  116. package/skills/lit-hijack/references/clean-room-build.md +111 -0
  117. package/skills/lit-hijack/references/distil-and-licence.md +100 -0
  118. package/skills/lit-hijack/references/gap-and-port-design.md +126 -0
  119. package/skills/lit-hijack/references/parity-and-report.md +110 -0
  120. package/skills/lit-hijack/scripts/hijack.mjs +993 -0
  121. package/skills/lit-plan/SKILL.md +8 -0
  122. package/skills/lit-pptx/SKILL.md +27 -11
  123. package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  124. package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  125. package/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  126. package/skills/lit-pptx/fonts/jetbrains-mono/SOURCE.md +17 -0
  127. package/skills/lit-pptx/references/density-and-fill.md +9 -3
  128. package/skills/lit-pptx/references/direction-step.md +21 -6
  129. package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +3 -6
  130. package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +1 -5
  131. package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +0 -4
  132. package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +2 -2
  133. package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +4 -7
  134. package/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
  135. package/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
  136. package/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
  137. package/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
  138. package/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
  139. package/skills/lit-pptx/references/layout-families.md +65 -47
  140. package/skills/lit-pptx/references/title-treatments.md +83 -134
  141. package/skills/lit-pptx/references/tonalities/atlas.md +3 -3
  142. package/skills/lit-pptx/references/tonalities/bauhaus.md +140 -0
  143. package/skills/lit-pptx/references/tonalities/chalk.md +7 -6
  144. package/skills/lit-pptx/references/tonalities/computational-brutalism.md +150 -0
  145. package/skills/lit-pptx/references/tonalities/gazette.md +1 -1
  146. package/skills/lit-pptx/references/tonalities/ledger.md +1 -1
  147. package/skills/lit-pptx/references/tonalities/neo-brutalism.md +161 -0
  148. package/skills/lit-pptx/references/tonalities/newsroom.md +144 -0
  149. package/skills/lit-pptx/references/tonalities/night.md +1 -1
  150. package/skills/lit-pptx/references/tonalities/paper.md +2 -2
  151. package/skills/lit-pptx/references/tonalities/riso.md +137 -0
  152. package/skills/lit-pptx/references/tonalities/signal.md +3 -3
  153. package/skills/lit-pptx/references/tonalities/studio.md +7 -6
  154. package/skills/lit-pptx/run.mjs +2 -1
  155. package/skills/lit-pptx/scripts/compile-deck.js +1 -1
  156. package/skills/lit-pptx/scripts/craft-thresholds.json +4 -8
  157. package/skills/lit-pptx/scripts/craft_extras.py +56 -3
  158. package/skills/lit-pptx/scripts/deck_output.py +75 -90
  159. package/skills/lit-pptx/scripts/embed_fonts.py +13 -1
  160. package/skills/lit-pptx/scripts/layout_inventory.py +12 -5
  161. package/skills/lit-pptx/scripts/lib/grid-resolver.js +101 -110
  162. package/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
  163. package/skills/lit-pptx/scripts/lib/render-pack.js +373 -566
  164. package/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
  165. package/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
  166. package/skills/lit-pptx/scripts/qa_deck.py +10 -11
  167. package/skills/lit-pptx/scripts/render_pages.py +118 -0
  168. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
  169. package/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
  170. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
  171. package/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  172. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
  173. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
  174. package/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  175. package/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  176. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
  177. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
  178. package/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  179. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
  180. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
  181. package/skills/lit-recap/SKILL.md +4 -0
  182. package/skills/lit-schematic/SKILL.md +118 -0
  183. package/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
  184. package/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
  185. package/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
  186. package/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
  187. package/skills/lit-schematic/references/cutaway-outputs.md +70 -0
  188. package/skills/lit-schematic/references/isometric-craft.md +101 -0
  189. package/skills/lit-schematic/references/journal-figures.md +72 -0
  190. package/skills/lit-schematic/references/kit-api.md +134 -0
  191. package/skills/lit-schematic/references/pfd-and-layout.md +53 -0
  192. package/skills/lit-schematic/references/pid-drafting.md +103 -0
  193. package/skills/lit-schematic/references/pid-symbols.md +163 -0
  194. package/skills/lit-schematic/references/verification.md +100 -0
  195. package/skills/lit-schematic/scripts/doctor.mjs +57 -0
  196. package/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
  197. package/skills/lit-schematic/scripts/lib/fonts.mjs +25 -0
  198. package/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
  199. package/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
  200. package/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
  201. package/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
  202. package/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
  203. package/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
  204. package/skills/lit-schematic/scripts/lib/png.mjs +132 -0
  205. package/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
  206. package/skills/lit-schematic/scripts/lib/route.mjs +218 -0
  207. package/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
  208. package/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
  209. package/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
  210. package/skills/lit-schematic/scripts/motion.mjs +129 -0
  211. package/skills/lit-schematic/scripts/qa.mjs +92 -0
  212. package/skills/lit-schematic/scripts/render.mjs +233 -0
  213. package/skills/lit-schematic/scripts/scaffold.mjs +60 -0
  214. package/skills/lit-scientific-visualization/SKILL.md +27 -4
  215. package/skills/lit-typographic-motion/SKILL.md +19 -17
  216. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +9 -0
  217. package/skills/lit-typographic-motion/engine/accents.mjs +6 -0
  218. package/skills/lit-typographic-motion/engine/assets.mjs +282 -0
  219. package/skills/lit-typographic-motion/engine/blender/runner.py +228 -0
  220. package/skills/lit-typographic-motion/engine/blender.mjs +126 -0
  221. package/skills/lit-typographic-motion/engine/board.mjs +138 -0
  222. package/skills/lit-typographic-motion/engine/cover/assets.mjs +11 -8
  223. package/skills/lit-typographic-motion/engine/cover/capture.mjs +6 -6
  224. package/skills/lit-typographic-motion/engine/cover/render.mjs +20 -7
  225. package/skills/lit-typographic-motion/engine/cover/treatment.mjs +30 -3
  226. package/skills/lit-typographic-motion/engine/fill.mjs +54 -0
  227. package/skills/lit-typographic-motion/engine/gallery.mjs +118 -0
  228. package/skills/lit-typographic-motion/engine/house.mjs +42 -0
  229. package/skills/lit-typographic-motion/engine/look.mjs +65 -8
  230. package/skills/lit-typographic-motion/engine/loudness.mjs +101 -0
  231. package/skills/lit-typographic-motion/engine/scaffold.mjs +219 -0
  232. package/skills/lit-typographic-motion/engine/score.mjs +594 -0
  233. package/skills/lit-typographic-motion/engine/sound.mjs +9 -57
  234. package/skills/lit-typographic-motion/engine/stage/capture.mjs +23 -9
  235. package/skills/lit-typographic-motion/engine/stage/clock.js +8 -2
  236. package/skills/lit-typographic-motion/engine/stage/cover-init.js +12 -1
  237. package/skills/lit-typographic-motion/engine/stage/frame-worker.mjs +15 -7
  238. package/skills/lit-typographic-motion/engine/stage/gate.mjs +48 -4
  239. package/skills/lit-typographic-motion/engine/stage/qa-page.js +16 -5
  240. package/skills/lit-typographic-motion/engine/stage/qa.mjs +148 -7
  241. package/skills/lit-typographic-motion/engine/stage/render.mjs +69 -7
  242. package/skills/lit-typographic-motion/engine/stage/scan.mjs +20 -1
  243. package/skills/lit-typographic-motion/engine/stage/stage-kit.js +85 -4
  244. package/skills/lit-typographic-motion/engine/stills.mjs +1 -1
  245. package/skills/lit-typographic-motion/engine/styles.mjs +221 -0
  246. package/skills/lit-typographic-motion/engine/text.mjs +1 -1
  247. package/skills/lit-typographic-motion/engine/treatment.mjs +190 -6
  248. package/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  249. package/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  250. package/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  251. package/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  252. package/skills/lit-typographic-motion/gate.mjs +18 -2
  253. package/skills/lit-typographic-motion/probe.mjs +13 -1
  254. package/skills/lit-typographic-motion/recipes/hero-3d/expected.json +64 -0
  255. package/skills/lit-typographic-motion/recipes/hero-3d/index.html +132 -0
  256. package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +2 -0
  257. package/skills/lit-typographic-motion/recipes/painted-light/expected.json +2 -0
  258. package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +2 -0
  259. package/skills/lit-typographic-motion/references/cover.md +56 -13
  260. package/skills/lit-typographic-motion/references/craft-loop.md +20 -7
  261. package/skills/lit-typographic-motion/references/depth.md +112 -0
  262. package/skills/lit-typographic-motion/references/runtime.md +21 -6
  263. package/skills/lit-typographic-motion/references/sound.md +76 -0
  264. package/skills/lit-typographic-motion/references/stage.md +50 -7
  265. package/skills/lit-typographic-motion/references/studio.md +162 -0
  266. package/skills/lit-typographic-motion/references/style-bibles.md +18 -0
  267. package/skills/lit-typographic-motion/references/treatment.md +108 -9
  268. package/skills/lit-typographic-motion/render.mjs +60 -3
  269. package/skills/lit-typographic-motion/styles/paper.json +185 -0
  270. package/skills/lit-typographic-motion/styles/swiss-signal.json +188 -0
  271. package/skills/lit-typographic-motion/styles/terminalcore.json +182 -0
  272. package/skills/lit-typographic-motion/styles/tidal.json +187 -0
  273. package/skills/litresearch/SKILL.md +2 -0
  274. package/skills/managed-skill-manifest.json +517 -88
  275. package/skills/readme-studio/SKILL.md +1 -1
  276. package/skills/review-work/SKILL.md +2 -0
  277. package/skills/start-work/SKILL.md +8 -0
  278. package/skills/workflow-loop/SKILL.md +4 -0
  279. package/tools/check-pack-payload.mjs +5 -2
  280. package/tools/payload-substance-parity.json +4 -3
  281. package/tools/qa-cover-recipes.mjs +4 -3
  282. package/tools/version-manifests.json +2 -2
@@ -14,6 +14,7 @@
14
14
 
15
15
  const path = require("path");
16
16
  const G = require("./grid-resolver");
17
+ const SG = require("./slide-grid");
17
18
  const { looksLikeKpi } = require("./layout-resolver");
18
19
 
19
20
  // Helpers shared with the legacy renderer (hex, cleanMd, tables, charts, images, placements).
@@ -35,9 +36,9 @@ function packStyle(pack) {
35
36
  pack, P, sizes: pack.tok.sizes, tok: pack.tok, grid: pack.grid, faces: pack.faces,
36
37
  deco: new Set(pack.decoration || []),
37
38
  // Heavier faces set wider: the advance estimate grows with the weight so frames hold their lines.
38
- wide(role = "body") { return G.faceWidth(pack.faces[role]); },
39
- ems(text, role) { return G.textEms(text) * this.wide(role); },
40
- lines(text, w, size, role) { return G.lineCount(text, w / this.wide(role), size); },
39
+ wide(role = "body", text) { return G.faceWidth(pack.faces[role], text); },
40
+ ems(text, role) { return G.textEms(text) * this.wide(role, text); },
41
+ lines(text, w, size, role) { return G.lineCount(text, w / this.wide(role, text), size); },
41
42
  colour: (role) => shared().hex(P[role] || P.ink),
42
43
  // Tracking is a percentage of the size; Hangul body text is never tracked.
43
44
  tracking(face, size) {
@@ -75,7 +76,7 @@ function balanced(S, text, w, size, role, room = Infinity) {
75
76
  if (/\*\*/u.test(s)) return s;
76
77
  const lines = S.lines(s, w, size, role);
77
78
  if (lines < 2) return s;
78
- const per = w / S.wide(role) / size;
79
+ const per = w / S.wide(role, s) / size;
79
80
  const set = G.balanceLines(s, lines, per);
80
81
  // A set ending on one word gets a line more when the frame holds it and that ends on two.
81
82
  const widow = (t) => t.includes("\n") && !/ /u.test(t.slice(t.lastIndexOf("\n") + 1));
@@ -111,7 +112,7 @@ function packText(slide, S, text, box, o = {}) {
111
112
  text = String(text == null ? "" : text).replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/gu, (_, label, url) => { links.push([label, url]); return label; });
112
113
  if (o.balance) text = balanced(S, text, box.w, size, faceRole, Math.floor(box.h / (size * (o.lineSpacing || 1.15)) + 0.01));
113
114
  else if (!o.title && faceRole !== "numeral") {
114
- text = G.keepLines(text, box.w / S.wide(faceRole) / size, Math.floor(box.h / (size * (o.lineSpacing || G.leading(plainText(text)))) + 0.01));
115
+ text = G.keepLines(text, box.w / S.wide(faceRole, plainText(text)) / size, Math.floor(box.h / (size * (o.lineSpacing || G.leading(plainText(text)))) + 0.01));
115
116
  }
116
117
  const tracking = o.tracking != null ? o.tracking : S.tracking(faceRole, size);
117
118
  const base = { fontFace: face.font, fontSize: size, color: o.colour || S.colour("ink"), bold: Boolean(face.bold || o.bold),
@@ -136,11 +137,15 @@ function packText(slide, S, text, box, o = {}) {
136
137
 
137
138
  function packRect(slide, S, box, o = {}) {
138
139
  const radius = o.radius || 0;
140
+ // In a pack with hard edges a block of the field (a band, a plate, a rail) carries the border too.
141
+ if (S.pack.shadow && o.fill === "field" && !o.line && box.w >= 24 && box.h >= 24) o = { ...o, line: S.pack["edge-colour"] || "line", lineWidth: S.pack["edge-weight"] };
139
142
  slide.addShape(radius ? "roundRect" : "rect", {
140
143
  x: IN(box.x), y: IN(box.y), w: IN(box.w), h: IN(box.h),
141
144
  ...(radius ? { rectRadius: IN(radius) } : {}),
142
145
  fill: o.fill ? { color: S.colour(o.fill), transparency: o.alpha || 0 } : { type: "none" },
143
146
  line: o.line ? { color: S.colour(o.line), width: o.lineWidth || 0.75 } : { type: "none" },
147
+ // A hard offset shadow: no blur, fully opaque, down and to the right.
148
+ ...(o.shadow && S.pack.shadow ? { shadow: { type: "outer", blur: 0, offset: S.pack.shadow.offset, angle: 45, color: S.colour(S.pack.shadow.colour).replace("#", ""), opacity: 1 } } : {}),
144
149
  ...(o.name ? { objectName: o.name } : {}),
145
150
  });
146
151
  }
@@ -148,13 +153,23 @@ function packRect(slide, S, box, o = {}) {
148
153
  /** A surface (card, box, panel) in the pack's edge treatment and radius. */
149
154
  function surfaceRect(slide, S, box, fill = "surface") {
150
155
  const edge = S.pack.edge;
156
+ // A shadowed surface is filled, so its shadow never shows through it.
157
+ const shadow = S.pack.shadow || null;
151
158
  packRect(slide, S, box, {
152
- fill: edge === "border" ? null : fill,
153
- line: edge === "fill" ? null : "line",
159
+ fill: edge === "border" && !shadow ? null : edge === "border" ? "ground" : fill,
160
+ line: edge === "fill" ? null : S.pack["edge-colour"] || "line",
161
+ lineWidth: S.pack["edge-weight"],
154
162
  radius: S.pack.radius,
163
+ shadow,
155
164
  });
156
165
  }
157
166
 
167
+ /** A pack with a hard shadow draws every box with its border and shadow (filled, `fill` when given). */
168
+ function hardEdge(S, fill) {
169
+ if (!S.pack.shadow) return {};
170
+ return { line: S.pack["edge-colour"] || "line", lineWidth: S.pack["edge-weight"], shadow: S.pack.shadow, ...(fill ? { fill } : {}) };
171
+ }
172
+
158
173
  // ── Content atoms ───────────────────────────────────────────────────────────
159
174
 
160
175
  /** Body paragraphs of a list, in reading order. */
@@ -264,8 +279,8 @@ function measureTable(item, w, S, state) {
264
279
  const minRow = S.tok.rowMin * (state.anchor ? 1.25 : 1);
265
280
  // Rows are measured at the face's natural line (about 1.45 of the size with its gap), which is
266
281
  // what office renderers lay a cell out at; a smaller estimate lets rows grow past the frame.
267
- const rowH = (cells, size) => Math.max(minRow, ...cells.map((t, i) => G.textHeight(plainText(t), (widths[i] || w) - cell * 2, size, 1.45) + cell * 2));
268
- const heights = [rowH(headers, header), ...rows.map((r) => rowH(r, data))];
282
+ const rowH = (cells, size, role) => Math.max(minRow, ...cells.map((t, i) => G.textHeight(plainText(t), ((widths[i] || w) - cell * 2) / S.wide(role, plainText(t)), size, 1.45) + cell * 2));
283
+ const heights = [rowH(headers, header, "label"), ...rows.map((r) => rowH(r, data, "body"))];
269
284
  const tableH = heights.reduce((a, b) => a + b, 0);
270
285
  return { kind: "table", heights, widths, header, data, tableH, h: tableH + captionHeight(item.caption, w, S) };
271
286
  }
@@ -420,9 +435,9 @@ function textAt(paras, R, S, state, o = {}) {
420
435
  function visualWithText(ctx, visuals, texts, share, o = {}) {
421
436
  const { S, state, Z } = ctx;
422
437
  const out = [];
423
- // A visual alone (its takeaways in the rail, or none): a table opens its rows; on the compact step a
438
+ // A visual alone (no takeaways): a table opens its rows; on the compact step a
424
439
  // chart carries its values under it.
425
- if (!texts.length) return ((S.tok.tight && !ctx.railValues && valuesUnder(visuals, Z, S, state)) || openRows(stack(visuals, Z, S, state).placed, Z, S));
440
+ if (!texts.length) return ((S.tok.tight && valuesUnder(visuals, Z, S, state)) || openRows(stack(visuals, Z, S, state).placed, Z, S));
426
441
  if (!visuals.length) return stack(texts, Z, S, state).placed;
427
442
  // Takeaways too short for their column give the visual columns: a narrower column wraps them onto more
428
443
  // lines and a wider chart or picture grows, down to a three-column takeaway.
@@ -477,16 +492,17 @@ function visualWithText(ctx, visuals, texts, share, o = {}) {
477
492
  // The strip closes the column once the visual's own height is settled.
478
493
  if (col.strip && col.end <= col.R.bottom + 0.5 && v.bottom > col.end) t.placed.push(...col.strip.placed.map((q) => ({ ...q, strip: true })));
479
494
  // A table keeps its own height, so beside it a short takeaway leaves the body half empty. When
480
- // that band is over the density cap and the takeaway fits under the table, it goes there, its
481
- // points side by side in two columns so they read as notes on the table above them.
495
+ // that band is over the density cap and the takeaway fits under the table, it goes there as notes on
496
+ // the table above: points that pair off side by side in two columns, an odd count down one column at the
497
+ // reading measure, so neither column stops a point short of the other.
482
498
  if (v.placed.every((p) => !p.flex) && (Z.bottom - Math.max(v.bottom, t.bottom)) / (Z.bottom - Z.y) > S.tok.cap) {
483
499
  const top = stack(visuals, Z, S, state);
484
500
  const below = { ...Z, y: top.bottom + state.blockGap };
485
501
  const paras = texts.flatMap(itemParas);
486
502
  const half = Math.ceil(paras.length / 2);
487
- const notes = paras.length >= 2
503
+ const notes = paras.length >= 2 && paras.length % 2 === 0
488
504
  ? splitRegion(S, below, 0.5).map((R, i) => textAt(i ? paras.slice(half) : paras.slice(0, half), R, S, state))
489
- : stack(texts, below, S, state).placed;
505
+ : [textAt(paras, region(S, Z.a, Math.min(Z.b, Z.a + 8), below.y, below.bottom), S, state)];
490
506
  const end = Math.max(...notes.map((q) => q.y + q.h));
491
507
  if (end <= Z.bottom) {
492
508
  // Still short of the density band: the table's rows open up first (half the room, at most
@@ -564,7 +580,7 @@ function takeawayColumn(ctx, texts, visuals, full, visualBottom) {
564
580
  }
565
581
 
566
582
  /**
567
- * A table standing alone in its region (its takeaways in a side title's rail) opens its rows, at most one
583
+ * A table standing alone in its region (no takeaways beside it) opens its rows, at most one
568
584
  * minimum row height each, while the region leaves more than the density band empty under it.
569
585
  */
570
586
  function openRows(placed, Z, S) {
@@ -617,13 +633,13 @@ const PLANS = {
617
633
  const items = ctx.items;
618
634
  // Running text keeps its measure: cols 1-9 on the presented ramp, 1-7 on the reading ramp.
619
635
  const limit = S.tok.ramp === "presented" ? 9 : 7;
620
- const R = ctx.cls === "side" ? Z : region(S, Z.a, Math.min(Z.b, Z.a + limit - 1), Z.y, Z.bottom);
636
+ const R = region(S, Z.a, Math.min(Z.b, Z.a + limit - 1), Z.y, Z.bottom);
621
637
  const { visuals, texts } = sortItems(items);
622
638
  if (visuals.length && texts.length) return visualWithText(ctx, visuals, texts, 7 / 12);
623
639
  const placed = stack(items, R, S, state).placed;
624
640
  // Two to five points that each open with a bold head, and that leave the body more than the
625
641
  // density band empty, read as rows: the head in its own column, the point beside it.
626
- const ents = !visuals.length && ctx.cls !== "side" ? entries(texts.flatMap(itemParas)) : [];
642
+ const ents = !visuals.length ? entries(texts.flatMap(itemParas)) : [];
627
643
  // Groups of sub-points under bold heads, held to one reading measure narrower than the body,
628
644
  // run in two columns instead: the measure stays and the body is used across.
629
645
  const groups = ents.length >= 2 && ents.every((e) => e.head && !e.rest && e.children.length);
@@ -651,7 +667,8 @@ const PLANS = {
651
667
  "text-two-column"(ctx) {
652
668
  const { S, state, Z } = ctx;
653
669
  const { columns, texts, visuals } = sortItems(ctx.items);
654
- if (columns) return stack([columns, ...visuals], Z, S, state).placed;
670
+ // Points after the columns are the slide's takeaways: they run under both columns.
671
+ if (columns) return stack([columns, ...visuals, ...texts], Z, S, state).placed;
655
672
  const paras = texts.flatMap(itemParas);
656
673
  const [l, r] = splitRegion(S, Z, 0.5);
657
674
  // Break at a section boundary nearest to half the height.
@@ -727,8 +744,6 @@ const PLANS = {
727
744
 
728
745
  "sidebar-note"(ctx) {
729
746
  const { S, state, Z } = ctx;
730
- // The note stands in a side title's rail: the main points take the body.
731
- if (ctx.railNote) return PLANS["text-column"](ctx);
732
747
  const { texts, visuals } = sortItems(ctx.items);
733
748
  // The note is the fenced main-box, else a last text block after the main one; a lone text block is the main.
734
749
  const note = texts.find((t) => t.type === "main-box") || (texts.length > 1 ? texts[texts.length - 1] : null);
@@ -752,7 +767,7 @@ const PLANS = {
752
767
  if (!overflows(narrow) && shortColumn(narrow, Z.bottom, S).share <= RAIL_EMPTY && (Z.bottom - contentBottom(narrow, Z.y)) / (Z.bottom - Z.y) <= S.tok.cap) return narrow;
753
768
  }
754
769
  if (main.length && !visuals.length && shortColumn(out, Z.bottom, S).share > RAIL_EMPTY) {
755
- // The note keeps a reading measure inside the box (S4.4): about 32 Hangul or 70 Latin characters.
770
+ // The note keeps a reading measure inside the box: about 32 Hangul or 70 Latin characters.
756
771
  const korean = G.isKorean(itemParas(note).map((p) => p.text).join(""));
757
772
  const inner = Math.min(Z.w - pad * 2, (korean ? 30 : 35) * S.sizes.lead);
758
773
  const lead = measureText(itemParas(note).map((p) => ({ ...p, kind: "text" })), inner, S, state, { size: S.sizes.lead });
@@ -851,7 +866,7 @@ const PLANS = {
851
866
  const kpi = visuals.find((v) => v.type === "kpi-table" && looksLikeKpi(v.content || {}, { basis: true }));
852
867
  if (!kpi) return PLANS["table-insight"](ctx);
853
868
  const others = visuals.filter((v) => v !== kpi);
854
- if (ctx.cls === "side" || Z.b - Z.a + 1 < 10 || (state.anchor && ctx.cls === "top")) {
869
+ if (Z.b - Z.a + 1 < 10 || (state.anchor && !S.pack.shadow)) {
855
870
  // Under a side title, or when the values take the room, they run down the body as rows:
856
871
  // the value, then what it measures.
857
872
  const cards = shared().kpiCards(kpi.content).slice(0, 6);
@@ -863,12 +878,13 @@ const PLANS = {
863
878
  if (restTexts.length) out.push(...stack(restTexts, { ...Z, y: rowsZone.bottom + state.blockGap }, S, state).placed);
864
879
  return out;
865
880
  }
866
- const top = ctx.cls === "bottom" ? Z.y + 72 : Z.y;
881
+ const top = Z.y;
867
882
  const cardsPlan = stack([kpi, ...others, ...texts], { ...Z, y: top }, S, state);
868
- // On the compact step a row of cards with its takeaways under it stops high on the page. The
869
- // figures then run as rows down the first seven columns (value, label, basis), the caption at
870
- // their foot, and the takeaways stand beside them.
871
- if (S.tok.tight && texts.length && !others.length && (ctx.cls === "bottom" || (Z.bottom - cardsPlan.bottom) / (Z.bottom - Z.y) > S.tok.cap)) {
883
+ // A row of cards with its takeaways under it that stops high on the page: the figures then run as
884
+ // rows down the first seven columns (value, label, basis), the caption at their foot, and the
885
+ // takeaways stand beside them. A pack with hard shadows keeps its cards: the bordered, shadowed card
886
+ // is that pack's figure.
887
+ if (texts.length && !others.length && !S.pack.shadow && (Z.bottom - cardsPlan.bottom) / (Z.bottom - Z.y) > S.tok.cap) {
872
888
  const cards = shared().kpiCards(kpi.content).slice(0, 6);
873
889
  const [fR, tR] = splitRegion(S, Z, 7 / 12);
874
890
  const capH = kpi.caption ? captionHeight(kpi.caption, fR.w, S) + 6 : 0;
@@ -902,7 +918,7 @@ const PLANS = {
902
918
  Object.assign(b, { pitch: a.pitch, value: a.value, labelSize: a.labelSize, noteSize: a.noteSize, h: b.rows.length ? b.rows[b.rows.length - 1].y - b.y + (a.h - (a.rows[a.rows.length - 1].y - a.y)) : 0 });
903
919
  }
904
920
  // A band left open between the rows and the takeaways under them is worse than a column whose points share
905
- // the room beside the rows, when that sharing fills the column (spec-v2 Amendments 9).
921
+ // the room beside the rows, when that sharing fills the column.
906
922
  const band = (Z.bottom - notesH - state.blockGap - capH - Math.max(...across.map((q) => q.y + q.h))) / (Z.bottom - Z.y);
907
923
  if (band > S.tok.cap) {
908
924
  const filled = structuredClone(out);
@@ -937,17 +953,12 @@ const PLANS = {
937
953
  const capH = table.caption ? captionHeight(table.caption, nR.w, S) + 6 : 0;
938
954
  const blockH = size * 1.15 + 12 + labelH + capH;
939
955
  const out = [];
940
- // The number opens the body, its evidence beside it or under it. Under a bottom title the
941
- // number sits on the visual zone's floor, next to the title that explains it.
942
- const y = ctx.cls === "bottom" ? Z.bottom - blockH : Z.y;
956
+ // The number opens the body, its evidence beside it or under it.
957
+ const y = Z.y;
943
958
  if (texts.length) {
944
959
  const R = tR ? region(S, tR.a + 1, tR.b, Z.y, Z.bottom)
945
- : ctx.cls === "bottom" ? { ...Z, bottom: y - state.blockGap } : { ...Z, y: Z.y + blockH + state.blockGap };
946
- const evidence = stack(texts, R, S, { ...state, stepped: true });
947
- // Beside a number on the floor the evidence ends level with the number block, so it reads
948
- // with the number instead of hanging above it at the top of the page.
949
- const lift = tR && ctx.cls === "bottom" ? Math.max(0, Math.min(y + blockH, Z.bottom) - evidence.bottom) : 0;
950
- out.push(...evidence.placed.map((q) => ({ ...q, y: q.y + lift })));
960
+ : { ...Z, y: Z.y + blockH + state.blockGap };
961
+ out.push(...stack(texts, R, S, { ...state, stepped: true }).placed);
951
962
  }
952
963
  out.push({ kind: "number", value, label, size, labelSize, x: nR.x, y, w: nR.w, h: size * 1.15 + 12 + labelH, limit: Z.bottom });
953
964
  if (table.caption) out.push({ kind: "caption", item: { content: table.caption }, x: nR.x, y: y + size * 1.15 + 12 + labelH + 6, w: nR.w, h: capH - 6, limit: Z.bottom });
@@ -1008,9 +1019,12 @@ const PLANS = {
1008
1019
  },
1009
1020
 
1010
1021
  comparison(ctx) {
1011
- const { S, state, Z } = ctx;
1022
+ const { S, state } = ctx;
1012
1023
  const { columns, texts, visuals } = sortItems(ctx.items);
1013
1024
  if (visuals.length && !columns) return visualWithText(ctx, visuals, texts, 7 / 12);
1025
+ // Beside written columns the loose points are the slide's takeaways: they run under the rows.
1026
+ const notes = columns && texts.length ? measureText(texts.flatMap(itemParas), ctx.Z.w, S, state) : null;
1027
+ const Z = notes ? { ...ctx.Z, bottom: ctx.Z.bottom - notes.h - state.blockGap } : ctx.Z;
1014
1028
  let halves;
1015
1029
  if (columns) halves = columns.columns.map((col) => (col || []).filter(isText).flatMap(itemParas));
1016
1030
  else {
@@ -1031,8 +1045,7 @@ const PLANS = {
1031
1045
  const LABEL = /^([^::]{1,24})[::]\s*(.+)$/u;
1032
1046
  const isLabel = (t) => { const m = LABEL.exec(plainText(t)); return Boolean(m) && m[1].trim().split(/\s+/u).length <= 3; };
1033
1047
  let criteria = null;
1034
- // Under a side title the criteria go to the rail at any density.
1035
- if ((S.tok.tight || ctx.rail) && sides.length === 2 && sides.every((sd) => sd.items.length && sd.items.every(isLabel))) {
1048
+ if (S.tok.tight && sides.length === 2 && sides.every((sd) => sd.items.length && sd.items.every(isLabel))) {
1036
1049
  const n = Math.max(...sides.map((sd) => sd.items.length));
1037
1050
  const sets = Array.from({ length: n }, (_, r) => [...new Set(sides.map((sd) => sd.items[r] && LABEL.exec(plainText(sd.items[r]))[1].trim()).filter(Boolean))]);
1038
1051
  const labels = sets.map((set) => set.join(" / "));
@@ -1042,12 +1055,9 @@ const PLANS = {
1042
1055
  for (const sd of sides) sd.items = sd.items.map((t) => LABEL.exec(plainText(t))[2]);
1043
1056
  }
1044
1057
  }
1045
- // Under a side title the criteria stand in the rail, level with their rows, and the two sides take
1046
- // the body beside it; the rows then start under the title's last line and end above the rail's strip.
1047
- const inRail = Boolean(criteria && ctx.rail);
1048
- const floor = inRail ? Math.min(Z.bottom, ctx.rail.bottom) : Z.bottom;
1049
- const critR = inRail ? { ...ctx.rail, bottom: floor } : criteria ? region(S, Z.a, Z.a + 1, Z.y, Z.bottom) : null;
1050
- const parts = criteria && !inRail ? splitRegion(S, region(S, Z.a + 2, Z.b, Z.y, Z.bottom), 0.5) : splitRegion(S, Z, 0.5);
1058
+ const floor = Z.bottom;
1059
+ const critR = criteria ? region(S, Z.a, Z.a + 1, Z.y, Z.bottom) : null;
1060
+ const parts = criteria ? splitRegion(S, region(S, Z.a + 2, Z.b, Z.y, Z.bottom), 0.5) : splitRegion(S, Z, 0.5);
1051
1061
  const headSize = bodySize(S, state, "lead");
1052
1062
  // Up to three rows of short points are set a step or two larger when that still keeps a dozen
1053
1063
  // glyphs a line and fits the body; a two-row comparison otherwise holds a third of the page.
@@ -1072,10 +1082,9 @@ const PLANS = {
1072
1082
  });
1073
1083
  top = Z.y + headH + 30;
1074
1084
  }
1075
- if (inRail) top = Math.max(top, ctx.rail.y);
1076
1085
  // Parallel points share a row, so they are read across; the rows run down to the floor.
1077
1086
  const n = Math.max(...sides.map((sd) => sd.items.length));
1078
- const labelH = (r) => (criteria ? G.textHeight(criteria[r], critR.w / S.wide("title"), itemSize) : 0);
1087
+ const labelH = (r) => (criteria ? G.textHeight(criteria[r], critR.w / S.wide("title", criteria[r]), itemSize) : 0);
1079
1088
  // Measured on the lines the text frame will set (word by word), so a narrow side keeps its lines.
1080
1089
  const sideH = (t, i) => {
1081
1090
  const w = parts[i].w / S.wide("body");
@@ -1084,18 +1093,18 @@ const PLANS = {
1084
1093
  const rowH = Array.from({ length: n }, (_, r) => Math.max(labelH(r), ...sides.map((sd, i) => (sd.items[r] ? sideH(sd.items[r], i) : 0))));
1085
1094
  const pitch = rowPitch(n, Math.max(...rowH), { y: top, bottom: floor }, state.groupGap, Math.max(112, Math.max(...rowH) + 72));
1086
1095
  // On the compact step each row starts one even gap under the one before (rows of two and three
1087
- // lines alike). The gap opens up to a seventh of the body so four short rows reach the floor, and
1088
- // never ends the rows higher than a pitch of the tallest row plus 48 pt would.
1096
+ // lines alike). The gap opens up to a fifth of the body so four one-line rows across the full body
1097
+ // width reach the floor, and never ends the rows higher than a pitch of the tallest row plus 48 pt would.
1089
1098
  const sum = rowH.reduce((a, b) => a + b, 0);
1090
1099
  const reach = n > 1 ? ((n - 1) * (Math.max(...rowH) + 48) + rowH[n - 1] - sum) / (n - 1) : 0;
1091
- const gap = n > 1 ? Math.max(state.groupGap, Math.min(Math.max(48, (floor - Z.y) / 7, reach), (floor - top - sum) / (n - 1))) : 0;
1100
+ const gap = n > 1 ? Math.max(state.groupGap, Math.min(Math.max(48, (floor - Z.y) / 5, reach), (floor - top - sum) / (n - 1))) : 0;
1092
1101
  const ys = rowH.map((_, r) => (S.tok.tight ? top + rowH.slice(0, r).reduce((a, b) => a + b, 0) + r * gap : top + r * pitch));
1093
1102
  for (let r = 0; r < n; r++) {
1094
1103
  const y = ys[r];
1095
1104
  if (r > 0 && (S.deco.has("hairline-rule") || S.deco.has("column-hairlines"))) {
1096
1105
  out.push({ decor: true, kind: "rect", x: Z.x, y: y - Math.min(18, (y - ys[r - 1] - rowH[r - 1]) / 2), w: Z.w, h: 0.5, fill: "line" });
1097
1106
  }
1098
- if (criteria) out.push({ kind: "text", paras: [{ kind: "text", text: criteria[r], inset: 0, h: labelH(r), gap: 0 }], size: itemSize, x: critR.x, y, w: critR.w, h: labelH(r), limit: floor, bold: true, rail: inRail });
1107
+ if (criteria) out.push({ kind: "text", paras: [{ kind: "text", text: criteria[r], inset: 0, h: labelH(r), gap: 0 }], size: itemSize, x: critR.x, y, w: critR.w, h: labelH(r), limit: floor, bold: true });
1099
1108
  sides.forEach((sd, i) => {
1100
1109
  if (!sd.items[r]) return;
1101
1110
  const h = sideH(sd.items[r], i);
@@ -1103,6 +1112,7 @@ const PLANS = {
1103
1112
  });
1104
1113
  }
1105
1114
  if (S.deco.has("column-hairlines")) out.push(columnHairline(S, parts[1], Z));
1115
+ if (notes) out.push({ ...notes, x: Z.x, y: Z.bottom + state.blockGap, limit: ctx.Z.bottom });
1106
1116
  return out;
1107
1117
  },
1108
1118
 
@@ -1136,23 +1146,28 @@ const PLANS = {
1136
1146
  // The same readable measure as the stacked rows (the gate counts spaces as characters). That
1137
1147
  // measure at body size leaves the body's right columns empty, so what happens is set a step up (lead)
1138
1148
  // when the rows still fit: the measure grows with the size and reaches across.
1139
- const at = (size) => {
1149
+ // `flush` sets what happens against the body's right edge and gives the step's name the columns
1150
+ // between, so a body-size measure still reaches across when the lead step does not fit.
1151
+ const at = (size, flush = false) => {
1140
1152
  const restW = Math.min(Z.x + Z.w - restX, (korean ? 26 : 35) * size);
1153
+ const rx = flush ? Z.x + Z.w - restW : restX;
1154
+ const headW = rx - S.grid.gutter - headR.x;
1141
1155
  const rows = steps.map((st) => {
1142
- const hh = G.textHeight(st.head, headR.w, headSize);
1156
+ const hh = G.textHeight(st.head, headW, headSize);
1143
1157
  const rh = st.rest ? G.textHeight(st.rest, restW, size) : 0;
1144
1158
  return { num: st.n, head: st.head, rest: st.rest, hh, rh, h: Math.max(numSize * 1.15, hh, rh) };
1145
1159
  });
1146
1160
  const tallest = Math.max(...rows.map((r) => r.h));
1147
1161
  const pitch = rowPitch(rows.length, tallest, Z, state.groupGap, tallest + 48);
1148
1162
  rows.forEach((r, i) => { r.y = Z.y + i * pitch; });
1149
- return { kind: "rows", rows, x: Z.x, numW, textX: headR.x, textW: headR.w, restX, restW, head: headSize, rest: size, numSize, pitch, rule: true,
1163
+ return { kind: "rows", rows, x: Z.x, numW, textX: headR.x, textW: headW, restX: rx, restW, head: headSize, rest: size, numSize, pitch, rule: true,
1150
1164
  y: Z.y, h: (rows.length - 1) * pitch + rows[rows.length - 1].h, limit: Z.bottom };
1151
1165
  };
1152
1166
  const body = at(restSize);
1153
1167
  if ((Z.x + Z.w - (restX + body.restW)) / Z.w > 0.25) {
1154
1168
  const lead = at(S.sizes.lead);
1155
1169
  if (lead.y + lead.h <= Z.bottom + 0.5) return [lead];
1170
+ return [at(restSize, true)];
1156
1171
  }
1157
1172
  return [body];
1158
1173
  }
@@ -1167,7 +1182,6 @@ const PLANS = {
1167
1182
  rows.forEach((r, i) => { r.y = Z.y + i * pitch; });
1168
1183
  return [{ kind: "rows", rows, x: Z.x, numW, textX, textW, head: headSize, rest: restSize, numSize, pitch, rule: true, y: Z.y, h: (rows.length - 1) * pitch + rows[rows.length - 1].h, limit: Z.bottom }];
1169
1184
  };
1170
- if (ctx.cls === "side") return withNotes(asRows());
1171
1185
  const slots = equalParts(S, Z, steps.length);
1172
1186
  const placedSteps = steps.map((s, i) => {
1173
1187
  const R = slots[i];
@@ -1247,7 +1261,7 @@ const PLANS = {
1247
1261
  const M = wide ? region(S, Z.a, Z.b - 4, Z.y, Z.bottom) : Z;
1248
1262
  const notesH = texts.length && !wide ? stack(texts, Z, S, state).bottom - Z.y + state.blockGap : 0;
1249
1263
  const mBottom = M.bottom - notesH;
1250
- const labelCols = ctx.cls === "side" || wide ? 1 : 2;
1264
+ const labelCols = wide ? 1 : 2;
1251
1265
  const labels = region(S, M.a, M.a + labelCols - 1, M.y, mBottom);
1252
1266
  const grid = region(S, M.a + labelCols, M.b, M.y, mBottom);
1253
1267
  const capH = table.caption ? captionHeight(table.caption, grid.w, S) + 6 : 0;
@@ -1283,13 +1297,13 @@ const PLANS = {
1283
1297
  const by = paras.find((p) => /^[—–-]\s*/u.test(plainText(p.text)));
1284
1298
  const said = paras.filter((p) => p !== by).map((p) => p.text).join(" ");
1285
1299
  const markW = S.grid.span(1, 1).w;
1286
- const R = region(S, Math.min(Z.b, Z.a + 1), Math.max(Z.a + 1, Z.b - (ctx.cls === "side" ? 0 : 2)), Z.y + 24, Z.bottom);
1300
+ const R = region(S, Math.min(Z.b, Z.a + 1), Math.max(Z.a + 1, Z.b - 2), Z.y + 24, Z.bottom);
1287
1301
  const size = S.sizes.title;
1288
1302
  const h = G.textHeight(plainText(said), R.w, size, 1.3);
1289
1303
  const blockH = h + (by ? 18 + S.sizes.label * 1.4 : 0);
1290
1304
  // Beside a side title the quote is the slide's display element: it sits in the middle of the
1291
1305
  // body, as a statement does, instead of at the top of an empty page.
1292
- const y = ctx.cls === "side" ? Math.max(R.y, Math.round(Z.y + (Z.bottom - Z.y - blockH) / 2)) : R.y;
1306
+ const y = R.y;
1293
1307
  return [{ kind: "quote", text: said, by: by ? plainText(by.text) : "", size, x: R.x, w: R.w, y, h: blockH, textH: h, markX: Z.x, markW, limit: Z.bottom }];
1294
1308
  },
1295
1309
 
@@ -1339,16 +1353,22 @@ const PLANS = {
1339
1353
  const size = visuals.length === 1 && visuals[0].type === "image" && visuals[0].imageSize;
1340
1354
  if (S.tok.tight && size && size.width / size.height >= 2 && Z.b - Z.a + 1 >= 8) {
1341
1355
  const cap = captionHeight(visuals[0].caption, Z.w, S);
1342
- for (const [share, n] of [[0.5, 2], [0.5, 3], [0.42, 3], [0.36, 3]]) {
1356
+ for (const [share, n] of [[0.5, 2], [0.45, 2], [0.5, 3], [0.42, 3], [0.36, 3]]) {
1343
1357
  const figH = Math.min(Math.round(Z.w * size.height / size.width) + cap, Math.round((Z.bottom - Z.y) * share));
1344
1358
  place({ ...Z, bottom: Z.y + figH });
1345
- const cols = defsInColumns(defs, { ...Z, y: Z.y + figH + state.blockGap }, n);
1359
+ // Measured at their closest step first: whether the terms fit under the figure is not a question of
1360
+ // how far they may then open to fill the room.
1361
+ const under = { ...Z, y: Z.y + figH + state.blockGap };
1362
+ let cols = defsInColumns(defs, under, n, false);
1346
1363
  // The source strip closes the shortest column when it fits there.
1347
1364
  const last = cols.reduce((m, q) => (q.y + q.h < m.y + m.h ? q : m));
1348
1365
  const strip = ctx.strip ? stripPlacements(ctx.strip, { ...Z, x: last.x, w: last.w }, S) : null;
1349
1366
  const closes = strip && last.y + last.h * 1.1 + S.tok.block + strip.h <= Z.bottom;
1350
1367
  // A figure across the body is wider at 130 pt than it stands beside its terms at full column height.
1351
1368
  if (figH - cap >= 130 && cols.every((q) => q.y + q.h * 1.1 <= Z.bottom) && (!strip || closes)) {
1369
+ // Then the terms open over the room left above the strip, when they still fit there.
1370
+ const roomy = defsInColumns(defs, { ...under, bottom: strip ? Z.bottom - strip.h - S.tok.block : Z.bottom }, n);
1371
+ if (roomy.every((q) => q.y + q.h * 1.05 <= (strip ? Z.bottom - strip.h - S.tok.block : Z.bottom))) cols = roomy;
1352
1372
  return [...out, ...cols.map((q) => ({ ...q, limit: Z.bottom - q.h * 0.1 })), ...(strip ? strip.placed.map((q) => ({ ...q, strip: true })) : [])];
1353
1373
  }
1354
1374
  out.length = 0;
@@ -1397,14 +1417,18 @@ const PLANS = {
1397
1417
 
1398
1418
  // The terms split between two columns at the entry that brings the first nearest half the height (three
1399
1419
  // columns: the cuts that keep the tallest column lowest).
1400
- function defsInColumns(list, R, n = 2) {
1420
+ function defsInColumns(list, R, n = 2, open = true) {
1401
1421
  const ents = entries(list);
1402
1422
  const flat = (part) => part.flatMap((e) => [{ kind: e.kind === "section" ? "section" : "bullet", text: e.head ? `**${e.head}** ${e.rest}`.trim() : e.rest },
1403
1423
  ...e.children.map((c) => ({ kind: "child", text: c }))]);
1404
1424
  const cols = equalParts(S, R, n);
1405
1425
  const fits = (st) => cols.every((C) => C.y + measureText(flat(ents), C.w, S, st).h / n <= C.bottom);
1406
- // Terms keep a 12 pt step between them when the columns hold it, else the item gap.
1407
- const gapped = fits({ ...state, itemGap: Math.max(state.itemGap, 12) }) ? { ...state, itemGap: Math.max(state.itemGap, 12) } : state;
1426
+ // Terms keep a 12 pt step between them when the columns hold it, else the item gap. Terms that leave
1427
+ // more than the density band empty under them open the step, up to 36 pt, while the columns hold it.
1428
+ const at = (g) => ({ ...state, itemGap: Math.max(state.itemGap, g) });
1429
+ const loose = (st) => (C) => (C.bottom - C.y - measureText(flat(ents), C.w, S, st).h / n) / (C.bottom - C.y) > S.tok.cap;
1430
+ let gapped = fits(at(12)) ? at(12) : state;
1431
+ if (open && gapped !== state && cols.every(loose(gapped))) gapped = [36, 30, 24, 18].map(at).find(fits) || gapped;
1408
1432
  const tall = (part) => (part.length ? measureText(flat(part), cols[0].w, S, gapped).h : 0);
1409
1433
  if (n === 3 && ents.length >= 3) {
1410
1434
  let best = null;
@@ -1629,8 +1653,8 @@ function figureRows(cards, Z, S, state, spread = null) {
1629
1653
  }
1630
1654
 
1631
1655
  /**
1632
- * A big-number slide as a data panel: the panel takes seven of twelve columns beside a top or bottom
1633
- * title (five of eight beside a rail) down to the zone's floor. With two to four figures (a KPI table
1656
+ * A big-number slide as a data panel: the panel takes seven of twelve columns under the title down to
1657
+ * the zone's floor. With two to four figures (a KPI table
1634
1658
  * whose second body row gives each figure's basis) the panel holds one row per figure: the value at
1635
1659
  * title size, its label and its basis beside it. One figure is set at title size with its label on a
1636
1660
  * panel as tall as they are. The
@@ -1643,7 +1667,7 @@ function numberPanel(ctx, table, texts) {
1643
1667
  const c = table.content || {};
1644
1668
  const cards = shared().kpiCards(c).slice(0, 6).filter((k) => plainText(k.value));
1645
1669
  const device = S.pack.display.number;
1646
- const [pR, eR] = texts.length ? splitRegion(S, Z, ctx.cls === "side" ? 5 / 8 : 7 / 12) : [Z, null];
1670
+ const [pR, eR] = texts.length ? splitRegion(S, Z, 7 / 12) : [Z, null];
1647
1671
  const pad = Math.max(18, S.tok.pad);
1648
1672
  const inner = pR.w - pad * 2;
1649
1673
  const panelH = pR.bottom - pR.y;
@@ -1881,8 +1905,8 @@ function drawPlaced(slide, S, p, ctx) {
1881
1905
  return;
1882
1906
  }
1883
1907
  if (p.kind === "box") {
1884
- if (p.outline) packRect(slide, S, p, { line: "ink", lineWidth: 1 });
1885
- else if (p.tint) packRect(slide, S, p, { fill: "accent-tint", radius: S.pack.radius });
1908
+ if (p.outline) packRect(slide, S, p, { line: "ink", lineWidth: S.pack["edge-weight"] || 1, ...hardEdge(S, "ground") });
1909
+ else if (p.tint) packRect(slide, S, p, { fill: "accent-tint", radius: S.pack.radius, ...hardEdge(S) });
1886
1910
  else surfaceRect(slide, S, p);
1887
1911
  return;
1888
1912
  }
@@ -1908,7 +1932,7 @@ function drawPlaced(slide, S, p, ctx) {
1908
1932
  return;
1909
1933
  }
1910
1934
  if (p.kind === "callout") {
1911
- packRect(slide, S, p, { fill: "accent-tint", radius: S.pack.radius });
1935
+ packRect(slide, S, p, { fill: "accent-tint", radius: S.pack.radius, ...hardEdge(S) });
1912
1936
  packText(slide, S, plainText(c), { x: p.x + p.pad, y: p.y + p.pad, w: p.w - p.pad * 2, h: p.h - p.pad * 2 },
1913
1937
  { size: p.size, bold: true, colour: S.colour("accent-deep") });
1914
1938
  return;
@@ -2178,7 +2202,7 @@ function drawPackTable(slide, S, c, p, title) {
2178
2202
  const border = (ri) => {
2179
2203
  const last = ri === rows.length - 1;
2180
2204
  switch (t.rules) {
2181
- case "grid": return [hair, hair, hair, hair];
2205
+ case "grid": return Array(4).fill(S.pack["edge-weight"] ? { type: "solid", pt: S.pack["edge-weight"], color: S.colour(S.pack["edge-colour"] || "line") } : hair);
2182
2206
  case "booktabs": return [ri === -1 ? heavy(1) : none, none, ri === -1 ? heavy(0.5) : last ? heavy(1) : none, none];
2183
2207
  case "top-bottom": return [ri === -1 ? hair : none, none, last ? hair : ri === -1 ? hair : none, none];
2184
2208
  case "header-rule": return [none, none, ri === -1 ? heavy(2) : none, none];
@@ -2189,7 +2213,7 @@ function drawPackTable(slide, S, c, p, title) {
2189
2213
  };
2190
2214
  const cellMargin = [IN(6), IN(6), IN(6), IN(6)];
2191
2215
  // Cell text that wraps is broken at spaces here, as in body text (see G.keepLines).
2192
- const keep = (text, ci, ri, size, role) => G.keepLines(text, ((p.widths[ci] || p.w) - 12) / S.wide(role) / size,
2216
+ const keep = (text, ci, ri, size, role) => G.keepLines(text, ((p.widths[ci] || p.w) - 12) / S.wide(role, text) / size,
2193
2217
  Math.floor(((p.heights[ri + 1] || 0) - 12) / (size * 1.45) + 0.01));
2194
2218
  const tableRows = [headers.map((h, ci) => ({
2195
2219
  text: keep(plainText(h), ci, -1, p.header, "label"),
@@ -2225,15 +2249,13 @@ function overflows(placed) {
2225
2249
 
2226
2250
  /**
2227
2251
  * The zones of one treatment frame: body Z, the class and where the empty band is measured from.
2228
- * Nothing is set beside a bottom title: a text block there stops the page reading as that treatment.
2229
- * The rail under a side title is another matter: it carries the slide's supporting content (see railOf).
2252
+ * Every treatment's body is the shared body zone under the title zone, so the class is "top".
2230
2253
  */
2231
2254
  function zonesOf(frame, S) {
2232
2255
  const t = frame.title;
2233
- const cls = frame.id === "side-rail" ? "side" : frame.id === "bottom-anchor" ? "bottom" : "top";
2234
2256
  if (!frame.body) return { cls: "none" };
2235
2257
  const Z = region(S, frame.body.a, frame.body.b, frame.body.y, frame.body.bottom);
2236
- return { cls, Z, bandTop: cls === "bottom" ? Z.y : Math.min(t.y + t.h, Z.y) };
2258
+ return { cls: "top", Z, bandTop: Math.min(t.y + t.h, Z.y) };
2237
2259
  }
2238
2260
 
2239
2261
  /**
@@ -2279,112 +2301,18 @@ function stripPlacements(lines, Z, S) {
2279
2301
  ] };
2280
2302
  }
2281
2303
 
2282
- // Families that pair a visual with its takeaways: under a side title the takeaways may stand in the rail
2283
- // and the visual takes the body (a table opens its rows; how full the body ends up still decides the title).
2284
- const RAIL_TAKEAWAYS = new Set(["chart-insight", "full-chart", "table-insight", "figure-academic", "image-split",
2285
- "kpi-row", "big-number", "kpi-over-chart", "dashboard-grid", "photo-grid", "figure-pair", "asymmetric-feature"]);
2286
- // A rail more empty than this, from the title down, costs the slide the side rail.
2304
+ // A column more empty than this, beside a longer one, costs the slide its title.
2287
2305
  const RAIL_EMPTY = 0.4;
2288
2306
 
2289
- /**
2290
- * The rail under a side title: columns 1-4 from just under the title frame (which keeps a spare line for
2291
- * a title that wraps once more than estimated) to the floor. It holds the slide's supporting content: the
2292
- * source strip at its foot, the takeaways of a visual when they fit, a comparison's criteria labels level
2293
- * with their rows.
2294
- */
2295
- function railOf(frame, S) {
2296
- const t = frame.title;
2297
- const s = S.grid.span(1, 4);
2298
- // `top` is where the rail's empty share is measured from: the title frame's foot, as the gate reads it.
2299
- return { a: 1, b: 4, x: s.x, w: s.w, y: Math.ceil(t.y + t.h + S.tok.group), top: t.y + t.h, bottom: G.ZONE.floor };
2300
- }
2301
-
2302
- /** The largest empty stretch of the rail under the title frame as a share of its height (rail placements only). */
2303
- function railBand(rail, placed) {
2304
- let gap = 0;
2305
- let cursor = rail.top;
2306
- for (const q of placed.filter((p) => p.rail && !p.decor).sort((a, b) => a.y - b.y)) {
2307
- gap = Math.max(gap, q.y - cursor);
2308
- cursor = Math.max(cursor, q.y + q.h);
2309
- }
2310
- return Math.max(gap, rail.bottom - cursor) / Math.max(1, rail.bottom - rail.top);
2311
- }
2312
-
2313
2307
  function planSlide(spec, frame, S, items) {
2314
2308
  const z = zonesOf(frame, S);
2315
2309
  if (spec.lead && spec.family !== "summary-box-list") items = [{ type: "main-box", content: spec.lead }, ...items];
2316
2310
  if (z.cls === "none") return { placed: [], band: 0, applied: [], before: 0, z };
2317
2311
  const full = z.Z;
2318
- const rail = z.cls === "side" ? railOf(frame, S) : null;
2319
- const railPlaced = [];
2320
2312
  const taken = takeStrip(spec, items);
2321
- // The source strip: at the rail's foot under a side title; else offered to a takeaway column beside a
2322
- // visual, and across the body's foot when no column takes it.
2323
- let lines = taken.lines;
2313
+ // The source strip: offered to a takeaway column beside a visual, and across the body's foot when no column takes it.
2314
+ const lines = taken.lines;
2324
2315
  items = taken.items;
2325
- if (lines.length && rail) {
2326
- const strip = stripPlacements(lines, rail, S);
2327
- railPlaced.push(...strip.placed.map((q) => ({ ...q, rail: true })));
2328
- rail.bottom -= strip.h + S.tok.block;
2329
- lines = [];
2330
- }
2331
- // A visual's takeaways stand in the rail when they fit there, a chart's values table under them; the
2332
- // visual then takes the body. Takeaways that leave the rail more than RAIL_EMPTY empty at body size are
2333
- // set a step up (lead) when they fit: the rail is a display column, the one place the compact step
2334
- // sets text above body size.
2335
- if (rail && RAIL_TAKEAWAYS.has(spec.family) && !spec.stacked) {
2336
- const { texts, visuals } = sortItems(items);
2337
- const paras = texts.filter((t) => t.type !== "main-box").flatMap(itemParas);
2338
- // A table keeps its own height: its takeaways leave the body only when the table, its rows opened,
2339
- // still fills the body to the density band.
2340
- const fills = () => {
2341
- // Figure rows and number panels spread to the floor; so do charts and pictures.
2342
- if (["kpi-row", "big-number"].includes(spec.family) || visuals.some((v) => v.type !== "kpi-table" || (v.content || {}).chart)) return true;
2343
- const alone = openRows(stack(visuals, full, S, { stepped: false, itemGap: S.tok.item, groupGap: S.tok.group, blockGap: S.tok.block }).placed, full, S);
2344
- return (full.bottom - contentBottom(alone, full.y)) / (full.bottom - full.y) <= S.tok.cap;
2345
- };
2346
- if (visuals.length && paras.length && fills()) {
2347
- const state = { stepped: false, itemGap: S.tok.item, groupGap: S.tok.group, blockGap: S.tok.block };
2348
- const sets = [S.sizes.body, S.sizes.lead].map((size) => {
2349
- const m = { ...measureText(paras, rail.w, S, state, { size }), x: rail.x, y: rail.y, limit: rail.bottom, rail: true };
2350
- const values = S.tok.tight ? chartValues(visuals, rail, S, state, rail.y + m.h) : null;
2351
- const set = [m, ...(values ? [{ ...values, rail: true }] : [])];
2352
- return { set, fits: set.every((q) => q.y + q.h <= rail.bottom + 0.5), band: railBand({ ...rail, bottom: G.ZONE.floor }, [...set, ...railPlaced]) };
2353
- }).filter((x) => x.fits);
2354
- // Body size when it fills the rail; else whichever size leaves the rail least empty.
2355
- const pick = sets.find((x) => x.band <= RAIL_EMPTY) || sets.reduce((a, b) => (b.band < a.band ? b : a), sets[0]);
2356
- if (pick) {
2357
- // Takeaways that still leave a quarter of the rail empty share that room between them, at most a
2358
- // sixth of the rail a gap: the rail reads as one column down to its source.
2359
- const [m] = pick.set;
2360
- const breaks = m.paras.filter((para, i) => i > 0 && para.kind !== "child");
2361
- const room = rail.bottom - S.tok.block - (pick.set[pick.set.length - 1].y + pick.set[pick.set.length - 1].h);
2362
- if (pick.band > 0.25 && pick.set.length === 1 && breaks.length && room > 0) {
2363
- const per = Math.min(room / (breaks.length + 1), (G.ZONE.floor - rail.top) / 6);
2364
- for (const para of breaks) para.gap += per;
2365
- m.h += per * breaks.length;
2366
- }
2367
- railPlaced.push(...pick.set);
2368
- items = items.filter((i) => !(isText(i) && i.type !== "main-box"));
2369
- }
2370
- }
2371
- }
2372
- // A sidebar note is the slide's supporting content: under a side title it stands in the rail, at lead size
2373
- // when it fits there.
2374
- if (rail && spec.family === "sidebar-note") {
2375
- const texts = sortItems(items).texts;
2376
- const note = texts.find((t) => t.type === "main-box") || (texts.length > 1 ? texts[texts.length - 1] : null);
2377
- if (note) {
2378
- const state = { stepped: false, itemGap: S.tok.item, groupGap: S.tok.group, blockGap: S.tok.block };
2379
- const paras = itemParas(note).map((p) => ({ ...p, kind: p.kind === "child" ? "child" : "text" }));
2380
- const m = [S.sizes.lead, S.sizes.body].map((size) => ({ ...measureText(paras, rail.w, S, state, { size }), x: rail.x, y: rail.y, limit: rail.bottom, rail: true, note: true }))
2381
- .find((q) => q.y + q.h <= rail.bottom + 0.5);
2382
- if (m) {
2383
- railPlaced.push(m);
2384
- items = items.filter((i) => i !== note);
2385
- }
2386
- }
2387
- }
2388
2316
  const across = lines.length ? stripPlacements(lines, full, S) : null;
2389
2317
  const tok = S.tok;
2390
2318
  const plan = (spec.stacked && PLANS[STACKS[spec.family]]) || PLANS[spec.family] || PLANS["text-column"];
@@ -2394,15 +2322,14 @@ function planSlide(spec, frame, S, items) {
2394
2322
  const zone = () => (across && !offer ? { ...full, bottom: full.bottom - across.h - S.tok.block } : full);
2395
2323
  // A later run (another fill state) that leaves the offered strip is refused like an overflow.
2396
2324
  const run = (settle = false) => {
2397
- const ctx = { spec, S, state, Z: zone(), cls: z.cls, family: spec.family, items, rail, strip: offer ? lines : null,
2398
- railValues: railPlaced.some((q) => q.item && q.item.values), railNote: railPlaced.some((q) => q.note) };
2325
+ const ctx = { spec, S, state, Z: zone(), family: spec.family, items, strip: offer ? lines : null };
2399
2326
  const placed = plan(ctx);
2400
2327
  const lost = offer && !placed.some((q) => q.strip);
2401
2328
  if (lost && settle) {
2402
2329
  offer = false;
2403
2330
  return run();
2404
2331
  }
2405
- return { placed, lost, bottom: contentBottom(placed.filter((q) => !q.rail), zone().y) };
2332
+ return { placed, lost, bottom: contentBottom(placed, zone().y) };
2406
2333
  };
2407
2334
  const band = (o) => Math.max(0, Math.min(1, (zone().bottom - o.bottom) / Math.max(1, zone().bottom - z.bandTop)));
2408
2335
  let out = run(true);
@@ -2449,15 +2376,10 @@ function planSlide(spec, frame, S, items) {
2449
2376
  fillColumn(short, floor, S, state);
2450
2377
  short = shortColumn([...out.placed, ...(across && !offer ? across.placed : [])], floor, S);
2451
2378
  }
2452
- // On a rail drawn in the field colour, what stands in it is set in the field's ink.
2453
- const field = (frame.decor || []).some((d) => d.kind === "rail" && d.role === "field");
2454
- const placed = [...out.placed, ...railPlaced, ...(across && !offer ? across.placed : [])].map((q) => (q.rail && field ? { ...q, onField: true } : q));
2379
+ const placed = [...out.placed, ...(across && !offer ? across.placed : [])];
2455
2380
  const overflow = overflows(placed);
2456
- const railEmpty = rail ? railBand({ ...rail, bottom: G.ZONE.floor }, placed) : 0;
2457
- // A quote or statement beside a rail is a display slide: its rail holds the title alone by design.
2458
- const display = ["quote", "statement"].includes(spec.family);
2459
- return { ...out, placed, band: band(out), before, applied, z, overflow, rail: railEmpty, short: short.share,
2460
- unfit: overflow || (railEmpty > RAIL_EMPTY && !display) || short.share > RAIL_EMPTY };
2381
+ return { ...out, placed, band: band(out), before, applied, z, overflow, short: short.share,
2382
+ unfit: overflow || short.share > RAIL_EMPTY };
2461
2383
  }
2462
2384
 
2463
2385
  /** The frame of a treatment for this slide (a quote under a statement title says the quote). */
@@ -2473,8 +2395,8 @@ function frameFor(spec, treatment) {
2473
2395
  * on and a large one past about a third of the deck, so filling never collapses a deck into one look. The variance dial keeps the number of
2474
2396
  * distinct treatments under its maximum.
2475
2397
  */
2476
- // A bottom title is for a chart, a picture, a big number, a row of figures or a timeline axis that takes
2477
- // the zone above it, not for a table.
2398
+ // The bottom-anchor style (the title frame closed by a heavy rule) is for a slide led by a chart, a picture,
2399
+ // a big number, a row of figures or a timeline axis, not for a table.
2478
2400
  const drawn = (spec) => ["big-number", "kpi-row", "timeline"].includes(spec.family) || (spec.items || []).some((i) => (i.type === "kpi-table" && (i.content || {}).chart) || i.type === "image" || i.type === "image-grid");
2479
2401
 
2480
2402
  function chooseTreatment(spec, S, deck) {
@@ -2484,9 +2406,8 @@ function chooseTreatment(spec, S, deck) {
2484
2406
  // Titles that fit come first; a table goes under a bottom title only when nothing else fits it.
2485
2407
  const fit = tries.filter((x) => !x.plan.unfit);
2486
2408
  const proper = fit.filter((x) => x.t !== "bottom-anchor" || drawn(spec));
2487
- // When every title leaves the slide unfit, the one that overflows least and leaves its rail or a column least
2488
- // empty stands (an overflowing or empty-rail plan came first by order before).
2489
- const badness = (x) => (x.plan.overflow ? 10 : 0) + Math.max(0, x.plan.rail - RAIL_EMPTY) + Math.max(0, (x.plan.short || 0) - RAIL_EMPTY);
2409
+ // When every title leaves the slide unfit, the one that overflows least and leaves a column least empty stands.
2410
+ const badness = (x) => (x.plan.overflow ? 10 : 0) + Math.max(0, (x.plan.short || 0) - RAIL_EMPTY);
2490
2411
  const pool = proper.length ? proper : fit.length ? fit : [...tries].sort((m, n) => badness(m) - badness(n));
2491
2412
  const first = pool[0];
2492
2413
  if (first.t === options[0] && first.plan.band <= S.tok.cap && first.t !== deck.prev && (deck.count[first.t] || 0) < deck.limit) return first;
@@ -2598,6 +2519,8 @@ function spreadTreatments(choices, S, pack, slideCount) {
2598
2519
  const layoutOf = (t, plan) => `${ZONE_OF[t]}/${partitionOf(plan, S)}`;
2599
2520
  // The check also fails a deck where one layout covers more than 40 % of those slides, so while
2600
2521
  // one does, a slide of that layout moves to a title that gives a layout still under the share.
2522
+ // A title style never moves the body (every treatment shares the title zone), so any slide whose
2523
+ // source leaves its title open may take another here, not only a data slide.
2601
2524
  const shareCap = 0.4 * counted.length;
2602
2525
  for (let guard = 0; guard < counted.length; guard++) {
2603
2526
  const keys = {};
@@ -2610,7 +2533,7 @@ function spreadTreatments(choices, S, pack, slideCount) {
2610
2533
  for (const spec of counted) {
2611
2534
  const current = choices.get(spec);
2612
2535
  const from = layoutOf(current.t, current.plan);
2613
- if (!movable(spec) || keys[from] < 2 || (!short && from !== topKey)) continue;
2536
+ if (keys[from] < 2 || (!short && from !== topKey)) continue;
2614
2537
  for (const t of spec.candidates || []) {
2615
2538
  if (t === current.t || (t === "bottom-anchor" && !drawn(spec))) continue;
2616
2539
  const inUse = new Set([...choices.values()].map((c) => c.t));
@@ -2619,8 +2542,10 @@ function spreadTreatments(choices, S, pack, slideCount) {
2619
2542
  const to = layoutOf(t, plan);
2620
2543
  if (plan.unfit || plan.band > Math.max(S.tok.cap, current.plan.band) || to === from) continue;
2621
2544
  // A move that adds a layout comes first; else one that relieves the crowded layout.
2622
- if (short ? keys[to] : (keys[to] || 0) + 1 > shareCap) continue;
2623
- const loss = plan.band - current.plan.band + (from === topKey && crowded ? -1 : 0);
2545
+ const adds = short && !keys[to];
2546
+ const relieves = crowded && from === topKey && (keys[to] || 0) + 1 <= shareCap;
2547
+ if (!adds && !relieves) continue;
2548
+ const loss = plan.band - current.plan.band - (adds ? 2 : 0) - (relieves ? 1 : 0);
2624
2549
  if (!best || loss < best.loss) best = { spec, t, plan, loss, added: !keys[to] };
2625
2550
  }
2626
2551
  }
@@ -2712,13 +2637,15 @@ function drawnBoxes(p, S) {
2712
2637
  */
2713
2638
  function partitionOf(plan, S) {
2714
2639
  const Z = plan.z && plan.z.Z;
2715
- // What stands in a side title's rail is part of the title zone, as the check reads it.
2716
- const placed = (plan.placed || []).filter((q) => !q.decor && !q.rail);
2640
+ const placed = (plan.placed || []).filter((q) => !q.decor);
2717
2641
  if (!Z || !placed.length) return "one";
2718
2642
  const all = placed.flatMap((q) => drawnBoxes(q, S)).filter((b) => b.w > 0 && b.h > 0);
2719
2643
  const cards = all.filter((b) => b.card);
2720
2644
  const inCard = (b) => cards.some((c) => c !== b && b.x + b.w / 2 >= c.x - 1 && b.x + b.w / 2 <= c.x + c.w + 1 && b.y + b.h / 2 >= c.y - 1 && b.y + b.h / 2 <= c.y + c.h + 1);
2721
- const blocks = all.filter((b) => !inCard(b));
2645
+ // A one-line caption or source on the body floor is the slide's foot, not a column (as the gate reads it).
2646
+ const body = SG.zone(S.grid.canvas, "content", "body");
2647
+ const floor = body.y + body.h;
2648
+ const blocks = all.filter((b) => !inCard(b) && !(!b.card && b.h <= 16 && b.y + b.h >= floor - 2));
2722
2649
  if (!blocks.length) return "one";
2723
2650
  const bodyW = S.grid.W - 2 * S.grid.margin;
2724
2651
  let narrow = blocks.filter((b) => b.w < 0.7 * bodyW);
@@ -2763,7 +2690,7 @@ const TEXT_KINDS = new Set(["text", "caption", "callout", "box", "panel"]);
2763
2690
  const SHORT_KINDS = new Set([...TEXT_KINDS, "image"]);
2764
2691
  function shortColumn(placed, floor, S) {
2765
2692
  const flat = (list) => list.flatMap((q) => (q.kind === "group" ? flat(q.parts) : [q]));
2766
- const owners = flat(placed.filter((q) => !q.decor && !q.rail));
2693
+ const owners = flat(placed.filter((q) => !q.decor));
2767
2694
  // A matrix's cells are drawn whole on the page, whatever its composition estimate reads of them.
2768
2695
  const boxesOf = (q) => (q.kind === "matrix" ? q.cells.map((c) => ({ x: c.x, y: c.y, w: c.w, h: c.h })) : drawnBoxes(q, S));
2769
2696
  // A chart's values table under its takeaways counts with their column.
@@ -2785,7 +2712,7 @@ function shortColumn(placed, floor, S) {
2785
2712
  for (const [col, other] of [[a, c], [c, a]]) {
2786
2713
  if (!col.every((b) => b.text)) continue;
2787
2714
  // Beside figure rows (not on their own panel) the column runs to the floor: the rows stop where their
2788
- // spacing ends, not where the slide's content does (spec-v2 Amendments 9).
2715
+ // spacing ends, not where the slide's content does.
2789
2716
  const figures = other.some((b) => ["figrows", "number"].includes(b.q.kind)) && !other.some((b) => b.q.kind === "panel");
2790
2717
  const reach = figures ? floor : Math.min(floor, Math.max(...other.map((b) => b.y + b.h)));
2791
2718
  if (reach - top < 0.75 * (floor - top)) continue;
@@ -2847,9 +2774,11 @@ function fillColumn(short, floor, S, state) {
2847
2774
  /** Families whose visual may take its takeaways under it across the body, and the plan that does. */
2848
2775
  const STACKS = { "table-insight": "ledger-table", "chart-insight": "full-chart" };
2849
2776
 
2850
- /** The composition title zone of each treatment. */
2851
- const ZONE_OF = { "top-rule": "top", "top-plain-large": "top", "kicker-numeral": "top", "side-rail": "side", band: "band",
2852
- "bottom-anchor": "bottom", overlay: "overlay", statement: "none" };
2777
+ /**
2778
+ * The composition title zone of each treatment. Every treatment styles the one title frame of the
2779
+ * slide grid, so a slide's title "zone" in its signature is its treatment, as the gate reads it.
2780
+ */
2781
+ const ZONE_OF = Object.fromEntries(G.TREATMENTS.map((t) => [t, t]));
2853
2782
 
2854
2783
  // ── Content, cover and section slides ───────────────────────────────────────
2855
2784
 
@@ -2867,7 +2796,7 @@ function renderContent(slide, spec, S, ctx, chosen) {
2867
2796
  const items = spec.items || [];
2868
2797
  if (!f.image) drawDecor(slide, S, f.decor);
2869
2798
  if (f.numeral) {
2870
- packText(slide, S, f.numeral.text, f.numeral, { face: "numeral", size: f.numeral.size, colour: S.colour("accent"), lineSpacing: 1.15 });
2799
+ packText(slide, S, f.numeral.text, f.numeral, { face: "numeral", size: f.numeral.size, colour: S.colour("accent"), lineSpacing: 1.15, align: f.numeral.align, name: "numeral@kicker" });
2871
2800
  }
2872
2801
  const dctx = { sourceDir: ctx.sourceDir, title: spec.title };
2873
2802
  let ink = t.colour;
@@ -2883,15 +2812,8 @@ function renderContent(slide, spec, S, ctx, chosen) {
2883
2812
  } else if (f.support) {
2884
2813
  const text = spec.supportText || spec.lead || items.filter(isText).flatMap(itemParas).map((p) => plainText(p.text)).join(" ");
2885
2814
  const supportH = text ? G.textHeight(plainText(text), f.support.w, S.sizes.body) : 0;
2886
- // A tall statement with a support line moves up so the line ends on the body floor.
2887
- const over = f.support.y + supportH - G.ZONE.floor;
2888
- if (over > 0) {
2889
- const dy = Math.min(over, t.y - G.ZONE.top);
2890
- t.y -= dy;
2891
- f.support.y -= dy;
2892
- }
2893
2815
  ink = statementDevice(slide, S, f, supportH);
2894
- if (text) packText(slide, S, text, { ...f.support, h: supportH }, { colour: S.colour(ink === "on-field" ? "on-field" : "ink-muted") });
2816
+ if (text) packText(slide, S, text, { ...f.support, h: supportH }, { colour: S.colour(ink === "on-field" ? "on-field" : "ink-muted"), name: "support@statement" });
2895
2817
  } else if (f.body) {
2896
2818
  const plan = chosen.plan;
2897
2819
  if (plan.before > S.tok.cap || plan.band > S.tok.cap) {
@@ -2902,46 +2824,45 @@ function renderContent(slide, spec, S, ctx, chosen) {
2902
2824
  }
2903
2825
  packText(slide, S, chosen.t === "statement" && spec.statementText ? spec.statementText : spec.title, t, {
2904
2826
  // Every title that wraps is set in balanced lines, never ending on one word.
2905
- face: t.face, size: t.size, colour: S.colour(ink), lineSpacing: 1.15, title: true, name: `title@${chosen.t}`, balance: true,
2827
+ face: t.face, size: t.size, colour: S.colour(ink), lineSpacing: t.lineSpacing || 1.15, valign: t.valign, title: true, name: `title@${chosen.t}`, balance: true,
2906
2828
  });
2907
2829
  }
2908
2830
 
2909
2831
  /**
2910
2832
  * What carries a statement, from the pack's display keys: nothing (open), the field over the whole
2911
- * page (drench), the field under the sentence from just above it to the foot (plate), a hairline
2912
- * above the sentence and one under its support line (rules), or a rail beside a sentence set off
2913
- * the left edge (offset). Returns the colour role the sentence takes.
2833
+ * page (drench), the field from just above the sentence's zone to the page foot (plate), a hairline
2834
+ * above the sentence and one under its support line (rules), the primaries' composition in the side
2835
+ * zone (geometry), or the rail colour filling the side zone (offset). The sentence stays in the display
2836
+ * title zone whichever it is. Returns the colour role the sentence takes.
2914
2837
  */
2915
2838
  function statementDevice(slide, S, f, supportH) {
2916
2839
  const g = S.grid;
2917
2840
  const t = f.title;
2841
+ const side = SG.zone(g.canvas, "display", "side");
2918
2842
  const device = (S.pack.display || {}).statement || "open";
2919
2843
  if (device === "drench") {
2920
2844
  packRect(slide, S, { x: 0, y: 0, w: g.W, h: g.H }, { fill: "field" });
2921
2845
  return "on-field";
2922
2846
  }
2923
2847
  if (device === "plate") {
2924
- // The sentence moves down onto a plate that bleeds off three edges and covers less than half
2925
- // of the page, so the white above stays the page and the plate reads as the sentence's ground.
2926
- // A sentence too tall to sit that low keeps the open page.
2927
- const top = Math.round((376 - t.h / 2) / 6) * 6;
2928
- if (top - 24 < 282 || top + t.h + 24 + supportH > G.ZONE.floor) return t.colour;
2929
- f.support.y += top - t.y;
2930
- t.y = top;
2931
- packRect(slide, S, { x: 0, y: top - 24, w: g.W, h: g.H - top + 24 }, { fill: "field" });
2848
+ packRect(slide, S, { x: 0, y: t.y - 24, w: g.W, h: g.H - t.y + 24 }, { fill: "field" });
2932
2849
  return "on-field";
2933
2850
  }
2934
2851
  if (device === "rules") {
2852
+ // The rules follow the sentence centred in its zone, not the zone's edges.
2935
2853
  const full = g.span(1, 12);
2936
- const below = supportH ? f.support.y + supportH + 24 : t.y + t.h + 30;
2937
- packRect(slide, S, { x: full.x, y: t.y - 30, w: full.w, h: 0.75 }, { fill: "line" });
2854
+ const top = t.y + (t.h - t.textH) / 2;
2855
+ const below = supportH ? f.support.y + supportH + 24 : top + t.textH + 30;
2856
+ packRect(slide, S, { x: full.x, y: top - 30, w: full.w, h: 0.75 }, { fill: "line" });
2938
2857
  packRect(slide, S, { x: full.x, y: below, w: full.w, h: 0.75 }, { fill: "line" });
2939
2858
  return t.colour;
2940
2859
  }
2860
+ if (device === "geometry") {
2861
+ geometry(slide, S, side);
2862
+ return t.colour;
2863
+ }
2941
2864
  if (device === "offset") {
2942
- // The rail stops a column short of the sentence, so the gap between them is part of the grid,
2943
- // and at the body floor, so the footer zone stays the page's.
2944
- packRect(slide, S, { x: 0, y: 0, w: t.x - g.gutter / 2 - g.pitch, h: G.ZONE.floor }, { fill: S.pack.rail === "field" ? "field" : "surface" });
2865
+ packRect(slide, S, { ...side, h: G.ZONE.floor }, { fill: S.pack.rail === "field" ? "field" : "surface" });
2945
2866
  return t.colour;
2946
2867
  }
2947
2868
  return t.colour;
@@ -2951,12 +2872,48 @@ function metaLine(meta) {
2951
2872
  return [meta.date, meta.department, meta.presenter].filter(Boolean).join(" · ");
2952
2873
  }
2953
2874
 
2954
- /** Cover title at cover-max, stepping down to display when it would run past `maxLines`. */
2955
- function coverTitle(S, text, w, maxLines, start = S.sizes.cover) {
2956
- let size = start;
2957
- let lines = S.lines(plainText(text), w, size, "display");
2958
- if (lines > maxLines) { size = S.sizes.display; lines = S.lines(plainText(text), w, size, "display"); }
2959
- return { size, h: Math.ceil(lines * size * 1.1) };
2875
+ /**
2876
+ * The cover's words, the same places in every tonality (slide-grid cover zones): the meta line in the
2877
+ * header zone, the title standing on the title zone's foot at the largest step that fits it (from the
2878
+ * hero step while the pack's hero is on), and the
2879
+ * subtitle in the subtitle zone. A device only paints what lies behind or beside them.
2880
+ */
2881
+ function coverWords(slide, spec, S, meta, o = {}) {
2882
+ const Z = SG.zones(S.grid.canvas).cover;
2883
+ const soft = S.colour(o.soft || "ink-muted");
2884
+ const line = metaLine(meta);
2885
+ if (line) packText(slide, S, line, Z.header, { size: S.sizes.label, colour: S.colour(o.meta || o.soft || "ink-muted"), lineSpacing: 1.2, name: "header@cover" });
2886
+ if (meta.subtitle) {
2887
+ const size = G.textHeight(meta.subtitle, Z.subtitle.w, S.sizes.lead) <= Z.subtitle.h ? S.sizes.lead : S.sizes.body;
2888
+ packText(slide, S, meta.subtitle, Z.subtitle, { name: "subtitle@cover", size, colour: soft });
2889
+ }
2890
+ const steps = [...new Set([o.start || (S.pack.hero ? S.sizes.hero : S.sizes.cover), S.sizes.cover, S.sizes.display])].sort((a, b) => b - a);
2891
+ const fits = (z) => S.lines(plainText(spec.title), Z.title.w, z, "display") * z * 1.1 <= Z.title.h;
2892
+ const size = steps.find(fits) || S.sizes.display;
2893
+ packText(slide, S, spec.title, Z.title, { face: "display", size, colour: S.colour(o.ink || "ink"), lineSpacing: 1.1, title: true, balance: true, valign: "bottom", name: "title@cover" });
2894
+ }
2895
+
2896
+ /** A row of keys over short labels in the cover's strip zone (the deck's parts or its headline figures), under a hairline. */
2897
+ function coverStrip(slide, S, parts) {
2898
+ const g = S.grid;
2899
+ const Z = SG.zones(g.canvas).cover.strip;
2900
+ const row = parts.slice(0, 4);
2901
+ const cols = equalParts(S, region(S, 1, 12, Z.y, Z.y + Z.h), row.length);
2902
+ packRect(slide, S, { x: Z.x, y: Z.y - 9, w: Z.w, h: 0.75 }, { fill: "line" });
2903
+ row.forEach(([key, text], i) => {
2904
+ const R = cols[i];
2905
+ const k = [S.sizes.title, S.sizes.lead].find((z) => S.ems(key, "numeral") * z * 1.1 <= R.w) || S.sizes.body;
2906
+ packText(slide, S, key, { x: R.x, y: Z.y, w: R.w, h: k * 1.15 }, { face: "numeral", size: k, colour: S.colour(i === 0 ? "accent" : "ink"), lineSpacing: 1.15 });
2907
+ const top = Z.y + k * 1.32 + 4;
2908
+ packText(slide, S, text, { x: R.x, y: top, w: R.w, h: Math.max(S.sizes.body * 1.4, Z.y + Z.h - top) }, { size: S.sizes.body, colour: S.colour("ink-muted") });
2909
+ });
2910
+ }
2911
+
2912
+ /** The deck's parts, else its headline figures, as strip entries. */
2913
+ function stripParts(facts) {
2914
+ if ((facts.index || []).length >= 2) return facts.index.map((name, i) => [String(i + 1).padStart(2, "0"), name]);
2915
+ if ((facts.figures || []).length >= 2) return facts.figures.map((f) => [f.value, f.label]);
2916
+ return [];
2960
2917
  }
2961
2918
 
2962
2919
  function coverImage(slide, box, src, sourceDir) {
@@ -2974,80 +2931,41 @@ function croppedTo(file, box) {
2974
2931
  return { x: IN(box.x), y: IN(box.y), w: IN(box.w), h: IN(box.w * ratio), sizing: { type: "cover", w: IN(box.w), h: IN(box.h) } };
2975
2932
  }
2976
2933
 
2934
+ /** The field behind the title zone and what stands on it, from `top` down to 6 pt under the title's foot. */
2935
+ function titleField(slide, S, top) {
2936
+ const T = SG.zones(S.grid.canvas).cover.title;
2937
+ packRect(slide, S, { x: 0, y: top, w: S.grid.W, h: T.y + T.h + 6 - top }, { fill: "field" });
2938
+ }
2939
+
2977
2940
  /**
2978
- * A type-led cover drawn with the pack's display device: the field over the whole page with the
2979
- * title anchored low (drench), a field plate under the title (plate), a journal title page between
2980
- * rules (rules), or the plain page. The rail, band, figures and numeral devices draw the pack's
2981
- * cover of that name when the deck holds what it needs.
2941
+ * A type-led cover drawn with the pack's display device. Every device leaves the words where
2942
+ * coverWords puts them and paints only around them: the field over the whole page (drench), a field
2943
+ * plate from over the title to the page foot (plate), a journal page between rules (rules), the
2944
+ * composition in the side zone (geometry), or the plain page.
2982
2945
  */
2983
2946
  const TYPE_COVERS = {
2984
2947
  drench(slide, spec, S, meta) {
2985
- const g = S.grid;
2986
- packRect(slide, S, { x: 0, y: 0, w: g.W, h: g.H }, { fill: "field" });
2987
- const line = metaLine(meta);
2988
- if (line) packText(slide, S, line, { ...g.span(1, 8), y: 36, h: 24 }, { size: S.sizes.label, colour: S.colour("on-field"), lineSpacing: 1.2 });
2989
- const span = g.span(1, 11);
2990
- const { size, h } = coverTitle(S, spec.title, span.w, 3, S.pack.hero ? S.sizes.hero : S.sizes.cover);
2991
- const sub = g.span(1, 8);
2992
- const subH = meta.subtitle ? G.textHeight(meta.subtitle, sub.w, S.sizes.lead) + 18 : 0;
2993
- // The title block ends just above the footer zone, so the field holds the title from below.
2994
- const y = Math.round((456 - subH - h) / 6) * 6;
2995
- if (meta.subtitle) packText(slide, S, meta.subtitle, { ...sub, y: y + h + 18, h: subH - 18 }, { name: "subtitle@cover", size: S.sizes.lead, colour: S.colour("on-field") });
2996
- packText(slide, S, spec.title, { ...span, y, h }, { face: "display", size, colour: S.colour("on-field"), lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
2948
+ packRect(slide, S, { x: 0, y: 0, w: S.grid.W, h: S.grid.H }, { fill: "field" });
2949
+ coverWords(slide, spec, S, meta, { ink: "on-field", soft: "on-field", start: S.pack.hero ? S.sizes.hero : S.sizes.cover });
2997
2950
  },
2998
2951
  plate(slide, spec, S, meta) {
2999
- // The plate covers a little under half the page, so the white above it stays the page's ground.
3000
- const g = S.grid;
3001
- const top = 288;
3002
- packRect(slide, S, { x: 0, y: top, w: g.W, h: g.H - top }, { fill: "field" });
3003
- const line = metaLine(meta);
3004
- if (line) packText(slide, S, line, { ...g.span(1, 8), y: 36, h: 24 }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.2 });
3005
- const span = g.span(1, 10);
3006
- const { size, h } = coverTitle(S, spec.title, span.w, 2);
3007
- const y = top + 30;
3008
- if (meta.subtitle && y + h + 18 + S.sizes.body * 1.4 <= 462) {
3009
- const sub = g.span(1, 8);
3010
- packText(slide, S, meta.subtitle, { ...sub, y: y + h + 18, h: G.textHeight(meta.subtitle, sub.w, S.sizes.body) }, { name: "subtitle@cover", colour: S.colour("on-field") });
3011
- }
3012
- packText(slide, S, spec.title, { ...span, y, h }, { face: "display", size, colour: S.colour("on-field"), lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
2952
+ // The plate runs from over the title to the page foot; the meta line stays on the ground above it.
2953
+ const top = SG.zones(S.grid.canvas).cover.title.y - 24;
2954
+ packRect(slide, S, { x: 0, y: top, w: S.grid.W, h: S.grid.H - top }, { fill: "field" });
2955
+ coverWords(slide, spec, S, meta, { ink: "on-field", soft: "on-field", meta: "ink-muted" });
3013
2956
  },
3014
2957
  rules(slide, spec, S, meta) {
3015
- const g = S.grid;
3016
- const full = g.span(1, 12);
3017
- const span = g.span(1, 10);
3018
- const top = 96;
3019
- packRect(slide, S, { x: full.x, y: top, w: full.w, h: 2 }, { fill: "ink" });
3020
- const { size, h } = coverTitle(S, spec.title, span.w, 3);
3021
- const y = top + 30;
3022
- const ruleY = y + h + 24;
3023
- packRect(slide, S, { x: full.x, y: ruleY, w: full.w, h: 0.75 }, { fill: "line" });
3024
- // Under the rule, as on a journal's title page: who presents, then where and when, in one frame.
3025
- const place = [meta.department, meta.date].filter(Boolean).join(" · ");
3026
- const runs = [
3027
- ...(meta.presenter ? [{ text: meta.presenter, options: { fontFace: S.faces.title.font, fontSize: S.sizes.lead, bold: true, color: S.colour("ink"), breakLine: Boolean(place) } }] : []),
3028
- ...(place ? [{ text: place, options: { fontFace: S.faces.label.font, fontSize: S.sizes.label, color: S.colour("ink-muted") } }] : []),
3029
- ];
3030
- let at = ruleY + 18;
3031
- if (runs.length) {
3032
- // A frame as tall as an office renderer sets two lines of mixed sizes (about 1.5 of each).
3033
- const hh = (meta.presenter ? S.sizes.lead * 1.5 : 0) + (place ? S.sizes.label * 1.5 : 0);
3034
- slide.addText(runs, { x: IN(span.x), y: IN(at), w: IN(span.w), h: IN(hh), align: "left", valign: "top", margin: [0, 0, 0, 0], lineSpacingMultiple: 1.2 });
3035
- at += hh;
3036
- }
3037
- if (meta.subtitle) packText(slide, S, meta.subtitle, { ...span, y: at + 18, h: G.textHeight(meta.subtitle, span.w, S.sizes.label, 1.3) }, { name: "subtitle@cover", size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.3 });
3038
- packText(slide, S, spec.title, { ...span, y, h }, { face: "display", size, lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
2958
+ const Z = SG.zones(S.grid.canvas).cover;
2959
+ packRect(slide, S, { x: Z.strip.x, y: Z.title.y - 18, w: Z.strip.w, h: 2 }, { fill: "ink" });
2960
+ packRect(slide, S, { x: Z.strip.x, y: Z.strip.y - 9, w: Z.strip.w, h: 0.75 }, { fill: "line" });
2961
+ coverWords(slide, spec, S, meta);
2962
+ },
2963
+ geometry(slide, spec, S, meta) {
2964
+ geometry(slide, S, SG.zones(S.grid.canvas).cover.side);
2965
+ coverWords(slide, spec, S, meta);
3039
2966
  },
3040
2967
  plain(slide, spec, S, meta) {
3041
- const g = S.grid;
3042
- const span = g.span(1, 9);
3043
- const { size, h } = coverTitle(S, spec.title, span.w, 3);
3044
- if (meta.subtitle) {
3045
- const sub = g.span(1, 8);
3046
- packText(slide, S, meta.subtitle, { ...sub, y: 144 + h + 24, h: G.textHeight(meta.subtitle, sub.w, S.sizes.lead) }, { name: "subtitle@cover", size: S.sizes.lead, colour: S.colour("ink-muted") });
3047
- }
3048
- const line = metaLine(meta);
3049
- if (line) packText(slide, S, line, { ...g.span(1, 6), y: 438, h: 24 }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.2 });
3050
- packText(slide, S, spec.title, { ...span, y: 144, h }, { face: "display", size, lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
2968
+ coverWords(slide, spec, S, meta);
3051
2969
  },
3052
2970
  };
3053
2971
 
@@ -3062,333 +2980,178 @@ const COVERS = {
3062
2980
  return (TYPE_COVERS[device] || TYPE_COVERS.plain)(slide, spec, S, meta);
3063
2981
  },
3064
2982
  "cover-split-field"(slide, spec, S, meta) {
3065
- const g = S.grid;
3066
- const x = g.colX(8) - g.gutter / 2;
3067
- packRect(slide, S, { x, y: 0, w: g.W - x, h: g.H }, { fill: "field" });
2983
+ const side = SG.zones(S.grid.canvas).cover.side;
2984
+ packRect(slide, S, side, { fill: "field" });
3068
2985
  if (spec.cover.numeral) {
3069
- const s = g.span(9, 12);
2986
+ const s = S.grid.span(side.a, side.b);
3070
2987
  packText(slide, S, spec.cover.numeral, { ...s, y: 384, h: S.sizes.title * 1.15 }, { face: "numeral", size: S.sizes.title, colour: S.colour("on-field"), lineSpacing: 1.15 });
3071
2988
  }
3072
- splitText(slide, spec, S, meta, 6);
2989
+ coverWords(slide, spec, S, meta);
3073
2990
  },
3074
2991
  "cover-split-image"(slide, spec, S, meta) {
3075
- const g = S.grid;
3076
- const x = g.colX(8) - g.gutter / 2;
3077
- coverImage(slide, { x, y: 0, w: g.W - x, h: g.H }, spec.cover.image, spec.sourceDir);
3078
- splitText(slide, spec, S, meta, 6);
2992
+ coverImage(slide, SG.zones(S.grid.canvas).cover.side, spec.cover.image, spec.sourceDir);
2993
+ coverWords(slide, spec, S, meta);
3079
2994
  },
3080
2995
  "cover-full-image"(slide, spec, S, meta) {
3081
2996
  const g = S.grid;
3082
2997
  coverImage(slide, { x: 0, y: 0, w: g.W, h: g.H }, spec.cover.image, spec.sourceDir);
3083
- const span = g.span(1, 8);
3084
- // The panel is as tall as the title and its meta line and stands on the page foot (bleeding off it,
3085
- // so the sample tag sits inside it, clear of its edge); the picture keeps the rest of the page.
3086
- const { size, h } = coverTitle(S, spec.title, span.w, 2, S.sizes.display);
3087
- const line = [meta.subtitle, metaLine(meta)].filter(Boolean).join(" · ");
3088
- const lineH = line ? G.textHeight(line, span.w, S.sizes.label, 1.3) : 0;
3089
- const y = Math.round(G.ZONE.floor - 24 - (line ? lineH + 18 : 0) - h);
3090
- packRect(slide, S, { x: 0, y: y - 24, w: span.x + span.w + 24, h: g.H - y + 24 }, { fill: "field", alpha: 12 });
3091
- packText(slide, S, spec.title, { ...span, y, h }, { face: "display", size, colour: S.colour("on-field"), lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
3092
- if (line) packText(slide, S, line, { ...span, y: y + h + 18, h: lineH }, { size: S.sizes.label, colour: S.colour("on-field"), lineSpacing: 1.3 });
2998
+ // A translucent field panel under the words' columns, the full page height; the picture keeps the side zone.
2999
+ const side = SG.zones(g.canvas).cover.side;
3000
+ packRect(slide, S, { x: 0, y: 0, w: side.x, h: g.H }, { fill: "field", alpha: 12 });
3001
+ coverWords(slide, spec, S, meta, { ink: "on-field", soft: "on-field" });
3093
3002
  },
3094
3003
  "cover-band"(slide, spec, S, meta) {
3095
- const g = S.grid;
3096
- packRect(slide, S, { x: 0, y: 0, w: g.W, h: 216 }, { fill: "field" });
3097
- const span = g.span(1, 10);
3098
- const { size, h } = coverTitle(S, spec.title, span.w, 2, S.sizes.display);
3099
- packText(slide, S, spec.title, { ...span, y: Math.max(48, 180 - h), h }, { face: "display", size, colour: S.colour("on-field"), lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
3100
- const subH = meta.subtitle ? G.textHeight(meta.subtitle, g.span(1, 8).w, S.sizes.lead) : 0;
3101
- if (meta.subtitle) packText(slide, S, meta.subtitle, { ...g.span(1, 8), y: 252, h: subH }, { name: "subtitle@cover", size: S.sizes.lead, colour: S.colour("ink-muted") });
3102
- // Under the band, the deck's parts (else its headline figures) run across the page as the
3103
- // issue's contents, between a hairline and the meta line.
3104
- const parts = (spec.cover.index || []).length >= 2 ? spec.cover.index.map((name, i) => [String(i + 1).padStart(2, "0"), name])
3105
- : (spec.cover.figures || []).length >= 2 ? spec.cover.figures.map((f) => [f.value, f.label]) : [];
3106
- if (parts.length) {
3107
- const top = Math.max(300, 252 + subH + 36);
3108
- const cols = equalParts(S, region(S, 1, 12, top, 420), Math.min(parts.length, 4));
3109
- packRect(slide, S, { x: g.margin, y: top - 18, w: g.span(1, 12).w, h: 0.75 }, { fill: "line" });
3110
- parts.slice(0, 4).forEach(([key, text], i) => {
3111
- const R = cols[i];
3112
- const k = [S.sizes.title, S.sizes.lead].find((z) => S.ems(key, "numeral") * z * 1.1 <= R.w) || S.sizes.lead;
3113
- packText(slide, S, key, { x: R.x, y: top, w: R.w, h: k * 1.15 }, { face: "numeral", size: k, colour: S.colour("accent"), lineSpacing: 1.15 });
3114
- packText(slide, S, text, { x: R.x, y: top + k * 1.15 + 8, w: R.w, h: G.textHeight(text, R.w, S.sizes.body) }, { size: S.sizes.body });
3115
- });
3116
- }
3117
- const line = metaLine(meta);
3118
- if (line) packText(slide, S, line, { ...g.span(1, 8), y: 438, h: 24 }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.2 });
3004
+ // The header band deepened behind the title; the subtitle and the deck's parts sit on the ground under it.
3005
+ titleField(slide, S, 0);
3006
+ const parts = stripParts(spec.cover || {});
3007
+ if (parts.length) coverStrip(slide, S, parts);
3008
+ coverWords(slide, spec, S, meta, { ink: "on-field", meta: "on-field" });
3119
3009
  },
3120
3010
  "cover-numeral"(slide, spec, S, meta) {
3121
- const g = S.grid;
3122
- const n = spec.cover.numeral;
3123
- // A number is never the display element: title size at most.
3124
- const numeral = S.sizes.title;
3125
- packText(slide, S, n, { ...g.span(1, 8), y: 96, h: numeral * 1.15 }, { face: "numeral", size: numeral, colour: S.colour("accent"), lineSpacing: 1.15 });
3126
- const span = g.span(1, 9);
3127
- const { size, h } = coverTitle(S, spec.title, span.w, 3, S.sizes.display);
3128
- const y = 96 + numeral * 1.1 + 24;
3129
- packText(slide, S, spec.title, { ...span, y, h }, { face: "display", size, lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
3130
- const line = metaLine(meta);
3131
- if (line) packText(slide, S, line, { ...g.span(1, 6), y: 438, h: 24 }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.2 });
3011
+ // A number is never the display element: title size at most. It stands in the
3012
+ // side zone level with the title zone's top.
3013
+ const Z = SG.zones(S.grid.canvas).cover;
3014
+ const s = S.grid.span(Z.side.a, Z.side.b);
3015
+ packText(slide, S, spec.cover.numeral, { ...s, y: Z.title.y, h: S.sizes.title * 1.15 }, { face: "numeral", size: S.sizes.title, colour: S.colour("accent"), align: "right", lineSpacing: 1.15 });
3016
+ coverWords(slide, spec, S, meta);
3132
3017
  },
3133
3018
  "cover-index"(slide, spec, S, meta) {
3134
- const g = S.grid;
3135
- const span = g.span(1, 6);
3136
- const { size, h } = coverTitle(S, spec.title, span.w, 4, S.sizes.display);
3137
- packText(slide, S, spec.title, { ...span, y: 144, h }, { face: "display", size, lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
3138
- const list = g.span(8, 12);
3139
- // The part names run from the title's top down to the meta line, at most 96 pt apart.
3140
- const pitch = Math.min(96, (438 - 144) / Math.max(1, spec.cover.index.length - 1));
3141
- packRect(slide, S, { x: list.x - 12, y: 144, w: 0.75, h: pitch * spec.cover.index.length }, { fill: "line" });
3142
- spec.cover.index.forEach((name, i) => {
3143
- packText(slide, S, `${String(i + 1).padStart(2, "0")} ${name}`, { ...list, y: 144 + i * pitch, h: G.textHeight(name, list.w, S.sizes.lead, 1.3) }, { size: S.sizes.lead, colour: S.colour("ink"), lineSpacing: 1.3 });
3144
- });
3145
- const line = metaLine(meta);
3146
- if (line) packText(slide, S, line, { ...span, y: 438, h: G.textHeight(line, span.w, S.sizes.label, 1.3) }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.3 });
3019
+ coverStrip(slide, S, stripParts({ index: spec.cover.index }));
3020
+ coverWords(slide, spec, S, meta);
3147
3021
  },
3148
3022
  "cover-figures"(slide, spec, S, meta) {
3149
- const g = S.grid;
3150
- const span = g.span(1, 9);
3151
- const { size, h } = coverTitle(S, spec.title, span.w, 2, S.sizes.display);
3152
- // The figures sit between the title and the meta line, with a hairline above them; the title
3153
- // is drawn last, so the slide's first shape is an unnamed one that carries the family.
3154
- const figs = spec.cover.figures.slice(0, 4);
3155
- // The row stands in the middle of the room between the title and the meta line.
3156
- const rowH = S.sizes.title * 1.3 + 6 + S.sizes.body * 1.4;
3157
- const top = Math.max(240, 96 + h + 36, Math.round((96 + h + 438 - rowH) / 2 / 6) * 6);
3158
- const parts = equalParts(S, region(S, 1, 12, top, top + 120), figs.length);
3159
- packRect(slide, S, { x: g.margin, y: top - 18, w: g.span(1, 12).w, h: 0.75 }, { fill: "line" });
3160
- figs.forEach((f, i) => {
3161
- const R = parts[i];
3162
- const v = [S.sizes.title, S.sizes.lead, S.sizes.body].find((s) => S.ems(f.value, "numeral") * s * 1.1 <= R.w) || S.sizes.body;
3163
- packText(slide, S, f.value, { x: R.x, y: top, w: R.w, h: v * 1.15 }, { face: "numeral", size: v, colour: S.colour(i === 0 ? "accent" : "ink"), lineSpacing: 1.15 });
3164
- packText(slide, S, f.label, { x: R.x, y: top + v * 1.3 + 6, w: R.w, h: S.sizes.body * 1.4 }, { size: S.sizes.body, colour: S.colour("ink-muted"), lineSpacing: 1.3 });
3165
- });
3166
- const line = metaLine(meta);
3167
- if (line) packText(slide, S, line, { ...g.span(1, 6), y: 438, h: 24 }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.2 });
3168
- packText(slide, S, spec.title, { ...span, y: 96, h }, { face: "display", size, lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
3023
+ coverStrip(slide, S, stripParts({ figures: spec.cover.figures }));
3024
+ coverWords(slide, spec, S, meta);
3169
3025
  },
3170
3026
  "cover-rail"(slide, spec, S, meta) {
3171
- const g = S.grid;
3172
- const rail = g.span(1, 4);
3173
- packRect(slide, S, { x: 0, y: 0, w: rail.x + rail.w + g.gutter / 2, h: g.H }, { fill: "field" });
3174
- const side = g.span(1, 3);
3175
- const lines = [meta.department, meta.presenter, meta.date].filter(Boolean);
3176
- lines.forEach((l, i) => packText(slide, S, l, { ...side, y: 402 + i * 24, h: 22 }, { size: S.sizes.label, colour: S.colour("on-field"), lineSpacing: 1.2 }));
3177
- const span = g.span(5, 12);
3178
- const { size, h } = coverTitle(S, spec.title, span.w, 3);
3179
- packText(slide, S, spec.title, { ...span, y: 144, h }, { face: "display", size, lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
3180
- if (meta.subtitle) packText(slide, S, meta.subtitle, { ...span, y: 144 + h + 24, h: G.textHeight(meta.subtitle, span.w, S.sizes.lead) }, { name: "subtitle@cover", size: S.sizes.lead, colour: S.colour("ink-muted") });
3027
+ // The rail is the left margin in the field, the page's height; the words keep their zones.
3028
+ packRect(slide, S, { x: 0, y: 0, w: 12, h: S.grid.H }, { fill: "field" });
3029
+ coverWords(slide, spec, S, meta);
3181
3030
  },
3182
3031
  };
3183
3032
 
3184
- /** Title and meta in columns 1-k beside a field or image (the split covers). */
3185
- function splitText(slide, spec, S, meta, k) {
3186
- const g = S.grid;
3187
- const span = g.span(1, k);
3188
- const { size, h } = coverTitle(S, spec.title, span.w, 4, S.sizes.display);
3189
- packText(slide, S, spec.title, { ...span, y: 144, h }, { face: "display", size, lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
3190
- if (meta.subtitle) packText(slide, S, meta.subtitle, { ...span, y: 144 + h + 24, h: G.textHeight(meta.subtitle, span.w, S.sizes.body) }, { name: "subtitle@cover", colour: S.colour("ink-muted") });
3191
- const line = metaLine(meta);
3192
- if (line) packText(slide, S, line, { ...span, y: 438, h: G.textHeight(line, span.w, S.sizes.label, 1.3) }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.3 });
3193
- }
3194
-
3195
3033
  /**
3196
- * Section slides carry a part number and the part title. The title frame keeps the statement
3197
- * frame (cols 1-10, centred in the body height) on every variant but the rail and the index, so a
3198
- * section reads as one more statement-sized title and its frame repeats across the deck. A part
3199
- * number shows only when the deck says how many parts there are; a section without one opens with
3200
- * its first lines instead (see sectionOpener).
3034
+ * Section slides use the display zones (slide-grid), the same in every tonality: the part number in
3035
+ * the header zone, the part title centred in the title zone, and what the part opens with (its slide
3036
+ * titles, or their first lines) in the support zone. A part number shows only when the deck says how
3037
+ * many parts there are; without one the title takes the cover step (the hero step in a hero tonality).
3038
+ * A variant paints only the device around the words.
3201
3039
  */
3202
3040
  const SECTIONS = {
3203
3041
  "section-field"(slide, spec, S) {
3204
3042
  const g = S.grid;
3205
3043
  packRect(slide, S, { x: 0, y: 0, w: g.W, h: g.H }, { fill: "field" });
3206
- sectionTitle(slide, spec, S, { colour: "on-field", numeralColour: "on-field" });
3044
+ sectionWords(slide, spec, S, { onField: true });
3207
3045
  },
3208
3046
  "section-numeral"(slide, spec, S) {
3209
3047
  // With the agenda known, the numeral's page is the index page: the title is never alone on it.
3210
3048
  if (spec.parts && ((spec.cover || {}).index || []).length >= 2) { sectionIndex(slide, spec, S); return; }
3211
- sectionTitle(slide, spec, S, { hero: true });
3049
+ sectionWords(slide, spec, S);
3212
3050
  },
3213
3051
  "section-rule"(slide, spec, S) {
3214
3052
  if (showsIndex(spec, S)) { sectionIndex(slide, spec, S); return; }
3215
- if (!spec.parts) { sectionOpener(slide, spec, S, { rule: true }); return; }
3216
- const full = S.grid.span(1, 12);
3217
- sectionTitle(slide, spec, S, { numeral: false, under: (box) => {
3218
- packRect(slide, S, { x: full.x, y: box.y + box.h + 18, w: full.w, h: 0.75 }, { fill: "line" });
3219
- } });
3053
+ const Z = SG.zones(S.grid.canvas).display;
3054
+ packRect(slide, S, { ...S.grid.span(1, 12), y: Z.support.y - 12, h: 0.75 }, { fill: "line" });
3055
+ sectionWords(slide, spec, S);
3220
3056
  },
3221
3057
  "section-band"(slide, spec, S) {
3222
- // The header band deepened to y 216; the title opens the ground under it. With the deck's part
3223
- // index the band carries that index (this part marked) and the title stands without a numeral.
3058
+ // The header band deepened to hold the header zone. With the deck's part index the band carries that
3059
+ // index (this part marked) in place of the part number.
3224
3060
  const g = S.grid;
3225
- packRect(slide, S, { x: 0, y: 0, w: g.W, h: 216 }, { fill: "field" });
3061
+ const Z = SG.zones(g.canvas).display;
3062
+ packRect(slide, S, { x: 0, y: 0, w: g.W, h: Z.header.y + Z.header.h + 6 }, { fill: "field" });
3226
3063
  const index = showsIndex(spec, S);
3227
- if (index) {
3228
- const h = S.sizes.body * 1.4;
3229
- partIndex(slide, S, spec, { ...g.span(1, 12), y: 216 - 36 - h }, true);
3230
- }
3231
- // Under a band that carries the index, the title is a statement over what the part opens with.
3232
- if (index) sectionOpener(slide, spec, S, { top: 252, centre: 351 });
3233
- else sectionTitle(slide, spec, S, { top: 240 });
3064
+ if (index) partIndex(slide, S, spec, Z.header, true);
3065
+ sectionWords(slide, spec, S, { headerOnField: true, numeral: !index });
3234
3066
  },
3235
3067
  "section-rail"(slide, spec, S) {
3236
- const g = S.grid;
3237
- const rail = g.span(1, 4);
3238
- packRect(slide, S, { x: 0, y: 0, w: rail.x + rail.w + g.gutter / 2, h: g.H }, { fill: "field" });
3239
- const numeral = S.sizes.title;
3240
- if (spec.parts) partNumeral(slide, S, spec, { ...g.span(1, 3), y: 216, h: numeral * 1.15 }, numeral, "on-field");
3241
- else if ((spec.contents || []).length) {
3242
- // No part count: the rail names what the section opens with, one entry under the next at lead size
3243
- // with a step between them, so the list reads as the part's contents and not as one wrapped block.
3244
- const w = g.span(1, 3).w;
3245
- let y = 216;
3246
- for (const entry of spec.contents) {
3247
- const h = G.textHeight(entry, w, S.sizes.lead, 1.3);
3248
- packText(slide, S, entry, { ...g.span(1, 3), y, h }, { size: S.sizes.lead, colour: S.colour("on-field"), lineSpacing: 1.3 });
3249
- y += h + S.tok.group;
3250
- }
3251
- }
3252
- const span = g.span(5, 12);
3253
- const display = S.sizes.display;
3254
- const h = Math.ceil(S.lines(plainText(spec.title), span.w, display, "display") * display * 1.15);
3255
- packText(slide, S, spec.title, { ...span, y: Math.round((303 - h / 2) / 6) * 6, h }, { face: "display", size: display, lineSpacing: 1.15, title: true, balance: true, name: "title@section" });
3068
+ packRect(slide, S, { x: 0, y: 0, w: 12, h: S.grid.H }, { fill: "field" });
3069
+ sectionWords(slide, spec, S);
3256
3070
  },
3257
3071
  "section-image"(slide, spec, S) {
3258
- // The panel stands on the page foot (bleeding off it) and is as tall as the part number and the title.
3072
+ // A translucent field panel under the words' columns, the page's height; the picture keeps the side zone.
3259
3073
  const g = S.grid;
3260
3074
  coverImage(slide, { x: 0, y: 0, w: g.W, h: g.H }, spec.cover.image, spec.sourceDir);
3261
- const s = g.span(1, 7);
3262
- const display = S.sizes.display;
3263
- const h = Math.ceil(S.lines(plainText(spec.title), s.w, display, "display") * display * 1.15);
3264
- const y = Math.round(G.ZONE.floor - 18 - h);
3265
- const numY = y - 24;
3266
- packRect(slide, S, { x: 0, y: (spec.parts ? numY : y) - 18, w: s.x + s.w + 24, h: g.H - (spec.parts ? numY : y) + 18 }, { fill: "field", alpha: 12 });
3267
- if (spec.parts) packText(slide, S, String(spec.part).padStart(2, "0"), { ...s, y: numY, h: S.sizes.label * 1.4 }, { size: S.sizes.label, colour: S.colour("on-field"), lineSpacing: 1.3 });
3268
- packText(slide, S, spec.title, { ...s, y, h }, { face: "display", size: display, colour: S.colour("on-field"), lineSpacing: 1.15, title: true, balance: true, name: "title@section" });
3075
+ packRect(slide, S, { x: 0, y: 0, w: SG.zones(g.canvas).display.side.x, h: g.H }, { fill: "field", alpha: 12 });
3076
+ sectionWords(slide, spec, S, { onField: true });
3269
3077
  },
3270
3078
  };
3271
3079
 
3272
- function sectionTitle(slide, spec, S, o = {}) {
3273
- if (!spec.parts) return sectionOpener(slide, spec, S, o);
3274
- const g = S.grid;
3275
- const span = g.span(1, 10);
3276
- const display = S.sizes.display;
3277
- const h = Math.ceil(S.lines(plainText(spec.title), span.w, display, "display") * display * 1.15);
3278
- // The part number is set at title size: a number never takes the display step.
3279
- const size = S.sizes.title;
3280
- const numeralH = o.numeral === false ? 0 : size * 1.15 + 12;
3281
- // Centred in the body height like a statement, and never above `top` with its numeral.
3282
- const y = Math.max(228, (o.top || 0) + numeralH, Math.round((303 - h / 2) / 6) * 6);
3283
- if (o.numeral !== false) {
3284
- partNumeral(slide, S, spec, { ...g.span(1, spec.parts ? 6 : 3), y: y - numeralH, h: size * 1.15 }, size, o.numeralColour || "accent");
3285
- }
3286
- const box = { ...span, y, h };
3287
- // Anything drawn with the title (a rule under it) goes first, so the title stays the last shape.
3288
- if (o.under) o.under(box);
3289
- packText(slide, S, spec.title, box, { face: "display", size: display, colour: S.colour(o.colour || "ink"), lineSpacing: 1.15, title: true, balance: true, name: "title@section" });
3290
- return box;
3291
- }
3292
-
3293
- /**
3294
- * A section the deck gives no part count (one section, no agenda): the title at the cover step (the
3295
- * hero step in a hero tonality) as a statement centred in the body, and under it the titles of the
3296
- * slides the section opens, so the page says what comes next instead of showing a bare count. With
3297
- * `rule` the pack's hairline runs between them; `top` and `centre` place it under a band.
3298
- */
3299
- function sectionOpener(slide, spec, S, o = {}) {
3300
- const g = S.grid;
3301
- const span = g.span(1, 10);
3302
- const size = S.pack.hero ? S.sizes.hero : S.sizes.cover;
3303
- const h = Math.ceil(S.lines(plainText(spec.title), span.w, size, "display") * size * 1.1);
3304
- const onField = o.colour === "on-field";
3305
- const digest = spec.digest || [];
3306
- if (digest.some((d) => d.lines.length)) return sectionDigestPage(slide, spec, S, o, { span, size, h, digest, onField });
3307
- const text = (spec.contents || []).join("\n");
3308
- const lead = S.sizes.lead;
3309
- const ch = text ? G.textHeight(text, span.w, lead, 1.4) : 0;
3310
- const gap = text ? (o.rule ? 60 : 30) : 0;
3311
- const y = Math.max(o.top || 0, Math.round(((o.centre || 303) - (h + gap + ch) / 2) / 6) * 6);
3312
- if (text && o.rule) packRect(slide, S, { ...g.span(1, 12), y: y + h + 30, h: 0.75 }, { fill: "line" });
3313
- if (text) packText(slide, S, text, { ...span, y: y + h + gap, h: ch }, { size: lead, colour: S.colour(onField ? "on-field" : "ink-muted"), lineSpacing: 1.4 });
3314
- const box = { ...span, y, h };
3315
- packText(slide, S, spec.title, box, { face: "display", size, colour: S.colour(o.colour || "ink"), lineSpacing: 1.1, title: true, balance: true, name: "title@section" });
3316
- return box;
3080
+ function sectionWords(slide, spec, S, o = {}) {
3081
+ const Z = SG.zones(S.grid.canvas).display;
3082
+ const ink = S.colour(o.onField ? "on-field" : "ink");
3083
+ const soft = S.colour(o.onField ? "on-field" : "ink-muted");
3084
+ if (spec.parts && o.numeral !== false) partNumeral(slide, S, spec, Z.header, S.sizes.title, o.onField || o.headerOnField ? "on-field" : "accent");
3085
+ // A numbered part sets its title at the display step; an unnumbered one at the cover step, down to display to fit.
3086
+ const steps = spec.parts ? [S.sizes.display] : [...new Set([S.pack.hero ? S.sizes.hero : S.sizes.cover, S.sizes.cover, S.sizes.display])].sort((a, b) => b - a);
3087
+ const fits = (z) => S.lines(plainText(spec.title), Z.title.w, z, "display") * z * 1.1 <= Z.title.h;
3088
+ const size = steps.find(fits) || S.sizes.display;
3089
+ sectionSupport(slide, spec, S, Z.support, ink, soft);
3090
+ packText(slide, S, spec.title, Z.title, { face: "display", size, colour: ink, lineSpacing: 1.1, title: true, balance: true, valign: "middle", name: "title@section" });
3317
3091
  }
3318
3092
 
3319
3093
  /**
3320
- * A section with no part count whose slides carry body lines: the title on the upper body, and under
3321
- * a hairline what the section opens with, one row per slide (its title, then its first lines) spread
3322
- * down to the body floor, so the page previews the part instead of standing as a title alone.
3094
+ * What an unnumbered section opens with, in the support zone: one row per opened slide (its title, then
3095
+ * up to two of its first lines) when the slides carry body lines, else their titles one to a line. Lines
3096
+ * and then rows drop until the preview fits the zone.
3323
3097
  */
3324
- function sectionDigestPage(slide, spec, S, o, d) {
3325
- const g = S.grid;
3098
+ function sectionSupport(slide, spec, S, Z, ink, soft) {
3326
3099
  const head = S.sizes.lead;
3327
3100
  const body = S.sizes.body;
3328
- // The preview starts at the margin and is the widest column span within 70 % of the page and about 70
3329
- // characters of body text: Korean 8 columns, English 6 on the 16:9 compact grid.
3330
- const korean = G.isKorean(d.digest.map((e) => e.title + e.lines.join("")).join(""));
3331
- const cap = Math.min(0.7 * g.W, 70 * body * (korean ? 0.94 : 0.5));
3332
- let k = 12;
3333
- while (k > 1 && g.span(1, k).w > cap) k--;
3334
- const right = g.span(1, k);
3335
- const ink = S.colour(d.onField ? "on-field" : "ink");
3336
- const soft = S.colour(d.onField ? "on-field" : "ink-muted");
3337
- // Each opened slide shows up to two of its lines (a preview, not a copy); lines and then entries
3338
- // drop until the preview fits between the title and the body floor.
3339
- const gap = 24;
3340
- const room = G.ZONE.floor - Math.max(o.top || 0, G.ZONE.body) - d.h - 54;
3101
+ const digest = (spec.digest || []).filter((d) => d.lines.length);
3102
+ if (!digest.length) {
3103
+ const text = (spec.contents || []).join("\n");
3104
+ if (text) packText(slide, S, text, Z, { size: head, colour: soft, lineSpacing: 1.4 });
3105
+ return;
3106
+ }
3107
+ // About 70 characters of body text at most: the widest column span inside the zone under that.
3108
+ const korean = G.isKorean(digest.map((e) => e.title + e.lines.join("")).join(""));
3109
+ const cap = 70 * body * (korean ? 0.94 : 0.5);
3110
+ let k = Z.b;
3111
+ while (k > Z.a && S.grid.span(Z.a, k).w > cap) k--;
3112
+ const span = S.grid.span(Z.a, k);
3113
+ const gap = 12;
3341
3114
  const build = (entries, per) => entries.map((e) => {
3342
3115
  const text = e.lines.slice(0, per).join("\n");
3343
- const headH = G.textHeight(e.title, right.w, head, 1.3);
3344
- const textH = text ? G.textHeight(plainText(text), right.w, body) : 0;
3345
- return { ...e, text, headH, textH, h: headH + (text ? 6 + textH : 0) };
3116
+ const headH = G.textHeight(e.title, span.w, head, 1.3);
3117
+ const textH = text ? G.textHeight(plainText(text), span.w, body) : 0;
3118
+ return { ...e, text, headH, textH, h: headH + (text ? 4 + textH : 0) };
3346
3119
  });
3347
3120
  const height = (rows) => rows.reduce((a, r) => a + r.h, 0) + gap * (rows.length - 1);
3348
3121
  let rows = null;
3349
- for (let n = d.digest.length; n >= 1 && !rows; n--) {
3122
+ for (let n = digest.length; n >= 1 && !rows; n--) {
3350
3123
  for (let per = 2; per >= 1; per--) {
3351
- const tried = build(d.digest.slice(0, n), per);
3352
- if (height(tried) <= room) { rows = tried; break; }
3124
+ const tried = build(digest.slice(0, n), per);
3125
+ if (height(tried) <= Z.h) { rows = tried; break; }
3353
3126
  }
3354
3127
  }
3355
- if (!rows) rows = build(d.digest.slice(0, 1), 1);
3356
- const sum = rows.reduce((a, r) => a + r.h, 0);
3357
- // Title, hairline and preview form one block centred in the body (under `top` when a band sits
3358
- // above), like a statement that says what the part holds.
3359
- const total = d.h + 54 + sum + gap * (rows.length - 1);
3360
- const y = Math.max(o.top || 0, G.ZONE.body, Math.round(((o.centre || 303) - total / 2) / 6) * 6);
3361
- const listTop = y + d.h + 54;
3362
- packRect(slide, S, { ...g.span(1, 12), y: listTop - 18, h: 0.75 }, { fill: d.onField ? "on-field" : "line" });
3363
- let at = listTop;
3128
+ if (!rows) rows = build(digest.slice(0, 1), 0);
3129
+ let at = Z.y;
3364
3130
  for (const r of rows) {
3365
- packText(slide, S, r.title, { ...right, y: at, h: r.headH }, { size: head, bold: true, colour: ink, lineSpacing: 1.3 });
3366
- if (r.text) packText(slide, S, r.text, { ...right, y: at + r.headH + 6, h: r.textH }, { size: body, colour: soft });
3131
+ packText(slide, S, r.title, { ...span, y: at, h: r.headH }, { size: head, bold: true, colour: ink, lineSpacing: 1.3 });
3132
+ if (r.text) packText(slide, S, r.text, { ...span, y: at + r.headH + 4, h: r.textH }, { size: body, colour: soft });
3367
3133
  at += r.h + gap;
3368
3134
  }
3369
- const box = { ...d.span, y, h: d.h };
3370
- packText(slide, S, spec.title, box, { face: "display", size: d.size, colour: S.colour(o.colour || "ink"), lineSpacing: 1.1, title: true, balance: true, name: "title@section" });
3371
- return box;
3372
3135
  }
3373
3136
 
3374
3137
  /**
3375
- * A section in a deck with an agenda, set as its index page: the part numeral at the hero step over
3376
- * the part title on the left, and the agenda down the right columns, one part a line spread over the
3377
- * body, a hairline above each and a heavier accent rule above this part, which is set in the accent.
3138
+ * A section in a deck with an agenda, set as its index page: the part number in the header zone over the
3139
+ * part title, and the agenda down the side zone, one part a line from the title zone's top, a hairline
3140
+ * above each and a heavier accent rule above this part, which is set in the accent.
3378
3141
  */
3379
3142
  function sectionIndex(slide, spec, S) {
3380
- const g = S.grid;
3381
- const left = g.span(1, 6);
3382
- const right = g.span(8, 12);
3143
+ const Z = SG.zones(S.grid.canvas).display;
3144
+ const right = S.grid.span(Z.side.a, Z.side.b);
3383
3145
  const names = spec.cover.index;
3384
3146
  const lead = S.sizes.lead;
3385
3147
  const rowH = lead * 1.4;
3386
3148
  const label = (name, i) => `${String(i + 1).padStart(2, "0")} ${name}`;
3387
3149
  const heights = names.map((name, i) => G.textHeight(label(name, i), right.w, lead, 1.4));
3388
3150
  const sum = heights.reduce((a, h) => a + h, 0);
3389
- const after = Math.max(24, Math.min(96 - rowH, (G.ZONE.floor - G.ZONE.body - sum) / Math.max(1, names.length - 1)));
3151
+ const floor = Z.support.y + Z.support.h;
3152
+ const after = Math.max(12, Math.min(60 - rowH, (floor - Z.title.y - sum) / Math.max(1, names.length - 1)));
3390
3153
  const listH = sum + (names.length - 1) * after;
3391
- const listY = Math.max(G.ZONE.body, Math.round(303 - listH / 2));
3154
+ const listY = Z.title.y + 12;
3392
3155
  const quiet = S.colour("ink-muted");
3393
3156
  const runs = [];
3394
3157
  let ruleY = listY - 12;
@@ -3405,16 +3168,10 @@ function sectionIndex(slide, spec, S) {
3405
3168
  // The gap goes before each part after the first, so the frame ends on the last line.
3406
3169
  slide.addText(runs, { x: IN(right.x), y: IN(listY), w: IN(right.w), h: IN(listH), align: "left", valign: "top", margin: [0, 0, 0, 0],
3407
3170
  lineSpacing: +rowH.toFixed(1) });
3408
- const size = S.sizes.title;
3409
- const display = S.sizes.display;
3410
- const h = Math.ceil(S.lines(plainText(spec.title), left.w, display, "display") * display * 1.15);
3411
- const blockH = size * 1.15 + 24 + h;
3412
- const y = Math.round((303 - blockH / 2) / 6) * 6;
3413
- partNumeral(slide, S, spec, { ...left, y, h: size * 1.15 }, size, "accent");
3414
- packText(slide, S, spec.title, { ...left, y: y + size * 1.15 + 24, h }, { face: "display", size: display, lineSpacing: 1.15, title: true, balance: true, name: "title@section" });
3171
+ sectionWords(slide, spec, S);
3415
3172
  }
3416
3173
 
3417
- /** The part number as the agenda writes it ("03"), followed by the part count when it is known. */
3174
+ /** The part number as the agenda writes it ("03"), followed by the part count when it is known, standing on the box's foot. */
3418
3175
  function partNumeral(slide, S, spec, box, size, colour) {
3419
3176
  const face = S.faces.numeral;
3420
3177
  const base = { fontFace: face.font, bold: Boolean(face.bold), color: S.colour(colour) };
@@ -3424,14 +3181,14 @@ function partNumeral(slide, S, spec, box, size, colour) {
3424
3181
  // The count is a step below the number, so the number stays the display element.
3425
3182
  if (spec.parts) runs.push({ text: ` / ${pad(spec.parts)}`, options: { ...base, fontSize: S.sizes.title, color: S.colour(colour === "on-field" ? "on-field" : "ink-muted") } });
3426
3183
  slide.addText(runs, { x: IN(box.x), y: IN(box.y), w: IN(box.w), h: IN(box.h), align: "left", valign: "bottom", margin: [0, 0, 0, 0],
3427
- lineSpacing: +(size * 1.15).toFixed(1) });
3184
+ lineSpacing: +(size * 1.15).toFixed(1), objectName: "header@section" });
3428
3185
  }
3429
3186
 
3430
3187
  function showsIndex(spec, S) {
3431
3188
  return Boolean((S.pack.display || {}).index && spec.parts && ((spec.cover || {}).index || []).length >= 2);
3432
3189
  }
3433
3190
 
3434
- /** The deck's parts in one line, numbered as in the agenda; this section's part bold in the accent. */
3191
+ /** The deck's parts in one line, numbered as in the agenda; this section's part bold in the accent, on the box's foot. */
3435
3192
  function partIndex(slide, S, spec, box, onField = false) {
3436
3193
  const size = S.sizes.body;
3437
3194
  const quiet = onField ? mix(S.P["on-field"], S.P.field, 0.3) : S.P["ink-muted"];
@@ -3444,13 +3201,50 @@ function partIndex(slide, S, spec, box, onField = false) {
3444
3201
  color: current ? S.colour(onField ? "on-field" : "accent") : shared().hex(quiet),
3445
3202
  } });
3446
3203
  });
3447
- const text = runs.map((r) => r.text).join("");
3448
- const h = G.textHeight(text, box.w, size, 1.4);
3449
- slide.addText(runs, { x: IN(box.x), y: IN(box.y), w: IN(box.w), h: IN(h), align: "left", valign: "top", margin: [0, 0, 0, 0],
3450
- lineSpacing: +(size * 1.4).toFixed(1) });
3204
+ slide.addText(runs, { x: IN(box.x), y: IN(box.y), w: IN(box.w), h: IN(box.h), align: "left", valign: "bottom", margin: [0, 0, 0, 0],
3205
+ lineSpacing: +(size * 1.4).toFixed(1), objectName: "header@section" });
3451
3206
  }
3452
3207
 
3453
3208
  /** The sample-data tag in the footer zone, on the grid's left edge, in the pack's colours. */
3209
+ /**
3210
+ * The grid shown on purpose: a tick and a two-digit number over each column start in the top margin,
3211
+ * and the slide's place in the deck at the right, all in the label face and the muted ink.
3212
+ */
3213
+ function gridMarks(slide, S, n, total) {
3214
+ const g = S.grid;
3215
+ const size = S.sizes.source;
3216
+ const y = 12;
3217
+ for (let a = 1; a <= 12; a++) {
3218
+ const x = g.colX(a);
3219
+ packRect(slide, S, { x, y, w: 0.75, h: size * 1.2 }, { fill: "line", name: "grid-mark" });
3220
+ if (a < 11) packText(slide, S, String(a).padStart(2, "0"), { x: x + 4, y, w: g.col - 4, h: size * 1.2 }, { face: "label", size, colour: S.colour("ink-muted"), lineSpacing: 1.2, name: "grid-mark" });
3221
+ }
3222
+ const index = `[${String(n).padStart(2, "0")}/${String(total).padStart(2, "0")}]`;
3223
+ const right = g.span(11, 12);
3224
+ packText(slide, S, index, { ...right, y, h: size * 1.2 }, { face: "label", size, colour: S.colour("ink"), align: "right", lineSpacing: 1.2, name: "grid-mark index" });
3225
+ }
3226
+
3227
+ /**
3228
+ * Elementary geometry in the primaries, in a region of the page that holds no text: a square in the
3229
+ * surface colour, a circle in the accent, a bar in the ink and a triangle in the field, one per cell
3230
+ * of a two-by-two grid, so no shape overlaps another or the text.
3231
+ */
3232
+ function geometry(slide, S, region) {
3233
+ const cw = region.w / 2;
3234
+ const ch = region.h / 2;
3235
+ const d = Math.min(cw, ch);
3236
+ const cell = (i, j) => ({ x: region.x + i * cw, y: region.y + j * ch });
3237
+ const shape = (kind, box, fill) => slide.addShape(kind, { x: IN(box.x), y: IN(box.y), w: IN(box.w), h: IN(box.h), fill: { color: S.colour(fill) }, line: { type: "none" } });
3238
+ const a = cell(0, 0);
3239
+ shape("rect", { x: a.x, y: a.y, w: cw, h: ch }, "accent-tint");
3240
+ const b = cell(1, 0);
3241
+ shape("ellipse", { x: b.x + (cw - d) / 2, y: b.y + (ch - d) / 2, w: d, h: d }, "accent");
3242
+ const c = cell(0, 1);
3243
+ shape("rect", { x: c.x + cw * 0.38, y: c.y, w: cw * 0.24, h: ch }, "ink");
3244
+ const e = cell(1, 1);
3245
+ shape("triangle", { x: e.x, y: e.y + (ch - d), w: cw, h: d }, "field");
3246
+ }
3247
+
3454
3248
  function packNotice(slide, S, text) {
3455
3249
  const label = plainText(text);
3456
3250
  const size = S.sizes.source;
@@ -3536,11 +3330,13 @@ async function renderPack(resolved, templateObj, outputPath) {
3536
3330
  // Nothing unnamed was drawn (a statement alone): an empty marker carries the family.
3537
3331
  slide.addShape("rect", { x: IN(S.grid.margin), y: IN(G.ZONE.top), w: 0, h: 0, fill: { type: "none" }, line: { type: "none" }, objectName: `family@${spec.family}` });
3538
3332
  }
3333
+ // The marks read on the ground; a statement page may be drenched in the field, so it goes without.
3334
+ if (spec.kind === "content" && chosenBy[spec.index] !== "statement" && S.deco.has("grid-marks")) gridMarks(slide, S, spec.index + 1, (resolved.slides || []).length);
3539
3335
  if (notice) packNotice(slide, S, notice);
3540
3336
  }
3541
3337
  for (const line of log) console.log(line);
3542
3338
  console.log(`Treatments: ${Object.entries(chosenBy).map(([i, t]) => `${Number(i) + 1}:${t}`).join(" ")}`);
3543
- await writeWithEastAsianFaces(pptx, S.faces.body.font, outputPath);
3339
+ await writeWithEastAsianFaces(pptx, S.faces.body.font, outputPath, Object.values(S.faces).filter((f) => f.ea), Boolean(S.pack.shadow));
3544
3340
  return outputPath;
3545
3341
  }
3546
3342
 
@@ -3549,10 +3345,21 @@ async function renderPack(resolved, templateObj, outputPath) {
3549
3345
  * Chart text otherwise carries only a Latin face, and a renderer sets Hangul axis labels in
3550
3346
  * whatever system font it finds. Korean runs are tagged ko-KR on the way (see writeTagged).
3551
3347
  */
3552
- async function writeWithEastAsianFaces(pptx, face, outputPath) {
3553
- await shared().writeTagged(pptx, outputPath, (name, xml) => (/^ppt\/(charts\/chart\d+|theme\/theme\d+)\.xml$/u.test(name)
3554
- ? xml.replace(/<a:ea typeface=""\/>/gu, `<a:ea typeface="${face}"/>`).replace(/(<a:latin typeface="([^"]*)"\/>)(?!<a:ea)/gu, `$1<a:ea typeface="$2"/>`)
3555
- : xml));
3348
+ async function writeWithEastAsianFaces(pptx, face, outputPath, latinOnly = [], hardShadows = false) {
3349
+ await shared().writeTagged(pptx, outputPath, (name, xml) => {
3350
+ if (/^ppt\/(charts\/chart\d+|theme\/theme\d+)\.xml$/u.test(name)) {
3351
+ xml = xml.replace(/<a:ea typeface=""\/>/gu, `<a:ea typeface="${face}"/>`).replace(/(<a:latin typeface="([^"]*)"\/>)(?!<a:ea)/gu, `$1<a:ea typeface="$2"/>`);
3352
+ }
3353
+ // A Latin-only face names its East Asian face for the Hangul in its runs.
3354
+ if (/^ppt\/(slides\/slide\d+|charts\/chart\d+)\.xml$/u.test(name)) {
3355
+ for (const f of latinOnly) xml = xml.split(`<a:ea typeface="${f.font}"`).join(`<a:ea typeface="${f.ea}"`);
3356
+ }
3357
+ // pptxgenjs reads a blur of 0 as "unset" and writes its default; a pack's shadow is drawn with none.
3358
+ if (hardShadows && /^ppt\/slides\/slide\d+\.xml$/u.test(name)) {
3359
+ xml = xml.replace(/(<a:outerShdw\b[^>]*?\bblurRad=")\d+"/gu, '$10"');
3360
+ }
3361
+ return xml;
3362
+ });
3556
3363
  }
3557
3364
 
3558
3365
  module.exports = { renderPack, PLANS, COVERS, SECTIONS };