@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.
- package/CHANGELOG.md +11 -0
- package/README.md +20 -0
- package/README_ko-KR.md +20 -0
- package/bin/litmuse.mjs +4 -0
- package/lib/stage-card.mjs +180 -5
- package/package.json +1 -1
- package/plugin/.muse-plugin/plugin.json +26 -11
- package/plugin/commands/lit-status.md +2 -1
- package/plugin/commands/lit.md +1 -1
- package/plugin/hooks/lib/digest.mjs +1 -1
- package/plugin/hooks/lib/next-step.mjs +68 -0
- package/plugin/hooks/lib/routes.mjs +59 -0
- package/plugin/hooks/lib/workflow-stack.mjs +318 -0
- package/plugin/hooks/session-start.mjs +29 -2
- package/plugin/hooks/user-prompt-submit.mjs +138 -7
- package/plugin/lib/capped-fetch.mjs +25 -0
- package/plugin/lib/pinned-fetch.mjs +79 -0
- package/plugin/lib/plan-task-rows.mjs +32 -0
- package/plugin/lib/render_pages.py +29 -1
- package/plugin/lib/rewind/collect.mjs +679 -0
- package/plugin/lib/rewind/gauge.mjs +72 -0
- package/plugin/lib/rewind/next-step.mjs +91 -0
- package/plugin/lib/rewind/redact.mjs +83 -0
- package/plugin/lib/rewind/transcripts.mjs +227 -0
- package/plugin/skills/frontend-ui-ux/SKILL.md +24 -9
- package/plugin/skills/frontend-ui-ux/kit/components.css +414 -0
- package/plugin/skills/frontend-ui-ux/kit/fonts.json +22 -0
- package/plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
- package/plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
- package/plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
- package/plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
- package/plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -1
- package/plugin/skills/frontend-ui-ux/references/craft-floor.md +9 -4
- package/plugin/skills/frontend-ui-ux/references/default-editorial-pixel.json +59 -7
- package/plugin/skills/frontend-ui-ux/references/house-styles.md +234 -0
- package/plugin/skills/frontend-ui-ux/references/motion-guide.md +3 -3
- package/plugin/skills/frontend-ui-ux/references/slop-register.md +7 -7
- package/plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-lock.mjs +196 -0
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-page.mjs +31 -10
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-rules.mjs +19 -7
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-static.mjs +4 -0
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe.mjs +59 -7
- package/plugin/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
- package/plugin/skills/lit-adhd/SKILL.md +188 -0
- package/plugin/skills/lit-adhd/references/card-examples.md +121 -0
- package/plugin/skills/lit-adhd/references/status-taxonomy.md +43 -0
- package/plugin/skills/lit-adhd/scripts/rewind.mjs +11 -0
- package/plugin/skills/lit-diagram-drawer/SKILL.md +3 -3
- package/plugin/skills/lit-diagram-drawer/scripts/diagram-craft-rules.mjs +1 -2
- package/plugin/skills/lit-diagram-drawer/scripts/visual-quality.mjs +0 -1
- package/plugin/skills/lit-docx/SKILL.md +1 -1
- package/plugin/skills/lit-hijack/SKILL.md +589 -0
- package/plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
- package/plugin/skills/lit-hijack/references/clean-room-build.md +133 -0
- package/plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
- package/plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
- package/plugin/skills/lit-hijack/references/parity-and-report.md +133 -0
- package/plugin/skills/lit-hijack/scripts/hijack.mjs +879 -0
- package/plugin/skills/lit-pptx/SKILL.md +20 -9
- package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
- package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
- package/plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
- package/plugin/skills/lit-pptx/fonts/provenance.json +32 -1
- package/plugin/skills/lit-pptx/references/direction-step.md +19 -2
- package/plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
- package/plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
- package/plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
- package/plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
- package/plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
- package/plugin/skills/lit-pptx/references/tonalities/bauhaus.md +112 -0
- package/plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +118 -0
- package/plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +125 -0
- package/plugin/skills/lit-pptx/references/tonalities/newsroom.md +115 -0
- package/plugin/skills/lit-pptx/references/tonalities/riso.md +109 -0
- package/plugin/skills/lit-pptx/scripts/compile-deck.js +1 -1
- package/plugin/skills/lit-pptx/scripts/craft_extras.py +136 -92
- package/plugin/skills/lit-pptx/scripts/embed_fonts.py +13 -1
- package/plugin/skills/lit-pptx/scripts/lib/grid-resolver.js +105 -116
- package/plugin/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
- package/plugin/skills/lit-pptx/scripts/lib/render-pack.js +368 -561
- package/plugin/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
- package/plugin/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
- package/plugin/skills/lit-pptx/scripts/qa_deck.py +4 -4
- package/plugin/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
- package/plugin/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
- package/plugin/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
- package/plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
- package/plugin/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
- package/plugin/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
- package/plugin/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
- package/plugin/skills/lit-recap/SKILL.md +3 -0
- package/plugin/skills/lit-schematic/SKILL.md +241 -0
- package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +14 -0
- package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +8 -0
- package/plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +22 -0
- package/plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +73 -0
- package/plugin/skills/lit-schematic/references/cutaway-outputs.md +25 -0
- package/plugin/skills/lit-schematic/references/isometric-craft.md +24 -0
- package/plugin/skills/lit-schematic/references/journal-figures.md +19 -0
- package/plugin/skills/lit-schematic/references/kit-api.md +27 -0
- package/plugin/skills/lit-schematic/references/pfd-and-layout.md +20 -0
- package/plugin/skills/lit-schematic/references/pid-drafting.md +26 -0
- package/plugin/skills/lit-schematic/references/pid-symbols.md +27 -0
- package/plugin/skills/lit-schematic/references/verification.md +24 -0
- package/plugin/skills/lit-schematic/scripts/doctor.mjs +13 -0
- package/plugin/skills/lit-schematic/scripts/lib/figure.mjs +49 -0
- package/plugin/skills/lit-schematic/scripts/lib/isa.mjs +18 -0
- package/plugin/skills/lit-schematic/scripts/lib/iso.mjs +58 -0
- package/plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +10 -0
- package/plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +241 -0
- package/plugin/skills/lit-schematic/scripts/lib/sheet.mjs +446 -0
- package/plugin/skills/lit-schematic/scripts/lib/svg.mjs +39 -0
- package/plugin/skills/lit-schematic/scripts/lib/theme.mjs +25 -0
- package/plugin/skills/lit-schematic/scripts/motion.mjs +8 -0
- package/plugin/skills/lit-schematic/scripts/qa.mjs +21 -0
- package/plugin/skills/lit-schematic/scripts/render.mjs +38 -0
- package/plugin/skills/lit-schematic/scripts/scaffold.mjs +73 -0
- package/plugin/skills/lit-scientific-visualization/SKILL.md +2 -0
- package/plugin/skills/lit-typographic-motion/SKILL.md +12 -8
- package/plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +11 -0
- package/plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
- package/plugin/skills/lit-typographic-motion/engine/core/presets.mjs +1 -1
- package/plugin/skills/lit-typographic-motion/engine/node/assets.mjs +218 -0
- package/plugin/skills/lit-typographic-motion/engine/node/blender.mjs +144 -0
- package/plugin/skills/lit-typographic-motion/engine/node/board.mjs +136 -0
- package/plugin/skills/lit-typographic-motion/engine/node/done.mjs +42 -3
- package/plugin/skills/lit-typographic-motion/engine/node/gallery.mjs +120 -0
- package/plugin/skills/lit-typographic-motion/engine/node/house.mjs +44 -0
- package/plugin/skills/lit-typographic-motion/engine/node/pipeline.mjs +18 -7
- package/plugin/skills/lit-typographic-motion/engine/node/runtime.mjs +6 -3
- package/plugin/skills/lit-typographic-motion/engine/node/scaffold.mjs +210 -0
- package/plugin/skills/lit-typographic-motion/engine/node/score.mjs +639 -0
- package/plugin/skills/lit-typographic-motion/engine/node/sound-cli.mjs +21 -4
- package/plugin/skills/lit-typographic-motion/engine/node/sound.mjs +126 -27
- package/plugin/skills/lit-typographic-motion/engine/node/stage-serve.mjs +29 -5
- package/plugin/skills/lit-typographic-motion/engine/node/stage.mjs +82 -14
- package/plugin/skills/lit-typographic-motion/engine/node/styles.mjs +214 -0
- package/plugin/skills/lit-typographic-motion/engine/node/treatment.mjs +311 -10
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
- package/plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +69 -0
- package/plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
- package/plugin/skills/lit-typographic-motion/references/depth.md +122 -0
- package/plugin/skills/lit-typographic-motion/references/sound.md +74 -16
- package/plugin/skills/lit-typographic-motion/references/stage.md +29 -5
- package/plugin/skills/lit-typographic-motion/references/studio.md +245 -0
- package/plugin/skills/lit-typographic-motion/references/treatment.md +31 -8
- package/plugin/skills/lit-typographic-motion/scripts/motion.mjs +48 -7
- package/plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
- package/plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
- package/plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
- package/plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
- package/plugin/skills/readme-studio/SKILL.md +1 -1
- 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 (
|
|
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 &&
|
|
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
|
|
482
|
-
// points side by side in two columns
|
|
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
|
-
:
|
|
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 (
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 (
|
|
856
|
-
//
|
|
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 =
|
|
881
|
+
const top = Z.y;
|
|
868
882
|
const cardsPlan = stack([kpi, ...others, ...texts], { ...Z, y: top }, S, state);
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
|
|
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.
|
|
942
|
-
|
|
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
|
-
:
|
|
960
|
+
: { ...Z, y: Z.y + blockH + state.blockGap };
|
|
947
961
|
const evidence = stack(texts, R, S, { ...state, stepped: true });
|
|
948
|
-
|
|
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
|
|
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
|
-
//
|
|
1036
|
-
|
|
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
|
-
|
|
1047
|
-
|
|
1048
|
-
const
|
|
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
|
|
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) /
|
|
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
|
|
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,
|
|
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:
|
|
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 =
|
|
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 -
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1634
|
-
*
|
|
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,
|
|
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 [
|
|
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
|
|
2229
|
-
*
|
|
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 =
|
|
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:
|
|
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
|
-
//
|
|
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:
|
|
2323
|
-
//
|
|
2324
|
-
|
|
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(),
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2458
|
-
|
|
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
|
-
//
|
|
2478
|
-
//
|
|
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
|
|
2489
|
-
|
|
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 (
|
|
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
|
-
|
|
2624
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
2938
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
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
|
|
2980
|
-
*
|
|
2981
|
-
*
|
|
2982
|
-
*
|
|
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
|
-
|
|
2987
|
-
|
|
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
|
|
3001
|
-
const
|
|
3002
|
-
|
|
3003
|
-
|
|
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
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
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
|
-
|
|
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
|
|
3067
|
-
|
|
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 =
|
|
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
|
-
|
|
2990
|
+
coverWords(slide, spec, S, meta);
|
|
3074
2991
|
},
|
|
3075
2992
|
"cover-split-image"(slide, spec, S, meta) {
|
|
3076
|
-
|
|
3077
|
-
|
|
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
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
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
|
-
|
|
3097
|
-
|
|
3098
|
-
const
|
|
3099
|
-
|
|
3100
|
-
|
|
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
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
const
|
|
3126
|
-
packText(slide, S,
|
|
3127
|
-
|
|
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
|
-
|
|
3136
|
-
|
|
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
|
-
|
|
3151
|
-
|
|
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
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
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
|
|
3198
|
-
*
|
|
3199
|
-
*
|
|
3200
|
-
*
|
|
3201
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
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
|
|
3224
|
-
// index
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3238
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
3263
|
-
|
|
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
|
|
3274
|
-
|
|
3275
|
-
const
|
|
3276
|
-
const
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
const
|
|
3281
|
-
const
|
|
3282
|
-
|
|
3283
|
-
|
|
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
|
-
*
|
|
3296
|
-
*
|
|
3297
|
-
*
|
|
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
|
|
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
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
const
|
|
3337
|
-
const
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
const
|
|
3341
|
-
const
|
|
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,
|
|
3345
|
-
const textH = text ? G.textHeight(plainText(text),
|
|
3346
|
-
return { ...e, text, headH, textH, h: headH + (text ?
|
|
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 =
|
|
3123
|
+
for (let n = digest.length; n >= 1 && !rows; n--) {
|
|
3351
3124
|
for (let per = 2; per >= 1; per--) {
|
|
3352
|
-
const tried = build(
|
|
3353
|
-
if (height(tried) <=
|
|
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(
|
|
3357
|
-
|
|
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, { ...
|
|
3367
|
-
if (r.text) packText(slide, S, r.text, { ...
|
|
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
|
|
3377
|
-
*
|
|
3378
|
-
*
|
|
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
|
|
3382
|
-
const
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
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) =>
|
|
3555
|
-
|
|
3556
|
-
|
|
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 };
|