@litfamily/litmuse 0.1.1 → 0.1.2

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