@litfamily/litopencode 1.0.16 → 1.0.18
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 +13 -0
- package/README-Ko-KR.md +11 -11
- package/README.md +11 -11
- package/dist/activation-routing.js +1 -1
- package/dist/deliverable-hedge-guard.js +3 -1
- package/dist/features.js +2 -2
- package/dist/hooks.js +2 -1
- package/dist/skills.js +6 -4
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference-Ko-KR.md +1 -1
- package/package.json +1 -1
- package/skills/frontend-ui-ux/data/THIRD-PARTY-NOTICE.txt +1 -3
- package/skills/lit-docx/SKILL.md +80 -19
- package/skills/lit-docx/references/components.md +253 -0
- package/skills/lit-docx/references/direction-step.md +234 -0
- package/skills/lit-docx/references/examples/01-report-ko.md +160 -0
- package/skills/lit-docx/references/examples/02-brief-ko.md +58 -0
- package/skills/lit-docx/references/examples/03-manual-ko.md +107 -0
- package/skills/lit-docx/references/examples/04-proposal-ko.md +93 -0
- package/skills/lit-docx/references/examples/05-memo-ko.md +42 -0
- package/skills/lit-docx/references/examples/06-journal-en.md +84 -0
- package/skills/lit-docx/references/examples/07-report-en.md +137 -0
- package/skills/lit-docx/references/examples/08-journal-ko.md +93 -0
- package/skills/lit-docx/references/examples/09-brief-en.md +64 -0
- package/skills/lit-docx/references/page-composition.md +306 -0
- package/skills/lit-docx/references/tonalities/brief.md +104 -0
- package/skills/lit-docx/references/tonalities/journal.md +101 -0
- package/skills/lit-docx/references/tonalities/manual.md +111 -0
- package/skills/lit-docx/references/tonalities/memo.md +105 -0
- package/skills/lit-docx/references/tonalities/proposal.md +101 -0
- package/skills/lit-docx/references/tonalities/report.md +126 -0
- package/skills/lit-docx/run.py +2 -1
- package/skills/lit-docx/scripts/convert_md_to_docx.py +384 -67
- package/skills/lit-docx/scripts/craft_docx.py +204 -1
- package/skills/lit-docx/scripts/docx_design.py +2445 -0
- package/skills/lit-docx/scripts/docx_layout.py +959 -0
- package/skills/lit-docx/scripts/slop_lint.py +23 -6
- package/skills/lit-docx/templates/tonalities/brief.yaml +28 -0
- package/skills/lit-docx/templates/tonalities/journal.yaml +25 -0
- package/skills/lit-docx/templates/tonalities/manual.yaml +26 -0
- package/skills/lit-docx/templates/tonalities/memo.yaml +26 -0
- package/skills/lit-docx/templates/tonalities/proposal.yaml +26 -0
- package/skills/lit-docx/templates/tonalities/report.yaml +26 -0
- package/skills/lit-pptx/NOTICE.md +1 -1
- package/skills/lit-pptx/SKILL.md +108 -19
- package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
- package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
- package/skills/lit-pptx/references/density-and-fill.md +283 -0
- package/skills/lit-pptx/references/direction-step.md +249 -0
- package/skills/lit-pptx/references/examples/01-ops-review-ledger-ko.md +173 -0
- package/skills/lit-pptx/references/examples/02-launch-pitch-signal-en.md +153 -0
- package/skills/lit-pptx/references/examples/03-field-survey-atlas-ko.md +120 -0
- package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +132 -0
- package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +162 -0
- package/skills/lit-pptx/references/examples/06-policy-briefing-gazette-ko.md +176 -0
- package/skills/lit-pptx/references/examples/07-annual-story-studio-en.md +131 -0
- package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +182 -0
- package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +147 -0
- package/skills/lit-pptx/references/examples/10-investor-pitch-signal-ko.md +136 -0
- package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +177 -0
- package/skills/lit-pptx/references/examples/12-exhibition-proposal-studio-ko.md +149 -0
- package/skills/lit-pptx/references/layout-families.md +470 -0
- package/skills/lit-pptx/references/title-treatments.md +271 -0
- package/skills/lit-pptx/references/tonalities/atlas.md +131 -0
- package/skills/lit-pptx/references/tonalities/chalk.md +132 -0
- package/skills/lit-pptx/references/tonalities/gazette.md +137 -0
- package/skills/lit-pptx/references/tonalities/ledger.md +150 -0
- package/skills/lit-pptx/references/tonalities/night.md +148 -0
- package/skills/lit-pptx/references/tonalities/paper.md +135 -0
- package/skills/lit-pptx/references/tonalities/signal.md +147 -0
- package/skills/lit-pptx/references/tonalities/studio.md +149 -0
- package/skills/lit-pptx/scripts/compile-deck.js +68 -21
- package/skills/lit-pptx/scripts/craft-thresholds.json +45 -1
- package/skills/lit-pptx/scripts/craft_extras.py +37 -8
- package/skills/lit-pptx/scripts/deck_output.py +654 -0
- package/skills/lit-pptx/scripts/embed_fonts.py +2 -2
- package/skills/lit-pptx/scripts/layout_inventory.py +35 -2
- package/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
- package/skills/lit-pptx/scripts/lib/font-map.js +4 -4
- package/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
- package/skills/lit-pptx/scripts/lib/layout-resolver.js +403 -21
- package/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
- package/skills/lit-pptx/scripts/lib/render-adapter-html.js +3 -9
- package/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -113
- package/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
- package/skills/lit-pptx/scripts/lib/slide-ast.js +55 -24
- package/skills/lit-pptx/scripts/lib/spec-validator.js +2 -2
- package/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
- package/skills/lit-pptx/scripts/qa_deck.py +48 -13
- package/skills/lit-pptx/scripts/validate_pptx.py +25 -7
- package/skills/lit-pptx/specs/template-enrollment-contract.md +4 -4
- package/skills/lit-pptx/templates/boilerplate_professional.css +13 -13
- package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
- package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +5 -2
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/capabilities.yaml +20 -20
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -11
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +10 -7
- package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/capabilities.yaml +17 -17
- package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/template.yaml +8 -8
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +104 -0
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +100 -0
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +101 -0
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +100 -0
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +105 -0
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +101 -0
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +99 -0
- package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +2 -2
- package/skills/lit-typographic-motion/engine/treatment.mjs +1 -1
- package/skills/lit-typographic-motion/fonts/pins.json +2 -2
- package/skills/lit-typographic-motion/gate.mjs +1 -1
- package/skills/lit-typographic-motion/references/stage.md +1 -1
- package/skills/lit-typographic-motion/references/style-bibles.md +2 -2
- package/skills/lit-typographic-motion/references/treatment.md +1 -1
- package/skills/lit-typographic-motion/references/type-craft.md +3 -3
- package/skills/managed-skill-manifest.json +288 -44
- package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Bold.otf +0 -0
- package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Regular.otf +0 -0
|
@@ -0,0 +1,3558 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* render-pack.js — one renderer for every tonality pack.
|
|
5
|
+
*
|
|
6
|
+
* It reads palette roles, faces, ramp sizes, density tokens, the treatment geometry the resolver
|
|
7
|
+
* worked out and the slide's layout family; nothing here branches on which pack it is. A family
|
|
8
|
+
* is a plan: it splits the treatment's body zone into regions on the 12-column grid and places the
|
|
9
|
+
* slide's content in them. A plan runs again under the pack's fill policies (larger type, wider
|
|
10
|
+
* gaps, the visual taking the room) until the body is used, and the treatment is chosen among the
|
|
11
|
+
* ones the role and family allow by how well the content fills it. Geometry is in points until
|
|
12
|
+
* the moment a shape is added.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const G = require("./grid-resolver");
|
|
17
|
+
const { looksLikeKpi } = require("./layout-resolver");
|
|
18
|
+
|
|
19
|
+
// Helpers shared with the legacy renderer (hex, cleanMd, tables, charts, images, placements).
|
|
20
|
+
let A = null;
|
|
21
|
+
function shared() {
|
|
22
|
+
if (!A) A = require("./render-adapter-pptx").shared;
|
|
23
|
+
return A;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const IN = (pt) => pt / 72;
|
|
27
|
+
const plainText = (s) => shared().cleanMd(String(s || "").replace(/\*\*/g, ""));
|
|
28
|
+
|
|
29
|
+
// ── Style ───────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function packStyle(pack) {
|
|
32
|
+
const P = pack.palette;
|
|
33
|
+
const voice = pack["type-voice"] || {};
|
|
34
|
+
return {
|
|
35
|
+
pack, P, sizes: pack.tok.sizes, tok: pack.tok, grid: pack.grid, faces: pack.faces,
|
|
36
|
+
deco: new Set(pack.decoration || []),
|
|
37
|
+
// Heavier faces set wider: the advance estimate grows with the weight so frames hold their lines.
|
|
38
|
+
wide(role = "body") { return G.faceWidth(pack.faces[role]); },
|
|
39
|
+
ems(text, role) { return G.textEms(text) * this.wide(role); },
|
|
40
|
+
lines(text, w, size, role) { return G.lineCount(text, w / this.wide(role), size); },
|
|
41
|
+
colour: (role) => shared().hex(P[role] || P.ink),
|
|
42
|
+
// Tracking is a percentage of the size; Hangul body text is never tracked.
|
|
43
|
+
tracking(face, size) {
|
|
44
|
+
const pct = face === "display" || face === "numeral" ? voice["display-tracking"] : face === "title" ? voice["title-tracking"] : 0;
|
|
45
|
+
return pct ? +((size * pct) / 100).toFixed(2) : 0;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `a` moved toward `b` by `t` (0-1), as #RRGGBB. */
|
|
51
|
+
function mix(a, b, t) {
|
|
52
|
+
const c = (h, i) => parseInt(String(h).replace("#", "").slice(i, i + 2), 16);
|
|
53
|
+
const out = [0, 2, 4].map((i) => Math.round(c(a, i) + (c(b, i) - c(a, i)) * t).toString(16).padStart(2, "0"));
|
|
54
|
+
return `#${out.join("").toUpperCase()}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Drawing primitives ──────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/** Title runs: **marked** words in the accent, the rest in the title colour. */
|
|
60
|
+
function titleRuns(text, S, base) {
|
|
61
|
+
return String(text || "").split(/(\*\*[^*]+\*\*)/g).filter(Boolean).map((part) => {
|
|
62
|
+
const marked = part.startsWith("**") && part.endsWith("**");
|
|
63
|
+
return { text: marked ? part.slice(2, -2) : part, options: { ...base, ...(marked ? { color: S.colour("accent") } : {}) } };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Display text broken at word boundaries into lines of near-equal width, at the line count the
|
|
69
|
+
* frame was measured for, or one more when long words keep every split at that count too wide and
|
|
70
|
+
* the frame holds that line (the renderer would wrap to it anyway); text with marked words is left
|
|
71
|
+
* to the renderer's own wrapping.
|
|
72
|
+
*/
|
|
73
|
+
function balanced(S, text, w, size, role, room = Infinity) {
|
|
74
|
+
const s = String(text || "");
|
|
75
|
+
if (/\*\*/u.test(s)) return s;
|
|
76
|
+
const lines = S.lines(s, w, size, role);
|
|
77
|
+
if (lines < 2) return s;
|
|
78
|
+
const per = w / S.wide(role) / size;
|
|
79
|
+
const set = G.balanceLines(s, lines, per);
|
|
80
|
+
// A set ending on one word gets a line more when the frame holds it and that ends on two.
|
|
81
|
+
const widow = (t) => t.includes("\n") && !/ /u.test(t.slice(t.lastIndexOf("\n") + 1));
|
|
82
|
+
if (set !== s && !widow(set)) return set;
|
|
83
|
+
const more = lines + 1 <= room ? G.balanceLines(s, lines + 1, per) : s;
|
|
84
|
+
return more !== s && (set === s || !widow(more)) ? more : set;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A run split around the linked words it holds, each linked piece carrying its address. */
|
|
88
|
+
function linkRuns(run, links) {
|
|
89
|
+
for (const [label, url] of links) {
|
|
90
|
+
const at = run.text.indexOf(label);
|
|
91
|
+
if (at < 0) continue;
|
|
92
|
+
const { breakLine, ...rest } = run.options;
|
|
93
|
+
const parts = [
|
|
94
|
+
at > 0 ? { text: run.text.slice(0, at), options: rest } : null,
|
|
95
|
+
{ text: label, options: { ...rest, hyperlink: { url } } },
|
|
96
|
+
...linkRuns({ text: run.text.slice(at + label.length), options: run.options }, links),
|
|
97
|
+
].filter((p) => p && p.text !== "");
|
|
98
|
+
if (breakLine && parts.length) parts[parts.length - 1] = { ...parts[parts.length - 1], options: { ...parts[parts.length - 1].options, breakLine: true } };
|
|
99
|
+
return parts;
|
|
100
|
+
}
|
|
101
|
+
return [run];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A flush text frame (zero insets) on the grid. */
|
|
105
|
+
function packText(slide, S, text, box, o = {}) {
|
|
106
|
+
const faceRole = o.face || "body";
|
|
107
|
+
const face = S.faces[faceRole];
|
|
108
|
+
const size = o.size || S.sizes.body;
|
|
109
|
+
// A Markdown link is set as its words, linked; the brackets and the address never reach the page.
|
|
110
|
+
const links = [];
|
|
111
|
+
text = String(text == null ? "" : text).replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/gu, (_, label, url) => { links.push([label, url]); return label; });
|
|
112
|
+
if (o.balance) text = balanced(S, text, box.w, size, faceRole, Math.floor(box.h / (size * (o.lineSpacing || 1.15)) + 0.01));
|
|
113
|
+
else if (!o.title && faceRole !== "numeral") {
|
|
114
|
+
text = G.keepLines(text, box.w / S.wide(faceRole) / size, Math.floor(box.h / (size * (o.lineSpacing || G.leading(plainText(text)))) + 0.01));
|
|
115
|
+
}
|
|
116
|
+
const tracking = o.tracking != null ? o.tracking : S.tracking(faceRole, size);
|
|
117
|
+
const base = { fontFace: face.font, fontSize: size, color: o.colour || S.colour("ink"), bold: Boolean(face.bold || o.bold),
|
|
118
|
+
...(tracking ? { charSpacing: tracking } : {}) };
|
|
119
|
+
const runs = (o.title ? titleRuns(text, S, base)
|
|
120
|
+
: shared().mdRuns(text, base).map((r) => ({ ...r, options: { ...r.options, bold: base.bold || r.options.bold } })))
|
|
121
|
+
// A break inside a run ends the line there and nowhere else (the run's last piece flows on).
|
|
122
|
+
.flatMap((r) => r.text.split("\n").map((t, i, all) => ({ text: t, options: { ...r.options, ...(i < all.length - 1 ? { breakLine: true } : {}) } })))
|
|
123
|
+
.flatMap((r) => linkRuns(r, links));
|
|
124
|
+
// A numeral frame also holds the comma and the descenders that hang below a 1.15 line.
|
|
125
|
+
const h = faceRole === "numeral" ? Math.max(box.h, size * 1.32) : box.h;
|
|
126
|
+
slide.addText(runs, {
|
|
127
|
+
x: IN(box.x), y: IN(box.y), w: IN(box.w), h: IN(Math.max(h, 1)),
|
|
128
|
+
...base,
|
|
129
|
+
align: o.align || "left", valign: o.valign || "top",
|
|
130
|
+
margin: [0, 0, 0, 0],
|
|
131
|
+
// Exact leading in points, so a frame is as tall as the measure says on every renderer.
|
|
132
|
+
lineSpacing: +(size * (o.lineSpacing || G.leading(plainText(text)))).toFixed(1),
|
|
133
|
+
...(o.name ? { objectName: o.name } : {}),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function packRect(slide, S, box, o = {}) {
|
|
138
|
+
const radius = o.radius || 0;
|
|
139
|
+
slide.addShape(radius ? "roundRect" : "rect", {
|
|
140
|
+
x: IN(box.x), y: IN(box.y), w: IN(box.w), h: IN(box.h),
|
|
141
|
+
...(radius ? { rectRadius: IN(radius) } : {}),
|
|
142
|
+
fill: o.fill ? { color: S.colour(o.fill), transparency: o.alpha || 0 } : { type: "none" },
|
|
143
|
+
line: o.line ? { color: S.colour(o.line), width: o.lineWidth || 0.75 } : { type: "none" },
|
|
144
|
+
...(o.name ? { objectName: o.name } : {}),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A surface (card, box, panel) in the pack's edge treatment and radius. */
|
|
149
|
+
function surfaceRect(slide, S, box, fill = "surface") {
|
|
150
|
+
const edge = S.pack.edge;
|
|
151
|
+
packRect(slide, S, box, {
|
|
152
|
+
fill: edge === "border" ? null : fill,
|
|
153
|
+
line: edge === "fill" ? null : "line",
|
|
154
|
+
radius: S.pack.radius,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Content atoms ───────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/** Body paragraphs of a list, in reading order. */
|
|
161
|
+
function packParas(items) {
|
|
162
|
+
const paras = [];
|
|
163
|
+
let counter = 0;
|
|
164
|
+
const children = (list) => {
|
|
165
|
+
counter = 0;
|
|
166
|
+
for (const c of list || []) paras.push({ kind: "child", text: `(${++counter}) ${c.text || ""}` });
|
|
167
|
+
};
|
|
168
|
+
for (const it of items || []) {
|
|
169
|
+
if (it.type === "section") { paras.push({ kind: "section", text: it.heading || "" }); children(it.children); }
|
|
170
|
+
else if (it.type === "bullet") { paras.push({ kind: "bullet", text: it.text || "" }); children(it.children); }
|
|
171
|
+
else if (it.type === "numbered") paras.push({ kind: "child", text: `(${++counter}) ${it.text || ""}` });
|
|
172
|
+
else if (it.type === "text" && it.text) paras.push({ kind: "text", text: it.text });
|
|
173
|
+
}
|
|
174
|
+
return paras;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Paragraphs of one text item (body, summary group or callout). */
|
|
178
|
+
function itemParas(item) {
|
|
179
|
+
const c = item.content || {};
|
|
180
|
+
if (item.type === "main-box") return [{ kind: "text", text: String(c) }];
|
|
181
|
+
if (item.type === "summary-group") return [...(c.heading ? [{ kind: "section", text: c.heading }] : []), ...packParas(c.items)];
|
|
182
|
+
return packParas(c.items);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Top-level entries of a list with their sub-points: a bold head, the rest of the line, children. */
|
|
186
|
+
function entries(paras) {
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const p of paras) {
|
|
189
|
+
if (p.kind === "child" && out.length) { out[out.length - 1].children.push(p.text); continue; }
|
|
190
|
+
const m = String(p.text).match(/^\*\*(.+?)\*\*\s*(.*)$/u);
|
|
191
|
+
out.push({ kind: p.kind, head: m ? m[1].trim() : p.kind === "section" ? p.text : "", rest: m ? m[2].trim() : p.kind === "section" ? "" : p.text, children: [] });
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A leading ordinal in a step head ("1. 수거" → 1, "수거"). */
|
|
197
|
+
function ordinal(head, index) {
|
|
198
|
+
const m = String(head || "").match(/^(\d{1,2})[.)]\s*(.*)$/u);
|
|
199
|
+
return m ? { n: m[1], head: m[2] } : { n: String(index + 1), head };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const isText = (i) => ["body", "summary-group", "main-box"].includes(i.type);
|
|
203
|
+
const isVisual = (i) => ["kpi-table", "image", "image-grid"].includes(i.type);
|
|
204
|
+
|
|
205
|
+
function sortItems(items) {
|
|
206
|
+
return {
|
|
207
|
+
texts: items.filter(isText),
|
|
208
|
+
visuals: items.filter(isVisual),
|
|
209
|
+
columns: items.find((i) => i.type === "columns") || null,
|
|
210
|
+
captions: items.filter((i) => i.type === "caption"),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Measure ─────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/** A ramp size under the fill state: one step up when stepped, a lead set at body size when compact. */
|
|
217
|
+
function bodySize(S, state, role = "body") {
|
|
218
|
+
if (state.compact && role === "lead") return S.sizes.body;
|
|
219
|
+
return state.stepped ? S.sizes[G.stepRole(role)] : S.sizes[role];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* A point that opens with a bold run-in label reads "label: rest": the label keeps its own separator when it
|
|
224
|
+
* ends in one, else it takes a colon. A bold line with nothing after it is a heading.
|
|
225
|
+
*/
|
|
226
|
+
function runIn(text) {
|
|
227
|
+
const m = /^\*\*(.+?)\*\*\s+(\S[\s\S]*)$/u.exec(String(text == null ? "" : text));
|
|
228
|
+
if (!m || /[::.)\]?!–—-]$/u.test(m[1].trim())) return text;
|
|
229
|
+
return `**${m[1].trim()}:** ${m[2]}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** A text block of paragraphs at width w. */
|
|
233
|
+
function measureText(paras, w, S, state, o = {}) {
|
|
234
|
+
const size = o.size || bodySize(S, state);
|
|
235
|
+
const indent = Math.round(size * 1.1);
|
|
236
|
+
const lines = paras.map((p, i) => {
|
|
237
|
+
const inset = p.kind === "bullet" || p.kind === "child" ? indent : 0;
|
|
238
|
+
const h = G.textHeight(plainText(runIn(p.text)), w - inset, size);
|
|
239
|
+
const gap = i === 0 ? 0 : p.kind === "section" ? state.groupGap : state.itemGap;
|
|
240
|
+
return { ...p, inset, h, gap };
|
|
241
|
+
});
|
|
242
|
+
return { kind: "text", paras: lines, w, size, h: lines.reduce((sum, l) => sum + l.gap + l.h, 0), muted: o.muted };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function captionText(caption) {
|
|
246
|
+
// A source caption that already carries its own number ("표 1.") is renumbered, not doubled.
|
|
247
|
+
const text = plainText(caption.caption).replace(/^(?:표|도|Figure|Table)\s*\d+\.\s*/u, "");
|
|
248
|
+
return text ? `${caption.prefix} ${caption.number}. ${text}` : `${caption.prefix} ${caption.number}.`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function captionHeight(caption, w, S) {
|
|
252
|
+
if (!caption) return 0;
|
|
253
|
+
return 6 + G.textHeight(captionText(caption), w, S.sizes.label, 1.3);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function measureTable(item, w, S, state) {
|
|
257
|
+
const c = item.content;
|
|
258
|
+
const headers = c.headers || [];
|
|
259
|
+
const rows = c.rows || [];
|
|
260
|
+
const header = S.sizes.label;
|
|
261
|
+
const data = state.stepped ? S.sizes.body : S.sizes.label;
|
|
262
|
+
const widths = headers.length ? shared().proportionalWidths(headers, rows, IN(w)).map((v) => v * 72) : [w];
|
|
263
|
+
const cell = 6;
|
|
264
|
+
const minRow = S.tok.rowMin * (state.anchor ? 1.25 : 1);
|
|
265
|
+
// Rows are measured at the face's natural line (about 1.45 of the size with its gap), which is
|
|
266
|
+
// what office renderers lay a cell out at; a smaller estimate lets rows grow past the frame.
|
|
267
|
+
const rowH = (cells, size) => Math.max(minRow, ...cells.map((t, i) => G.textHeight(plainText(t), (widths[i] || w) - cell * 2, size, 1.45) + cell * 2));
|
|
268
|
+
const heights = [rowH(headers, header), ...rows.map((r) => rowH(r, data))];
|
|
269
|
+
const tableH = heights.reduce((a, b) => a + b, 0);
|
|
270
|
+
return { kind: "table", heights, widths, header, data, tableH, h: tableH + captionHeight(item.caption, w, S) };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function measureKpi(item, w, S, state) {
|
|
274
|
+
const cards = shared().kpiCards(item.content).slice(0, 6);
|
|
275
|
+
const n = cards.length;
|
|
276
|
+
const cardW = (w - (n - 1) * S.grid.gutter) / n;
|
|
277
|
+
// The value stays on one line: the step-up size when it fits, else the largest that does.
|
|
278
|
+
const longest = Math.max(...cards.map((k) => S.ems(plainText(k.value), "numeral")));
|
|
279
|
+
const pad = S.tok.pad;
|
|
280
|
+
const fitting = (size) => longest * size * 1.04 <= cardW - pad * 2;
|
|
281
|
+
// A figure sits at most two steps above the body (title size), never at display or hero scale.
|
|
282
|
+
const value = [S.sizes.title, S.sizes.lead, S.sizes.body].find((v) => fitting(v)) || S.sizes.body;
|
|
283
|
+
const labelH = Math.max(...cards.map((k) => G.textHeight(plainText(k.label), cardW - pad * 2, S.sizes.label, 1.3)));
|
|
284
|
+
const noteSize = S.sizes.label;
|
|
285
|
+
const noteH = Math.max(0, ...cards.map((k) => (k.note ? G.textHeight(plainText(k.note), cardW - pad * 2, noteSize, 1.3) : 0)));
|
|
286
|
+
const h = pad * 2 + value * 1.15 + 6 + labelH + (noteH ? 6 + noteH : 0);
|
|
287
|
+
return { kind: "kpi", cards, n, cardW, value, pad, labelH, noteSize, noteH, h: h + captionHeight(item.caption, w, S), cardH: h };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Measure one item at width w. Charts and images are flexible: they take the height the plan
|
|
292
|
+
* gives them (the rest of their region when they anchor it).
|
|
293
|
+
*/
|
|
294
|
+
function measureItem(item, w, S, state) {
|
|
295
|
+
const c = item.content || {};
|
|
296
|
+
if (isText(item)) {
|
|
297
|
+
if (item.type === "main-box") {
|
|
298
|
+
const pad = S.tok.pad;
|
|
299
|
+
const m = measureText([{ kind: "text", text: String(c) }], w - pad * 2, S, state);
|
|
300
|
+
return { kind: "callout", pad, size: m.size, h: pad * 2 + m.h };
|
|
301
|
+
}
|
|
302
|
+
return measureText(itemParas(item), w, S, state);
|
|
303
|
+
}
|
|
304
|
+
if (item.type === "kpi-table" && c.chart) return { kind: "chart", flex: true, cap: captionHeight(item.caption, w, S), min: 150 };
|
|
305
|
+
if (item.type === "kpi-table" && !item.values && looksLikeKpi(c, { basis: true })) {
|
|
306
|
+
const cards = shared().kpiCards(c).length;
|
|
307
|
+
if (cards && (w - (cards - 1) * S.grid.gutter) / cards >= S.grid.span(1, 2).w) return measureKpi(item, w, S, state);
|
|
308
|
+
}
|
|
309
|
+
if (item.type === "kpi-table") return measureTable(item, w, S, state);
|
|
310
|
+
if (item.type === "image" || item.type === "image-grid") return { kind: "image", flex: true, cap: captionHeight(item.caption, w, S), min: 150 };
|
|
311
|
+
if (item.type === "caption") return { kind: "caption", h: G.textHeight(captionText(c), w, S.sizes.label, 1.3) };
|
|
312
|
+
return { kind: "none", h: 0 };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Regions ─────────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
/** Columns a…b of the deck grid as a region from y to bottom. */
|
|
318
|
+
function region(S, a, b, y, bottom) {
|
|
319
|
+
const s = S.grid.span(a, b);
|
|
320
|
+
return { a, b, x: s.x, w: s.w, y, bottom };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Split a region's columns: the first `share` of them, and the rest. */
|
|
324
|
+
function splitRegion(S, R, share) {
|
|
325
|
+
const n = R.b - R.a + 1;
|
|
326
|
+
const k = Math.max(1, Math.min(n - 1, Math.round(n * share)));
|
|
327
|
+
return [region(S, R.a, R.a + k - 1, R.y, R.bottom), region(S, R.a + k, R.b, R.y, R.bottom)];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Equal parts of a region's columns (for 5 parts on 12 columns, equal slots off the column lines). */
|
|
331
|
+
function equalParts(S, R, n) {
|
|
332
|
+
const cols = R.b - R.a + 1;
|
|
333
|
+
if (cols % n === 0) {
|
|
334
|
+
const k = cols / n;
|
|
335
|
+
return Array.from({ length: n }, (_, i) => region(S, R.a + i * k, R.a + (i + 1) * k - 1, R.y, R.bottom));
|
|
336
|
+
}
|
|
337
|
+
const gap = n >= 5 ? S.grid.gutter * 1.5 : S.grid.gutter;
|
|
338
|
+
const w = (R.w - gap * (n - 1)) / n;
|
|
339
|
+
return Array.from({ length: n }, (_, i) => ({ a: R.a, b: R.b, x: R.x + i * (w + gap), w, y: R.y, bottom: R.bottom }));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Stack items down a region. Flexible visuals take an even share of the room the fixed items
|
|
344
|
+
* leave, so a chart or image anchors the region's bottom. Returns placements with the region
|
|
345
|
+
* bottom they must stay above.
|
|
346
|
+
*/
|
|
347
|
+
function stack(items, R, S, state) {
|
|
348
|
+
const placed = [];
|
|
349
|
+
let y = R.y;
|
|
350
|
+
items.forEach((item, i) => {
|
|
351
|
+
if (item.type === "columns") {
|
|
352
|
+
const weights = item.tracks.map((t) => parseFloat(String(t)) || 1);
|
|
353
|
+
const total = weights.reduce((a, b) => a + b, 0);
|
|
354
|
+
let at = R.a;
|
|
355
|
+
const parts = weights.map((v, ci) => {
|
|
356
|
+
const n = ci === weights.length - 1 ? R.b - at + 1 : Math.max(1, Math.round(((R.b - R.a + 1) * v) / total));
|
|
357
|
+
const part = region(S, at, at + n - 1, y, R.bottom);
|
|
358
|
+
at += n;
|
|
359
|
+
return part;
|
|
360
|
+
});
|
|
361
|
+
const cols = parts.map((part, ci) => stack(item.columns[ci] || [], part, S, state));
|
|
362
|
+
const bottom = Math.max(y, ...cols.map((c) => c.bottom));
|
|
363
|
+
placed.push({ kind: "group", parts: cols.flatMap((c) => c.placed), y, h: bottom - y, x: R.x, w: R.w, limit: R.bottom });
|
|
364
|
+
y = bottom + state.blockGap;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const m = measureItem(item, R.w, S, state);
|
|
368
|
+
if (m.kind === "none") return;
|
|
369
|
+
placed.push({ item, ...m, x: R.x, y, w: R.w, limit: R.bottom });
|
|
370
|
+
y += (m.h || 0) + state.blockGap;
|
|
371
|
+
});
|
|
372
|
+
const flex = placed.filter((p) => p.flex);
|
|
373
|
+
if (flex.length) {
|
|
374
|
+
const fixed = placed.filter((p) => !p.flex).reduce((sum, p) => sum + p.h, 0);
|
|
375
|
+
const gaps = state.blockGap * Math.max(0, placed.length - 1);
|
|
376
|
+
const share = Math.max(...flex.map((p) => p.min), (R.bottom - R.y - fixed - gaps) / flex.length);
|
|
377
|
+
let cursor = R.y;
|
|
378
|
+
for (const p of placed) {
|
|
379
|
+
if (p.flex) p.h = share;
|
|
380
|
+
if (p.kind === "group") shiftGroup(p, cursor - p.y);
|
|
381
|
+
p.y = cursor;
|
|
382
|
+
cursor += p.h + state.blockGap;
|
|
383
|
+
}
|
|
384
|
+
y = cursor;
|
|
385
|
+
}
|
|
386
|
+
const bottom = placed.length ? Math.max(...placed.map((p) => p.y + p.h)) : R.y;
|
|
387
|
+
return { placed, bottom };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function shiftGroup(p, dy) {
|
|
391
|
+
if (!dy) return;
|
|
392
|
+
for (const q of p.parts) {
|
|
393
|
+
q.y += dy;
|
|
394
|
+
if (q.kind === "group") shiftGroup(q, dy);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Pitch for n rows that share a region: the last row ends on the region floor, never closer than
|
|
400
|
+
* the tallest row plus a gap and never wider than `max`.
|
|
401
|
+
*/
|
|
402
|
+
function rowPitch(n, rowH, R, gap, max) {
|
|
403
|
+
if (n <= 1) return rowH;
|
|
404
|
+
return Math.max(rowH + gap, Math.min(max, (R.bottom - R.y - rowH) / (n - 1)));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** A text block placed in a region. */
|
|
408
|
+
function textAt(paras, R, S, state, o = {}) {
|
|
409
|
+
const m = measureText(paras, R.w, S, state, o);
|
|
410
|
+
return { ...m, x: R.x, y: R.y, limit: R.bottom };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── Layout families ─────────────────────────────────────────────────────────
|
|
414
|
+
//
|
|
415
|
+
// Each plan gets ctx = { spec, S, state, Z, cls, items } where Z is the treatment's body zone and
|
|
416
|
+
// cls the treatment class (top, side, bottom). It returns placements; decoration placements carry
|
|
417
|
+
// `decor: true`.
|
|
418
|
+
|
|
419
|
+
/** A visual with its takeaway: beside it (share of the columns), or under it on a narrow body. */
|
|
420
|
+
function visualWithText(ctx, visuals, texts, share, o = {}) {
|
|
421
|
+
const { S, state, Z } = ctx;
|
|
422
|
+
const out = [];
|
|
423
|
+
// A visual alone (its takeaways in the rail, or none): a table opens its rows; on the compact step a
|
|
424
|
+
// chart carries its values under it.
|
|
425
|
+
if (!texts.length) return ((S.tok.tight && !ctx.railValues && valuesUnder(visuals, Z, S, state)) || openRows(stack(visuals, Z, S, state).placed, Z, S));
|
|
426
|
+
if (!visuals.length) return stack(texts, Z, S, state).placed;
|
|
427
|
+
// Takeaways too short for their column give the visual columns: a narrower column wraps them onto more
|
|
428
|
+
// lines and a wider chart or picture grows, down to a three-column takeaway.
|
|
429
|
+
if (!o.fixed && Z.b - Z.a + 1 >= 6 && visuals.some((v) => v.type === "image" || (v.content || {}).chart)) {
|
|
430
|
+
const n = Z.b - Z.a + 1;
|
|
431
|
+
const base = visualWithText(ctx, visuals, texts, share, { ...o, fixed: true });
|
|
432
|
+
let pick = base;
|
|
433
|
+
// A picture drawn short of its column (a wide one in a narrow column) takes columns while either side
|
|
434
|
+
// leaves more than a fifth of the body empty; a chart only while its takeaways leave over RAIL_EMPTY.
|
|
435
|
+
const target = visuals.some((v) => v.type === "image") ? 0.2 : RAIL_EMPTY;
|
|
436
|
+
for (let step = 1; shortColumn(pick, Z.bottom, S).share > target; step++) {
|
|
437
|
+
const k = o.textFirst ? share - step / n : share + step / n;
|
|
438
|
+
const textCols = o.textFirst ? Math.round(n * k) : n - Math.round(n * k);
|
|
439
|
+
if (textCols < 3) break;
|
|
440
|
+
const next = visualWithText(ctx, visuals, texts, k, { ...o, fixed: true });
|
|
441
|
+
// A narrower column never costs the takeaways their size (a lead-size column dropping to body size read as shrunk).
|
|
442
|
+
const size = (plan) => Math.max(0, ...plan.filter((q) => q.kind === "text" && !q.strip && !q.muted).map((q) => q.size || 0));
|
|
443
|
+
if (!overflows(next) && size(next) >= size(base) && shortColumn(next, Z.bottom, S).share < shortColumn(pick, Z.bottom, S).share) pick = next;
|
|
444
|
+
}
|
|
445
|
+
// Takeaways too few for even a three-column column beside a chart run under it instead, side by side, and
|
|
446
|
+
// the chart (with its values) takes the body's width above them.
|
|
447
|
+
if (shortColumn(pick, Z.bottom, S).share > RAIL_EMPTY && visuals.some((v) => (v.content || {}).chart)) {
|
|
448
|
+
const paras = texts.flatMap(itemParas);
|
|
449
|
+
const half = Math.ceil(paras.length / 2);
|
|
450
|
+
const parts = paras.length >= 2 ? splitRegion(S, Z, 0.5) : [Z];
|
|
451
|
+
const measured = parts.map((R, i) => measureText(parts.length > 1 ? (i ? paras.slice(half) : paras.slice(0, half)) : paras, R.w, S, state));
|
|
452
|
+
const notesH = Math.max(...measured.map((m) => m.h));
|
|
453
|
+
const above = { ...Z, bottom: Z.bottom - notesH - state.blockGap };
|
|
454
|
+
const top = (S.tok.tight && valuesUnder(visuals, above, S, state, true)) || stack(visuals, above, S, state);
|
|
455
|
+
const chart = top.placed.find((q) => q.kind === "chart");
|
|
456
|
+
if (chart && chart.h - chart.cap >= 150) {
|
|
457
|
+
return [...top.placed, ...measured.map((m, i) => ({ ...m, x: parts[i].x, y: above.bottom + state.blockGap, limit: Z.bottom }))];
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return pick;
|
|
461
|
+
}
|
|
462
|
+
if (Z.b - Z.a + 1 >= 6) {
|
|
463
|
+
let [left, right] = splitRegion(S, Z, share);
|
|
464
|
+
// On the compact step takeaways too long for their column take half the body from the visual.
|
|
465
|
+
if (S.tok.tight && share > 0.5 && stack(texts, o.textFirst ? left : right, S, state).bottom > Z.bottom) [left, right] = splitRegion(S, Z, 0.5);
|
|
466
|
+
const [vR, full] = o.textFirst ? [right, left] : [left, right];
|
|
467
|
+
let v = stack(visuals, vR, S, state);
|
|
468
|
+
const col = takeawayColumn(ctx, texts, visuals, full, v.bottom);
|
|
469
|
+
const t = col.t;
|
|
470
|
+
// When the chart's values found no room beside it, they stand under the chart, which gives up the
|
|
471
|
+
// height the table takes: as columns, else turned across (the categories as columns, one row a
|
|
472
|
+
// series), which costs a row per series only.
|
|
473
|
+
if (S.tok.tight && !col.values) {
|
|
474
|
+
const under = valuesUnder(visuals, vR, S, state, true);
|
|
475
|
+
if (under) v = under;
|
|
476
|
+
}
|
|
477
|
+
// The strip closes the column once the visual's own height is settled.
|
|
478
|
+
if (col.strip && col.end <= col.R.bottom + 0.5 && v.bottom > col.end) t.placed.push(...col.strip.placed.map((q) => ({ ...q, strip: true })));
|
|
479
|
+
// A table keeps its own height, so beside it a short takeaway leaves the body half empty. When
|
|
480
|
+
// that band is over the density cap and the takeaway fits under the table, it goes there, its
|
|
481
|
+
// points side by side in two columns so they read as notes on the table above them.
|
|
482
|
+
if (v.placed.every((p) => !p.flex) && (Z.bottom - Math.max(v.bottom, t.bottom)) / (Z.bottom - Z.y) > S.tok.cap) {
|
|
483
|
+
const top = stack(visuals, Z, S, state);
|
|
484
|
+
const below = { ...Z, y: top.bottom + state.blockGap };
|
|
485
|
+
const paras = texts.flatMap(itemParas);
|
|
486
|
+
const half = Math.ceil(paras.length / 2);
|
|
487
|
+
const notes = paras.length >= 2
|
|
488
|
+
? splitRegion(S, below, 0.5).map((R, i) => textAt(i ? paras.slice(half) : paras.slice(0, half), R, S, state))
|
|
489
|
+
: stack(texts, below, S, state).placed;
|
|
490
|
+
const end = Math.max(...notes.map((q) => q.y + q.h));
|
|
491
|
+
if (end <= Z.bottom) {
|
|
492
|
+
// Still short of the density band: the table's rows open up first (half the room, at most
|
|
493
|
+
// one more minimum row height each), then the notes stand midway in what is left under the table.
|
|
494
|
+
let room = Z.bottom - end;
|
|
495
|
+
let grow = 0;
|
|
496
|
+
const table = top.placed.find((q) => q.kind === "table");
|
|
497
|
+
if (table && room / (Z.bottom - Z.y) > S.tok.cap) {
|
|
498
|
+
const rows = table.heights.length - 1;
|
|
499
|
+
grow = Math.floor(Math.min(room / 2, rows * S.tok.rowMin) / rows) * rows;
|
|
500
|
+
table.heights = table.heights.map((h, i) => (i ? h + grow / rows : h));
|
|
501
|
+
table.tableH += grow;
|
|
502
|
+
table.h += grow;
|
|
503
|
+
for (const q of top.placed) if (q !== table && q.y > table.y) q.y += grow;
|
|
504
|
+
room -= grow;
|
|
505
|
+
}
|
|
506
|
+
const dy = grow + (room / (Z.bottom - Z.y) > S.tok.cap ? Math.round(room / 2) : 0);
|
|
507
|
+
return [...top.placed, ...notes.map((q) => ({ ...q, y: q.y + dy }))];
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// A picture drawn shorter than its box (a wide one in a tall column) stands on the box floor when
|
|
511
|
+
// the takeaways beside it run past the picture's top, so the two columns leave no band empty across both.
|
|
512
|
+
for (const q of v.placed) if (q.kind === "image") q.beside = t.bottom;
|
|
513
|
+
out.push(...v.placed, ...t.placed);
|
|
514
|
+
if (S.deco.has("column-hairlines")) out.push(columnHairline(S, o.textFirst ? right : right, Z));
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
517
|
+
return stack([...visuals, ...texts], Z, S, state).placed;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* A chart over its values as a compact table in region R: as columns, else turned across (the categories
|
|
522
|
+
* as columns, one row a series), when the chart keeps at least 150 pt and every row holds one line.
|
|
523
|
+
* Returns the placements (or the stack result when `asStack`), or null.
|
|
524
|
+
*/
|
|
525
|
+
function valuesUnder(visuals, R, S, state, asStack = false) {
|
|
526
|
+
// Across the body the values run across (two or three rows under a wide chart); in a column, as columns.
|
|
527
|
+
const order = R.w >= S.grid.span(1, 8).w ? [chartTable(visuals, true), chartTable(visuals)] : [chartTable(visuals), chartTable(visuals, true)];
|
|
528
|
+
for (const table of order) {
|
|
529
|
+
if (!table) continue;
|
|
530
|
+
const under = stack([...visuals, table], R, S, state);
|
|
531
|
+
const chart = under.placed.find((q) => q.kind === "chart");
|
|
532
|
+
if (chart && chart.h - chart.cap >= 150 && under.bottom <= R.bottom + 0.5 && under.placed.every((q) => q.kind !== "table" || q.heights.slice(1).every((h) => h <= S.tok.rowMin * 1.6))) {
|
|
533
|
+
return asStack ? under : under.placed;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* The takeaway column beside a visual that ends at `visualBottom`. The source strip offered by the slide
|
|
541
|
+
* closes the column (it is placed by the caller once the visual is settled), so the column reaches the
|
|
542
|
+
* floor with the visual instead of stopping halfway; on the compact step a chart's values stand as a
|
|
543
|
+
* compact table under the takeaways when they fit, so the exact figures are beside the shape they make.
|
|
544
|
+
* Takeaways that still leave more than 40 % of the column empty beside a longer visual are set one step
|
|
545
|
+
* up (lead) when they fit: scaled within the ramp, never shrunk.
|
|
546
|
+
*/
|
|
547
|
+
function takeawayColumn(ctx, texts, visuals, full, visualBottom) {
|
|
548
|
+
const { S, state } = ctx;
|
|
549
|
+
const strip = ctx.strip ? stripPlacements(ctx.strip, full, S) : null;
|
|
550
|
+
const R = strip ? { ...full, bottom: full.bottom - strip.h - S.tok.block } : full;
|
|
551
|
+
const at = (st) => {
|
|
552
|
+
const t = stack(texts, R, S, st);
|
|
553
|
+
const values = S.tok.tight ? chartValues(visuals, R, S, state, t.bottom) : null;
|
|
554
|
+
if (values) t.placed.push(values);
|
|
555
|
+
const end = Math.max(t.bottom, values ? values.y + values.h : 0);
|
|
556
|
+
return { t, values, end, empty: (Math.min(visualBottom, R.bottom) - end) / Math.max(1, R.bottom - R.y) };
|
|
557
|
+
};
|
|
558
|
+
let pick = at(state);
|
|
559
|
+
if (pick.empty > 0.4 && !state.stepped && R.w / S.sizes.lead >= 12) {
|
|
560
|
+
const lead = at({ ...state, stepped: true });
|
|
561
|
+
if (lead.end <= R.bottom + 0.5 && lead.empty < pick.empty) pick = lead;
|
|
562
|
+
}
|
|
563
|
+
return { ...pick, strip, R };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* A table standing alone in its region (its takeaways in a side title's rail) opens its rows, at most one
|
|
568
|
+
* minimum row height each, while the region leaves more than the density band empty under it.
|
|
569
|
+
*/
|
|
570
|
+
function openRows(placed, Z, S) {
|
|
571
|
+
const tables = placed.filter((q) => q.kind === "table");
|
|
572
|
+
if (tables.length !== 1 || placed.some((q) => q.flex)) return placed;
|
|
573
|
+
const table = tables[0];
|
|
574
|
+
const room = Z.bottom - Math.max(...placed.map((q) => q.y + q.h));
|
|
575
|
+
if (room / (Z.bottom - Z.y) <= S.tok.cap) return placed;
|
|
576
|
+
const rows = table.heights.length - 1;
|
|
577
|
+
const grow = Math.floor(Math.min(room, rows * S.tok.rowMin) / rows) * rows;
|
|
578
|
+
table.heights = table.heights.map((h, i) => (i ? h + grow / rows : h));
|
|
579
|
+
table.tableH += grow;
|
|
580
|
+
table.h += grow;
|
|
581
|
+
for (const q of placed) if (q !== table && q.y > table.y) q.y += grow;
|
|
582
|
+
return placed;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* The first chart's data as a plain table item (at most 8 rows and 4 columns), or null. `across`
|
|
587
|
+
* turns it: the categories head the columns and each series is one row.
|
|
588
|
+
*/
|
|
589
|
+
function chartTable(visuals, across = false) {
|
|
590
|
+
const chart = visuals.find((v) => v.type === "kpi-table" && (v.content || {}).chart);
|
|
591
|
+
const c = chart && chart.content;
|
|
592
|
+
if (!c || !(c.headers || []).length || (c.rows || []).length > 8 || c.headers.length > 4) return null;
|
|
593
|
+
if (!across) return { type: "kpi-table", values: true, content: { headers: c.headers, rows: c.rows } };
|
|
594
|
+
const headers = [c.headers[0], ...c.rows.map((r) => r[0])];
|
|
595
|
+
const rows = c.headers.slice(1).map((h, j) => [h, ...c.rows.map((r) => r[j + 1])]);
|
|
596
|
+
return { type: "kpi-table", values: true, content: { headers, rows } };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** The first chart's data as a table at label size under `top` in region R, or null when it does not fit. */
|
|
600
|
+
function chartValues(visuals, R, S, state, top) {
|
|
601
|
+
const item = chartTable(visuals);
|
|
602
|
+
if (!item) return null;
|
|
603
|
+
const m = measureTable(item, R.w, S, { ...state, stepped: false });
|
|
604
|
+
const y = top + state.blockGap;
|
|
605
|
+
if (y + m.h > R.bottom || m.heights.slice(1).some((h) => h > S.tok.rowMin * 1.6)) return null;
|
|
606
|
+
return { item, ...m, x: R.x, y, w: R.w, limit: R.bottom };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** A 0.5 pt guide in the gutter before region R (the visible grid of an editorial pack). */
|
|
610
|
+
function columnHairline(S, R, Z) {
|
|
611
|
+
return { decor: true, kind: "rect", x: R.x - S.grid.gutter / 2, y: Z.y, w: 0.5, h: Z.bottom - Z.y, fill: "line" };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const PLANS = {
|
|
615
|
+
"text-column"(ctx) {
|
|
616
|
+
const { S, state, Z } = ctx;
|
|
617
|
+
const items = ctx.items;
|
|
618
|
+
// Running text keeps its measure: cols 1-9 on the presented ramp, 1-7 on the reading ramp.
|
|
619
|
+
const limit = S.tok.ramp === "presented" ? 9 : 7;
|
|
620
|
+
const R = ctx.cls === "side" ? Z : region(S, Z.a, Math.min(Z.b, Z.a + limit - 1), Z.y, Z.bottom);
|
|
621
|
+
const { visuals, texts } = sortItems(items);
|
|
622
|
+
if (visuals.length && texts.length) return visualWithText(ctx, visuals, texts, 7 / 12);
|
|
623
|
+
const placed = stack(items, R, S, state).placed;
|
|
624
|
+
// Two to five points that each open with a bold head, and that leave the body more than the
|
|
625
|
+
// density band empty, read as rows: the head in its own column, the point beside it.
|
|
626
|
+
const ents = !visuals.length && ctx.cls !== "side" ? entries(texts.flatMap(itemParas)) : [];
|
|
627
|
+
// Groups of sub-points under bold heads, held to one reading measure narrower than the body,
|
|
628
|
+
// run in two columns instead: the measure stays and the body is used across.
|
|
629
|
+
const groups = ents.length >= 2 && ents.every((e) => e.head && !e.rest && e.children.length);
|
|
630
|
+
if (groups && R.w < Z.w - S.grid.pitch) {
|
|
631
|
+
const two = PLANS["text-two-column"](ctx);
|
|
632
|
+
if (!two.some((q) => q.y + q.h > Z.bottom)) {
|
|
633
|
+
// Each column's groups share the height left under it. A gap past a sixth of the body is one
|
|
634
|
+
// more empty band than the page needs, so then the room is shared with the foot as well.
|
|
635
|
+
for (const q of two.filter((x) => x.kind === "text")) {
|
|
636
|
+
const breaks = q.paras.filter((para, i) => i > 0 && para.kind !== "child");
|
|
637
|
+
if (!breaks.length) continue;
|
|
638
|
+
const room = Z.bottom - (q.y + q.h);
|
|
639
|
+
const per = Math.min(room / breaks.length > (Z.bottom - Z.y) / 6 ? room / (breaks.length + 1) : room / breaks.length, 0.25 * (Z.bottom - Z.y));
|
|
640
|
+
for (const para of breaks) para.gap += per;
|
|
641
|
+
q.h += per * breaks.length;
|
|
642
|
+
}
|
|
643
|
+
return two;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
const headed = ents.length >= 2 && ents.length <= 5 && ents.every((e) => e.head && e.rest && !e.children.length && e.kind === "bullet");
|
|
647
|
+
if (headed && (Z.bottom - contentBottom(placed, Z.y)) / (Z.bottom - Z.y) > S.tok.cap) return headedRows(ctx, ents);
|
|
648
|
+
return placed;
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
"text-two-column"(ctx) {
|
|
652
|
+
const { S, state, Z } = ctx;
|
|
653
|
+
const { columns, texts, visuals } = sortItems(ctx.items);
|
|
654
|
+
if (columns) return stack([columns, ...visuals], Z, S, state).placed;
|
|
655
|
+
const paras = texts.flatMap(itemParas);
|
|
656
|
+
const [l, r] = splitRegion(S, Z, 0.5);
|
|
657
|
+
// Break at a section boundary nearest to half the height.
|
|
658
|
+
const heights = paras.map((p) => G.textHeight(plainText(p.text), l.w, bodySize(S, state)) + state.itemGap);
|
|
659
|
+
const total = heights.reduce((a, b) => a + b, 0);
|
|
660
|
+
let acc = 0;
|
|
661
|
+
let cut = paras.length;
|
|
662
|
+
for (let i = 0; i < paras.length; i++) {
|
|
663
|
+
if (i > 0 && paras[i].kind !== "child" && acc >= total / 2 - heights[i] / 2) { cut = i; break; }
|
|
664
|
+
acc += heights[i];
|
|
665
|
+
}
|
|
666
|
+
let out = [textAt(paras.slice(0, cut), l, S, state), textAt(paras.slice(cut), r, S, state)];
|
|
667
|
+
// Two sections of unequal length leave one column half empty: the break moves to the point (never between a
|
|
668
|
+
// point and its sub-points) that brings the columns nearest even.
|
|
669
|
+
if (shortColumn(out, Z.bottom, S).share > 0.35) {
|
|
670
|
+
const evenest = (ok) => {
|
|
671
|
+
let best = null;
|
|
672
|
+
for (let i = 1; i < paras.length; i++) {
|
|
673
|
+
if (!ok(i)) continue;
|
|
674
|
+
const pair = [textAt(paras.slice(0, i), l, S, state), textAt(paras.slice(i), r, S, state)];
|
|
675
|
+
const diff = Math.abs(pair[0].h - pair[1].h);
|
|
676
|
+
if (!best || diff < best.diff) best = { pair, diff };
|
|
677
|
+
}
|
|
678
|
+
return best;
|
|
679
|
+
};
|
|
680
|
+
let best = evenest((i) => paras[i].kind !== "child");
|
|
681
|
+
// Groups too unequal to balance whole may continue in the next column, a head never parted from its first point.
|
|
682
|
+
if (!best || shortColumn(best.pair, Z.bottom, S).share > RAIL_EMPTY) {
|
|
683
|
+
const inside = evenest((i) => paras[i - 1].kind !== "section");
|
|
684
|
+
if (inside && (!best || shortColumn(inside.pair, Z.bottom, S).share < shortColumn(best.pair, Z.bottom, S).share)) best = inside;
|
|
685
|
+
}
|
|
686
|
+
if (best && shortColumn(best.pair, Z.bottom, S).share < shortColumn(out, Z.bottom, S).share) out = best.pair;
|
|
687
|
+
}
|
|
688
|
+
if (S.deco.has("hairline-rule") || S.deco.has("column-hairlines")) out.push(columnHairline(S, r, Z));
|
|
689
|
+
return out;
|
|
690
|
+
},
|
|
691
|
+
|
|
692
|
+
"summary-box-list"(ctx) {
|
|
693
|
+
const { S, state, Z, spec } = ctx;
|
|
694
|
+
const paras = sortItems(ctx.items).texts.flatMap(itemParas);
|
|
695
|
+
let box = spec.lead ? [{ kind: "text", text: spec.lead }] : null;
|
|
696
|
+
let rest = paras;
|
|
697
|
+
if (!box) {
|
|
698
|
+
const first = paras.findIndex((p) => p.kind === "text" || p.kind === "bullet");
|
|
699
|
+
box = first >= 0 ? [paras[first]] : paras.slice(0, 1);
|
|
700
|
+
rest = paras.filter((_, i) => i !== (first >= 0 ? first : 0));
|
|
701
|
+
}
|
|
702
|
+
const pad = 18;
|
|
703
|
+
const boxText = measureText(box.map((p) => ({ ...p, kind: "text" })), Z.w - pad * 2, S, state, { size: S.sizes.lead });
|
|
704
|
+
const boxH = boxText.h + pad * 2;
|
|
705
|
+
const out = [
|
|
706
|
+
{ kind: "box", x: Z.x, y: Z.y, w: Z.w, h: boxH, outline: S.deco.has("box-outline"), limit: Z.bottom },
|
|
707
|
+
{ ...boxText, x: Z.x + pad, y: Z.y + pad, bold: true, limit: Z.bottom },
|
|
708
|
+
];
|
|
709
|
+
const below = { ...Z, y: Z.y + boxH + 18 };
|
|
710
|
+
const [l, r] = splitRegion(S, below, 0.5);
|
|
711
|
+
const ents = entries(rest);
|
|
712
|
+
const half = Math.ceil(ents.length / 2);
|
|
713
|
+
const flat = (list) => list.flatMap((e) => [{ kind: e.kind === "section" ? "section" : "bullet", text: e.head ? `**${e.head}** ${e.rest}`.trim() : e.rest },
|
|
714
|
+
...e.children.map((c) => ({ kind: "child", text: c }))]);
|
|
715
|
+
const split = (k) => [textAt(flat(ents.slice(0, k)), l, S, state), textAt(flat(ents.slice(k)), r, S, state)];
|
|
716
|
+
let cols = split(half);
|
|
717
|
+
// Groups of unequal length split by count leave one column short (the gate reads it whatever the other column's
|
|
718
|
+
// length): the split moves to the group boundary that brings the columns nearest even.
|
|
719
|
+
const uneven = (p) => Math.abs(p[0].h - p[1].h);
|
|
720
|
+
if (ents.length > 2 && uneven(cols) > 0.35 * (below.bottom - below.y)) {
|
|
721
|
+
const even = ents.slice(1).map((_, i) => split(i + 1)).reduce((m, p) => (uneven(p) < uneven(m) ? p : m));
|
|
722
|
+
if (uneven(even) < uneven(cols)) cols = even;
|
|
723
|
+
}
|
|
724
|
+
out.push(...cols);
|
|
725
|
+
return out;
|
|
726
|
+
},
|
|
727
|
+
|
|
728
|
+
"sidebar-note"(ctx) {
|
|
729
|
+
const { S, state, Z } = ctx;
|
|
730
|
+
// The note stands in a side title's rail: the main points take the body.
|
|
731
|
+
if (ctx.railNote) return PLANS["text-column"](ctx);
|
|
732
|
+
const { texts, visuals } = sortItems(ctx.items);
|
|
733
|
+
// The note is the fenced main-box, else a last text block after the main one; a lone text block is the main.
|
|
734
|
+
const note = texts.find((t) => t.type === "main-box") || (texts.length > 1 ? texts[texts.length - 1] : null);
|
|
735
|
+
const main = [...visuals, ...texts.filter((t) => t !== note)];
|
|
736
|
+
const [mR, nR] = splitRegion(S, Z, 8 / 12);
|
|
737
|
+
const out = stack(main, mR, S, state).placed;
|
|
738
|
+
if (note) {
|
|
739
|
+
const pad = S.tok.pad;
|
|
740
|
+
const m = measureText(itemParas(note), nR.w - pad * 2, S, state);
|
|
741
|
+
out.push({ kind: "box", x: nR.x, y: nR.y, w: nR.w, h: m.h + pad * 2, surface: true, limit: Z.bottom },
|
|
742
|
+
{ ...m, x: nR.x + pad, y: nR.y + pad, limit: Z.bottom });
|
|
743
|
+
// A short note beside a long main column leaves its column mostly empty: the note then stands across
|
|
744
|
+
// the top at lead size and the main points run in two columns under it.
|
|
745
|
+
if (main.length && shortColumn(out, Z.bottom, S).share > RAIL_EMPTY && Z.b - Z.a + 1 >= 12) {
|
|
746
|
+
// First a narrower note column at the body size (the note never set larger than the main points), the main
|
|
747
|
+
// column wider beside it.
|
|
748
|
+
const [mR3, nR3] = splitRegion(S, Z, 9 / 12);
|
|
749
|
+
const m3 = measureText(itemParas(note), nR3.w - pad * 2, S, state);
|
|
750
|
+
const narrow = [...stack(main, mR3, S, state).placed, { kind: "box", x: nR3.x, y: nR3.y, w: nR3.w, h: m3.h + pad * 2, surface: true, limit: Z.bottom },
|
|
751
|
+
{ ...m3, x: nR3.x + pad, y: nR3.y + pad, limit: Z.bottom }];
|
|
752
|
+
if (!overflows(narrow) && shortColumn(narrow, Z.bottom, S).share <= RAIL_EMPTY && (Z.bottom - contentBottom(narrow, Z.y)) / (Z.bottom - Z.y) <= S.tok.cap) return narrow;
|
|
753
|
+
}
|
|
754
|
+
if (main.length && !visuals.length && shortColumn(out, Z.bottom, S).share > RAIL_EMPTY) {
|
|
755
|
+
// The note keeps a reading measure inside the box (S4.4): about 32 Hangul or 70 Latin characters.
|
|
756
|
+
const korean = G.isKorean(itemParas(note).map((p) => p.text).join(""));
|
|
757
|
+
const inner = Math.min(Z.w - pad * 2, (korean ? 30 : 35) * S.sizes.lead);
|
|
758
|
+
const lead = measureText(itemParas(note).map((p) => ({ ...p, kind: "text" })), inner, S, state, { size: S.sizes.lead });
|
|
759
|
+
const boxH = lead.h + pad * 2;
|
|
760
|
+
const below = { ...Z, y: Z.y + boxH + state.blockGap };
|
|
761
|
+
const fits = (plan) => !plan.some((q) => !q.decor && q.y + q.h > Z.bottom + 0.5);
|
|
762
|
+
// One column at the reading measure when it holds the points and reaches the density band (the note at lead
|
|
763
|
+
// size, else at the points' own size); else grouped points pick their own two columns in the text-column plan
|
|
764
|
+
// and plain points run in two columns.
|
|
765
|
+
for (const z of [S.sizes.lead, bodySize(S, state)]) {
|
|
766
|
+
const m = measureText(itemParas(note).map((p) => ({ ...p, kind: "text" })), Math.min(Z.w - pad * 2, (korean ? 30 : 35) * z), S, state, { size: z });
|
|
767
|
+
const top = Z.y + m.h + pad * 2 + state.blockGap;
|
|
768
|
+
const single = stack(main, region(S, Z.a, Math.min(Z.b, Z.a + 8), top, Z.bottom), S, state).placed;
|
|
769
|
+
if (!fits(single)) continue;
|
|
770
|
+
// Room left under the single column is shared between its groups (at most a sixth of the body a gap).
|
|
771
|
+
if (single.length === 1 && single[0].kind === "text") {
|
|
772
|
+
const q = single[0];
|
|
773
|
+
const breaks = q.paras.filter((para, i) => i > 0 && para.kind === "section");
|
|
774
|
+
const room = Z.bottom - (q.y + q.h);
|
|
775
|
+
if (breaks.length && room / (Z.bottom - top) > S.tok.cap) {
|
|
776
|
+
const per = Math.min(room / (breaks.length + 1), (Z.bottom - Z.y) / 6);
|
|
777
|
+
for (const para of breaks) para.gap += per;
|
|
778
|
+
q.h += per * breaks.length;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if ((Z.bottom - contentBottom(single, top)) / (Z.bottom - top) <= S.tok.cap) {
|
|
782
|
+
return [{ kind: "box", x: Z.x, y: Z.y, w: Z.w, h: m.h + pad * 2, surface: true, limit: Z.bottom },
|
|
783
|
+
{ ...m, x: Z.x + pad, y: Z.y + pad, limit: Z.bottom }, ...single];
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
let two = PLANS["text-column"]({ ...ctx, Z: below, items: main });
|
|
787
|
+
const across = PLANS["text-two-column"]({ ...ctx, Z: below, items: main });
|
|
788
|
+
if (!fits(two) || (fits(across) && shortColumn(two, Z.bottom, S).share + (Z.bottom - contentBottom(two, below.y)) / (Z.bottom - below.y) > (Z.bottom - contentBottom(across, below.y)) / (Z.bottom - below.y) + 0.1)) two = across;
|
|
789
|
+
if (fits(two)) {
|
|
790
|
+
return [{ kind: "box", x: Z.x, y: Z.y, w: Z.w, h: boxH, surface: true, limit: Z.bottom },
|
|
791
|
+
{ ...lead, x: Z.x + pad, y: Z.y + pad, limit: Z.bottom }, ...two];
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return out;
|
|
796
|
+
},
|
|
797
|
+
|
|
798
|
+
agenda(ctx) {
|
|
799
|
+
const { S, state, Z } = ctx;
|
|
800
|
+
const ents = entries(sortItems(ctx.items).texts.flatMap(itemParas));
|
|
801
|
+
if (!ents.length) return [];
|
|
802
|
+
const n = ents.length;
|
|
803
|
+
const numW = S.grid.span(1, 1).w;
|
|
804
|
+
const textX = Z.x + numW + S.grid.gutter;
|
|
805
|
+
const textW = Z.x + Z.w - textX;
|
|
806
|
+
const head = bodySize(S, state, "lead");
|
|
807
|
+
const rest = bodySize(S, state);
|
|
808
|
+
const rows = ents.map((e, i) => {
|
|
809
|
+
const headText = e.head || e.rest;
|
|
810
|
+
const restText = e.head ? [e.rest, ...e.children].filter(Boolean).join(" · ") : e.children.join(" · ");
|
|
811
|
+
const h = G.textHeight(headText, textW, head) + (restText ? 6 + G.textHeight(restText, textW, rest) : 0);
|
|
812
|
+
return { num: String(i + 1).padStart(2, "0"), head: headText, rest: restText, h };
|
|
813
|
+
});
|
|
814
|
+
// Rows share the body down to its floor, at most 108 pt apart.
|
|
815
|
+
const tallest = Math.max(...rows.map((r) => r.h));
|
|
816
|
+
const pitch = rowPitch(n, tallest, Z, state.groupGap, 108);
|
|
817
|
+
rows.forEach((r, i) => { r.y = Z.y + i * pitch; });
|
|
818
|
+
return [{ kind: "rows", rows, x: Z.x, numW, textX, textW, head, rest, pitch, rule: S.deco.has("hairline-rule"), y: Z.y, h: (n - 1) * pitch + rows[n - 1].h, limit: Z.bottom }];
|
|
819
|
+
},
|
|
820
|
+
|
|
821
|
+
"references-appendix"(ctx) {
|
|
822
|
+
const { S, state, Z } = ctx;
|
|
823
|
+
const paras = sortItems(ctx.items).texts.flatMap(itemParas).map((p) => ({ ...p, kind: "text" }));
|
|
824
|
+
let size = S.tok.ramp === "presented" ? (state.stepped ? S.sizes.body : S.sizes.label) : bodySize(S, state);
|
|
825
|
+
// Entries are scanned for a name or a year: one per row, rows spread down the page with rules.
|
|
826
|
+
// A list longer than the body steps down to label size, then runs in two columns.
|
|
827
|
+
// Rows share one pitch, so the tallest entry sets the room every row takes.
|
|
828
|
+
const fits = (z, w, n) => n * Math.max(...paras.slice(0, n).map((p) => G.textHeight(plainText(p.text), w, z, 1.3))) + 12 * (n - 1) <= Z.bottom - Z.y;
|
|
829
|
+
let cols = [Z];
|
|
830
|
+
if (!fits(size, Z.w, paras.length)) {
|
|
831
|
+
if (fits(S.sizes.label, Z.w, paras.length)) size = S.sizes.label;
|
|
832
|
+
else if (paras.length >= 4) { size = S.sizes.label; cols = splitRegion(S, Z, 0.5); }
|
|
833
|
+
}
|
|
834
|
+
const per = Math.ceil(paras.length / cols.length);
|
|
835
|
+
return cols.flatMap((R, ci) => {
|
|
836
|
+
const part = paras.slice(ci * per, (ci + 1) * per);
|
|
837
|
+
if (!part.length) return [];
|
|
838
|
+
const heights = part.map((p) => G.textHeight(plainText(p.text), R.w, size, 1.3));
|
|
839
|
+
const pitch = rowPitch(part.length, Math.max(...heights, 1), R, 12, Math.max(...heights, 1) + 72);
|
|
840
|
+
return part.flatMap((p, i) => {
|
|
841
|
+
const y = R.y + i * pitch;
|
|
842
|
+
const row = { kind: "text", paras: [{ ...p, inset: 0, gap: 0, h: heights[i] }], size, x: R.x, y, w: R.w, h: heights[i], limit: Z.bottom };
|
|
843
|
+
return i > 0 && S.deco.has("hairline-rule") ? [{ decor: true, kind: "rect", x: R.x, y: y - (pitch - heights[i - 1]) / 2, w: R.w, h: 0.5, fill: "line" }, row] : [row];
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
},
|
|
847
|
+
|
|
848
|
+
"kpi-row"(ctx) {
|
|
849
|
+
const { S, state, Z } = ctx;
|
|
850
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
851
|
+
const kpi = visuals.find((v) => v.type === "kpi-table" && looksLikeKpi(v.content || {}, { basis: true }));
|
|
852
|
+
if (!kpi) return PLANS["table-insight"](ctx);
|
|
853
|
+
const others = visuals.filter((v) => v !== kpi);
|
|
854
|
+
if (ctx.cls === "side" || Z.b - Z.a + 1 < 10 || (state.anchor && ctx.cls === "top")) {
|
|
855
|
+
// Under a side title, or when the values take the room, they run down the body as rows:
|
|
856
|
+
// the value, then what it measures.
|
|
857
|
+
const cards = shared().kpiCards(kpi.content).slice(0, 6);
|
|
858
|
+
const out = [];
|
|
859
|
+
const restTexts = texts;
|
|
860
|
+
const restH = restTexts.length ? stack(restTexts, Z, S, state).bottom - Z.y + state.blockGap : 0;
|
|
861
|
+
const rowsZone = { ...Z, bottom: Z.bottom - restH };
|
|
862
|
+
out.push(figureRows(cards, rowsZone, S, state));
|
|
863
|
+
if (restTexts.length) out.push(...stack(restTexts, { ...Z, y: rowsZone.bottom + state.blockGap }, S, state).placed);
|
|
864
|
+
return out;
|
|
865
|
+
}
|
|
866
|
+
const top = ctx.cls === "bottom" ? Z.y + 72 : Z.y;
|
|
867
|
+
const cardsPlan = stack([kpi, ...others, ...texts], { ...Z, y: top }, S, state);
|
|
868
|
+
// On the compact step a row of cards with its takeaways under it stops high on the page. The
|
|
869
|
+
// figures then run as rows down the first seven columns (value, label, basis), the caption at
|
|
870
|
+
// their foot, and the takeaways stand beside them.
|
|
871
|
+
if (S.tok.tight && texts.length && !others.length && (ctx.cls === "bottom" || (Z.bottom - cardsPlan.bottom) / (Z.bottom - Z.y) > S.tok.cap)) {
|
|
872
|
+
const cards = shared().kpiCards(kpi.content).slice(0, 6);
|
|
873
|
+
const [fR, tR] = splitRegion(S, Z, 7 / 12);
|
|
874
|
+
const capH = kpi.caption ? captionHeight(kpi.caption, fR.w, S) + 6 : 0;
|
|
875
|
+
const rows = figureRows(cards, { ...fR, bottom: fR.bottom - capH }, S, state, 48);
|
|
876
|
+
const col = takeawayColumn(ctx, texts, [], tR, fR.bottom);
|
|
877
|
+
const notes = col.t;
|
|
878
|
+
if (col.strip && col.end <= col.R.bottom + 0.5) notes.placed.push(...col.strip.placed.map((q) => ({ ...q, strip: true })));
|
|
879
|
+
if (rows.y + rows.h <= fR.bottom - capH + 0.5 && notes.bottom <= Z.bottom + 0.5) {
|
|
880
|
+
const out = [rows, ...notes.placed];
|
|
881
|
+
if (kpi.caption) out.push({ kind: "caption", item: { content: kpi.caption }, x: fR.x, y: fR.bottom - capH + 6, w: fR.w, h: capH - 6, limit: Z.bottom });
|
|
882
|
+
if (shortColumn(out, Z.bottom, S).share <= 0.35) return out;
|
|
883
|
+
// Takeaways too few to fill the column beside the rows: the rows run across the body and the takeaways
|
|
884
|
+
// stand under them side by side, the caption between.
|
|
885
|
+
const paras = texts.flatMap(itemParas);
|
|
886
|
+
const half = Math.ceil(paras.length / 2);
|
|
887
|
+
// Up to three points read down one column at the reading measure; four or more stand side by side.
|
|
888
|
+
const parts = paras.length >= 4 ? splitRegion(S, Z, 0.5) : [region(S, Z.a, Math.min(Z.b, Z.a + 8), Z.y, Z.bottom)];
|
|
889
|
+
// The takeaways keep the size they had beside the rows (lead when the column stepped up).
|
|
890
|
+
const keep = (notes.placed.find((q) => q.kind === "text" && !q.strip) || {}).size;
|
|
891
|
+
const measured = parts.map((R, i) => measureText(parts.length > 1 ? (i ? paras.slice(half) : paras.slice(0, half)) : paras, R.w, S, state, keep ? { size: keep } : {}));
|
|
892
|
+
const notesH = Math.max(...measured.map((m) => m.h));
|
|
893
|
+
const rowsBottom = Z.bottom - notesH - state.blockGap - capH;
|
|
894
|
+
// Four figures or more stand in two columns of rows, so the rows use the body's width.
|
|
895
|
+
const halves = cards.length >= 4 ? splitRegion(S, { ...Z, bottom: rowsBottom }, 0.5) : [{ ...Z, bottom: rowsBottom }];
|
|
896
|
+
const split = Math.ceil(cards.length / halves.length);
|
|
897
|
+
const across = halves.map((R, i) => figureRows(cards.slice(i * split, (i + 1) * split), R, S, state, 72));
|
|
898
|
+
// The second column's rows stand level with the first's (an odd count leaves its last row open).
|
|
899
|
+
if (across.length === 2) {
|
|
900
|
+
const [a, b] = across;
|
|
901
|
+
b.rows.forEach((r, k) => { r.y = a.rows[k].y; });
|
|
902
|
+
Object.assign(b, { pitch: a.pitch, value: a.value, labelSize: a.labelSize, noteSize: a.noteSize, h: b.rows.length ? b.rows[b.rows.length - 1].y - b.y + (a.h - (a.rows[a.rows.length - 1].y - a.y)) : 0 });
|
|
903
|
+
}
|
|
904
|
+
// A band left open between the rows and the takeaways under them is worse than a column whose points share
|
|
905
|
+
// the room beside the rows, when that sharing fills the column (spec-v2 Amendments 9).
|
|
906
|
+
const band = (Z.bottom - notesH - state.blockGap - capH - Math.max(...across.map((q) => q.y + q.h))) / (Z.bottom - Z.y);
|
|
907
|
+
if (band > S.tok.cap) {
|
|
908
|
+
const filled = structuredClone(out);
|
|
909
|
+
fillColumn(shortColumn(filled, Z.bottom, S), Z.bottom, S, state);
|
|
910
|
+
if (shortColumn(filled, Z.bottom, S).share <= 0.35) return filled;
|
|
911
|
+
}
|
|
912
|
+
if (across.every((q) => q.y + q.h <= rowsBottom + 0.5)) {
|
|
913
|
+
const res = [...across, ...measured.map((m, i) => ({ ...m, x: parts[i].x, y: Z.bottom - notesH, limit: Z.bottom }))];
|
|
914
|
+
if (kpi.caption) res.push({ kind: "caption", item: { content: kpi.caption }, x: Z.x, y: rowsBottom + 6, w: Z.w, h: capH - 6, limit: Z.bottom });
|
|
915
|
+
return res;
|
|
916
|
+
}
|
|
917
|
+
return out;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return cardsPlan.placed;
|
|
921
|
+
},
|
|
922
|
+
|
|
923
|
+
"big-number"(ctx) {
|
|
924
|
+
const { S, state, Z } = ctx;
|
|
925
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
926
|
+
const table = visuals.find((v) => v.type === "kpi-table");
|
|
927
|
+
if (!table) return PLANS["text-column"](ctx);
|
|
928
|
+
if ((S.pack.display || {}).number) return numberPanel(ctx, table, texts);
|
|
929
|
+
const c = table.content || {};
|
|
930
|
+
const value = plainText(((c.rows || [])[0] || [])[0] || (c.headers || [])[1] || "");
|
|
931
|
+
const label = plainText((c.headers || [])[0] || "");
|
|
932
|
+
const wide = Z.b - Z.a + 1 >= 10;
|
|
933
|
+
const [nR, tR] = wide ? splitRegion(S, Z, 6 / 12) : [Z, null];
|
|
934
|
+
const size = [S.sizes.title, S.sizes.lead].find((v) => S.ems(value, "numeral") * v * 1.04 <= nR.w) || S.sizes.body;
|
|
935
|
+
const labelSize = bodySize(S, state, "lead");
|
|
936
|
+
const labelH = G.textHeight(label, nR.w, labelSize);
|
|
937
|
+
const capH = table.caption ? captionHeight(table.caption, nR.w, S) + 6 : 0;
|
|
938
|
+
const blockH = size * 1.15 + 12 + labelH + capH;
|
|
939
|
+
const out = [];
|
|
940
|
+
// The number opens the body, its evidence beside it or under it. Under a bottom title the
|
|
941
|
+
// number sits on the visual zone's floor, next to the title that explains it.
|
|
942
|
+
const y = ctx.cls === "bottom" ? Z.bottom - blockH : Z.y;
|
|
943
|
+
if (texts.length) {
|
|
944
|
+
const R = tR ? region(S, tR.a + 1, tR.b, Z.y, Z.bottom)
|
|
945
|
+
: ctx.cls === "bottom" ? { ...Z, bottom: y - state.blockGap } : { ...Z, y: Z.y + blockH + state.blockGap };
|
|
946
|
+
const evidence = stack(texts, R, S, { ...state, stepped: true });
|
|
947
|
+
// Beside a number on the floor the evidence ends level with the number block, so it reads
|
|
948
|
+
// with the number instead of hanging above it at the top of the page.
|
|
949
|
+
const lift = tR && ctx.cls === "bottom" ? Math.max(0, Math.min(y + blockH, Z.bottom) - evidence.bottom) : 0;
|
|
950
|
+
out.push(...evidence.placed.map((q) => ({ ...q, y: q.y + lift })));
|
|
951
|
+
}
|
|
952
|
+
out.push({ kind: "number", value, label, size, labelSize, x: nR.x, y, w: nR.w, h: size * 1.15 + 12 + labelH, limit: Z.bottom });
|
|
953
|
+
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 });
|
|
954
|
+
return out;
|
|
955
|
+
},
|
|
956
|
+
|
|
957
|
+
"chart-insight"(ctx) {
|
|
958
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
959
|
+
return visualWithText(ctx, visuals, texts, 8 / 12);
|
|
960
|
+
},
|
|
961
|
+
|
|
962
|
+
"full-chart"(ctx) {
|
|
963
|
+
const { S, state, Z } = ctx;
|
|
964
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
965
|
+
// The note runs under the chart.
|
|
966
|
+
return stack([...visuals, ...texts], Z, S, state).placed;
|
|
967
|
+
},
|
|
968
|
+
|
|
969
|
+
"kpi-over-chart"(ctx) {
|
|
970
|
+
const { S, state, Z } = ctx;
|
|
971
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
972
|
+
const kpi = visuals.find((v) => v.type === "kpi-table" && looksLikeKpi(v.content || {}, { basis: true }) && !(v.content || {}).chart);
|
|
973
|
+
const charts = visuals.filter((v) => v !== kpi);
|
|
974
|
+
if (!kpi || !charts.length) return PLANS["chart-insight"](ctx);
|
|
975
|
+
return stack([kpi, ...charts, ...texts], Z, S, state).placed;
|
|
976
|
+
},
|
|
977
|
+
|
|
978
|
+
"dashboard-grid"(ctx) {
|
|
979
|
+
const { S, state, Z } = ctx;
|
|
980
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
981
|
+
if (visuals.length < 2) return PLANS["chart-insight"](ctx);
|
|
982
|
+
const panels = visuals.slice(0, 4);
|
|
983
|
+
const out = [];
|
|
984
|
+
const cols = panels.length === 3 ? 3 : 2;
|
|
985
|
+
const rows = Math.ceil(panels.length / cols);
|
|
986
|
+
const textH = texts.length ? stack(texts, Z, S, state).bottom - Z.y + state.blockGap : 0;
|
|
987
|
+
const gridZ = { ...Z, bottom: Z.bottom - textH };
|
|
988
|
+
const rowH = (gridZ.bottom - gridZ.y - (rows - 1) * S.grid.gutter) / rows;
|
|
989
|
+
const parts = equalParts(S, Z, cols);
|
|
990
|
+
panels.forEach((p, i) => {
|
|
991
|
+
const part = parts[i % cols];
|
|
992
|
+
const y = gridZ.y + Math.floor(i / cols) * (rowH + S.grid.gutter);
|
|
993
|
+
out.push(...stack([p], { ...part, y, bottom: y + rowH }, S, state).placed);
|
|
994
|
+
});
|
|
995
|
+
if (texts.length) out.push(...stack(texts, { ...Z, y: gridZ.bottom + state.blockGap }, S, state).placed);
|
|
996
|
+
return out;
|
|
997
|
+
},
|
|
998
|
+
|
|
999
|
+
"table-insight"(ctx) {
|
|
1000
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
1001
|
+
return visualWithText(ctx, visuals, texts, 8 / 12);
|
|
1002
|
+
},
|
|
1003
|
+
|
|
1004
|
+
"ledger-table"(ctx) {
|
|
1005
|
+
const { S, state, Z } = ctx;
|
|
1006
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
1007
|
+
return stack([...visuals, ...texts], Z, S, state).placed;
|
|
1008
|
+
},
|
|
1009
|
+
|
|
1010
|
+
comparison(ctx) {
|
|
1011
|
+
const { S, state, Z } = ctx;
|
|
1012
|
+
const { columns, texts, visuals } = sortItems(ctx.items);
|
|
1013
|
+
if (visuals.length && !columns) return visualWithText(ctx, visuals, texts, 7 / 12);
|
|
1014
|
+
let halves;
|
|
1015
|
+
if (columns) halves = columns.columns.map((col) => (col || []).filter(isText).flatMap(itemParas));
|
|
1016
|
+
else {
|
|
1017
|
+
const ents = entries(texts.flatMap(itemParas));
|
|
1018
|
+
const half = Math.ceil(ents.length / 2);
|
|
1019
|
+
const flat = (list) => list.flatMap((e) => [{ kind: "section", text: e.head || e.rest }, ...(e.head && e.rest ? [{ kind: "text", text: e.rest }] : []), ...e.children.map((c) => ({ kind: "child", text: c }))]);
|
|
1020
|
+
halves = [flat(ents.slice(0, half)), flat(ents.slice(half))];
|
|
1021
|
+
}
|
|
1022
|
+
const sides = halves.slice(0, 2).map((paras) => {
|
|
1023
|
+
const headIdx = paras.findIndex((p) => p.kind === "section");
|
|
1024
|
+
return { head: headIdx >= 0 ? paras[headIdx].text : null,
|
|
1025
|
+
items: paras.filter((_, j) => j !== headIdx).map((p) => String(p.text).replace(/^\(\d+\)\s*/u, "")) };
|
|
1026
|
+
});
|
|
1027
|
+
// Points that open with the same label on both sides ("비용: …") read as criteria rows on the
|
|
1028
|
+
// compact step: the label stands once in a column of its own, each side keeps the rest. A label
|
|
1029
|
+
// is a short name (three words at most); one row whose two labels differ names both ("Limit /
|
|
1030
|
+
// Question"), so a single odd row does not cost the whole slide its criteria.
|
|
1031
|
+
const LABEL = /^([^::]{1,24})[::]\s*(.+)$/u;
|
|
1032
|
+
const isLabel = (t) => { const m = LABEL.exec(plainText(t)); return Boolean(m) && m[1].trim().split(/\s+/u).length <= 3; };
|
|
1033
|
+
let criteria = null;
|
|
1034
|
+
// Under a side title the criteria go to the rail at any density.
|
|
1035
|
+
if ((S.tok.tight || ctx.rail) && sides.length === 2 && sides.every((sd) => sd.items.length && sd.items.every(isLabel))) {
|
|
1036
|
+
const n = Math.max(...sides.map((sd) => sd.items.length));
|
|
1037
|
+
const sets = Array.from({ length: n }, (_, r) => [...new Set(sides.map((sd) => sd.items[r] && LABEL.exec(plainText(sd.items[r]))[1].trim()).filter(Boolean))]);
|
|
1038
|
+
const labels = sets.map((set) => set.join(" / "));
|
|
1039
|
+
const odd = sets.filter((set) => set.length > 1).length;
|
|
1040
|
+
if (odd <= 1 && odd < n / 2) {
|
|
1041
|
+
criteria = labels;
|
|
1042
|
+
for (const sd of sides) sd.items = sd.items.map((t) => LABEL.exec(plainText(t))[2]);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// Under a side title the criteria stand in the rail, level with their rows, and the two sides take
|
|
1046
|
+
// the body beside it; the rows then start under the title's last line and end above the rail's strip.
|
|
1047
|
+
const inRail = Boolean(criteria && ctx.rail);
|
|
1048
|
+
const floor = inRail ? Math.min(Z.bottom, ctx.rail.bottom) : Z.bottom;
|
|
1049
|
+
const critR = inRail ? { ...ctx.rail, bottom: floor } : criteria ? region(S, Z.a, Z.a + 1, Z.y, Z.bottom) : null;
|
|
1050
|
+
const parts = criteria && !inRail ? splitRegion(S, region(S, Z.a + 2, Z.b, Z.y, Z.bottom), 0.5) : splitRegion(S, Z, 0.5);
|
|
1051
|
+
const headSize = bodySize(S, state, "lead");
|
|
1052
|
+
// Up to three rows of short points are set a step or two larger when that still keeps a dozen
|
|
1053
|
+
// glyphs a line and fits the body; a two-row comparison otherwise holds a third of the page.
|
|
1054
|
+
const rowsN = Math.max(...sides.map((sd) => sd.items.length));
|
|
1055
|
+
const fitsAt = (z) => {
|
|
1056
|
+
if (parts[0].w / z < 12) return false;
|
|
1057
|
+
const hs = Array.from({ length: rowsN }, (_, r) => Math.max(...sides.map((sd, i) => (sd.items[r] ? G.textHeight(plainText(sd.items[r]), parts[i].w, z) : 0))));
|
|
1058
|
+
const headRoom = Math.max(0, ...sides.map((sd, i) => (sd.head ? G.textHeight(plainText(sd.head), parts[i].w, headSize) + 30 : 0)));
|
|
1059
|
+
return Z.y + headRoom + hs.reduce((a, b) => a + b, 0) + (rowsN - 1) * state.groupGap * 2 <= floor;
|
|
1060
|
+
};
|
|
1061
|
+
// The compact step never inflates type to fill: its rows keep the body size.
|
|
1062
|
+
const itemSize = (rowsN <= 3 && !S.tok.tight ? [S.sizes.title, S.sizes.lead].filter((z) => z > bodySize(S, state)) : []).find(fitsAt) || bodySize(S, state);
|
|
1063
|
+
const out = [];
|
|
1064
|
+
const headH = Math.max(0, ...sides.map((sd, i) => (sd.head ? G.textHeight(plainText(sd.head), parts[i].w, headSize) : 0)));
|
|
1065
|
+
let top = Z.y;
|
|
1066
|
+
if (headH) {
|
|
1067
|
+
sides.forEach((sd, i) => {
|
|
1068
|
+
if (!sd.head) return;
|
|
1069
|
+
out.push({ kind: "text", paras: [{ kind: "section", text: sd.head, inset: 0, h: headH, gap: 0 }], size: headSize, x: parts[i].x, y: Z.y, w: parts[i].w, h: headH, limit: Z.bottom, bold: true });
|
|
1070
|
+
const accent = S.deco.has("accent-rule");
|
|
1071
|
+
out.push({ decor: true, kind: "rect", x: parts[i].x, y: Z.y + headH + 12, w: accent ? S.grid.col : parts[i].w, h: accent ? 3 : 0.75, fill: accent ? "accent" : "line" });
|
|
1072
|
+
});
|
|
1073
|
+
top = Z.y + headH + 30;
|
|
1074
|
+
}
|
|
1075
|
+
if (inRail) top = Math.max(top, ctx.rail.y);
|
|
1076
|
+
// Parallel points share a row, so they are read across; the rows run down to the floor.
|
|
1077
|
+
const n = Math.max(...sides.map((sd) => sd.items.length));
|
|
1078
|
+
const labelH = (r) => (criteria ? G.textHeight(criteria[r], critR.w / S.wide("title"), itemSize) : 0);
|
|
1079
|
+
// Measured on the lines the text frame will set (word by word), so a narrow side keeps its lines.
|
|
1080
|
+
const sideH = (t, i) => {
|
|
1081
|
+
const w = parts[i].w / S.wide("body");
|
|
1082
|
+
return G.textHeight(G.keepLines(plainText(t), w / itemSize, 99), w, itemSize);
|
|
1083
|
+
};
|
|
1084
|
+
const rowH = Array.from({ length: n }, (_, r) => Math.max(labelH(r), ...sides.map((sd, i) => (sd.items[r] ? sideH(sd.items[r], i) : 0))));
|
|
1085
|
+
const pitch = rowPitch(n, Math.max(...rowH), { y: top, bottom: floor }, state.groupGap, Math.max(112, Math.max(...rowH) + 72));
|
|
1086
|
+
// On the compact step each row starts one even gap under the one before (rows of two and three
|
|
1087
|
+
// lines alike). The gap opens up to a seventh of the body so four short rows reach the floor, and
|
|
1088
|
+
// never ends the rows higher than a pitch of the tallest row plus 48 pt would.
|
|
1089
|
+
const sum = rowH.reduce((a, b) => a + b, 0);
|
|
1090
|
+
const reach = n > 1 ? ((n - 1) * (Math.max(...rowH) + 48) + rowH[n - 1] - sum) / (n - 1) : 0;
|
|
1091
|
+
const gap = n > 1 ? Math.max(state.groupGap, Math.min(Math.max(48, (floor - Z.y) / 7, reach), (floor - top - sum) / (n - 1))) : 0;
|
|
1092
|
+
const ys = rowH.map((_, r) => (S.tok.tight ? top + rowH.slice(0, r).reduce((a, b) => a + b, 0) + r * gap : top + r * pitch));
|
|
1093
|
+
for (let r = 0; r < n; r++) {
|
|
1094
|
+
const y = ys[r];
|
|
1095
|
+
if (r > 0 && (S.deco.has("hairline-rule") || S.deco.has("column-hairlines"))) {
|
|
1096
|
+
out.push({ decor: true, kind: "rect", x: Z.x, y: y - Math.min(18, (y - ys[r - 1] - rowH[r - 1]) / 2), w: Z.w, h: 0.5, fill: "line" });
|
|
1097
|
+
}
|
|
1098
|
+
if (criteria) out.push({ kind: "text", paras: [{ kind: "text", text: criteria[r], inset: 0, h: labelH(r), gap: 0 }], size: itemSize, x: critR.x, y, w: critR.w, h: labelH(r), limit: floor, bold: true, rail: inRail });
|
|
1099
|
+
sides.forEach((sd, i) => {
|
|
1100
|
+
if (!sd.items[r]) return;
|
|
1101
|
+
const h = sideH(sd.items[r], i);
|
|
1102
|
+
out.push({ kind: "text", paras: [{ kind: "text", text: sd.items[r], inset: 0, h, gap: 0 }], size: itemSize, x: parts[i].x, y, w: parts[i].w, h, limit: Z.bottom });
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
if (S.deco.has("column-hairlines")) out.push(columnHairline(S, parts[1], Z));
|
|
1106
|
+
return out;
|
|
1107
|
+
},
|
|
1108
|
+
|
|
1109
|
+
process(ctx) {
|
|
1110
|
+
const { S, state } = ctx;
|
|
1111
|
+
const all = entries(sortItems(ctx.items).texts.flatMap(itemParas));
|
|
1112
|
+
// Points without a bold head after the steps are the slide's takeaways: they run under the
|
|
1113
|
+
// steps instead of being read as more steps (or dropped past the fifth).
|
|
1114
|
+
const headed = all.filter((e) => e.head);
|
|
1115
|
+
const loose = headed.length >= 2 && headed.length < all.length ? all.filter((e) => !e.head) : [];
|
|
1116
|
+
const ents = loose.length ? headed : all;
|
|
1117
|
+
if (ents.length < 2) return PLANS["text-column"](ctx);
|
|
1118
|
+
const notes = loose.length ? measureText(loose.flatMap((e) => [{ kind: "bullet", text: e.rest }, ...e.children.map((c) => ({ kind: "child", text: c }))]), ctx.Z.w, S, state) : null;
|
|
1119
|
+
const Z = notes ? { ...ctx.Z, bottom: ctx.Z.bottom - notes.h - state.blockGap } : ctx.Z;
|
|
1120
|
+
const withNotes = (placed) => (notes ? [...placed, { ...notes, x: Z.x, y: Z.bottom + state.blockGap, limit: ctx.Z.bottom }] : placed);
|
|
1121
|
+
const steps = ents.slice(0, 5).map((e, i) => {
|
|
1122
|
+
const o = ordinal(e.head || "", i);
|
|
1123
|
+
// Sub-points read as details of the step, joined with the step's text, without their "(1)".
|
|
1124
|
+
return { n: o.n, head: o.head || e.rest, rest: [o.head ? e.rest : "", ...e.children.map((c) => String(c).replace(/^\(\d+\)\s*/u, ""))].filter(Boolean).join(" · ") };
|
|
1125
|
+
});
|
|
1126
|
+
const numSize = S.sizes.display;
|
|
1127
|
+
const headSize = bodySize(S, state, "lead");
|
|
1128
|
+
const restSize = bodySize(S, state);
|
|
1129
|
+
const asRows = () => {
|
|
1130
|
+
if (S.tok.tight && Z.b - Z.a + 1 >= 10) {
|
|
1131
|
+
// On the compact step a row reads across: the numeral, the step's name, then what happens.
|
|
1132
|
+
const numW = S.grid.span(1, 1).w;
|
|
1133
|
+
const headR = region(S, Z.a + 1, Z.a + 3, Z.y, Z.bottom);
|
|
1134
|
+
const restX = headR.x + headR.w + S.grid.gutter;
|
|
1135
|
+
const korean = G.isKorean(steps.map((st) => st.head + st.rest).join(""));
|
|
1136
|
+
// The same readable measure as the stacked rows (the gate counts spaces as characters). That
|
|
1137
|
+
// measure at body size leaves the body's right columns empty, so what happens is set a step up (lead)
|
|
1138
|
+
// when the rows still fit: the measure grows with the size and reaches across.
|
|
1139
|
+
const at = (size) => {
|
|
1140
|
+
const restW = Math.min(Z.x + Z.w - restX, (korean ? 26 : 35) * size);
|
|
1141
|
+
const rows = steps.map((st) => {
|
|
1142
|
+
const hh = G.textHeight(st.head, headR.w, headSize);
|
|
1143
|
+
const rh = st.rest ? G.textHeight(st.rest, restW, size) : 0;
|
|
1144
|
+
return { num: st.n, head: st.head, rest: st.rest, hh, rh, h: Math.max(numSize * 1.15, hh, rh) };
|
|
1145
|
+
});
|
|
1146
|
+
const tallest = Math.max(...rows.map((r) => r.h));
|
|
1147
|
+
const pitch = rowPitch(rows.length, tallest, Z, state.groupGap, tallest + 48);
|
|
1148
|
+
rows.forEach((r, i) => { r.y = Z.y + i * pitch; });
|
|
1149
|
+
return { kind: "rows", rows, x: Z.x, numW, textX: headR.x, textW: headR.w, restX, restW, head: headSize, rest: size, numSize, pitch, rule: true,
|
|
1150
|
+
y: Z.y, h: (rows.length - 1) * pitch + rows[rows.length - 1].h, limit: Z.bottom };
|
|
1151
|
+
};
|
|
1152
|
+
const body = at(restSize);
|
|
1153
|
+
if ((Z.x + Z.w - (restX + body.restW)) / Z.w > 0.25) {
|
|
1154
|
+
const lead = at(S.sizes.lead);
|
|
1155
|
+
if (lead.y + lead.h <= Z.bottom + 0.5) return [lead];
|
|
1156
|
+
}
|
|
1157
|
+
return [body];
|
|
1158
|
+
}
|
|
1159
|
+
const numW = S.grid.span(1, 2).w;
|
|
1160
|
+
const textX = Z.x + numW + S.grid.gutter;
|
|
1161
|
+
// Across the full body a step's text keeps a readable measure (the gate counts spaces and separators as characters).
|
|
1162
|
+
const korean = G.isKorean(steps.map((s) => s.head + s.rest).join(""));
|
|
1163
|
+
const textW = Math.min(Z.x + Z.w - textX, korean ? 26 * restSize : 35 * restSize);
|
|
1164
|
+
const rows = steps.map((s) => ({ num: s.n, head: s.head, rest: s.rest,
|
|
1165
|
+
h: Math.max(numSize * 1.15, G.textHeight(s.head, textW, headSize) + (s.rest ? 6 + G.textHeight(s.rest, textW, restSize) : 0)) }));
|
|
1166
|
+
const pitch = rowPitch(rows.length, Math.max(...rows.map((r) => r.h)), Z, state.groupGap, 160);
|
|
1167
|
+
rows.forEach((r, i) => { r.y = Z.y + i * pitch; });
|
|
1168
|
+
return [{ kind: "rows", rows, x: Z.x, numW, textX, textW, head: headSize, rest: restSize, numSize, pitch, rule: true, y: Z.y, h: (rows.length - 1) * pitch + rows[rows.length - 1].h, limit: Z.bottom }];
|
|
1169
|
+
};
|
|
1170
|
+
if (ctx.cls === "side") return withNotes(asRows());
|
|
1171
|
+
const slots = equalParts(S, Z, steps.length);
|
|
1172
|
+
const placedSteps = steps.map((s, i) => {
|
|
1173
|
+
const R = slots[i];
|
|
1174
|
+
const headH = G.textHeight(s.head, R.w, headSize);
|
|
1175
|
+
const restH = s.rest ? G.textHeight(s.rest, R.w, restSize) : 0;
|
|
1176
|
+
return { ...s, x: R.x, w: R.w, headH, restH };
|
|
1177
|
+
});
|
|
1178
|
+
const h = numSize * 1.1 + 18 + Math.max(...placedSteps.map((s) => s.headH + (s.restH ? 12 + s.restH : 0)));
|
|
1179
|
+
// Up to four steps whose columns stop high on the body run as rows down it instead.
|
|
1180
|
+
if (steps.length <= 4 && (Z.bottom - Z.y - h) / (Z.bottom - Z.y) > S.tok.cap) {
|
|
1181
|
+
const rows = asRows();
|
|
1182
|
+
if (rows[0].y + rows[0].h <= Z.bottom + 0.5) return withNotes(rows);
|
|
1183
|
+
}
|
|
1184
|
+
return withNotes([{ kind: "steps", steps: placedSteps, numSize, headSize, restSize, y: Z.y, h, limit: Z.bottom }]);
|
|
1185
|
+
},
|
|
1186
|
+
|
|
1187
|
+
timeline(ctx) {
|
|
1188
|
+
const { S, state, Z } = ctx;
|
|
1189
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
1190
|
+
const table = visuals.find((v) => v.type === "kpi-table");
|
|
1191
|
+
let events;
|
|
1192
|
+
if (table) events = (table.content.rows || []).map((r) => ({ date: plainText(r[0]), label: plainText(r[1] || ""), status: plainText(r.slice(2).join(" · ")) }));
|
|
1193
|
+
else events = entries(texts.flatMap(itemParas)).map((e) => ({ date: e.head, label: e.rest, status: e.children.join(" · ") }));
|
|
1194
|
+
events = events.slice(0, 6);
|
|
1195
|
+
if (!events.length) return PLANS["text-column"](ctx);
|
|
1196
|
+
const slots = equalParts(S, Z, events.length);
|
|
1197
|
+
const dateSize = bodySize(S, state, "lead");
|
|
1198
|
+
const labelSize = bodySize(S, state);
|
|
1199
|
+
// The axis runs under the tallest date at the top of the body; the room left under the whole
|
|
1200
|
+
// plan is shared out below (see the end of this plan).
|
|
1201
|
+
const dateH = Math.max(...events.map((e, i) => G.textHeight(e.date, slots[i].w, dateSize, 1.25)));
|
|
1202
|
+
const axisY = Math.round(Z.y + dateH + 18);
|
|
1203
|
+
const items = events.map((e, i) => {
|
|
1204
|
+
const R = slots[i];
|
|
1205
|
+
const labelH = G.textHeight(e.label, R.w, labelSize);
|
|
1206
|
+
const statusH = e.status ? 6 + G.textHeight(e.status, R.w, S.sizes.label, 1.3) : 0;
|
|
1207
|
+
return { ...e, x: R.x, w: R.w, labelH, statusH };
|
|
1208
|
+
});
|
|
1209
|
+
const below = Math.max(...items.map((e) => e.labelH + e.statusH));
|
|
1210
|
+
const cap = table && table.caption ? { kind: "caption", item: { content: table.caption }, x: Z.x, w: Z.w, h: captionHeight(table.caption, Z.w, S) } : null;
|
|
1211
|
+
const out = [{ kind: "timeline", events: items, axisY, dateSize, labelSize, x: Z.x, w: Z.w, y: axisY - dateH - 18, h: dateH + 18 + 18 + below, limit: Z.bottom }];
|
|
1212
|
+
if (cap) out.push({ ...cap, y: out[0].y + out[0].h + 18, limit: Z.bottom });
|
|
1213
|
+
// When a table carries the events, the bullets are the takeaway and run under the axis.
|
|
1214
|
+
if (table && texts.length) {
|
|
1215
|
+
const top = Math.max(...out.map((q) => q.y + q.h)) + state.blockGap;
|
|
1216
|
+
out.push(...stack(texts, { ...Z, y: top }, S, state).placed);
|
|
1217
|
+
}
|
|
1218
|
+
// Room past the density band is shared evenly: over the axis, between the axis and its takeaways,
|
|
1219
|
+
// and under them, so no one band holds it (an axis halfway down left the top third empty).
|
|
1220
|
+
const room = Z.bottom - contentBottom(out, Z.y);
|
|
1221
|
+
if (room / (Z.bottom - Z.y) > S.tok.cap) {
|
|
1222
|
+
// A single takeaway line stays with the axis it reads (a band over one line set it adrift), so the
|
|
1223
|
+
// pair moves together, the room shared over and under it.
|
|
1224
|
+
const single = texts.flatMap(itemParas).length === 1;
|
|
1225
|
+
const blocks = out.length > (cap ? 2 : 1) && !single ? 3 : 2;
|
|
1226
|
+
const step = Math.floor(room / blocks);
|
|
1227
|
+
out.forEach((q, i) => {
|
|
1228
|
+
const dy = step * (i < (cap ? 2 : 1) || single ? 1 : 2);
|
|
1229
|
+
q.y += dy;
|
|
1230
|
+
if (q.kind === "timeline") q.axisY += dy;
|
|
1231
|
+
if (q.kind === "group") shiftGroup(q, dy);
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
return out;
|
|
1235
|
+
},
|
|
1236
|
+
|
|
1237
|
+
"matrix-2x2"(ctx) {
|
|
1238
|
+
const { S, state, Z } = ctx;
|
|
1239
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
1240
|
+
const table = visuals.find((v) => v.type === "kpi-table");
|
|
1241
|
+
const c = (table && table.content) || {};
|
|
1242
|
+
if (!table || (c.headers || []).length !== 3 || (c.rows || []).length !== 2) return PLANS["table-insight"](ctx);
|
|
1243
|
+
const [yName, xName] = String(plainText(c.headers[0])).split(/\s*[\\/]\s*/u);
|
|
1244
|
+
// The takeaways stand beside the quadrants in the last four columns (under the matrix on a
|
|
1245
|
+
// narrow body), so the matrix never drops them.
|
|
1246
|
+
const wide = texts.length && Z.b - Z.a + 1 >= 10;
|
|
1247
|
+
const M = wide ? region(S, Z.a, Z.b - 4, Z.y, Z.bottom) : Z;
|
|
1248
|
+
const notesH = texts.length && !wide ? stack(texts, Z, S, state).bottom - Z.y + state.blockGap : 0;
|
|
1249
|
+
const mBottom = M.bottom - notesH;
|
|
1250
|
+
const labelCols = ctx.cls === "side" || wide ? 1 : 2;
|
|
1251
|
+
const labels = region(S, M.a, M.a + labelCols - 1, M.y, mBottom);
|
|
1252
|
+
const grid = region(S, M.a + labelCols, M.b, M.y, mBottom);
|
|
1253
|
+
const capH = table.caption ? captionHeight(table.caption, grid.w, S) + 6 : 0;
|
|
1254
|
+
const headH = S.sizes.label * 1.4 + 12;
|
|
1255
|
+
const gap = S.grid.gutter;
|
|
1256
|
+
const top = grid.y + headH;
|
|
1257
|
+
const qh = (grid.bottom - capH - top - gap) / 2;
|
|
1258
|
+
const qw = (grid.w - gap) / 2;
|
|
1259
|
+
const pad = 18;
|
|
1260
|
+
// A cell is its item over its details: "포장재 단가 재협상 · 연 6억 원 · 즉시" sets the item as the
|
|
1261
|
+
// cell's head and each detail on a line under it.
|
|
1262
|
+
const head = bodySize(S, state, "lead");
|
|
1263
|
+
const detail = bodySize(S, state);
|
|
1264
|
+
const cells = [0, 1].flatMap((ri) => [0, 1].map((ci) => {
|
|
1265
|
+
const [first, ...rest] = plainText(c.rows[ri][ci + 1]).split(/\s+·\s+/u);
|
|
1266
|
+
const headH = G.textHeight(first, qw - pad * 2, head, 1.3);
|
|
1267
|
+
const details = rest.map((t) => ({ text: t, h: G.textHeight(t, qw - pad * 2, detail) }));
|
|
1268
|
+
return { head: first, headH, details, x: grid.x + ci * (qw + gap), y: top + ri * (qh + gap), w: qw, h: qh };
|
|
1269
|
+
}));
|
|
1270
|
+
const out = [{ kind: "matrix", cells, xHeads: [plainText(c.headers[1]), plainText(c.headers[2])], yHeads: [plainText(c.rows[0][0]), plainText(c.rows[1][0])],
|
|
1271
|
+
xName: xName || "", yName: yName || "", labels, grid, top, qw, qh, gap, pad, head, detail,
|
|
1272
|
+
caption: table.caption, x: M.x, y: M.y, w: M.w, h: grid.bottom - M.y, limit: Z.bottom }];
|
|
1273
|
+
if (texts.length) {
|
|
1274
|
+
const R = wide ? region(S, Z.b - 3, Z.b, Z.y, Z.bottom) : { ...Z, y: mBottom + state.blockGap };
|
|
1275
|
+
out.push(...stack(texts, R, S, state).placed);
|
|
1276
|
+
}
|
|
1277
|
+
return out;
|
|
1278
|
+
},
|
|
1279
|
+
|
|
1280
|
+
quote(ctx) {
|
|
1281
|
+
const { S, state, Z } = ctx;
|
|
1282
|
+
const paras = sortItems(ctx.items).texts.flatMap(itemParas);
|
|
1283
|
+
const by = paras.find((p) => /^[—–-]\s*/u.test(plainText(p.text)));
|
|
1284
|
+
const said = paras.filter((p) => p !== by).map((p) => p.text).join(" ");
|
|
1285
|
+
const markW = S.grid.span(1, 1).w;
|
|
1286
|
+
const R = region(S, Math.min(Z.b, Z.a + 1), Math.max(Z.a + 1, Z.b - (ctx.cls === "side" ? 0 : 2)), Z.y + 24, Z.bottom);
|
|
1287
|
+
const size = S.sizes.title;
|
|
1288
|
+
const h = G.textHeight(plainText(said), R.w, size, 1.3);
|
|
1289
|
+
const blockH = h + (by ? 18 + S.sizes.label * 1.4 : 0);
|
|
1290
|
+
// Beside a side title the quote is the slide's display element: it sits in the middle of the
|
|
1291
|
+
// body, as a statement does, instead of at the top of an empty page.
|
|
1292
|
+
const y = ctx.cls === "side" ? Math.max(R.y, Math.round(Z.y + (Z.bottom - Z.y - blockH) / 2)) : R.y;
|
|
1293
|
+
return [{ kind: "quote", text: said, by: by ? plainText(by.text) : "", size, x: R.x, w: R.w, y, h: blockH, textH: h, markX: Z.x, markW, limit: Z.bottom }];
|
|
1294
|
+
},
|
|
1295
|
+
|
|
1296
|
+
"image-split"(ctx) {
|
|
1297
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
1298
|
+
return visualWithText(ctx, visuals, texts, 7 / 12);
|
|
1299
|
+
},
|
|
1300
|
+
|
|
1301
|
+
"figure-academic"(ctx) {
|
|
1302
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
1303
|
+
return visualWithText(ctx, visuals, texts, 8 / 12);
|
|
1304
|
+
},
|
|
1305
|
+
|
|
1306
|
+
method(ctx) {
|
|
1307
|
+
const { S, state, Z } = ctx;
|
|
1308
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
1309
|
+
const paras = texts.flatMap(itemParas);
|
|
1310
|
+
const formula = paras.filter((p) => p.kind === "text");
|
|
1311
|
+
const defs = paras.filter((p) => p.kind !== "text");
|
|
1312
|
+
if (!visuals.length && !formula.length) return PLANS["text-column"](ctx);
|
|
1313
|
+
const defsItem = defs.length ? [{ type: "body", content: { items: [] }, paras: defs }] : [];
|
|
1314
|
+
const out = [];
|
|
1315
|
+
const place = (R) => {
|
|
1316
|
+
if (visuals.length) out.push(...stack(visuals, R, S, state).placed);
|
|
1317
|
+
else {
|
|
1318
|
+
const size = S.sizes.title;
|
|
1319
|
+
const h = G.textHeight(plainText(formula.map((p) => p.text).join("\n")), R.w, size, 1.4);
|
|
1320
|
+
out.push({ kind: "text", paras: formula.map((p) => ({ ...p, inset: 0, gap: 0, h: G.textHeight(plainText(p.text), R.w, size, 1.4) })), size, x: R.x, w: R.w, y: R.y, h, limit: R.bottom, accent: true });
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
if (!defsItem.length) { place(Z); return out; }
|
|
1324
|
+
const defsAt = (R) => textAt(defs, R, S, { ...state, itemGap: Math.max(state.itemGap, 12) });
|
|
1325
|
+
if (!visuals.length && Z.b - Z.a + 1 >= 8) {
|
|
1326
|
+
// The formula runs across the top and its terms in columns under it, instead of one line over an empty
|
|
1327
|
+
// column beside the terms (on every density step).
|
|
1328
|
+
place(Z);
|
|
1329
|
+
const below = { ...Z, y: Math.max(...out.map((q) => q.y + q.h)) + state.blockGap };
|
|
1330
|
+
// Two columns of terms, else three (a formula beside its terms left its own column four-fifths empty).
|
|
1331
|
+
for (const n of [2, 3]) {
|
|
1332
|
+
const cols = defsInColumns(defs, below, n);
|
|
1333
|
+
if (cols.every((q) => q.y + q.h <= Z.bottom)) return [...out, ...cols];
|
|
1334
|
+
}
|
|
1335
|
+
out.length = 0;
|
|
1336
|
+
}
|
|
1337
|
+
// A wide figure (twice as wide as tall or more) set in half the body is drawn at a third of its column's
|
|
1338
|
+
// height: it runs across the top instead and its terms stand in two columns under it.
|
|
1339
|
+
const size = visuals.length === 1 && visuals[0].type === "image" && visuals[0].imageSize;
|
|
1340
|
+
if (S.tok.tight && size && size.width / size.height >= 2 && Z.b - Z.a + 1 >= 8) {
|
|
1341
|
+
const cap = captionHeight(visuals[0].caption, Z.w, S);
|
|
1342
|
+
for (const [share, n] of [[0.5, 2], [0.5, 3], [0.42, 3], [0.36, 3]]) {
|
|
1343
|
+
const figH = Math.min(Math.round(Z.w * size.height / size.width) + cap, Math.round((Z.bottom - Z.y) * share));
|
|
1344
|
+
place({ ...Z, bottom: Z.y + figH });
|
|
1345
|
+
const cols = defsInColumns(defs, { ...Z, y: Z.y + figH + state.blockGap }, n);
|
|
1346
|
+
// The source strip closes the shortest column when it fits there.
|
|
1347
|
+
const last = cols.reduce((m, q) => (q.y + q.h < m.y + m.h ? q : m));
|
|
1348
|
+
const strip = ctx.strip ? stripPlacements(ctx.strip, { ...Z, x: last.x, w: last.w }, S) : null;
|
|
1349
|
+
const closes = strip && last.y + last.h * 1.1 + S.tok.block + strip.h <= Z.bottom;
|
|
1350
|
+
// A figure across the body is wider at 130 pt than it stands beside its terms at full column height.
|
|
1351
|
+
if (figH - cap >= 130 && cols.every((q) => q.y + q.h * 1.1 <= Z.bottom) && (!strip || closes)) {
|
|
1352
|
+
return [...out, ...cols.map((q) => ({ ...q, limit: Z.bottom - q.h * 0.1 })), ...(strip ? strip.placed.map((q) => ({ ...q, strip: true })) : [])];
|
|
1353
|
+
}
|
|
1354
|
+
out.length = 0;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
const [vR, full] = splitRegion(S, Z, 7 / 12);
|
|
1358
|
+
place(vR);
|
|
1359
|
+
// Beside a figure the terms' column closes with the source strip when it ends above the figure, and
|
|
1360
|
+
// terms that still leave 40 % of it empty are set a step up.
|
|
1361
|
+
const strip = ctx.strip && visuals.length ? stripPlacements(ctx.strip, full, S) : null;
|
|
1362
|
+
const dR = strip ? { ...full, bottom: full.bottom - strip.h - S.tok.block } : full;
|
|
1363
|
+
let beside = defsAt(dR);
|
|
1364
|
+
if (visuals.length && (dR.bottom - (beside.y + beside.h)) / (dR.bottom - dR.y) > 0.4 && dR.w / S.sizes.lead >= 12) {
|
|
1365
|
+
const lead = textAt(defs, dR, S, { ...state, stepped: true, itemGap: Math.max(state.itemGap, 12) });
|
|
1366
|
+
if (lead.y + lead.h <= dR.bottom + 0.5) beside = lead;
|
|
1367
|
+
}
|
|
1368
|
+
if (strip && beside.y + beside.h <= dR.bottom + 0.5) out.push(...strip.placed.map((q) => ({ ...q, strip: true })));
|
|
1369
|
+
if (S.tok.tight && visuals.length && beside.y + beside.h > Z.bottom) {
|
|
1370
|
+
// Terms too long for five columns beside the figure: first half and half, the terms in one column
|
|
1371
|
+
// (the figure keeps six columns, so its own labels stay readable); then the figure takes five
|
|
1372
|
+
// columns and the terms run in two columns over the other seven.
|
|
1373
|
+
const [hR, tR6] = splitRegion(S, Z, 6 / 12);
|
|
1374
|
+
const strip6 = ctx.strip ? stripPlacements(ctx.strip, tR6, S) : null;
|
|
1375
|
+
const one = textAt(defs, strip6 ? { ...tR6, bottom: tR6.bottom - strip6.h - S.tok.block } : tR6, S, { ...state, itemGap: Math.max(state.itemGap, 12) });
|
|
1376
|
+
if (one.y + one.h * 1.1 <= (strip6 ? tR6.bottom - strip6.h - S.tok.block : Z.bottom)) {
|
|
1377
|
+
out.length = 0;
|
|
1378
|
+
place(hR);
|
|
1379
|
+
return [...out, { ...one, limit: Z.bottom - one.h * 0.1 }, ...(strip6 ? strip6.placed.map((q) => ({ ...q, strip: true })) : [])];
|
|
1380
|
+
}
|
|
1381
|
+
// The text estimate runs short of what a renderer sets for Latin, so the columns keep a tenth
|
|
1382
|
+
// of their height spare; the figure gives up a further column when five leave too little.
|
|
1383
|
+
for (const share of [5 / 12, 4 / 12]) {
|
|
1384
|
+
out.length = 0;
|
|
1385
|
+
const [fR, tR] = splitRegion(S, Z, share);
|
|
1386
|
+
const cols = defsInColumns(defs, tR);
|
|
1387
|
+
if (share < 5 / 12 || cols.every((q) => q.y + q.h * 1.1 <= Z.bottom)) {
|
|
1388
|
+
place(fR);
|
|
1389
|
+
// The spare tenth is part of each column's limit, so a slide that cannot keep it reads as
|
|
1390
|
+
// overflowing and takes a title that leaves the terms more room.
|
|
1391
|
+
return [...out, ...cols.map((q) => ({ ...q, limit: Z.bottom - q.h * 0.1 }))];
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
out.push(beside);
|
|
1396
|
+
return out;
|
|
1397
|
+
|
|
1398
|
+
// The terms split between two columns at the entry that brings the first nearest half the height (three
|
|
1399
|
+
// columns: the cuts that keep the tallest column lowest).
|
|
1400
|
+
function defsInColumns(list, R, n = 2) {
|
|
1401
|
+
const ents = entries(list);
|
|
1402
|
+
const flat = (part) => part.flatMap((e) => [{ kind: e.kind === "section" ? "section" : "bullet", text: e.head ? `**${e.head}** ${e.rest}`.trim() : e.rest },
|
|
1403
|
+
...e.children.map((c) => ({ kind: "child", text: c }))]);
|
|
1404
|
+
const cols = equalParts(S, R, n);
|
|
1405
|
+
const fits = (st) => cols.every((C) => C.y + measureText(flat(ents), C.w, S, st).h / n <= C.bottom);
|
|
1406
|
+
// Terms keep a 12 pt step between them when the columns hold it, else the item gap.
|
|
1407
|
+
const gapped = fits({ ...state, itemGap: Math.max(state.itemGap, 12) }) ? { ...state, itemGap: Math.max(state.itemGap, 12) } : state;
|
|
1408
|
+
const tall = (part) => (part.length ? measureText(flat(part), cols[0].w, S, gapped).h : 0);
|
|
1409
|
+
if (n === 3 && ents.length >= 3) {
|
|
1410
|
+
let best = null;
|
|
1411
|
+
for (let a = 1; a < ents.length - 1; a++) for (let b = a + 1; b < ents.length; b++) {
|
|
1412
|
+
const top = Math.max(tall(ents.slice(0, a)), tall(ents.slice(a, b)), tall(ents.slice(b)));
|
|
1413
|
+
if (!best || top < best.top) best = { a, b, top };
|
|
1414
|
+
}
|
|
1415
|
+
const parts = [ents.slice(0, best.a), ents.slice(best.a, best.b), ents.slice(best.b)];
|
|
1416
|
+
return cols.map((C, i) => textAt(flat(parts[i]), C, S, gapped));
|
|
1417
|
+
}
|
|
1418
|
+
let cut = 1;
|
|
1419
|
+
for (let k = 1; k < ents.length; k++) if (Math.abs(tall(ents.slice(0, k)) - tall(ents.slice(k))) < Math.abs(tall(ents.slice(0, cut)) - tall(ents.slice(cut)))) cut = k;
|
|
1420
|
+
const res = cols.map((C, i) => textAt(flat(i ? ents.slice(cut) : ents.slice(0, cut)), C, S, gapped));
|
|
1421
|
+
return res;
|
|
1422
|
+
}
|
|
1423
|
+
},
|
|
1424
|
+
|
|
1425
|
+
"photo-grid"(ctx) {
|
|
1426
|
+
const { S, state, Z } = ctx;
|
|
1427
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
1428
|
+
const images = visuals.flatMap((v) => (v.type === "image-grid" ? (v.content.images || []).map((im) => ({ type: "image", content: im })) : v.type === "image" ? [v] : []));
|
|
1429
|
+
if (images.length < 2) return PLANS["image-split"](ctx);
|
|
1430
|
+
const textH = texts.length ? stack(texts, Z, S, state).bottom - Z.y + state.blockGap : 0;
|
|
1431
|
+
const parts = equalParts(S, { ...Z, bottom: Z.bottom - textH }, Math.min(3, images.length));
|
|
1432
|
+
const out = images.slice(0, 3).flatMap((im, i) => stack([im], parts[i], S, state).placed);
|
|
1433
|
+
if (texts.length) out.push(...stack(texts, { ...Z, y: Z.bottom - textH + state.blockGap }, S, state).placed);
|
|
1434
|
+
return out;
|
|
1435
|
+
},
|
|
1436
|
+
|
|
1437
|
+
"figure-pair"(ctx) {
|
|
1438
|
+
const { S, state, Z } = ctx;
|
|
1439
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
1440
|
+
if (visuals.length < 2) return PLANS["figure-academic"](ctx);
|
|
1441
|
+
const textH = texts.length ? stack(texts, Z, S, state).bottom - Z.y + state.blockGap : 0;
|
|
1442
|
+
const [l, r] = splitRegion(S, { ...Z, bottom: Z.bottom - textH }, 0.5);
|
|
1443
|
+
const out = [...stack([visuals[0]], l, S, state).placed, ...stack([visuals[1]], r, S, state).placed];
|
|
1444
|
+
if (texts.length) out.push(...stack(texts, { ...Z, y: Z.bottom - textH + state.blockGap }, S, state).placed);
|
|
1445
|
+
return out;
|
|
1446
|
+
},
|
|
1447
|
+
|
|
1448
|
+
"asymmetric-feature"(ctx) {
|
|
1449
|
+
const { S, state, Z } = ctx;
|
|
1450
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
1451
|
+
const table = visuals.find((v) => v.type === "kpi-table" && (v.content.rows || []).length <= 1);
|
|
1452
|
+
if (table && !visuals.some((v) => v.type !== "kpi-table")) return PLANS["big-number"](ctx);
|
|
1453
|
+
return visualWithText(ctx, visuals, texts, 5 / 12);
|
|
1454
|
+
},
|
|
1455
|
+
|
|
1456
|
+
// ── Closings (content slides whose title states the ask) ──
|
|
1457
|
+
"closing-ask"(ctx) {
|
|
1458
|
+
const { S, state, Z } = ctx;
|
|
1459
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
1460
|
+
const acts = visuals.length === 1 && visuals[0].type === "kpi-table" && !visuals[0].content.chart ? actionRows(visuals[0].content) : null;
|
|
1461
|
+
if (!acts) return visualWithText(ctx, visuals, texts, 8 / 12);
|
|
1462
|
+
const step = texts.length ? nextStep(ctx, texts, Z, (S.pack.display || {}).closing || "box") : null;
|
|
1463
|
+
// The rows share the room down to the step's own top, so the gap above the step is one of theirs.
|
|
1464
|
+
const R = { ...Z, bottom: step ? step.top + state.blockGap : Z.bottom };
|
|
1465
|
+
const rows = placeActions(ctx, acts, R, { last: Boolean(step) });
|
|
1466
|
+
// Rows that do not fit above the step keep the table.
|
|
1467
|
+
if (!rows) return visualWithText(ctx, visuals, texts, 8 / 12);
|
|
1468
|
+
return [...rows, ...(step ? step.placed : [])];
|
|
1469
|
+
},
|
|
1470
|
+
|
|
1471
|
+
"closing-decision-box"(ctx) {
|
|
1472
|
+
const { S, state, Z } = ctx;
|
|
1473
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
1474
|
+
const out = [];
|
|
1475
|
+
let y = Z.y;
|
|
1476
|
+
const acts = visuals.length === 1 && visuals[0].type === "kpi-table" && !visuals[0].content.chart ? actionRows(visuals[0].content) : null;
|
|
1477
|
+
if (texts.length) {
|
|
1478
|
+
const pad = 18;
|
|
1479
|
+
const paras = texts.flatMap(itemParas).map((p) => ({ ...p, kind: "text" }));
|
|
1480
|
+
const m = measureText(paras, Z.w - pad * 2, S, state, { size: S.sizes.lead });
|
|
1481
|
+
out.push({ kind: "box", x: Z.x, y, w: Z.w, h: m.h + pad * 2, outline: S.deco.has("box-outline"), tint: !S.deco.has("box-outline"), limit: Z.bottom },
|
|
1482
|
+
{ ...m, x: Z.x + pad, y: y + pad, bold: true, tinted: !S.deco.has("box-outline"), limit: Z.bottom });
|
|
1483
|
+
y += m.h + pad * 2 + state.blockGap;
|
|
1484
|
+
}
|
|
1485
|
+
// The items to decide on run as action rows under the box, down to the floor, when they fit.
|
|
1486
|
+
const rows = acts ? placeActions(ctx, acts, { ...Z, y }, {}) : null;
|
|
1487
|
+
if (rows) return [...out, ...rows];
|
|
1488
|
+
if (visuals.length) out.push(...stack(visuals, { ...Z, y }, S, state).placed);
|
|
1489
|
+
return out;
|
|
1490
|
+
},
|
|
1491
|
+
|
|
1492
|
+
"closing-summary-list"(ctx) {
|
|
1493
|
+
const { S, state, Z } = ctx;
|
|
1494
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
1495
|
+
const [lR, bR] = splitRegion(S, Z, 8 / 12);
|
|
1496
|
+
const out = [];
|
|
1497
|
+
const paras = texts.flatMap(itemParas);
|
|
1498
|
+
if (visuals.length) {
|
|
1499
|
+
out.push(...stack(visuals, lR, S, state).placed);
|
|
1500
|
+
if (paras.length) {
|
|
1501
|
+
const pad = S.tok.pad;
|
|
1502
|
+
const m = measureText(paras, bR.w - pad * 2, S, state);
|
|
1503
|
+
out.push({ kind: "box", x: bR.x, y: bR.y, w: bR.w, h: m.h + pad * 2, surface: true, limit: Z.bottom }, { ...m, x: bR.x + pad, y: bR.y + pad, limit: Z.bottom });
|
|
1504
|
+
}
|
|
1505
|
+
return out;
|
|
1506
|
+
}
|
|
1507
|
+
// Numbered takeaways across the body, one row each, spread down to the floor.
|
|
1508
|
+
const size = bodySize(S, state, "lead");
|
|
1509
|
+
const heights = paras.map((p) => G.textHeight(plainText(p.text), Z.w - 48, size));
|
|
1510
|
+
const pitch = rowPitch(paras.length, Math.max(...heights, 1), Z, state.groupGap, Math.max(...heights, 1) + 48);
|
|
1511
|
+
paras.forEach((p, i) => {
|
|
1512
|
+
const y = Z.y + i * pitch;
|
|
1513
|
+
out.push({ kind: "text", paras: [{ kind: "text", text: String(i + 1), inset: 0, gap: 0, h: size * 1.3 }], size, x: Z.x, y, w: 36, h: size * 1.3, accent: true, limit: Z.bottom },
|
|
1514
|
+
{ kind: "text", paras: [{ kind: "text", text: p.text, inset: 0, gap: 0, h: heights[i] }], size, x: Z.x + 48, y, w: Z.w - 48, h: heights[i], limit: Z.bottom });
|
|
1515
|
+
});
|
|
1516
|
+
return out;
|
|
1517
|
+
},
|
|
1518
|
+
|
|
1519
|
+
"closing-contact-split"(ctx) {
|
|
1520
|
+
const { S, state, Z } = ctx;
|
|
1521
|
+
const { visuals, texts } = sortItems(ctx.items);
|
|
1522
|
+
const acts = visuals.length === 1 && visuals[0].type === "kpi-table" && !visuals[0].content.chart ? actionRows(visuals[0].content) : null;
|
|
1523
|
+
// The field starts where the rows' text ends (at most seven of twelve columns, at least five), so each
|
|
1524
|
+
// key stands near the row it belongs to instead of across an empty stretch, as long
|
|
1525
|
+
// as the rows and the next step keep the sizes and line counts they have in seven columns.
|
|
1526
|
+
const n = Z.b - Z.a + 1;
|
|
1527
|
+
if (acts && !ctx.splitCols) {
|
|
1528
|
+
const at = (k) => PLANS["closing-contact-split"]({ ...ctx, splitCols: k });
|
|
1529
|
+
const sig = (plan) => JSON.stringify(plan.filter((q) => q.kind === "actions" || q.kind === "text").map((q) => (q.kind === "actions" ? [q.head, q.meta, q.keySize] : [q.size, Math.round(q.h)])));
|
|
1530
|
+
const ref = at(7);
|
|
1531
|
+
const need = Math.max(...acts.rows.map((r) => Math.max(G.textEms(r.head) * S.sizes.lead, G.textEms(r.meta) * S.sizes.label) * 1.06));
|
|
1532
|
+
for (const k of [5, 6]) {
|
|
1533
|
+
if (S.grid.span(Z.a, Z.a + Math.round(n * k / 12) - 1).w < need) continue;
|
|
1534
|
+
const plan = at(k);
|
|
1535
|
+
if (sig(plan) === sig(ref)) return plan;
|
|
1536
|
+
}
|
|
1537
|
+
return ref;
|
|
1538
|
+
}
|
|
1539
|
+
const cols = ctx.splitCols || 7;
|
|
1540
|
+
const [lR, fR] = splitRegion(S, Z, cols / 12);
|
|
1541
|
+
// The field bleeds off the right and bottom edges; what it holds sits inset from its edges.
|
|
1542
|
+
const fieldX = fR.x - S.grid.gutter / 2;
|
|
1543
|
+
const inset = 30;
|
|
1544
|
+
const F = { ...fR, x: fieldX + inset, w: fR.x + fR.w - fieldX - inset, y: Z.y + inset, bottom: Z.bottom };
|
|
1545
|
+
const field = { decor: true, kind: "rect", x: fieldX, y: Z.y, w: S.grid.W - fieldX, h: S.grid.H - Z.y, fill: "field", bleed: true };
|
|
1546
|
+
if (acts) {
|
|
1547
|
+
// The field is the ask: each row's key (the amount or the date) in line with its row, the rows
|
|
1548
|
+
// down the left columns. The next step or contact is a band of the field's colour on the body
|
|
1549
|
+
// floor across the left columns, meeting the field, so the two read as one L whose foot holds
|
|
1550
|
+
// the step and the field has no empty stretch under its last key.
|
|
1551
|
+
// A step at lead size that leaves the rows too little height drops to body size.
|
|
1552
|
+
// Amounts add up to the ask: their total closes the field. Beside the step's band it sits on
|
|
1553
|
+
// the band's floor, so the field below the keys ends in the sum, not in an empty stretch.
|
|
1554
|
+
// Dates do not add up: the field's foot then states how many items run over which span.
|
|
1555
|
+
const total = actionTotal(acts) || actionSpan(acts);
|
|
1556
|
+
for (const small of [false, true]) {
|
|
1557
|
+
// The band stops at the field's edge. Its text may carry on across the field to the field's
|
|
1558
|
+
// inset only while the field's foot is empty; a total stands there, so the text stays in the band.
|
|
1559
|
+
const stepW = (total ? fieldX : F.x + F.w) - lR.x;
|
|
1560
|
+
const step = texts.length ? nextStep(ctx, texts, { ...lR, w: stepW }, "band", { small, panelW: fieldX - lR.x }) : null;
|
|
1561
|
+
const foot = step ? step.top + state.blockGap : null;
|
|
1562
|
+
const bottom = step ? foot - state.groupGap : Z.bottom;
|
|
1563
|
+
const totalH = total ? G.textHeight(total, F.w - inset, S.sizes.lead, 1.3) : 0;
|
|
1564
|
+
const totalY = total ? (step ? Z.bottom - totalH - 6 : bottom - totalH) : null;
|
|
1565
|
+
const rowsBottom = total && !step ? bottom - totalH - 24 : bottom;
|
|
1566
|
+
const rows = placeActions(ctx, acts, { ...lR, y: F.y, bottom: rowsBottom }, { keys: { ...F, bottom: rowsBottom } });
|
|
1567
|
+
if (rows) {
|
|
1568
|
+
const sum = total ? [{ decor: true, kind: "rect", x: F.x, y: totalY - 12, w: F.w, h: 0.75, fill: "on-field" },
|
|
1569
|
+
{ kind: "text", paras: [{ kind: "text", text: total, gap: 0, h: totalH }], size: S.sizes.lead, bold: true, onField: true,
|
|
1570
|
+
x: F.x, y: totalY, w: F.w - inset, h: totalH, limit: Z.bottom }] : [];
|
|
1571
|
+
return [field, ...rows, ...sum, ...(step ? step.placed : [])];
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
const out = [field, ...stack(visuals.length ? visuals : texts, lR, S, state).placed];
|
|
1576
|
+
if (visuals.length && texts.length) {
|
|
1577
|
+
const m = measureText(texts.flatMap(itemParas), F.w, S, state);
|
|
1578
|
+
out.push({ ...m, x: F.x, y: F.y, onField: true, limit: Z.bottom });
|
|
1579
|
+
}
|
|
1580
|
+
return out;
|
|
1581
|
+
},
|
|
1582
|
+
};
|
|
1583
|
+
|
|
1584
|
+
// Families that share another's plan.
|
|
1585
|
+
PLANS["step-diagram"] = PLANS.process;
|
|
1586
|
+
PLANS.statement = PLANS["text-column"];
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* Headed points as rows: the head bold in the first three columns, the point beside it at lead
|
|
1590
|
+
* size when a dozen glyphs a line fit there, rows spread down to the floor with hairlines between.
|
|
1591
|
+
*/
|
|
1592
|
+
function headedRows(ctx, ents) {
|
|
1593
|
+
const { S, state, Z } = ctx;
|
|
1594
|
+
const headR = region(S, Z.a, Z.a + 2, Z.y, Z.bottom);
|
|
1595
|
+
const textR = region(S, Z.a + 3, Z.b, Z.y, Z.bottom);
|
|
1596
|
+
const lead = bodySize(S, state, "lead");
|
|
1597
|
+
const body = bodySize(S, state);
|
|
1598
|
+
const rowsAt = (z) => ents.map((e) => ({ head: e.head, text: e.rest, headH: G.textHeight(e.head, headR.w, z),
|
|
1599
|
+
textH: G.textHeight(plainText(e.rest), textR.w, z) })).map((r) => ({ ...r, h: Math.max(r.headH, r.textH) }));
|
|
1600
|
+
let size = S.tok.tight ? body : lead;
|
|
1601
|
+
let rows = rowsAt(size);
|
|
1602
|
+
if (rows.reduce((a, r) => a + r.h, 0) + (rows.length - 1) * state.groupGap > Z.bottom - Z.y) { size = body; rows = rowsAt(body); }
|
|
1603
|
+
const pitch = rowPitch(rows.length, Math.max(...rows.map((r) => r.h)), Z, state.groupGap, Math.max(...rows.map((r) => r.h)) + 96);
|
|
1604
|
+
rows.forEach((r, i) => { r.y = Z.y + i * pitch; });
|
|
1605
|
+
const last = rows[rows.length - 1];
|
|
1606
|
+
return [{ kind: "headed", rows, size, headX: headR.x, headW: headR.w, textX: textR.x, textW: textR.w, pitch,
|
|
1607
|
+
x: Z.x, w: Z.w, y: Z.y, h: last.y + last.h - Z.y, limit: Z.bottom }];
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/** Value rows: each KPI as one row across the body, value first. */
|
|
1611
|
+
function figureRows(cards, Z, S, state, spread = null) {
|
|
1612
|
+
const n = cards.length;
|
|
1613
|
+
const longest = Math.max(...cards.map((c) => S.ems(plainText(c.value), "numeral")));
|
|
1614
|
+
const room = (Z.bottom - Z.y - (n - 1) * 24) / n;
|
|
1615
|
+
// At most title size (two steps above the body); the value column is as wide as the widest value.
|
|
1616
|
+
const value = [S.sizes.title, S.sizes.lead, S.sizes.body].find((v) => longest * v * 1.15 <= Z.w * 0.5 && v * 1.15 <= room) || S.sizes.body;
|
|
1617
|
+
const valueW = Math.min(Math.round(Z.w * 0.5), Math.max(Math.round(Z.w * 0.3), Math.ceil(longest * value * 1.15)));
|
|
1618
|
+
const labelX = Z.x + valueW + S.grid.gutter;
|
|
1619
|
+
const labelW = Z.x + Z.w - labelX;
|
|
1620
|
+
// On the compact step the label is set bold at body size, its basis under it at label size.
|
|
1621
|
+
const labelSize = S.tok.tight ? S.sizes.body : bodySize(S, state, "lead");
|
|
1622
|
+
const noteSize = S.tok.tight ? S.sizes.label : bodySize(S, { ...state, stepped: false });
|
|
1623
|
+
const heights = cards.map((c) => Math.max(value * 1.15, G.textHeight(plainText(c.label), labelW, labelSize)
|
|
1624
|
+
+ (c.note ? 4 + G.textHeight(plainText(c.note), labelW, noteSize) : 0)));
|
|
1625
|
+
// Values run down to the floor, the rows evenly apart (at most `spread` apart beyond the tallest row).
|
|
1626
|
+
const pitch = rowPitch(n, Math.max(...heights), Z, S.tok.tight ? state.groupGap : 24, spread == null ? 200 : Math.max(...heights) + spread);
|
|
1627
|
+
const rows = cards.map((c, i) => ({ value: plainText(c.value), label: plainText(c.label), note: c.note ? plainText(c.note) : "", y: Z.y + i * pitch }));
|
|
1628
|
+
return { kind: "figrows", rows, x: Z.x, w: Z.w, valueW, labelX, labelW, value, labelSize, noteSize, pitch, y: Z.y, h: (n - 1) * pitch + heights[n - 1], limit: Z.bottom };
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/**
|
|
1632
|
+
* A big-number slide as a data panel: the panel takes seven of twelve columns beside a top or bottom
|
|
1633
|
+
* title (five of eight beside a rail) down to the zone's floor. With two to four figures (a KPI table
|
|
1634
|
+
* whose second body row gives each figure's basis) the panel holds one row per figure: the value at
|
|
1635
|
+
* title size, its label and its basis beside it. One figure is set at title size with its label on a
|
|
1636
|
+
* panel as tall as they are. The
|
|
1637
|
+
* evidence stands beside the panel as notes. The pack's display device sets the panel: the field, the
|
|
1638
|
+
* accent tint, or the surface inside an ink outline. The panel stays under half the page, so it reads
|
|
1639
|
+
* as the figures' ground and not as the page's. A number is never set above title size.
|
|
1640
|
+
*/
|
|
1641
|
+
function numberPanel(ctx, table, texts) {
|
|
1642
|
+
const { S, state, Z } = ctx;
|
|
1643
|
+
const c = table.content || {};
|
|
1644
|
+
const cards = shared().kpiCards(c).slice(0, 6).filter((k) => plainText(k.value));
|
|
1645
|
+
const device = S.pack.display.number;
|
|
1646
|
+
const [pR, eR] = texts.length ? splitRegion(S, Z, ctx.cls === "side" ? 5 / 8 : 7 / 12) : [Z, null];
|
|
1647
|
+
const pad = Math.max(18, S.tok.pad);
|
|
1648
|
+
const inner = pR.w - pad * 2;
|
|
1649
|
+
const panelH = pR.bottom - pR.y;
|
|
1650
|
+
const role = device === "field" ? { fill: "field", number: "on-field", label: "on-field", note: "on-field" }
|
|
1651
|
+
: device === "tint" ? { fill: "accent-tint", number: "accent", label: "ink", note: "ink" }
|
|
1652
|
+
: { fill: "surface", line: "ink", number: "accent", label: "ink", note: "ink-muted" };
|
|
1653
|
+
const out = [{ kind: "panel", x: pR.x, y: pR.y, w: pR.w, h: panelH, fill: role.fill, line: role.line, limit: Z.bottom }];
|
|
1654
|
+
const capH = table.caption ? captionHeight(table.caption, inner, S) + 6 : 0;
|
|
1655
|
+
if (cards.length >= 2 || (cards[0] && cards[0].note)) {
|
|
1656
|
+
// One row per figure, rows spread over the panel, the caption at the panel's foot.
|
|
1657
|
+
const R = { x: pR.x + pad, w: inner, y: pR.y + pad, bottom: pR.bottom - pad - capH, a: pR.a, b: pR.b };
|
|
1658
|
+
const rows = figureRows(cards, R, S, state);
|
|
1659
|
+
out.push({ ...rows, colour: role.number, labelColour: role.label, noteColour: role.note });
|
|
1660
|
+
if (table.caption) out.push({ kind: "caption", item: { content: table.caption }, x: R.x, y: pR.bottom - pad - capH + 6, w: inner, h: capH - 6, role: role.label, limit: Z.bottom });
|
|
1661
|
+
} else {
|
|
1662
|
+
const value = plainText((cards[0] || {}).value || ((c.rows || [])[0] || [])[0] || "");
|
|
1663
|
+
const label = plainText((cards[0] || {}).label || (c.headers || [])[0] || "");
|
|
1664
|
+
const labelSize = bodySize(S, { ...state, stepped: false }, "lead");
|
|
1665
|
+
const size = [S.sizes.title, S.sizes.lead].find((v) => S.ems(value, "numeral") * v * 1.04 <= inner) || S.sizes.body;
|
|
1666
|
+
const labelH = label ? G.textHeight(label, inner, labelSize) : 0;
|
|
1667
|
+
const blockH = size * 1.15 + (label ? 12 + labelH : 0) + capH;
|
|
1668
|
+
// One figure at title size: the panel hugs it instead of standing as an empty field.
|
|
1669
|
+
out[0].h = Math.min(panelH, Math.round(blockH + pad * 2));
|
|
1670
|
+
const y = Math.round(pR.y + pad);
|
|
1671
|
+
out.push({ kind: "number", value, label, size, labelSize, x: pR.x + pad, y, w: inner, h: blockH - capH, colour: role.number, labelColour: role.label, limit: Z.bottom });
|
|
1672
|
+
if (table.caption) out.push({ kind: "caption", item: { content: table.caption }, x: pR.x + pad, y: y + blockH - capH + 6, w: inner, h: capH - 6, role: role.label, limit: Z.bottom });
|
|
1673
|
+
}
|
|
1674
|
+
if (eR) {
|
|
1675
|
+
const R = region(S, eR.a, eR.b, pR.y, pR.bottom);
|
|
1676
|
+
// The evidence reads as notes on the figures: plain lines, no list markers.
|
|
1677
|
+
const paras = texts.flatMap(itemParas).map((q) => ({ ...q, kind: q.kind === "section" ? "section" : "text" }));
|
|
1678
|
+
const labelSize = bodySize(S, { ...state, stepped: false }, "lead");
|
|
1679
|
+
const evidence = R.w >= 12 * labelSize * 1.1 ? labelSize : bodySize(S, { ...state, stepped: false });
|
|
1680
|
+
let m = measureText(paras, R.w, S, { ...state, itemGap: Math.max(state.itemGap, 12) }, { size: evidence });
|
|
1681
|
+
if (m.h > panelH) m = measureText(paras, R.w, S, state);
|
|
1682
|
+
out.push({ ...m, x: R.x, y: Math.round(pR.y + Math.max(0, (panelH - m.h) / 2)), limit: Z.bottom });
|
|
1683
|
+
}
|
|
1684
|
+
return out;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// A cell that is an amount: digits, an optional Korean unit word, an optional currency or percent.
|
|
1688
|
+
const AMOUNT = /^(\d[\d,.]*)\s*([조억만천]*)\s*(원|달러|USD|EUR|KRW|%)?$/u;
|
|
1689
|
+
const WHEN = /기한|시점|일정|날짜|마감|언제|when|date|due|deadline/iu;
|
|
1690
|
+
|
|
1691
|
+
/**
|
|
1692
|
+
* A closing's table read as action rows. The key is the column of amounts in one unit (they add up
|
|
1693
|
+
* to the ask), else the column headed as a date or deadline, else the last column; the row's first
|
|
1694
|
+
* cell is what is asked or done, the other cells (owner, timing) its detail.
|
|
1695
|
+
*/
|
|
1696
|
+
function actionRows(content) {
|
|
1697
|
+
const headers = (content.headers || []).map((h) => plainText(h));
|
|
1698
|
+
// A totals row is not an action: the ask line (or the field's total) carries the sum.
|
|
1699
|
+
const rows = (content.rows || []).map((r) => r.map((cell) => plainText(cell).trim()))
|
|
1700
|
+
.filter((r, i, all) => !(all.length > 1 && i === all.length - 1 && TOTALS.test(r[0] || "")));
|
|
1701
|
+
if (rows.length < 1 || rows.length > 5 || headers.length < 2) return null;
|
|
1702
|
+
// A bare number under a head that names its unit ("Budget (k USD)") carries that unit, as a key
|
|
1703
|
+
// and in a row's detail, so no amount stands on the page without it.
|
|
1704
|
+
const units = headers.map((h) => (/\(([^()]{1,12})\)\s*$/u.exec(h) || [])[1] || null);
|
|
1705
|
+
const withUnit = (cell, j) => (units[j] && /^\d[\d,.]*$/u.test(cell) ? joinUnit(cell, units[j]) : cell);
|
|
1706
|
+
let key = -1;
|
|
1707
|
+
let kind = "date";
|
|
1708
|
+
for (let j = 1; j < headers.length && key < 0; j++) {
|
|
1709
|
+
const m = rows.map((r) => AMOUNT.exec(r[j] || ""));
|
|
1710
|
+
if (m.every((x) => x && (x[2] || x[3])) && new Set(m.map((x) => `${x[2]}${x[3] || ""}`)).size === 1) {
|
|
1711
|
+
key = j;
|
|
1712
|
+
kind = "amount";
|
|
1713
|
+
} else if (units[j] && !/%/u.test(units[j]) && rows.every((r) => /^\d[\d,.]*$/u.test(r[j] || ""))) {
|
|
1714
|
+
key = j;
|
|
1715
|
+
kind = "amount";
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
if (key < 0) key = headers.findIndex((h, j) => j > 0 && WHEN.test(h));
|
|
1719
|
+
const korean = G.isKorean(headers.join(" ") + rows.map((r) => r[0]).join(" "));
|
|
1720
|
+
if (key < 0) key = headers.length - 1;
|
|
1721
|
+
return {
|
|
1722
|
+
kind,
|
|
1723
|
+
rows: rows.map((r) => ({
|
|
1724
|
+
key: withUnit(r[key] || "", key),
|
|
1725
|
+
num: kind === "amount" ? /^\d[\d,.]*/u.exec(r[key])[0] : null,
|
|
1726
|
+
head: r[0] || "",
|
|
1727
|
+
// An empty or dash-only cell ("—") says nothing about the row and is left out of its detail.
|
|
1728
|
+
meta: r.map(withUnit).filter((cell, j) => j !== 0 && j !== key && cell && !/^[—–-]+$/u.test(cell)).join(" · "),
|
|
1729
|
+
value: kind === "amount" ? parseFloat(/^\d[\d,.]*/u.exec(r[key])[0].replace(/,/gu, "")) : null,
|
|
1730
|
+
})),
|
|
1731
|
+
korean,
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/** "30k USD", "14억 원", "12 L": a figure with its unit, joined the way the unit is written. */
|
|
1736
|
+
function joinUnit(n, unit) {
|
|
1737
|
+
return /^(%|\p{Script=Hangul}|[kKMB](\s|$))/u.test(unit) ? `${n}${unit}` : `${n} ${unit}`;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/** "합계 30억 원": the rows' amounts summed and written in the unit the keys use, or null. */
|
|
1741
|
+
function actionTotal(acts) {
|
|
1742
|
+
if (!acts || acts.kind !== "amount" || acts.rows.length < 2) return null;
|
|
1743
|
+
const sum = acts.rows.reduce((a, r) => a + r.value, 0);
|
|
1744
|
+
const digits = Math.max(...acts.rows.map((r) => ((/\.(\d+)/u.exec(r.num) || [, ""])[1]).length));
|
|
1745
|
+
const commas = acts.rows.some((r) => r.key.includes(","));
|
|
1746
|
+
const n = commas ? sum.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits }) : sum.toFixed(digits);
|
|
1747
|
+
return `${acts.korean ? "합계" : "Total"} ${acts.rows[0].key.replace(/^\d[\d,.]*/u, n)}`;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/** "안건 4건 · 10월 셋째 주 – 11월 첫째 주": the count of date-keyed rows and their first and last date, or null. */
|
|
1751
|
+
function actionSpan(acts) {
|
|
1752
|
+
if (!acts || acts.kind !== "date" || acts.rows.length < 2) return null;
|
|
1753
|
+
const first = acts.rows[0].key;
|
|
1754
|
+
const last = acts.rows[acts.rows.length - 1].key;
|
|
1755
|
+
const n = acts.rows.length;
|
|
1756
|
+
const count = acts.korean ? `안건 ${n}건` : `${n} items`;
|
|
1757
|
+
return first && last && first !== last ? `${count} · ${first} – ${last}` : count;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* Action rows down a region: the key large on the left (or in `o.keys`, a field beside the rows),
|
|
1762
|
+
* what is asked bold at lead size with its detail under it, and for amounts a bar as long as the
|
|
1763
|
+
* row's share of the total. The free height is shared out as equal gaps between the rows (and
|
|
1764
|
+
* before the next step when one follows), so the rows and the step fill the body together.
|
|
1765
|
+
*/
|
|
1766
|
+
function placeActions(ctx, acts, R, o = {}) {
|
|
1767
|
+
const { S, state } = ctx;
|
|
1768
|
+
const n = acts.rows.length;
|
|
1769
|
+
const keyR = o.keys || region(S, R.a, R.a + 2, R.y, R.bottom);
|
|
1770
|
+
const textR = o.keys ? R : region(S, R.a + 3, R.b, R.y, R.bottom);
|
|
1771
|
+
const longest = Math.max(...acts.rows.map((r) => S.ems(r.key, "numeral")));
|
|
1772
|
+
const sizes = [S.sizes.title, S.sizes.lead, S.sizes.body];
|
|
1773
|
+
const total = acts.kind === "amount" ? acts.rows.reduce((a, r) => a + r.value, 0) : 0;
|
|
1774
|
+
const gaps = o.last ? n : Math.max(1, n - 1);
|
|
1775
|
+
// At lead size first; rows that do not fit step down (lead to body, body to label), then the
|
|
1776
|
+
// key steps down the ramp until the rows fit.
|
|
1777
|
+
const fit = (step) => {
|
|
1778
|
+
const fitting = sizes.filter((v) => v && longest * v * 1.04 <= keyR.w);
|
|
1779
|
+
const keySize = fitting[Math.min(step, fitting.length - 1)] || S.sizes.body;
|
|
1780
|
+
const head = step ? S.sizes.body : S.sizes.lead;
|
|
1781
|
+
const meta = step ? S.sizes.label : S.sizes.body;
|
|
1782
|
+
const rows = acts.rows.map((r) => {
|
|
1783
|
+
const headH = G.textHeight(r.head, textR.w, head);
|
|
1784
|
+
const metaH = r.meta ? G.textHeight(r.meta, textR.w, meta) : 0;
|
|
1785
|
+
const bar = total > 0 ? (o.keys ? 14 : 18) : 0;
|
|
1786
|
+
// On the field the share bar runs under the key; beside the rows it runs under the text.
|
|
1787
|
+
const h = o.keys ? Math.max(keySize * 1.15 + bar, headH + (metaH ? 6 + metaH : 0))
|
|
1788
|
+
: Math.max(keySize * 1.15, headH + (metaH ? 6 + metaH : 0) + bar);
|
|
1789
|
+
return { ...r, headH, metaH, bar, h };
|
|
1790
|
+
});
|
|
1791
|
+
const sum = rows.reduce((a, r) => a + r.h, 0);
|
|
1792
|
+
return { keySize, head, meta, rows, room: R.bottom - R.y - sum - gaps * state.itemGap };
|
|
1793
|
+
};
|
|
1794
|
+
let chosen = fit(0);
|
|
1795
|
+
for (let step = 1; chosen.room < 0 && step <= 3; step++) chosen = fit(step);
|
|
1796
|
+
if (chosen.room < 0) return null;
|
|
1797
|
+
const { keySize, head, meta, rows } = chosen;
|
|
1798
|
+
const sum = rows.reduce((a, r) => a + r.h, 0);
|
|
1799
|
+
// Rows sit at most 96 pt apart (wider gaps read as separate blocks, and at reading sizes left one
|
|
1800
|
+
// empty band between two short rows); what is left over is shared above and below the rows.
|
|
1801
|
+
const gap = Math.max(state.itemGap, Math.min(96, (R.bottom - R.y - sum) / gaps));
|
|
1802
|
+
const spare = Math.max(0, R.bottom - R.y - sum - gap * gaps);
|
|
1803
|
+
let y = R.y + Math.round(spare / 2);
|
|
1804
|
+
for (const r of rows) { r.y = y; y += r.h + gap; }
|
|
1805
|
+
const h = rows[n - 1].y + rows[n - 1].h - R.y;
|
|
1806
|
+
return [{ kind: "actions", rows, keyX: keyR.x, keyW: keyR.w, keySize, onField: Boolean(o.keys), textX: textR.x, textW: textR.w,
|
|
1807
|
+
head, meta, total, gap, x: R.x, y: R.y, w: (o.keys ? keyR.x + keyR.w : R.x + R.w) - R.x, h, limit: R.bottom }];
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/**
|
|
1811
|
+
* The next step of a closing, set at lead size on the body floor in the pack's closing device: a
|
|
1812
|
+
* band of the field across the region (or `o.panelW` of it, where a field drawn beside continues
|
|
1813
|
+
* it; text in the field's colour), a tinted or outlined box, a
|
|
1814
|
+
* rule above the line, or (field) a rule above the line inside a field already drawn. A label
|
|
1815
|
+
* before a colon is set bold. Returns the placements and its top.
|
|
1816
|
+
*/
|
|
1817
|
+
function nextStep(ctx, texts, R, device, o = {}) {
|
|
1818
|
+
const { S, state } = ctx;
|
|
1819
|
+
const lead = bodySize(S, { ...state, stepped: false }, "lead");
|
|
1820
|
+
const ruled = device === "rules" || device === "field";
|
|
1821
|
+
const pad = ruled ? 0 : Math.max(18, S.tok.pad);
|
|
1822
|
+
// Each point keeps its own line; a source line is a note at label size. Lines keep a readable
|
|
1823
|
+
// measure inside the band.
|
|
1824
|
+
const paras = texts.flatMap(itemParas).map((p) => plainText(p.text));
|
|
1825
|
+
const notes = paras.filter((t) => /^(출처|자료|sources?)\b/iu.test(t));
|
|
1826
|
+
const points = paras.filter((t) => !notes.includes(t)).map((t) => t.replace(/^([^::]{1,16})([::])\s*/u, "**$1$2** "));
|
|
1827
|
+
const size = points.length > 2 || o.small ? S.sizes.body : lead;
|
|
1828
|
+
const box = R.w - pad * 2;
|
|
1829
|
+
const inner = Math.min(box, (G.isKorean(paras.join("")) ? 34 : 70) * size * (G.isKorean(paras.join("")) ? 1 : 0.5));
|
|
1830
|
+
// A line that fits the box on one line takes the box's width; a longer one keeps the reading measure, set
|
|
1831
|
+
// as narrow as keeps its line count so its lines come out even and none ends on a lone word.
|
|
1832
|
+
const widthOf = (t, z) => {
|
|
1833
|
+
if (G.lineCount(plainText(t), box, z) <= 1) return box;
|
|
1834
|
+
const n = G.lineCount(plainText(t), inner, z);
|
|
1835
|
+
let lo = inner / 2;
|
|
1836
|
+
let hi = inner;
|
|
1837
|
+
for (let i = 0; i < 12; i++) { const mid = (lo + hi) / 2; if (G.lineCount(plainText(t), mid, z) <= n) hi = mid; else lo = mid; }
|
|
1838
|
+
return Math.min(inner, Math.ceil(hi + 2 * z));
|
|
1839
|
+
};
|
|
1840
|
+
const items = [...points.map((t) => ({ text: t, size })), ...notes.map((t) => ({ text: t, size: S.sizes.label }))]
|
|
1841
|
+
.map((q) => { const w = widthOf(q.text, q.size); return { ...q, w, h: G.textHeight(plainText(q.text), w, q.size) }; });
|
|
1842
|
+
const th = items.reduce((a, q, i) => a + q.h + (i ? 6 : 0), 0);
|
|
1843
|
+
const top = ruled ? R.bottom - th - 18 : R.bottom - th - pad * 2;
|
|
1844
|
+
const placed = [];
|
|
1845
|
+
const outline = device === "box" && S.deco.has("box-outline");
|
|
1846
|
+
if (device === "band") placed.push({ kind: "panel", x: R.x, y: top, w: o.panelW || R.w, h: R.bottom - top, fill: "field", limit: R.bottom });
|
|
1847
|
+
else if (device === "box") placed.push({ kind: "panel", x: R.x, y: top, w: R.w, h: R.bottom - top, fill: outline ? null : "accent-tint", line: outline ? "ink" : null, limit: R.bottom });
|
|
1848
|
+
else placed.push({ decor: true, kind: "rect", x: R.x, y: top, w: R.w, h: 0.75, fill: "line" });
|
|
1849
|
+
let y = ruled ? top + 18 : top + pad;
|
|
1850
|
+
for (const q of items) {
|
|
1851
|
+
placed.push({ kind: "text", paras: [{ kind: "text", text: q.text, inset: 0, gap: 0, h: q.h }], size: q.size, x: R.x + pad, y, w: q.w, h: q.h,
|
|
1852
|
+
onField: device === "band" || device === "field", tinted: device === "box" && !outline, muted: q.size === S.sizes.label, limit: R.bottom });
|
|
1853
|
+
y += q.h + 6;
|
|
1854
|
+
}
|
|
1855
|
+
return { placed, top: top - state.blockGap };
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// ── Draw ────────────────────────────────────────────────────────────────────
|
|
1859
|
+
|
|
1860
|
+
function drawPlaced(slide, S, p, ctx) {
|
|
1861
|
+
const A = shared();
|
|
1862
|
+
const c = (p.item && p.item.content) || {};
|
|
1863
|
+
if (p.kind === "group") { for (const q of p.parts) drawPlaced(slide, S, q, ctx); return; }
|
|
1864
|
+
if (p.kind === "rect") { packRect(slide, S, p, { fill: p.fill }); return; }
|
|
1865
|
+
if (p.kind === "panel") { packRect(slide, S, p, { fill: p.fill, line: p.line, lineWidth: 1, radius: S.pack.radius }); return; }
|
|
1866
|
+
if (p.kind === "actions") {
|
|
1867
|
+
const ink = S.colour(p.onField ? "on-field" : "accent");
|
|
1868
|
+
p.rows.forEach((r, i) => {
|
|
1869
|
+
if (i > 0) packRect(slide, S, { x: p.textX, y: r.y - p.gap / 2, w: p.textW, h: 0.75 }, { fill: "line" });
|
|
1870
|
+
packText(slide, S, r.key, { x: p.keyX, y: r.y, w: p.keyW, h: p.keySize * 1.15 }, { face: "numeral", size: p.keySize, colour: ink, lineSpacing: 1.15 });
|
|
1871
|
+
packText(slide, S, r.head, { x: p.textX, y: r.y, w: p.textW, h: r.headH }, { size: p.head, bold: true });
|
|
1872
|
+
if (r.meta) packText(slide, S, r.meta, { x: p.textX, y: r.y + r.headH + 6, w: p.textW, h: r.metaH }, { size: p.meta, colour: S.colour("ink-muted") });
|
|
1873
|
+
if (r.bar) {
|
|
1874
|
+
// The row's share of the ask: a bar on a full-width track, under the key on the field.
|
|
1875
|
+
const [bx, bw] = p.onField ? [p.keyX, p.keyW] : [p.textX, p.textW];
|
|
1876
|
+
const by = p.onField ? r.y + p.keySize * 1.15 + 8 : r.y + r.headH + (r.metaH ? 6 + r.metaH : 0) + 12;
|
|
1877
|
+
packRect(slide, S, { x: bx, y: by, w: bw, h: 6 }, { fill: p.onField ? "ink-muted" : "accent-tint" });
|
|
1878
|
+
packRect(slide, S, { x: bx, y: by, w: Math.max(4, (bw * r.value) / p.total), h: 6 }, { fill: p.onField ? "on-field" : "accent" });
|
|
1879
|
+
}
|
|
1880
|
+
});
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
if (p.kind === "box") {
|
|
1884
|
+
if (p.outline) packRect(slide, S, p, { line: "ink", lineWidth: 1 });
|
|
1885
|
+
else if (p.tint) packRect(slide, S, p, { fill: "accent-tint", radius: S.pack.radius });
|
|
1886
|
+
else surfaceRect(slide, S, p);
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
if (p.kind === "text") {
|
|
1890
|
+
let y = p.y;
|
|
1891
|
+
const ink = p.onField ? S.colour("on-field") : p.tinted ? S.colour("accent-deep") : S.colour("ink");
|
|
1892
|
+
const soft = p.onField ? S.colour("on-field") : S.colour("ink-muted");
|
|
1893
|
+
for (const para of p.paras) {
|
|
1894
|
+
y += para.gap;
|
|
1895
|
+
// The marker is a small dot centred on the first line: a shape, so no renderer swaps in a
|
|
1896
|
+
// symbol font for it and no checker reads it as a column of text.
|
|
1897
|
+
if (para.kind === "bullet") {
|
|
1898
|
+
const d = Math.max(4, Math.round(p.size * 0.28));
|
|
1899
|
+
const lineH = p.size * G.leading(para.text);
|
|
1900
|
+
packRect(slide, S, { x: p.x, y: y + (lineH - d) / 2, w: d, h: d }, { fill: p.onField ? "on-field" : "accent" });
|
|
1901
|
+
}
|
|
1902
|
+
packText(slide, S, para.kind === "section" ? para.text : runIn(para.text), { x: p.x + (para.inset || 0), y, w: p.w - (para.inset || 0), h: para.h }, {
|
|
1903
|
+
size: p.size, bold: p.bold || para.kind === "section",
|
|
1904
|
+
colour: p.accent ? S.colour("accent-deep") : para.kind === "child" || p.muted ? soft : ink,
|
|
1905
|
+
});
|
|
1906
|
+
y += para.h;
|
|
1907
|
+
}
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
if (p.kind === "callout") {
|
|
1911
|
+
packRect(slide, S, p, { fill: "accent-tint", radius: S.pack.radius });
|
|
1912
|
+
packText(slide, S, plainText(c), { x: p.x + p.pad, y: p.y + p.pad, w: p.w - p.pad * 2, h: p.h - p.pad * 2 },
|
|
1913
|
+
{ size: p.size, bold: true, colour: S.colour("accent-deep") });
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
if (p.kind === "kpi") {
|
|
1917
|
+
p.cards.forEach((card, i) => {
|
|
1918
|
+
const x = p.x + i * (p.cardW + S.grid.gutter);
|
|
1919
|
+
surfaceRect(slide, S, { x, y: p.y, w: p.cardW, h: p.cardH });
|
|
1920
|
+
const inner = p.cardW - p.pad * 2;
|
|
1921
|
+
const valueH = p.value * 1.15;
|
|
1922
|
+
packText(slide, S, plainText(card.value), { x: x + p.pad, y: p.y + p.pad, w: inner, h: valueH },
|
|
1923
|
+
{ face: "numeral", size: p.value, lineSpacing: 1.15, colour: S.colour(i === 0 ? "accent" : "ink") });
|
|
1924
|
+
packText(slide, S, plainText(card.label), { x: x + p.pad, y: p.y + p.pad + valueH + 6, w: inner, h: p.labelH },
|
|
1925
|
+
{ size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.3 });
|
|
1926
|
+
if (card.note) packText(slide, S, plainText(card.note), { x: x + p.pad, y: p.y + p.pad + valueH + 6 + p.labelH + 6, w: inner, h: p.noteH },
|
|
1927
|
+
{ size: p.noteSize, colour: S.colour("ink"), lineSpacing: 1.3 });
|
|
1928
|
+
});
|
|
1929
|
+
drawCaption(slide, S, p.item.caption, p.x, p.y + p.cardH + 6, p.w);
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
if (p.kind === "figrows") {
|
|
1933
|
+
p.rows.forEach((r, i) => {
|
|
1934
|
+
if (i > 0) packRect(slide, S, { x: p.x, y: r.y - 12, w: p.w, h: 0.75 }, { fill: p.colour === "on-field" ? "on-field" : "line" });
|
|
1935
|
+
packText(slide, S, r.value, { x: p.x, y: r.y, w: p.valueW, h: p.value * 1.15 },
|
|
1936
|
+
{ face: "numeral", size: p.value, lineSpacing: 1.15, colour: S.colour(p.colour || (i === 0 ? "accent" : "ink")) });
|
|
1937
|
+
const lh = G.textHeight(r.label, p.labelW, p.labelSize);
|
|
1938
|
+
const nh = r.note ? G.textHeight(r.note, p.labelW, p.noteSize) : 0;
|
|
1939
|
+
// The label reads beside the value; the basis under the label.
|
|
1940
|
+
packText(slide, S, r.label, { x: p.labelX, y: r.y, w: p.labelW, h: lh }, { size: p.labelSize, colour: S.colour(p.labelColour || "ink"), bold: true });
|
|
1941
|
+
if (r.note) packText(slide, S, r.note, { x: p.labelX, y: r.y + lh + 4, w: p.labelW, h: nh }, { size: p.noteSize, colour: S.colour(p.noteColour || "ink-muted") });
|
|
1942
|
+
});
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
if (p.kind === "rows") {
|
|
1946
|
+
p.rows.forEach((r, i) => {
|
|
1947
|
+
const right = p.restX != null ? p.restX + p.restW : p.textX + p.textW;
|
|
1948
|
+
if (p.rule && i > 0) packRect(slide, S, { x: p.x, y: r.y - Math.min(12, (p.pitch - r.h) / 2), w: right - p.x, h: 0.75 }, { fill: "line" });
|
|
1949
|
+
const numSize = p.numSize || p.head;
|
|
1950
|
+
packText(slide, S, r.num, { x: p.x, y: r.y, w: p.numW, h: numSize * 1.15 }, { face: "numeral", size: numSize, colour: S.colour("accent"), lineSpacing: 1.15 });
|
|
1951
|
+
const hh = G.textHeight(r.head, p.textW, p.head);
|
|
1952
|
+
packText(slide, S, r.head, { x: p.textX, y: r.y, w: p.textW, h: hh }, { size: p.head, bold: true });
|
|
1953
|
+
// Beside the name (restX) or under it.
|
|
1954
|
+
if (r.rest && p.restX != null) packText(slide, S, r.rest, { x: p.restX, y: r.y, w: p.restW, h: G.textHeight(r.rest, p.restW, p.rest) }, { size: p.rest });
|
|
1955
|
+
else if (r.rest) packText(slide, S, r.rest, { x: p.textX, y: r.y + hh + 6, w: p.textW, h: G.textHeight(r.rest, p.textW, p.rest) }, { size: p.rest, colour: S.colour("ink-muted") });
|
|
1956
|
+
});
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
if (p.kind === "headed") {
|
|
1960
|
+
p.rows.forEach((r, i) => {
|
|
1961
|
+
if (i > 0) packRect(slide, S, { x: p.x, y: r.y - Math.min(12, (p.pitch - p.rows[i - 1].h) / 2), w: p.w, h: 0.75 }, { fill: "line" });
|
|
1962
|
+
packText(slide, S, r.head, { x: p.headX, y: r.y, w: p.headW, h: r.headH }, { size: p.size, bold: true, colour: S.colour("accent-deep") });
|
|
1963
|
+
packText(slide, S, r.text, { x: p.textX, y: r.y, w: p.textW, h: r.textH }, { size: p.size });
|
|
1964
|
+
});
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
if (p.kind === "steps") {
|
|
1968
|
+
p.steps.forEach((s) => {
|
|
1969
|
+
packText(slide, S, s.n, { x: s.x, y: p.y, w: s.w, h: p.numSize * 1.1 }, { face: "numeral", size: p.numSize, colour: S.colour("accent"), lineSpacing: 1.15 });
|
|
1970
|
+
const ruleY = p.y + p.numSize * 1.1 + 6;
|
|
1971
|
+
packRect(slide, S, { x: s.x, y: ruleY, w: s.w, h: 0.75 }, { fill: "line" });
|
|
1972
|
+
packText(slide, S, s.head, { x: s.x, y: ruleY + 12, w: s.w, h: s.headH }, { size: p.headSize, bold: true });
|
|
1973
|
+
if (s.rest) packText(slide, S, s.rest, { x: s.x, y: ruleY + 12 + s.headH + 12, w: s.w, h: s.restH }, { size: p.restSize, colour: S.colour("ink-muted") });
|
|
1974
|
+
});
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
if (p.kind === "timeline") {
|
|
1978
|
+
packRect(slide, S, { x: p.x, y: p.axisY, w: p.w, h: 1.5 }, { fill: "line" });
|
|
1979
|
+
p.events.forEach((e, i) => {
|
|
1980
|
+
packRect(slide, S, { x: e.x, y: p.axisY - 4, w: 9, h: 9 }, { fill: i === p.events.length - 1 ? "accent" : "ink-muted" });
|
|
1981
|
+
const dh = G.textHeight(e.date, e.w, p.dateSize, 1.25);
|
|
1982
|
+
packText(slide, S, e.date, { x: e.x, y: p.axisY - 18 - dh, w: e.w, h: dh }, { size: p.dateSize, bold: true, colour: S.colour("ink"), lineSpacing: 1.25 });
|
|
1983
|
+
packText(slide, S, e.label, { x: e.x, y: p.axisY + 18, w: e.w, h: e.labelH }, { size: p.labelSize });
|
|
1984
|
+
if (e.status) packText(slide, S, e.status, { x: e.x, y: p.axisY + 18 + e.labelH + 6, w: e.w, h: e.statusH - 6 }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.3 });
|
|
1985
|
+
});
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
if (p.kind === "matrix") {
|
|
1989
|
+
const label = S.sizes.label;
|
|
1990
|
+
p.xHeads.forEach((t, i) => packText(slide, S, t, { x: p.grid.x + i * (p.qw + p.gap), y: p.grid.y, w: p.qw, h: label * 1.4 }, { size: label, bold: true, colour: S.colour("ink-muted"), lineSpacing: 1.3 }));
|
|
1991
|
+
p.yHeads.forEach((t, i) => packText(slide, S, t, { x: p.labels.x, y: p.top + i * (p.qh + p.gap), w: p.labels.w, h: label * 1.4 }, { size: label, bold: true, colour: S.colour("ink-muted"), lineSpacing: 1.3 }));
|
|
1992
|
+
const axis = [p.yName && `↑ ${p.yName}`, p.xName && `→ ${p.xName}`].filter(Boolean).join(" ");
|
|
1993
|
+
if (axis) {
|
|
1994
|
+
// The axis names sit at the foot of the label column, as tall as they wrap in it.
|
|
1995
|
+
const h = G.textHeight(axis, p.labels.w, label, 1.3);
|
|
1996
|
+
packText(slide, S, axis, { x: p.labels.x, y: p.top + 2 * p.qh + p.gap - h, w: p.labels.w, h }, { size: label, colour: S.colour("ink-muted"), lineSpacing: 1.3 });
|
|
1997
|
+
}
|
|
1998
|
+
p.cells.forEach((cell) => {
|
|
1999
|
+
packRect(slide, S, cell, { line: "line", lineWidth: 0.75 });
|
|
2000
|
+
const w = cell.w - p.pad * 2;
|
|
2001
|
+
// The item and its details stand in the middle of the quadrant, so a short cell reads as placed.
|
|
2002
|
+
const blockH = cell.headH + cell.details.reduce((a, d) => a + 4 + d.h, 0);
|
|
2003
|
+
let y = cell.y + Math.max(p.pad, Math.round((cell.h - blockH) / 2));
|
|
2004
|
+
packText(slide, S, cell.head, { x: cell.x + p.pad, y, w, h: cell.headH }, { size: p.head, bold: true, lineSpacing: 1.3 });
|
|
2005
|
+
y += cell.headH;
|
|
2006
|
+
for (const d of cell.details) {
|
|
2007
|
+
y += 4;
|
|
2008
|
+
packText(slide, S, d.text, { x: cell.x + p.pad, y, w, h: d.h }, { size: p.detail, colour: S.colour("ink-muted") });
|
|
2009
|
+
y += d.h;
|
|
2010
|
+
}
|
|
2011
|
+
});
|
|
2012
|
+
if (p.caption) drawCaption(slide, S, p.caption, p.grid.x, p.top + 2 * p.qh + p.gap + 6, p.grid.w);
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
if (p.kind === "quote") {
|
|
2016
|
+
packText(slide, S, "“", { x: p.markX, y: p.y - 6, w: p.markW, h: S.sizes.display * 1.1 }, { face: "display", size: S.sizes.display, colour: S.colour("accent"), lineSpacing: 1.15 });
|
|
2017
|
+
packText(slide, S, plainText(p.text).replace(/^[“"]|[”"]$/gu, ""), { x: p.x, y: p.y, w: p.w, h: p.textH }, { face: "title", size: p.size, lineSpacing: 1.3 });
|
|
2018
|
+
if (p.by) packText(slide, S, p.by, { x: p.x, y: p.y + p.textH + 18, w: p.w, h: S.sizes.label * 1.4 }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.3 });
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
if (p.kind === "number") {
|
|
2022
|
+
packText(slide, S, p.value, { x: p.x, y: p.y, w: p.w, h: p.size * 1.1 }, { face: "numeral", size: p.size, colour: S.colour(p.colour || "accent"), lineSpacing: 1.15 });
|
|
2023
|
+
if (p.label) packText(slide, S, p.label, { x: p.x, y: p.y + p.size * 1.1 + 12, w: p.w, h: G.textHeight(p.label, p.w, p.labelSize) }, { size: p.labelSize, colour: S.colour(p.labelColour || "ink-muted") });
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
if (p.kind === "table") {
|
|
2027
|
+
drawPackTable(slide, S, c, p, ctx.title);
|
|
2028
|
+
drawCaption(slide, S, p.item.caption, p.x, p.y + p.tableH + 6, p.w);
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
if (p.kind === "chart") {
|
|
2032
|
+
drawChart(slide, S, c, p);
|
|
2033
|
+
drawCaption(slide, S, p.item.caption, p.x, p.y + p.h - p.cap + 6, p.w);
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
if (p.kind === "image") {
|
|
2037
|
+
const images = p.item.type === "image-grid" ? c.images || [] : [c];
|
|
2038
|
+
const h = p.h - p.cap;
|
|
2039
|
+
const cellW = (p.w - (images.length - 1) * S.grid.gutter) / Math.max(1, images.length);
|
|
2040
|
+
let bottom = p.y;
|
|
2041
|
+
images.forEach((image, i) => {
|
|
2042
|
+
const file = forGround(A.resolveImagePath(image.src, ctx.sourceDir), S);
|
|
2043
|
+
let fit = A.containBox(IN(p.x + i * (cellW + S.grid.gutter)), IN(p.y), IN(cellW), IN(h), A.readImageSize(file));
|
|
2044
|
+
const low = IN(p.y + h) - fit.h;
|
|
2045
|
+
if (p.beside != null && images.length === 1 && low * 72 <= p.beside) fit = { ...fit, y: low };
|
|
2046
|
+
slide.addImage({ path: file, ...fit, altText: image.caption || path.basename(image.src || file) });
|
|
2047
|
+
if (S.pack.image && S.pack.image.frame === "hairline") packRect(slide, S, { x: fit.x * 72, y: fit.y * 72, w: fit.w * 72, h: fit.h * 72 }, { line: "line", lineWidth: 0.5 });
|
|
2048
|
+
bottom = Math.max(bottom, (fit.y + fit.h) * 72);
|
|
2049
|
+
});
|
|
2050
|
+
// The caption binds to the picture as drawn: a letterboxed figure keeps its caption under it.
|
|
2051
|
+
drawCaption(slide, S, p.item.caption, p.x, bottom + 6, p.w);
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
if (p.kind === "caption") drawCaption(slide, S, p.item.content, p.x, p.y, p.w, p.role === "on-field" ? "on-field" : "ink-muted");
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
/**
|
|
2058
|
+
* A figure on a dark ground is drawn from its dark variant when the source folder has one beside it
|
|
2059
|
+
* (`flow.png` → `flow.dark.png`): a chart or diagram exported on white would stand as a white card.
|
|
2060
|
+
*/
|
|
2061
|
+
function forGround(file, S) {
|
|
2062
|
+
const c = (i) => parseInt(String(S.P.ground).replace("#", "").slice(i, i + 2), 16) / 255;
|
|
2063
|
+
if (0.2126 * c(0) + 0.7152 * c(2) + 0.0722 * c(4) >= 0.25) return file;
|
|
2064
|
+
const ext = path.extname(file);
|
|
2065
|
+
const dark = `${file.slice(0, file.length - ext.length)}.dark${ext}`;
|
|
2066
|
+
return require("fs").existsSync(dark) ? dark : file;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
function drawCaption(slide, S, caption, x, y, w, role = "ink-muted") {
|
|
2070
|
+
if (!caption) return;
|
|
2071
|
+
const text = captionText(caption);
|
|
2072
|
+
packText(slide, S, text, { x, y, w, h: G.textHeight(text, w, S.sizes.label, 1.3) },
|
|
2073
|
+
{ size: S.sizes.label, colour: S.colour(role), lineSpacing: 1.3 });
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
// Series names that are a reference for the measured one: a plan, target, budget, baseline, prior period.
|
|
2077
|
+
const REFERENCE = /계획|목표|예산|전년|전기|기준|이전|기존|평균|plan|target|budget|baseline|benchmark|forecast|prior|previous|last year|average/iu;
|
|
2078
|
+
|
|
2079
|
+
/** A series name's period as one comparable number (year, then half, quarter or month), or null. */
|
|
2080
|
+
function periodOf(name) {
|
|
2081
|
+
const t = plainText(name);
|
|
2082
|
+
const year = /(?:^|\D)((?:19|20)\d{2})(?!\d)/u.exec(t) || /(?:^|[^\d.])(\d{2})년/u.exec(t);
|
|
2083
|
+
const quarter = /\bQ([1-4])\b|\b([1-4])Q\b|([1-4])\s*분기/iu.exec(t);
|
|
2084
|
+
const half = /\bH([12])\b|([상하])반기/iu.exec(t);
|
|
2085
|
+
const month = /(?:^|\D)(1[0-2]|[1-9])월|\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\b/iu.exec(t);
|
|
2086
|
+
const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
2087
|
+
const within = quarter ? 3 * Number(quarter[1] || quarter[2] || quarter[3])
|
|
2088
|
+
: half ? 6 * (half[1] ? Number(half[1]) : half[2] === "상" ? 1 : 2)
|
|
2089
|
+
: month ? (month[1] ? Number(month[1]) : months.indexOf(month[2].slice(0, 3).toLowerCase()) + 1) : null;
|
|
2090
|
+
if (!year && within == null) return null;
|
|
2091
|
+
const y = year ? Number(year[1].length === 2 ? `20${year[1]}` : year[1]) : 0;
|
|
2092
|
+
return y * 100 + (within || 0);
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
/**
|
|
2096
|
+
* The series a chart highlights: the latest period when every series names one, else the one measured
|
|
2097
|
+
* series beside its plans, targets or prior periods, else the last series (where a comparison puts the
|
|
2098
|
+
* proposed or current one).
|
|
2099
|
+
*/
|
|
2100
|
+
function primarySeries(names) {
|
|
2101
|
+
const periods = names.map(periodOf);
|
|
2102
|
+
if (periods.every((v) => v != null) && new Set(periods).size === periods.length) return periods.indexOf(Math.max(...periods));
|
|
2103
|
+
const measured = names.map((n, i) => (REFERENCE.test(plainText(n)) ? -1 : i)).filter((i) => i >= 0);
|
|
2104
|
+
if (measured.length && measured.length < names.length) return measured[measured.length - 1];
|
|
2105
|
+
return names.length - 1;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/** A series name without its unit or case, so "Single feed (%)" and "single feed" are one series. */
|
|
2109
|
+
function seriesKey(name) {
|
|
2110
|
+
return plainText(name).replace(/\s*[((][^))]*[))]\s*$/u, "").trim().toLowerCase();
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
/**
|
|
2114
|
+
* A native chart in the pack's chart style: one hue per series, the accent on the deck's one highlighted
|
|
2115
|
+
* series (primarySeries), the others receding (accent-on-muted) or in the pack's other series hues.
|
|
2116
|
+
*/
|
|
2117
|
+
function drawChart(slide, S, c, p) {
|
|
2118
|
+
const P = S.P;
|
|
2119
|
+
const chart = S.pack.chart || {};
|
|
2120
|
+
const series = (c.headers || []).length - 1;
|
|
2121
|
+
const muted = mix(P["ink-muted"], P.ground, 0.45);
|
|
2122
|
+
let colours;
|
|
2123
|
+
if (series <= 1) {
|
|
2124
|
+
// One series is one hue (a per-point highlight would make the chart read as categories): the accent,
|
|
2125
|
+
// unless the deck shows the same series receding beside another, where it keeps that colour.
|
|
2126
|
+
const other = S.seriesRole && S.seriesRole.get(seriesKey((c.headers || [])[1])) === "other";
|
|
2127
|
+
const receding = chart.highlight === "accent-on-muted" ? muted : P.series.find((h) => String(h).toUpperCase() !== String(P.accent).toUpperCase()) || muted;
|
|
2128
|
+
colours = [other ? receding : P.accent];
|
|
2129
|
+
} else {
|
|
2130
|
+
const primary = primarySeries(c.headers.slice(1));
|
|
2131
|
+
const others = chart.highlight === "accent-on-muted" ? (series === 2 ? [muted] : P.series.slice(1, series).reverse())
|
|
2132
|
+
: P.series.filter((h) => String(h).toUpperCase() !== String(P.accent).toUpperCase());
|
|
2133
|
+
let k = 0;
|
|
2134
|
+
colours = Array.from({ length: series }, (_, i) => (i === primary ? P.accent : others[k++ % others.length]));
|
|
2135
|
+
}
|
|
2136
|
+
// Without a value axis, the top data label needs headroom inside the plot.
|
|
2137
|
+
const values = (c.rows || []).flatMap((r) => r.slice(1).map((v) => Number(plainText(v).replace(/[^\d.\-]/gu, "")))).filter(Number.isFinite);
|
|
2138
|
+
const top = values.length ? Math.max(...values) : 0;
|
|
2139
|
+
const style = {
|
|
2140
|
+
series: colours, ink: P.ink, ink_muted: P["ink-muted"], line: P.line,
|
|
2141
|
+
titleFont: S.faces.title.font, bodyFont: S.faces.body.font, labelSize: S.sizes.label,
|
|
2142
|
+
grid: chart.gridlines !== "none", valAxisHidden: chart.gridlines === "none" && chart.labels === "direct",
|
|
2143
|
+
...(top > 0 && !(c.chart && ["pie", "doughnut"].includes(c.chart.type)) ? { valMax: niceCeil(top * 1.15) } : {}),
|
|
2144
|
+
};
|
|
2145
|
+
const bottom = p.y + p.h - p.cap;
|
|
2146
|
+
shared().renderChart(slide, c, { x: IN(p.x), y: IN(p.y), w: IN(p.w), h: IN(bottom - p.y) }, style, IN(bottom));
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
/** A round axis maximum at or above v (1, 2 or 5 times a power of ten). */
|
|
2150
|
+
function niceCeil(v) {
|
|
2151
|
+
const p = 10 ** Math.floor(Math.log10(v));
|
|
2152
|
+
return [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((m) => m * p).find((x) => x >= v);
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
// A totals row's first cell; a word boundary after Hangul needs the lookahead (\b reads ASCII only).
|
|
2156
|
+
const TOTALS = /^(합계|총계|소계|계|total|sum)(?![\p{L}\p{N}])/iu;
|
|
2157
|
+
|
|
2158
|
+
/** A table in the pack's table style: header fill, rules and banding from the pack, numbers right-aligned. */
|
|
2159
|
+
function drawPackTable(slide, S, c, p, title) {
|
|
2160
|
+
const A = shared();
|
|
2161
|
+
const headers = c.headers || [];
|
|
2162
|
+
// Every row carries one cell per column, so a short source row cannot leave a ragged table.
|
|
2163
|
+
const rows = (c.rows || []).map((r) => headers.map((_, i) => (r[i] != null ? r[i] : "")));
|
|
2164
|
+
if (!headers.length) return;
|
|
2165
|
+
const t = S.pack.table || {};
|
|
2166
|
+
const numeric = A.numericColumns(headers, rows);
|
|
2167
|
+
const hair = { type: "solid", pt: 0.75, color: S.colour("line") };
|
|
2168
|
+
const heavy = (pt) => ({ type: "solid", pt, color: S.colour("ink") });
|
|
2169
|
+
const none = { type: "none" };
|
|
2170
|
+
const headerFill = t.header && t.header !== "none" ? S.colour(t.header) : null;
|
|
2171
|
+
const headerInk = S.colour(t["header-ink"] || (t.header === "field" ? "on-field" : "ink"));
|
|
2172
|
+
const lastIsTotal = rows.length > 1 && TOTALS.test(plainText(rows[rows.length - 1][0]));
|
|
2173
|
+
const titleText = plainText(title);
|
|
2174
|
+
const protagonist = t.protagonist && t.protagonist !== "none"
|
|
2175
|
+
? rows.findIndex((r) => { const k = plainText(r[0]); return k.length >= 2 && titleText.includes(k); })
|
|
2176
|
+
: -1;
|
|
2177
|
+
// [top, right, bottom, left] for the header (ri = -1) and each data row.
|
|
2178
|
+
const border = (ri) => {
|
|
2179
|
+
const last = ri === rows.length - 1;
|
|
2180
|
+
switch (t.rules) {
|
|
2181
|
+
case "grid": return [hair, hair, hair, hair];
|
|
2182
|
+
case "booktabs": return [ri === -1 ? heavy(1) : none, none, ri === -1 ? heavy(0.5) : last ? heavy(1) : none, none];
|
|
2183
|
+
case "top-bottom": return [ri === -1 ? hair : none, none, last ? hair : ri === -1 ? hair : none, none];
|
|
2184
|
+
case "header-rule": return [none, none, ri === -1 ? heavy(2) : none, none];
|
|
2185
|
+
case "rows": return [none, none, hair, none];
|
|
2186
|
+
default: // header-and-totals
|
|
2187
|
+
return [last && lastIsTotal ? hair : none, none, ri === -1 ? hair : none, none];
|
|
2188
|
+
}
|
|
2189
|
+
};
|
|
2190
|
+
const cellMargin = [IN(6), IN(6), IN(6), IN(6)];
|
|
2191
|
+
// Cell text that wraps is broken at spaces here, as in body text (see G.keepLines).
|
|
2192
|
+
const keep = (text, ci, ri, size, role) => G.keepLines(text, ((p.widths[ci] || p.w) - 12) / S.wide(role) / size,
|
|
2193
|
+
Math.floor(((p.heights[ri + 1] || 0) - 12) / (size * 1.45) + 0.01));
|
|
2194
|
+
const tableRows = [headers.map((h, ci) => ({
|
|
2195
|
+
text: keep(plainText(h), ci, -1, p.header, "label"),
|
|
2196
|
+
options: { bold: true, color: headerInk, fontFace: S.faces.label.font, fontSize: p.header, align: numeric[ci] ? "right" : "left", valign: "middle",
|
|
2197
|
+
margin: cellMargin, border: border(-1), ...(headerFill ? { fill: { color: headerFill } } : {}) },
|
|
2198
|
+
}))];
|
|
2199
|
+
rows.forEach((r, ri) => {
|
|
2200
|
+
const total = lastIsTotal && ri === rows.length - 1 && t.totals === "bold";
|
|
2201
|
+
const band = t.banding && t.banding !== "none";
|
|
2202
|
+
tableRows.push(r.map((cell, ci) => A.linkedTableCell(typeof cell === "string" && !/\]\(/u.test(cell) ? keep(cell, ci, ri, p.data, "body") : cell, {
|
|
2203
|
+
color: S.colour("ink"), fontFace: S.faces.body.font, fontSize: p.data, bold: total,
|
|
2204
|
+
align: numeric[ci] ? "right" : "left", valign: "middle", margin: cellMargin, border: border(ri),
|
|
2205
|
+
...(ri === protagonist ? { fill: { color: S.colour(t.protagonist) } } : band && ci === 0 ? { fill: { color: S.colour(t.banding) } } : {}),
|
|
2206
|
+
})));
|
|
2207
|
+
});
|
|
2208
|
+
slide.addTable(tableRows, {
|
|
2209
|
+
// The frame is as tall as its rows; without h the frame is declared 1in whatever the rows need.
|
|
2210
|
+
x: IN(p.x), y: IN(p.y), w: IN(p.w), h: IN(p.tableH), colW: p.widths.map(IN), rowH: p.heights.map(IN), autoPage: false,
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
// ── Fill and treatment choice ───────────────────────────────────────────────
|
|
2215
|
+
|
|
2216
|
+
/** Bottom of the content, decoration excluded. */
|
|
2217
|
+
function contentBottom(placed, top) {
|
|
2218
|
+
const counted = placed.filter((p) => !p.decor);
|
|
2219
|
+
return counted.length ? Math.max(...counted.map((p) => p.y + p.h)) : top;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
function overflows(placed) {
|
|
2223
|
+
return placed.some((p) => !p.decor && p.limit != null && p.y + p.h > p.limit + 0.5);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/**
|
|
2227
|
+
* The zones of one treatment frame: body Z, the class and where the empty band is measured from.
|
|
2228
|
+
* Nothing is set beside a bottom title: a text block there stops the page reading as that treatment.
|
|
2229
|
+
* The rail under a side title is another matter: it carries the slide's supporting content (see railOf).
|
|
2230
|
+
*/
|
|
2231
|
+
function zonesOf(frame, S) {
|
|
2232
|
+
const t = frame.title;
|
|
2233
|
+
const cls = frame.id === "side-rail" ? "side" : frame.id === "bottom-anchor" ? "bottom" : "top";
|
|
2234
|
+
if (!frame.body) return { cls: "none" };
|
|
2235
|
+
const Z = region(S, frame.body.a, frame.body.b, frame.body.y, frame.body.bottom);
|
|
2236
|
+
return { cls, Z, bandTop: cls === "bottom" ? Z.y : Math.min(t.y + t.h, Z.y) };
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
/**
|
|
2240
|
+
* Lay a content slide out under one frame and, when the body leaves more than the density band
|
|
2241
|
+
* empty, try the pack's fill policies in order: larger type one ramp step, the next spacing step,
|
|
2242
|
+
* the visual taking the room. Content is never invented and decoration never fills space.
|
|
2243
|
+
*/
|
|
2244
|
+
// A line that names the slide's source or a note on its basis ("출처:", "주:", "Source:", "Note:").
|
|
2245
|
+
const STRIP = /^(?:출처|자료|주|Sources?|Notes?)\s*[::]/iu;
|
|
2246
|
+
|
|
2247
|
+
/**
|
|
2248
|
+
* The source and note lines of a content slide's text, taken out of its takeaways: they are set as a
|
|
2249
|
+
* strip at the foot of the body zone, at source size under a hairline, so the slide states where its
|
|
2250
|
+
* figures come from without a takeaway column spending its lines on it. Closings keep theirs in the
|
|
2251
|
+
* next step.
|
|
2252
|
+
*/
|
|
2253
|
+
function takeStrip(spec, items) {
|
|
2254
|
+
// A closing with a next step keeps its source there; a closing summary has no such line, so it takes the strip.
|
|
2255
|
+
if (G.VARIANTS.closing.includes(spec.family) && spec.family !== "closing-summary-list") return { items, lines: [] };
|
|
2256
|
+
const lines = [];
|
|
2257
|
+
const keep = (list) => (list || []).filter((it) => {
|
|
2258
|
+
if ((it.type === "bullet" || it.type === "text") && STRIP.test(plainText(it.text || "")) && !(it.children || []).length) { lines.push(it.text); return false; }
|
|
2259
|
+
return true;
|
|
2260
|
+
});
|
|
2261
|
+
const out = items.map((item) => {
|
|
2262
|
+
if (item.type === "body" && item.content && Array.isArray(item.content.items)) return { ...item, content: { ...item.content, items: keep(item.content.items) } };
|
|
2263
|
+
return item;
|
|
2264
|
+
}).filter((item) => !(item.type === "body" && item.content && Array.isArray(item.content.items) && !item.content.items.length));
|
|
2265
|
+
return { items: out, lines };
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
function stripPlacements(lines, Z, S) {
|
|
2269
|
+
const size = S.sizes.source;
|
|
2270
|
+
const paras = lines.map((t) => ({ kind: "text", text: t, inset: 0, gap: 2, h: G.textHeight(plainText(t), Z.w, size, 1.3) }));
|
|
2271
|
+
paras[0].gap = 0;
|
|
2272
|
+
const h = paras.reduce((a, q) => a + q.gap + q.h, 0);
|
|
2273
|
+
const y = Z.bottom - h;
|
|
2274
|
+
// A hairline sets the strip off where the pack draws hairlines; elsewhere the size and the muted
|
|
2275
|
+
// ink do (a rule in a pack without them adds a colour to the slide).
|
|
2276
|
+
return { h: h + 10, placed: [
|
|
2277
|
+
...(S.deco.has("hairline-rule") ? [{ decor: true, kind: "rect", x: Z.x, y: y - 6, w: Z.w, h: 0.5, fill: "line" }] : []),
|
|
2278
|
+
{ kind: "text", paras, size, x: Z.x, y, w: Z.w, h, muted: true, limit: Z.bottom },
|
|
2279
|
+
] };
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
// Families that pair a visual with its takeaways: under a side title the takeaways may stand in the rail
|
|
2283
|
+
// and the visual takes the body (a table opens its rows; how full the body ends up still decides the title).
|
|
2284
|
+
const RAIL_TAKEAWAYS = new Set(["chart-insight", "full-chart", "table-insight", "figure-academic", "image-split",
|
|
2285
|
+
"kpi-row", "big-number", "kpi-over-chart", "dashboard-grid", "photo-grid", "figure-pair", "asymmetric-feature"]);
|
|
2286
|
+
// A rail more empty than this, from the title down, costs the slide the side rail.
|
|
2287
|
+
const RAIL_EMPTY = 0.4;
|
|
2288
|
+
|
|
2289
|
+
/**
|
|
2290
|
+
* The rail under a side title: columns 1-4 from just under the title frame (which keeps a spare line for
|
|
2291
|
+
* a title that wraps once more than estimated) to the floor. It holds the slide's supporting content: the
|
|
2292
|
+
* source strip at its foot, the takeaways of a visual when they fit, a comparison's criteria labels level
|
|
2293
|
+
* with their rows.
|
|
2294
|
+
*/
|
|
2295
|
+
function railOf(frame, S) {
|
|
2296
|
+
const t = frame.title;
|
|
2297
|
+
const s = S.grid.span(1, 4);
|
|
2298
|
+
// `top` is where the rail's empty share is measured from: the title frame's foot, as the gate reads it.
|
|
2299
|
+
return { a: 1, b: 4, x: s.x, w: s.w, y: Math.ceil(t.y + t.h + S.tok.group), top: t.y + t.h, bottom: G.ZONE.floor };
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
/** The largest empty stretch of the rail under the title frame as a share of its height (rail placements only). */
|
|
2303
|
+
function railBand(rail, placed) {
|
|
2304
|
+
let gap = 0;
|
|
2305
|
+
let cursor = rail.top;
|
|
2306
|
+
for (const q of placed.filter((p) => p.rail && !p.decor).sort((a, b) => a.y - b.y)) {
|
|
2307
|
+
gap = Math.max(gap, q.y - cursor);
|
|
2308
|
+
cursor = Math.max(cursor, q.y + q.h);
|
|
2309
|
+
}
|
|
2310
|
+
return Math.max(gap, rail.bottom - cursor) / Math.max(1, rail.bottom - rail.top);
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
function planSlide(spec, frame, S, items) {
|
|
2314
|
+
const z = zonesOf(frame, S);
|
|
2315
|
+
if (spec.lead && spec.family !== "summary-box-list") items = [{ type: "main-box", content: spec.lead }, ...items];
|
|
2316
|
+
if (z.cls === "none") return { placed: [], band: 0, applied: [], before: 0, z };
|
|
2317
|
+
const full = z.Z;
|
|
2318
|
+
const rail = z.cls === "side" ? railOf(frame, S) : null;
|
|
2319
|
+
const railPlaced = [];
|
|
2320
|
+
const taken = takeStrip(spec, items);
|
|
2321
|
+
// The source strip: at the rail's foot under a side title; else offered to a takeaway column beside a
|
|
2322
|
+
// visual, and across the body's foot when no column takes it.
|
|
2323
|
+
let lines = taken.lines;
|
|
2324
|
+
items = taken.items;
|
|
2325
|
+
if (lines.length && rail) {
|
|
2326
|
+
const strip = stripPlacements(lines, rail, S);
|
|
2327
|
+
railPlaced.push(...strip.placed.map((q) => ({ ...q, rail: true })));
|
|
2328
|
+
rail.bottom -= strip.h + S.tok.block;
|
|
2329
|
+
lines = [];
|
|
2330
|
+
}
|
|
2331
|
+
// A visual's takeaways stand in the rail when they fit there, a chart's values table under them; the
|
|
2332
|
+
// visual then takes the body. Takeaways that leave the rail more than RAIL_EMPTY empty at body size are
|
|
2333
|
+
// set a step up (lead) when they fit: the rail is a display column, the one place the compact step
|
|
2334
|
+
// sets text above body size.
|
|
2335
|
+
if (rail && RAIL_TAKEAWAYS.has(spec.family) && !spec.stacked) {
|
|
2336
|
+
const { texts, visuals } = sortItems(items);
|
|
2337
|
+
const paras = texts.filter((t) => t.type !== "main-box").flatMap(itemParas);
|
|
2338
|
+
// A table keeps its own height: its takeaways leave the body only when the table, its rows opened,
|
|
2339
|
+
// still fills the body to the density band.
|
|
2340
|
+
const fills = () => {
|
|
2341
|
+
// Figure rows and number panels spread to the floor; so do charts and pictures.
|
|
2342
|
+
if (["kpi-row", "big-number"].includes(spec.family) || visuals.some((v) => v.type !== "kpi-table" || (v.content || {}).chart)) return true;
|
|
2343
|
+
const alone = openRows(stack(visuals, full, S, { stepped: false, itemGap: S.tok.item, groupGap: S.tok.group, blockGap: S.tok.block }).placed, full, S);
|
|
2344
|
+
return (full.bottom - contentBottom(alone, full.y)) / (full.bottom - full.y) <= S.tok.cap;
|
|
2345
|
+
};
|
|
2346
|
+
if (visuals.length && paras.length && fills()) {
|
|
2347
|
+
const state = { stepped: false, itemGap: S.tok.item, groupGap: S.tok.group, blockGap: S.tok.block };
|
|
2348
|
+
const sets = [S.sizes.body, S.sizes.lead].map((size) => {
|
|
2349
|
+
const m = { ...measureText(paras, rail.w, S, state, { size }), x: rail.x, y: rail.y, limit: rail.bottom, rail: true };
|
|
2350
|
+
const values = S.tok.tight ? chartValues(visuals, rail, S, state, rail.y + m.h) : null;
|
|
2351
|
+
const set = [m, ...(values ? [{ ...values, rail: true }] : [])];
|
|
2352
|
+
return { set, fits: set.every((q) => q.y + q.h <= rail.bottom + 0.5), band: railBand({ ...rail, bottom: G.ZONE.floor }, [...set, ...railPlaced]) };
|
|
2353
|
+
}).filter((x) => x.fits);
|
|
2354
|
+
// Body size when it fills the rail; else whichever size leaves the rail least empty.
|
|
2355
|
+
const pick = sets.find((x) => x.band <= RAIL_EMPTY) || sets.reduce((a, b) => (b.band < a.band ? b : a), sets[0]);
|
|
2356
|
+
if (pick) {
|
|
2357
|
+
// Takeaways that still leave a quarter of the rail empty share that room between them, at most a
|
|
2358
|
+
// sixth of the rail a gap: the rail reads as one column down to its source.
|
|
2359
|
+
const [m] = pick.set;
|
|
2360
|
+
const breaks = m.paras.filter((para, i) => i > 0 && para.kind !== "child");
|
|
2361
|
+
const room = rail.bottom - S.tok.block - (pick.set[pick.set.length - 1].y + pick.set[pick.set.length - 1].h);
|
|
2362
|
+
if (pick.band > 0.25 && pick.set.length === 1 && breaks.length && room > 0) {
|
|
2363
|
+
const per = Math.min(room / (breaks.length + 1), (G.ZONE.floor - rail.top) / 6);
|
|
2364
|
+
for (const para of breaks) para.gap += per;
|
|
2365
|
+
m.h += per * breaks.length;
|
|
2366
|
+
}
|
|
2367
|
+
railPlaced.push(...pick.set);
|
|
2368
|
+
items = items.filter((i) => !(isText(i) && i.type !== "main-box"));
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
// A sidebar note is the slide's supporting content: under a side title it stands in the rail, at lead size
|
|
2373
|
+
// when it fits there.
|
|
2374
|
+
if (rail && spec.family === "sidebar-note") {
|
|
2375
|
+
const texts = sortItems(items).texts;
|
|
2376
|
+
const note = texts.find((t) => t.type === "main-box") || (texts.length > 1 ? texts[texts.length - 1] : null);
|
|
2377
|
+
if (note) {
|
|
2378
|
+
const state = { stepped: false, itemGap: S.tok.item, groupGap: S.tok.group, blockGap: S.tok.block };
|
|
2379
|
+
const paras = itemParas(note).map((p) => ({ ...p, kind: p.kind === "child" ? "child" : "text" }));
|
|
2380
|
+
const m = [S.sizes.lead, S.sizes.body].map((size) => ({ ...measureText(paras, rail.w, S, state, { size }), x: rail.x, y: rail.y, limit: rail.bottom, rail: true, note: true }))
|
|
2381
|
+
.find((q) => q.y + q.h <= rail.bottom + 0.5);
|
|
2382
|
+
if (m) {
|
|
2383
|
+
railPlaced.push(m);
|
|
2384
|
+
items = items.filter((i) => i !== note);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
const across = lines.length ? stripPlacements(lines, full, S) : null;
|
|
2389
|
+
const tok = S.tok;
|
|
2390
|
+
const plan = (spec.stacked && PLANS[STACKS[spec.family]]) || PLANS[spec.family] || PLANS["text-column"];
|
|
2391
|
+
const state = { stepped: false, anchor: false, itemGap: tok.item, groupGap: tok.group, blockGap: tok.block };
|
|
2392
|
+
// Offer the strip to a column first; once a run leaves it, it runs across the foot and the body ends above it.
|
|
2393
|
+
let offer = Boolean(across);
|
|
2394
|
+
const zone = () => (across && !offer ? { ...full, bottom: full.bottom - across.h - S.tok.block } : full);
|
|
2395
|
+
// A later run (another fill state) that leaves the offered strip is refused like an overflow.
|
|
2396
|
+
const run = (settle = false) => {
|
|
2397
|
+
const ctx = { spec, S, state, Z: zone(), cls: z.cls, family: spec.family, items, rail, strip: offer ? lines : null,
|
|
2398
|
+
railValues: railPlaced.some((q) => q.item && q.item.values), railNote: railPlaced.some((q) => q.note) };
|
|
2399
|
+
const placed = plan(ctx);
|
|
2400
|
+
const lost = offer && !placed.some((q) => q.strip);
|
|
2401
|
+
if (lost && settle) {
|
|
2402
|
+
offer = false;
|
|
2403
|
+
return run();
|
|
2404
|
+
}
|
|
2405
|
+
return { placed, lost, bottom: contentBottom(placed.filter((q) => !q.rail), zone().y) };
|
|
2406
|
+
};
|
|
2407
|
+
const band = (o) => Math.max(0, Math.min(1, (zone().bottom - o.bottom) / Math.max(1, zone().bottom - z.bandTop)));
|
|
2408
|
+
let out = run(true);
|
|
2409
|
+
if (overflows(out.placed)) {
|
|
2410
|
+
// Too much for the body at the family's sizes: leads drop to body size and gaps to the item gap.
|
|
2411
|
+
Object.assign(state, { compact: true, groupGap: tok.item, blockGap: tok.group });
|
|
2412
|
+
out = run(true);
|
|
2413
|
+
}
|
|
2414
|
+
const before = band(out);
|
|
2415
|
+
const applied = [];
|
|
2416
|
+
if (before > tok.cap && items.length) {
|
|
2417
|
+
for (const policy of S.pack["fill-order"] || []) {
|
|
2418
|
+
if (band(out) <= tok.cap) break;
|
|
2419
|
+
const saved = { ...state };
|
|
2420
|
+
if (policy === "step-up") {
|
|
2421
|
+
if (!tok.stepUp) continue;
|
|
2422
|
+
state.stepped = true;
|
|
2423
|
+
} else if (policy === "distribute") {
|
|
2424
|
+
state.itemGap = G.stepGap(state.itemGap, 24);
|
|
2425
|
+
state.groupGap = G.stepGap(state.groupGap, 48);
|
|
2426
|
+
state.blockGap = G.stepGap(state.blockGap, 48);
|
|
2427
|
+
} else if (policy === "anchor-visual") {
|
|
2428
|
+
state.anchor = true;
|
|
2429
|
+
} else {
|
|
2430
|
+
continue;
|
|
2431
|
+
}
|
|
2432
|
+
if (JSON.stringify(saved) === JSON.stringify(state)) continue;
|
|
2433
|
+
const next = run();
|
|
2434
|
+
if (next.lost || overflows(next.placed)) Object.assign(state, saved);
|
|
2435
|
+
else {
|
|
2436
|
+
if (next.bottom > out.bottom + 0.5) applied.push(policy);
|
|
2437
|
+
out = next;
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
z.Z = zone();
|
|
2442
|
+
// A text column that stops short of the column beside it is filled by layout; one that still leaves more
|
|
2443
|
+
// than RAIL_EMPTY of the body empty makes this title unfit for the slide when another fits.
|
|
2444
|
+
const floor = full.bottom;
|
|
2445
|
+
let short = shortColumn([...out.placed, ...(across && !offer ? across.placed : [])], floor, S);
|
|
2446
|
+
// The fill starts a little under the line (0.35), so a column the gate reads a point or two emptier than the
|
|
2447
|
+
// estimate still holds.
|
|
2448
|
+
if (short.share > 0.35) {
|
|
2449
|
+
fillColumn(short, floor, S, state);
|
|
2450
|
+
short = shortColumn([...out.placed, ...(across && !offer ? across.placed : [])], floor, S);
|
|
2451
|
+
}
|
|
2452
|
+
// On a rail drawn in the field colour, what stands in it is set in the field's ink.
|
|
2453
|
+
const field = (frame.decor || []).some((d) => d.kind === "rail" && d.role === "field");
|
|
2454
|
+
const placed = [...out.placed, ...railPlaced, ...(across && !offer ? across.placed : [])].map((q) => (q.rail && field ? { ...q, onField: true } : q));
|
|
2455
|
+
const overflow = overflows(placed);
|
|
2456
|
+
const railEmpty = rail ? railBand({ ...rail, bottom: G.ZONE.floor }, placed) : 0;
|
|
2457
|
+
// A quote or statement beside a rail is a display slide: its rail holds the title alone by design.
|
|
2458
|
+
const display = ["quote", "statement"].includes(spec.family);
|
|
2459
|
+
return { ...out, placed, band: band(out), before, applied, z, overflow, rail: railEmpty, short: short.share,
|
|
2460
|
+
unfit: overflow || (railEmpty > RAIL_EMPTY && !display) || short.share > RAIL_EMPTY };
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
/** The frame of a treatment for this slide (a quote under a statement title says the quote). */
|
|
2464
|
+
function frameFor(spec, treatment) {
|
|
2465
|
+
const ctx = treatment === "statement" && spec.statementText ? { ...spec.ctx, title: spec.statementText } : spec.ctx;
|
|
2466
|
+
return G.treatmentGeometry(treatment, ctx);
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
/**
|
|
2470
|
+
* Choose among the treatments the slide may take. The role default stays when its body fills to
|
|
2471
|
+
* the density band; otherwise every allowed treatment is laid out and scored by the empty band it
|
|
2472
|
+
* leaves, with a small cost for repeating the previous slide's title or one the deck already leans
|
|
2473
|
+
* on and a large one past about a third of the deck, so filling never collapses a deck into one look. The variance dial keeps the number of
|
|
2474
|
+
* distinct treatments under its maximum.
|
|
2475
|
+
*/
|
|
2476
|
+
// A bottom title is for a chart, a picture, a big number, a row of figures or a timeline axis that takes
|
|
2477
|
+
// the zone above it, not for a table.
|
|
2478
|
+
const drawn = (spec) => ["big-number", "kpi-row", "timeline"].includes(spec.family) || (spec.items || []).some((i) => (i.type === "kpi-table" && (i.content || {}).chart) || i.type === "image" || i.type === "image-grid");
|
|
2479
|
+
|
|
2480
|
+
function chooseTreatment(spec, S, deck) {
|
|
2481
|
+
const options = spec.candidates && spec.candidates.length ? spec.candidates : [spec.treatment];
|
|
2482
|
+
const allowed = deck.used.size >= deck.max && options.some((t) => deck.used.has(t)) ? options.filter((t) => deck.used.has(t)) : options;
|
|
2483
|
+
const tries = allowed.map((t) => ({ t, plan: planSlide(spec, frameFor(spec, t), S, spec.items || []) }));
|
|
2484
|
+
// Titles that fit come first; a table goes under a bottom title only when nothing else fits it.
|
|
2485
|
+
const fit = tries.filter((x) => !x.plan.unfit);
|
|
2486
|
+
const proper = fit.filter((x) => x.t !== "bottom-anchor" || drawn(spec));
|
|
2487
|
+
// When every title leaves the slide unfit, the one that overflows least and leaves its rail or a column least
|
|
2488
|
+
// empty stands (an overflowing or empty-rail plan came first by order before).
|
|
2489
|
+
const badness = (x) => (x.plan.overflow ? 10 : 0) + Math.max(0, x.plan.rail - RAIL_EMPTY) + Math.max(0, (x.plan.short || 0) - RAIL_EMPTY);
|
|
2490
|
+
const pool = proper.length ? proper : fit.length ? fit : [...tries].sort((m, n) => badness(m) - badness(n));
|
|
2491
|
+
const first = pool[0];
|
|
2492
|
+
if (first.t === options[0] && first.plan.band <= S.tok.cap && first.t !== deck.prev && (deck.count[first.t] || 0) < deck.limit) return first;
|
|
2493
|
+
const cost = (x) => x.plan.band + (x.plan.band > S.tok.cap ? 0.15 : 0) + 0.07 * (deck.count[x.t] || 0) +
|
|
2494
|
+
(x.t === deck.prev ? 0.08 : 0) - (x.t === options[0] ? 0.03 : 0) + ((deck.count[x.t] || 0) >= deck.limit ? 1 : 0);
|
|
2495
|
+
const best = pool.reduce((b, x) => (cost(x) < cost(b) - 1e-9 ? x : b));
|
|
2496
|
+
// The role's own title is the pack's structure: it stays unless another title
|
|
2497
|
+
// leaves at least 0.10 less of the body empty (0.15 when neither fills to the density band).
|
|
2498
|
+
// A bottom title the slide may not take (a table) hands the pack's structure to its next treatment in order.
|
|
2499
|
+
const ownT = options[0] === "bottom-anchor" && !drawn(spec) ? options.find((t) => t !== "bottom-anchor") : options[0];
|
|
2500
|
+
const own = pool.find((x) => x.t === ownT);
|
|
2501
|
+
const margin = best.plan.band > S.tok.cap ? 0.15 : 0.1;
|
|
2502
|
+
// A family the pack draws with its own title (`structure`) keeps it past the deck's share of that title.
|
|
2503
|
+
const pinned = (S.pack.structure || {})[spec.family] === ownT;
|
|
2504
|
+
if (own && own !== best && own.plan.band - best.plan.band < margin && (pinned || (deck.count[own.t] || 0) < deck.limit)) return own;
|
|
2505
|
+
return best;
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
/**
|
|
2509
|
+
* Fill alone can settle a deck on two titles. Variance 4 and up asks for three once a deck
|
|
2510
|
+
* has eight slides, so while it carries fewer, the slide that loses the least fill moves to a
|
|
2511
|
+
* treatment the deck has not used: one of its own candidates, never one that overflows or leaves
|
|
2512
|
+
* more than the density band (or what the slide already leaves) empty, and never the last slide of
|
|
2513
|
+
* a treatment.
|
|
2514
|
+
*/
|
|
2515
|
+
function spreadTreatments(choices, S, pack, slideCount) {
|
|
2516
|
+
const need = slideCount < 8 ? 0 : Math.min(pack.dials.variance <= 3 ? 1 : 3, pack.treatments.length, pack.dials.varianceMax);
|
|
2517
|
+
const lines = [];
|
|
2518
|
+
const used = () => new Set([...choices.values()].map((c) => c.t));
|
|
2519
|
+
while (used().size < need) {
|
|
2520
|
+
const have = used();
|
|
2521
|
+
const count = (t) => [...choices.values()].filter((c) => c.t === t).length;
|
|
2522
|
+
let best = null;
|
|
2523
|
+
for (const [spec, current] of choices) {
|
|
2524
|
+
if (count(current.t) < 2) continue;
|
|
2525
|
+
for (const t of spec.candidates || []) {
|
|
2526
|
+
if (have.has(t)) continue;
|
|
2527
|
+
const plan = planSlide(spec, frameFor(spec, t), S, spec.items || []);
|
|
2528
|
+
if (plan.unfit || plan.band > Math.max(S.tok.cap, current.plan.band) + 0.05) continue;
|
|
2529
|
+
const loss = plan.band - current.plan.band;
|
|
2530
|
+
if (!best || loss < best.loss) best = { spec, t, plan, loss };
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
if (!best) break;
|
|
2534
|
+
choices.set(best.spec, { t: best.t, plan: best.plan });
|
|
2535
|
+
lines.push(`variety: slide ${best.spec.index + 1} drawn under ${best.t} so the deck carries ${used().size} title treatments`);
|
|
2536
|
+
}
|
|
2537
|
+
// No title zone on more than 40 % of the content slides: a data slide (a table, chart or
|
|
2538
|
+
// figures under their takeaway) that fills no worse under another zone's treatment moves there,
|
|
2539
|
+
// the one that gains the most first. Agendas, sequences and definitions keep their own titles.
|
|
2540
|
+
const content = [...choices.keys()].filter((spec) => spec.kind === "content");
|
|
2541
|
+
const movable = (spec) => spec.role === "data" || spec.role === "data-takeaway";
|
|
2542
|
+
const cap = 0.4 * content.length;
|
|
2543
|
+
const zonesNow = () => {
|
|
2544
|
+
const zones = {};
|
|
2545
|
+
for (const spec of content) zones[ZONE_OF[choices.get(spec).t]] = (zones[ZONE_OF[choices.get(spec).t]] || 0) + 1;
|
|
2546
|
+
return zones;
|
|
2547
|
+
};
|
|
2548
|
+
// The data slide in zone `from` that fills best under a title of another zone with room.
|
|
2549
|
+
const moveOut = (from, zones, blocked) => {
|
|
2550
|
+
let best = null;
|
|
2551
|
+
for (const spec of content) {
|
|
2552
|
+
const current = choices.get(spec);
|
|
2553
|
+
if (ZONE_OF[current.t] !== from || !movable(spec)) continue;
|
|
2554
|
+
for (const t of spec.candidates || []) {
|
|
2555
|
+
const zone = ZONE_OF[t];
|
|
2556
|
+
if (zone === from || blocked.has(zone) || (zones[zone] || 0) + 1 > cap) continue;
|
|
2557
|
+
if (t === "bottom-anchor" && !drawn(spec)) continue;
|
|
2558
|
+
const inUse = new Set([...choices.values()].map((c) => c.t));
|
|
2559
|
+
if (!inUse.has(t) && inUse.size >= pack.dials.varianceMax) continue;
|
|
2560
|
+
const plan = planSlide(spec, frameFor(spec, t), S, spec.items || []);
|
|
2561
|
+
if (plan.unfit || plan.band > Math.max(S.tok.cap, current.plan.band)) continue;
|
|
2562
|
+
const gain = current.plan.band - plan.band;
|
|
2563
|
+
if (!best || gain > best.gain) best = { spec, t, plan, gain };
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
return best;
|
|
2567
|
+
};
|
|
2568
|
+
const say = (m) => lines.push(`variety: slide ${m.spec.index + 1} drawn under ${m.t} so no title zone covers more than 40 % of the content slides`);
|
|
2569
|
+
for (let guard = 0; guard < content.length; guard++) {
|
|
2570
|
+
const zones = zonesNow();
|
|
2571
|
+
const [crowded, n] = Object.entries(zones).sort((a, b) => b[1] - a[1])[0] || [];
|
|
2572
|
+
if (!crowded || n <= cap) break;
|
|
2573
|
+
let best = moveOut(crowded, zones, new Set());
|
|
2574
|
+
if (!best) {
|
|
2575
|
+
// Every zone a crowded slide could take is full: move one of that zone's own data slides on
|
|
2576
|
+
// to a third zone first, when that makes room.
|
|
2577
|
+
for (const [full, m] of Object.entries(zones)) {
|
|
2578
|
+
if (full === crowded || m + 1 <= cap) continue;
|
|
2579
|
+
const first = moveOut(full, zones, new Set([crowded]));
|
|
2580
|
+
if (!first) continue;
|
|
2581
|
+
const saved = choices.get(first.spec);
|
|
2582
|
+
choices.set(first.spec, { t: first.t, plan: first.plan });
|
|
2583
|
+
best = moveOut(crowded, zonesNow(), new Set());
|
|
2584
|
+
if (best) { say(first); break; }
|
|
2585
|
+
choices.set(first.spec, saved);
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
if (!best) break;
|
|
2589
|
+
choices.set(best.spec, { t: best.t, plan: best.plan });
|
|
2590
|
+
say(best);
|
|
2591
|
+
}
|
|
2592
|
+
// Composition variety counts title zone x column partition over the content slides,
|
|
2593
|
+
// closings excluded. While the deck has fewer distinct layouts than the check needs, a data slide
|
|
2594
|
+
// whose layout another slide shares moves to a title that gives a layout the deck lacks, when that
|
|
2595
|
+
// neither overflows nor leaves more than the density band (or what the slide already leaves).
|
|
2596
|
+
const counted = content.filter((spec) => !G.VARIANTS.closing.includes(spec.family));
|
|
2597
|
+
const layoutsNeeded = Math.min(5, Math.ceil(0.6 * counted.length));
|
|
2598
|
+
const layoutOf = (t, plan) => `${ZONE_OF[t]}/${partitionOf(plan, S)}`;
|
|
2599
|
+
// The check also fails a deck where one layout covers more than 40 % of those slides, so while
|
|
2600
|
+
// one does, a slide of that layout moves to a title that gives a layout still under the share.
|
|
2601
|
+
const shareCap = 0.4 * counted.length;
|
|
2602
|
+
for (let guard = 0; guard < counted.length; guard++) {
|
|
2603
|
+
const keys = {};
|
|
2604
|
+
for (const spec of counted) { const c = choices.get(spec); const k = layoutOf(c.t, c.plan); keys[k] = (keys[k] || 0) + 1; }
|
|
2605
|
+
const [topKey, topN] = Object.entries(keys).sort((a, b) => b[1] - a[1])[0] || [];
|
|
2606
|
+
const short = Object.keys(keys).length < layoutsNeeded;
|
|
2607
|
+
const crowded = topN > shareCap;
|
|
2608
|
+
if (!short && !crowded) break;
|
|
2609
|
+
let best = null;
|
|
2610
|
+
for (const spec of counted) {
|
|
2611
|
+
const current = choices.get(spec);
|
|
2612
|
+
const from = layoutOf(current.t, current.plan);
|
|
2613
|
+
if (!movable(spec) || keys[from] < 2 || (!short && from !== topKey)) continue;
|
|
2614
|
+
for (const t of spec.candidates || []) {
|
|
2615
|
+
if (t === current.t || (t === "bottom-anchor" && !drawn(spec))) continue;
|
|
2616
|
+
const inUse = new Set([...choices.values()].map((c) => c.t));
|
|
2617
|
+
if (!inUse.has(t) && inUse.size >= pack.dials.varianceMax) continue;
|
|
2618
|
+
const plan = planSlide(spec, frameFor(spec, t), S, spec.items || []);
|
|
2619
|
+
const to = layoutOf(t, plan);
|
|
2620
|
+
if (plan.unfit || plan.band > Math.max(S.tok.cap, current.plan.band) || to === from) continue;
|
|
2621
|
+
// A move that adds a layout comes first; else one that relieves the crowded layout.
|
|
2622
|
+
if (short ? keys[to] : (keys[to] || 0) + 1 > shareCap) continue;
|
|
2623
|
+
const loss = plan.band - current.plan.band + (from === topKey && crowded ? -1 : 0);
|
|
2624
|
+
if (!best || loss < best.loss) best = { spec, t, plan, loss, added: !keys[to] };
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
if (!best) {
|
|
2628
|
+
// No title gives a new layout: a table or chart slide may stack its takeaways under the visual
|
|
2629
|
+
// across the body (the composition of the ledger-table and full-chart families) instead.
|
|
2630
|
+
for (const spec of counted) {
|
|
2631
|
+
const current = choices.get(spec);
|
|
2632
|
+
const from = layoutOf(current.t, current.plan);
|
|
2633
|
+
if (!STACKS[spec.family] || spec.stacked || keys[from] < 2 || (!short && from !== topKey)) continue;
|
|
2634
|
+
const stacked = { ...spec, stacked: true };
|
|
2635
|
+
for (const t of [current.t, ...(spec.candidates || []).filter((c) => c !== current.t)]) {
|
|
2636
|
+
if (t === "bottom-anchor" && !drawn(spec)) continue;
|
|
2637
|
+
const plan = planSlide(stacked, frameFor(spec, t), S, spec.items || []);
|
|
2638
|
+
const to = layoutOf(t, plan);
|
|
2639
|
+
if (plan.unfit || plan.band > Math.max(S.tok.cap, current.plan.band) || to === from) continue;
|
|
2640
|
+
if (short ? keys[to] : (keys[to] || 0) + 1 > shareCap) continue;
|
|
2641
|
+
const loss = plan.band - current.plan.band;
|
|
2642
|
+
if (!best || loss < best.loss) best = { spec, t, plan, loss, added: !keys[to], stacked: true };
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
if (!best) break;
|
|
2647
|
+
if (best.stacked) best.spec.stacked = true;
|
|
2648
|
+
choices.set(best.spec, { t: best.t, plan: best.plan });
|
|
2649
|
+
lines.push(best.added ? `variety: slide ${best.spec.index + 1} drawn under ${best.t} so the deck carries ${Object.keys(keys).length + 1} layouts`
|
|
2650
|
+
: `variety: slide ${best.spec.index + 1} drawn under ${best.t} so no layout covers more than 40 % of the content slides`);
|
|
2651
|
+
}
|
|
2652
|
+
return lines;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
/**
|
|
2656
|
+
* The boxes a placement draws as the composition check reads them from the page: text frames,
|
|
2657
|
+
* tables, charts and pictures, and filled surfaces that hold text (cards). Rules, dots and outlines
|
|
2658
|
+
* are decoration there and are left out.
|
|
2659
|
+
*/
|
|
2660
|
+
function drawnBoxes(p, S) {
|
|
2661
|
+
const B = (x, y, w, h, card = false) => ({ x, y, w, h, card });
|
|
2662
|
+
const filled = S.pack.edge !== "border";
|
|
2663
|
+
switch (p.kind) {
|
|
2664
|
+
case "group": return p.parts.flatMap((q) => drawnBoxes(q, S));
|
|
2665
|
+
case "text": {
|
|
2666
|
+
let y = p.y;
|
|
2667
|
+
return p.paras.map((para) => { y += para.gap || 0; const box = B(p.x + (para.inset || 0), y, p.w - (para.inset || 0), para.h); y += para.h; return box; });
|
|
2668
|
+
}
|
|
2669
|
+
case "callout": return [B(p.x, p.y, p.w, p.h, true)];
|
|
2670
|
+
case "box": return p.outline || (!p.tint && !filled) ? [] : [B(p.x, p.y, p.w, p.h, true)];
|
|
2671
|
+
case "panel": return p.fill && p.h >= 40 ? [B(p.x, p.y, p.w, p.h, true)] : [];
|
|
2672
|
+
case "kpi": return p.cards.flatMap((card, i) => {
|
|
2673
|
+
const x = p.x + i * (p.cardW + S.grid.gutter);
|
|
2674
|
+
if (filled) return [B(x, p.y, p.cardW, p.cardH, true)];
|
|
2675
|
+
const y = p.y + p.pad + p.value * 1.15 + 6;
|
|
2676
|
+
return [B(x + p.pad, p.y + p.pad, p.cardW - p.pad * 2, p.value * 1.15), B(x + p.pad, y, p.cardW - p.pad * 2, p.labelH)];
|
|
2677
|
+
});
|
|
2678
|
+
case "figrows": return p.rows.flatMap((r) => [B(p.x, r.y, p.valueW, p.value * 1.15), B(p.labelX, r.y, p.labelW, p.labelSize * 1.4)]);
|
|
2679
|
+
case "rows": return p.rows.flatMap((r) => [B(p.x, r.y, p.numW, (p.numSize || p.head) * 1.15), B(p.textX, r.y, p.textW, p.restX != null ? r.hh : r.h),
|
|
2680
|
+
...(p.restX != null && r.rest ? [B(p.restX, r.y, p.restW, r.rh)] : [])]);
|
|
2681
|
+
case "headed": return p.rows.flatMap((r) => [B(p.headX, r.y, p.headW, r.headH), B(p.textX, r.y, p.textW, r.textH)]);
|
|
2682
|
+
case "steps": return p.steps.flatMap((st) => [B(st.x, p.y, st.w, p.numSize * 1.1), B(st.x, p.y + p.numSize * 1.1 + 18, st.w, st.headH)]);
|
|
2683
|
+
case "timeline": return p.events.flatMap((e) => [B(e.x, p.axisY - 18 - p.dateSize * 1.25, e.w, p.dateSize * 1.25), B(e.x, p.axisY + 18, e.w, e.labelH)]);
|
|
2684
|
+
case "actions": return p.rows.flatMap((r) => [B(p.keyX, r.y, p.keyW, p.keySize * 1.15), B(p.textX, r.y, p.textW, r.headH)]);
|
|
2685
|
+
case "matrix": return [...p.cells.map((c) => B(c.x + p.pad, c.y + p.pad, c.w - p.pad * 2, c.headH)),
|
|
2686
|
+
...p.xHeads.map((_, i) => B(p.grid.x + i * (p.qw + p.gap), p.grid.y, p.qw, 16)),
|
|
2687
|
+
...p.yHeads.map((_, i) => B(p.labels.x, p.top + i * (p.qh + p.gap), p.labels.w, 16))];
|
|
2688
|
+
case "quote": return [B(p.x, p.y, p.w, p.h)];
|
|
2689
|
+
case "number": return [B(p.x, p.y, p.w, p.h)];
|
|
2690
|
+
// A chart's caption is its own frame under the plot, as the check reads it.
|
|
2691
|
+
case "chart": return p.cap ? [B(p.x, p.y, p.w, p.h - p.cap), B(p.x, p.y + p.h - p.cap + 6, p.w, p.cap - 6)] : [B(p.x, p.y, p.w, p.h)];
|
|
2692
|
+
case "image": {
|
|
2693
|
+
// A single picture is drawn contained in its box (on the box floor beside a taller takeaway column),
|
|
2694
|
+
// its caption under it: the check reads those two frames.
|
|
2695
|
+
const size = p.item && p.item.type === "image" && p.item.imageSize;
|
|
2696
|
+
if (!size) return [B(p.x, p.y, p.w, p.h)];
|
|
2697
|
+
const fit = shared().containBox(p.x, p.y, p.w, p.h - p.cap, size);
|
|
2698
|
+
const low = p.y + p.h - p.cap - fit.h;
|
|
2699
|
+
if (p.beside != null && low <= p.beside) fit.y = low;
|
|
2700
|
+
return [B(fit.x, fit.y, fit.w, fit.h), ...(p.cap ? [B(p.x, fit.y + fit.h + 6, p.w, p.cap - 6)] : [])];
|
|
2701
|
+
}
|
|
2702
|
+
case "table": case "caption": return [B(p.x, p.y, p.w, p.h)];
|
|
2703
|
+
default: return p.x != null && p.w ? [B(p.x, p.y, p.w, p.h || 0)] : [];
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
/**
|
|
2708
|
+
* The column partition a plan draws, read the way the composition check reads the page:
|
|
2709
|
+
* boxes inside a card count as the card; boxes narrower than 70 % of the body are grouped into
|
|
2710
|
+
* columns by overlapping x-extent (half the narrower box joins, and a box that joins two columns
|
|
2711
|
+
* merges them); two or more columns sharing two or more rows, or four columns, are a grid.
|
|
2712
|
+
*/
|
|
2713
|
+
function partitionOf(plan, S) {
|
|
2714
|
+
const Z = plan.z && plan.z.Z;
|
|
2715
|
+
// What stands in a side title's rail is part of the title zone, as the check reads it.
|
|
2716
|
+
const placed = (plan.placed || []).filter((q) => !q.decor && !q.rail);
|
|
2717
|
+
if (!Z || !placed.length) return "one";
|
|
2718
|
+
const all = placed.flatMap((q) => drawnBoxes(q, S)).filter((b) => b.w > 0 && b.h > 0);
|
|
2719
|
+
const cards = all.filter((b) => b.card);
|
|
2720
|
+
const inCard = (b) => cards.some((c) => c !== b && b.x + b.w / 2 >= c.x - 1 && b.x + b.w / 2 <= c.x + c.w + 1 && b.y + b.h / 2 >= c.y - 1 && b.y + b.h / 2 <= c.y + c.h + 1);
|
|
2721
|
+
const blocks = all.filter((b) => !inCard(b));
|
|
2722
|
+
if (!blocks.length) return "one";
|
|
2723
|
+
const bodyW = S.grid.W - 2 * S.grid.margin;
|
|
2724
|
+
let narrow = blocks.filter((b) => b.w < 0.7 * bodyW);
|
|
2725
|
+
if (!narrow.length) narrow = blocks;
|
|
2726
|
+
const columns = [];
|
|
2727
|
+
for (const b of [...narrow].sort((m, n) => m.x - n.x)) {
|
|
2728
|
+
const hits = columns.filter((c) => Math.min(c.r, b.x + b.w) - Math.max(c.l, b.x) >= 0.5 * Math.min(c.r - c.l, b.w));
|
|
2729
|
+
if (hits.length) {
|
|
2730
|
+
const keep = hits[0];
|
|
2731
|
+
for (const other of hits.slice(1)) { keep.l = Math.min(keep.l, other.l); keep.r = Math.max(keep.r, other.r); keep.items.push(...other.items); columns.splice(columns.indexOf(other), 1); }
|
|
2732
|
+
keep.l = Math.min(keep.l, b.x); keep.r = Math.max(keep.r, b.x + b.w); keep.items.push(b);
|
|
2733
|
+
} else columns.push({ l: b.x, r: b.x + b.w, items: [b] });
|
|
2734
|
+
}
|
|
2735
|
+
const columnOf = new Map(columns.flatMap((c, i) => c.items.map((b) => [b, i])));
|
|
2736
|
+
const rows = [];
|
|
2737
|
+
for (const b of [...narrow].sort((m, n) => m.y - n.y)) {
|
|
2738
|
+
const last = rows[rows.length - 1];
|
|
2739
|
+
if (last && b.y < last.bottom - 2) { last.bottom = Math.max(last.bottom, b.y + b.h); last.items.push(b); }
|
|
2740
|
+
else rows.push({ bottom: b.y + b.h, items: [b] });
|
|
2741
|
+
}
|
|
2742
|
+
const gridRows = rows.filter((r) => new Set(r.items.map((b) => columnOf.get(b))).size >= 2).length;
|
|
2743
|
+
const k = columns.length;
|
|
2744
|
+
if (k >= 4 || (k >= 2 && gridRows >= 2)) return "grid";
|
|
2745
|
+
if (k === 3) return "three";
|
|
2746
|
+
if (k === 2) {
|
|
2747
|
+
const [a, c] = columns.map((col) => col.r - col.l);
|
|
2748
|
+
return Math.min(a, c) / Math.max(a, c) >= 0.85 ? "two-even" : "two-asym";
|
|
2749
|
+
}
|
|
2750
|
+
return "one";
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
/**
|
|
2754
|
+
* The emptiest text column of a two-column body, read as the gate reads it (OF-115):
|
|
2755
|
+
* for every vertical cut no block crosses, a side holding text only, beside a side that reaches at least
|
|
2756
|
+
* three quarters of the body, is measured by the largest band its blocks leave empty from the pair's top
|
|
2757
|
+
* down to where the other side ends (to the floor beside figure rows), as a share of the body from that top
|
|
2758
|
+
* to the floor. A source strip closing the column counts as a block but does not hide the band above it.
|
|
2759
|
+
* Returns { share, owners }.
|
|
2760
|
+
*/
|
|
2761
|
+
const TEXT_KINDS = new Set(["text", "caption", "callout", "box", "panel"]);
|
|
2762
|
+
// A picture drawn smaller than its box leaves its own column empty above it as a short text column does.
|
|
2763
|
+
const SHORT_KINDS = new Set([...TEXT_KINDS, "image"]);
|
|
2764
|
+
function shortColumn(placed, floor, S) {
|
|
2765
|
+
const flat = (list) => list.flatMap((q) => (q.kind === "group" ? flat(q.parts) : [q]));
|
|
2766
|
+
const owners = flat(placed.filter((q) => !q.decor && !q.rail));
|
|
2767
|
+
// A matrix's cells are drawn whole on the page, whatever its composition estimate reads of them.
|
|
2768
|
+
const boxesOf = (q) => (q.kind === "matrix" ? q.cells.map((c) => ({ x: c.x, y: c.y, w: c.w, h: c.h })) : drawnBoxes(q, S));
|
|
2769
|
+
// A chart's values table under its takeaways counts with their column.
|
|
2770
|
+
const boxes = owners.flatMap((q) => boxesOf(q).map((b) => ({ ...b, q, text: SHORT_KINDS.has(q.kind) || Boolean(q.item && q.item.values) })))
|
|
2771
|
+
.concat(owners.filter((q) => q.kind === "box" || q.kind === "panel").map((q) => ({ x: q.x, y: q.y, w: q.w, h: q.h, q, text: true })))
|
|
2772
|
+
.filter((b) => b.w > 0 && b.h > 0 && b.y < floor);
|
|
2773
|
+
let worst = { share: 0, owners: [] };
|
|
2774
|
+
if (boxes.length < 2) return worst;
|
|
2775
|
+
const left = Math.min(...boxes.map((b) => b.x));
|
|
2776
|
+
const right = Math.max(...boxes.map((b) => b.x + b.w));
|
|
2777
|
+
for (const cut of [...new Set(boxes.map((b) => b.x + b.w))].sort((m, n) => m - n)) {
|
|
2778
|
+
const a = boxes.filter((b) => b.x + b.w <= cut + 2);
|
|
2779
|
+
const c = boxes.filter((b) => b.x >= cut - 2);
|
|
2780
|
+
// A block across most of the body (a note under both columns) belongs to neither; a narrower one across the cut
|
|
2781
|
+
// means there is no column line here.
|
|
2782
|
+
const across = boxes.filter((b) => !a.includes(b) && !c.includes(b));
|
|
2783
|
+
if (!a.length || !c.length || across.some((b) => b.w < 0.7 * (right - left))) continue;
|
|
2784
|
+
const top = Math.min(...[...a, ...c].map((b) => b.y));
|
|
2785
|
+
for (const [col, other] of [[a, c], [c, a]]) {
|
|
2786
|
+
if (!col.every((b) => b.text)) continue;
|
|
2787
|
+
// Beside figure rows (not on their own panel) the column runs to the floor: the rows stop where their
|
|
2788
|
+
// spacing ends, not where the slide's content does (spec-v2 Amendments 9).
|
|
2789
|
+
const figures = other.some((b) => ["figrows", "number"].includes(b.q.kind)) && !other.some((b) => b.q.kind === "panel");
|
|
2790
|
+
const reach = figures ? floor : Math.min(floor, Math.max(...other.map((b) => b.y + b.h)));
|
|
2791
|
+
if (reach - top < 0.75 * (floor - top)) continue;
|
|
2792
|
+
let gap = 0;
|
|
2793
|
+
let cursor = top;
|
|
2794
|
+
for (const b of [...col].sort((m, n) => m.y - n.y)) {
|
|
2795
|
+
gap = Math.max(gap, b.y - cursor);
|
|
2796
|
+
cursor = Math.max(cursor, b.y + b.h);
|
|
2797
|
+
}
|
|
2798
|
+
gap = Math.max(gap, reach - cursor);
|
|
2799
|
+
const share = gap / Math.max(1, floor - top);
|
|
2800
|
+
if (share > worst.share) worst = { share, owners: [...new Set(col.map((b) => b.q))], top, reach, besideText: other.every((b) => b.q.kind === "text") };
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
return worst;
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
/**
|
|
2807
|
+
* Fill a short text column by layout: its points step up to lead size when they fit
|
|
2808
|
+
* and keep twelve glyphs a line, then the room left is shared between its points, at most a sixth of the
|
|
2809
|
+
* body a gap. Type never shrinks and nothing is added. Changes the placements in place.
|
|
2810
|
+
*/
|
|
2811
|
+
function fillColumn(short, floor, S, state) {
|
|
2812
|
+
const texts = short.owners.filter((q) => q.kind === "text" && !q.muted && !q.strip && q.paras);
|
|
2813
|
+
if (!texts.length) return;
|
|
2814
|
+
// The chart's values under the takeaways move down with them and bound nothing above them.
|
|
2815
|
+
const values = short.owners.filter((q) => q.item && q.item.values);
|
|
2816
|
+
const below = (q) => Math.min(floor, q.limit == null ? floor : q.limit, ...short.owners.filter((o) => o !== q && !values.includes(o) && o.y >= q.y + q.h - 0.5).map((o) => o.y - state.blockGap));
|
|
2817
|
+
const carried = (q, dy) => values.every((v) => v.y < q.y + q.h - 0.5 || v.y + v.h + dy <= below(v) + 0.5);
|
|
2818
|
+
const lead = S.sizes.lead;
|
|
2819
|
+
// Beside another text column the points keep its size: two columns of text at two sizes read as a mistake.
|
|
2820
|
+
for (const q of short.besideText ? [] : texts) {
|
|
2821
|
+
if (q.size >= lead || q.w / lead < 12) continue;
|
|
2822
|
+
const m = measureText(q.paras, q.w, S, { ...state, stepped: false }, { size: lead });
|
|
2823
|
+
if (q.y + m.h <= below(q) + 0.5 && carried(q, m.h - q.h)) {
|
|
2824
|
+
// Every later block in the column moves down by what the step adds.
|
|
2825
|
+
const dy = m.h - q.h;
|
|
2826
|
+
for (const o of short.owners) if (o !== q && o.y >= q.y + q.h - 0.5 && !o.strip && !o.muted) o.y += dy;
|
|
2827
|
+
Object.assign(q, { paras: m.paras, size: m.size, h: m.h });
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
const breaks = texts.flatMap((q) => q.paras.filter((para, i) => i > 0 && para.kind !== "child").map((para) => ({ q, para })));
|
|
2831
|
+
const last = [...texts, ...values].reduce((m, q) => (q.y + q.h > m.y + m.h ? q : m));
|
|
2832
|
+
const room = below(last) - (last.y + last.h);
|
|
2833
|
+
// The gap over a values table is shared like a gap between points.
|
|
2834
|
+
const gaps = breaks.length + values.length;
|
|
2835
|
+
if (!gaps || room <= 0) return;
|
|
2836
|
+
const per = Math.min(room / (gaps + 1), (floor - short.top) / 6);
|
|
2837
|
+
for (const { q, para } of breaks) { para.gap += per; q.h += per; }
|
|
2838
|
+
// A block that grew pushes the text blocks and values under it (never the strip, which stays on the floor).
|
|
2839
|
+
let shift = 0;
|
|
2840
|
+
for (const q of [...texts, ...values].sort((m, n) => m.y - n.y)) {
|
|
2841
|
+
if (values.includes(q)) shift += per;
|
|
2842
|
+
q.y += shift;
|
|
2843
|
+
shift += breaks.filter((b) => b.q === q).length * per;
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
/** Families whose visual may take its takeaways under it across the body, and the plan that does. */
|
|
2848
|
+
const STACKS = { "table-insight": "ledger-table", "chart-insight": "full-chart" };
|
|
2849
|
+
|
|
2850
|
+
/** The composition title zone of each treatment. */
|
|
2851
|
+
const ZONE_OF = { "top-rule": "top", "top-plain-large": "top", "kicker-numeral": "top", "side-rail": "side", band: "band",
|
|
2852
|
+
"bottom-anchor": "bottom", overlay: "overlay", statement: "none" };
|
|
2853
|
+
|
|
2854
|
+
// ── Content, cover and section slides ───────────────────────────────────────
|
|
2855
|
+
|
|
2856
|
+
function drawDecor(slide, S, list) {
|
|
2857
|
+
for (const d of list || []) packRect(slide, S, d, { fill: d.role, alpha: d.alpha });
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
/**
|
|
2861
|
+
* A content slide: decoration, numeral, body, then the title frame of its treatment. The title is
|
|
2862
|
+
* drawn last so the first shape on the slide is an unnamed one that can carry the family name.
|
|
2863
|
+
*/
|
|
2864
|
+
function renderContent(slide, spec, S, ctx, chosen) {
|
|
2865
|
+
const f = frameFor(spec, chosen.t);
|
|
2866
|
+
const t = f.title;
|
|
2867
|
+
const items = spec.items || [];
|
|
2868
|
+
if (!f.image) drawDecor(slide, S, f.decor);
|
|
2869
|
+
if (f.numeral) {
|
|
2870
|
+
packText(slide, S, f.numeral.text, f.numeral, { face: "numeral", size: f.numeral.size, colour: S.colour("accent"), lineSpacing: 1.15 });
|
|
2871
|
+
}
|
|
2872
|
+
const dctx = { sourceDir: ctx.sourceDir, title: spec.title };
|
|
2873
|
+
let ink = t.colour;
|
|
2874
|
+
if (f.image) {
|
|
2875
|
+
const image = items.find((i) => i.type === "image" || i.type === "image-grid");
|
|
2876
|
+
const src = image && (image.content.type === "image-grid" ? image.content.images[0] : image.content);
|
|
2877
|
+
if (src) {
|
|
2878
|
+
const file = shared().resolveImagePath(src.src, ctx.sourceDir);
|
|
2879
|
+
slide.addImage({ path: file, ...croppedTo(file, { x: 0, y: 0, w: f.image.w, h: f.image.h }), altText: src.caption || path.basename(file) });
|
|
2880
|
+
drawDecor(slide, S, f.decor);
|
|
2881
|
+
if (image.caption) drawCaption(slide, S, image.caption, t.x, 432, t.w, "on-field");
|
|
2882
|
+
}
|
|
2883
|
+
} else if (f.support) {
|
|
2884
|
+
const text = spec.supportText || spec.lead || items.filter(isText).flatMap(itemParas).map((p) => plainText(p.text)).join(" ");
|
|
2885
|
+
const supportH = text ? G.textHeight(plainText(text), f.support.w, S.sizes.body) : 0;
|
|
2886
|
+
// A tall statement with a support line moves up so the line ends on the body floor.
|
|
2887
|
+
const over = f.support.y + supportH - G.ZONE.floor;
|
|
2888
|
+
if (over > 0) {
|
|
2889
|
+
const dy = Math.min(over, t.y - G.ZONE.top);
|
|
2890
|
+
t.y -= dy;
|
|
2891
|
+
f.support.y -= dy;
|
|
2892
|
+
}
|
|
2893
|
+
ink = statementDevice(slide, S, f, supportH);
|
|
2894
|
+
if (text) packText(slide, S, text, { ...f.support, h: supportH }, { colour: S.colour(ink === "on-field" ? "on-field" : "ink-muted") });
|
|
2895
|
+
} else if (f.body) {
|
|
2896
|
+
const plan = chosen.plan;
|
|
2897
|
+
if (plan.before > S.tok.cap || plan.band > S.tok.cap) {
|
|
2898
|
+
ctx.log.push(`fill: slide ${spec.index + 1} band ${plan.before.toFixed(2)} -> ${plan.band.toFixed(2)} (${plan.applied.length ? plan.applied.join(", ") : "none: nothing on the slide can grow; choose a fuller family"})`);
|
|
2899
|
+
}
|
|
2900
|
+
for (const p of plan.placed.filter((q) => q.decor)) drawPlaced(slide, S, p, dctx);
|
|
2901
|
+
for (const p of plan.placed.filter((q) => !q.decor)) drawPlaced(slide, S, p, dctx);
|
|
2902
|
+
}
|
|
2903
|
+
packText(slide, S, chosen.t === "statement" && spec.statementText ? spec.statementText : spec.title, t, {
|
|
2904
|
+
// Every title that wraps is set in balanced lines, never ending on one word.
|
|
2905
|
+
face: t.face, size: t.size, colour: S.colour(ink), lineSpacing: 1.15, title: true, name: `title@${chosen.t}`, balance: true,
|
|
2906
|
+
});
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
/**
|
|
2910
|
+
* What carries a statement, from the pack's display keys: nothing (open), the field over the whole
|
|
2911
|
+
* page (drench), the field under the sentence from just above it to the foot (plate), a hairline
|
|
2912
|
+
* above the sentence and one under its support line (rules), or a rail beside a sentence set off
|
|
2913
|
+
* the left edge (offset). Returns the colour role the sentence takes.
|
|
2914
|
+
*/
|
|
2915
|
+
function statementDevice(slide, S, f, supportH) {
|
|
2916
|
+
const g = S.grid;
|
|
2917
|
+
const t = f.title;
|
|
2918
|
+
const device = (S.pack.display || {}).statement || "open";
|
|
2919
|
+
if (device === "drench") {
|
|
2920
|
+
packRect(slide, S, { x: 0, y: 0, w: g.W, h: g.H }, { fill: "field" });
|
|
2921
|
+
return "on-field";
|
|
2922
|
+
}
|
|
2923
|
+
if (device === "plate") {
|
|
2924
|
+
// The sentence moves down onto a plate that bleeds off three edges and covers less than half
|
|
2925
|
+
// of the page, so the white above stays the page and the plate reads as the sentence's ground.
|
|
2926
|
+
// A sentence too tall to sit that low keeps the open page.
|
|
2927
|
+
const top = Math.round((376 - t.h / 2) / 6) * 6;
|
|
2928
|
+
if (top - 24 < 282 || top + t.h + 24 + supportH > G.ZONE.floor) return t.colour;
|
|
2929
|
+
f.support.y += top - t.y;
|
|
2930
|
+
t.y = top;
|
|
2931
|
+
packRect(slide, S, { x: 0, y: top - 24, w: g.W, h: g.H - top + 24 }, { fill: "field" });
|
|
2932
|
+
return "on-field";
|
|
2933
|
+
}
|
|
2934
|
+
if (device === "rules") {
|
|
2935
|
+
const full = g.span(1, 12);
|
|
2936
|
+
const below = supportH ? f.support.y + supportH + 24 : t.y + t.h + 30;
|
|
2937
|
+
packRect(slide, S, { x: full.x, y: t.y - 30, w: full.w, h: 0.75 }, { fill: "line" });
|
|
2938
|
+
packRect(slide, S, { x: full.x, y: below, w: full.w, h: 0.75 }, { fill: "line" });
|
|
2939
|
+
return t.colour;
|
|
2940
|
+
}
|
|
2941
|
+
if (device === "offset") {
|
|
2942
|
+
// The rail stops a column short of the sentence, so the gap between them is part of the grid,
|
|
2943
|
+
// and at the body floor, so the footer zone stays the page's.
|
|
2944
|
+
packRect(slide, S, { x: 0, y: 0, w: t.x - g.gutter / 2 - g.pitch, h: G.ZONE.floor }, { fill: S.pack.rail === "field" ? "field" : "surface" });
|
|
2945
|
+
return t.colour;
|
|
2946
|
+
}
|
|
2947
|
+
return t.colour;
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
function metaLine(meta) {
|
|
2951
|
+
return [meta.date, meta.department, meta.presenter].filter(Boolean).join(" · ");
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
/** Cover title at cover-max, stepping down to display when it would run past `maxLines`. */
|
|
2955
|
+
function coverTitle(S, text, w, maxLines, start = S.sizes.cover) {
|
|
2956
|
+
let size = start;
|
|
2957
|
+
let lines = S.lines(plainText(text), w, size, "display");
|
|
2958
|
+
if (lines > maxLines) { size = S.sizes.display; lines = S.lines(plainText(text), w, size, "display"); }
|
|
2959
|
+
return { size, h: Math.ceil(lines * size * 1.1) };
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
function coverImage(slide, box, src, sourceDir) {
|
|
2963
|
+
const file = shared().resolveImagePath(src.src, sourceDir);
|
|
2964
|
+
slide.addImage({ path: file, ...croppedTo(file, box), altText: src.caption || path.basename(file) });
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
/**
|
|
2968
|
+
* A picture that fills a box by cropping, never by stretching: the cover sizing reads the picture's
|
|
2969
|
+
* own proportions from w and h and crops it to the box (without them it is stretched to the box).
|
|
2970
|
+
*/
|
|
2971
|
+
function croppedTo(file, box) {
|
|
2972
|
+
const size = shared().readImageSize(file);
|
|
2973
|
+
const ratio = size && size.width && size.height ? size.height / size.width : box.h / box.w;
|
|
2974
|
+
return { x: IN(box.x), y: IN(box.y), w: IN(box.w), h: IN(box.w * ratio), sizing: { type: "cover", w: IN(box.w), h: IN(box.h) } };
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
/**
|
|
2978
|
+
* A type-led cover drawn with the pack's display device: the field over the whole page with the
|
|
2979
|
+
* title anchored low (drench), a field plate under the title (plate), a journal title page between
|
|
2980
|
+
* rules (rules), or the plain page. The rail, band, figures and numeral devices draw the pack's
|
|
2981
|
+
* cover of that name when the deck holds what it needs.
|
|
2982
|
+
*/
|
|
2983
|
+
const TYPE_COVERS = {
|
|
2984
|
+
drench(slide, spec, S, meta) {
|
|
2985
|
+
const g = S.grid;
|
|
2986
|
+
packRect(slide, S, { x: 0, y: 0, w: g.W, h: g.H }, { fill: "field" });
|
|
2987
|
+
const line = metaLine(meta);
|
|
2988
|
+
if (line) packText(slide, S, line, { ...g.span(1, 8), y: 36, h: 24 }, { size: S.sizes.label, colour: S.colour("on-field"), lineSpacing: 1.2 });
|
|
2989
|
+
const span = g.span(1, 11);
|
|
2990
|
+
const { size, h } = coverTitle(S, spec.title, span.w, 3, S.pack.hero ? S.sizes.hero : S.sizes.cover);
|
|
2991
|
+
const sub = g.span(1, 8);
|
|
2992
|
+
const subH = meta.subtitle ? G.textHeight(meta.subtitle, sub.w, S.sizes.lead) + 18 : 0;
|
|
2993
|
+
// The title block ends just above the footer zone, so the field holds the title from below.
|
|
2994
|
+
const y = Math.round((456 - subH - h) / 6) * 6;
|
|
2995
|
+
if (meta.subtitle) packText(slide, S, meta.subtitle, { ...sub, y: y + h + 18, h: subH - 18 }, { name: "subtitle@cover", size: S.sizes.lead, colour: S.colour("on-field") });
|
|
2996
|
+
packText(slide, S, spec.title, { ...span, y, h }, { face: "display", size, colour: S.colour("on-field"), lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
|
|
2997
|
+
},
|
|
2998
|
+
plate(slide, spec, S, meta) {
|
|
2999
|
+
// The plate covers a little under half the page, so the white above it stays the page's ground.
|
|
3000
|
+
const g = S.grid;
|
|
3001
|
+
const top = 288;
|
|
3002
|
+
packRect(slide, S, { x: 0, y: top, w: g.W, h: g.H - top }, { fill: "field" });
|
|
3003
|
+
const line = metaLine(meta);
|
|
3004
|
+
if (line) packText(slide, S, line, { ...g.span(1, 8), y: 36, h: 24 }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.2 });
|
|
3005
|
+
const span = g.span(1, 10);
|
|
3006
|
+
const { size, h } = coverTitle(S, spec.title, span.w, 2);
|
|
3007
|
+
const y = top + 30;
|
|
3008
|
+
if (meta.subtitle && y + h + 18 + S.sizes.body * 1.4 <= 462) {
|
|
3009
|
+
const sub = g.span(1, 8);
|
|
3010
|
+
packText(slide, S, meta.subtitle, { ...sub, y: y + h + 18, h: G.textHeight(meta.subtitle, sub.w, S.sizes.body) }, { name: "subtitle@cover", colour: S.colour("on-field") });
|
|
3011
|
+
}
|
|
3012
|
+
packText(slide, S, spec.title, { ...span, y, h }, { face: "display", size, colour: S.colour("on-field"), lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
|
|
3013
|
+
},
|
|
3014
|
+
rules(slide, spec, S, meta) {
|
|
3015
|
+
const g = S.grid;
|
|
3016
|
+
const full = g.span(1, 12);
|
|
3017
|
+
const span = g.span(1, 10);
|
|
3018
|
+
const top = 96;
|
|
3019
|
+
packRect(slide, S, { x: full.x, y: top, w: full.w, h: 2 }, { fill: "ink" });
|
|
3020
|
+
const { size, h } = coverTitle(S, spec.title, span.w, 3);
|
|
3021
|
+
const y = top + 30;
|
|
3022
|
+
const ruleY = y + h + 24;
|
|
3023
|
+
packRect(slide, S, { x: full.x, y: ruleY, w: full.w, h: 0.75 }, { fill: "line" });
|
|
3024
|
+
// Under the rule, as on a journal's title page: who presents, then where and when, in one frame.
|
|
3025
|
+
const place = [meta.department, meta.date].filter(Boolean).join(" · ");
|
|
3026
|
+
const runs = [
|
|
3027
|
+
...(meta.presenter ? [{ text: meta.presenter, options: { fontFace: S.faces.title.font, fontSize: S.sizes.lead, bold: true, color: S.colour("ink"), breakLine: Boolean(place) } }] : []),
|
|
3028
|
+
...(place ? [{ text: place, options: { fontFace: S.faces.label.font, fontSize: S.sizes.label, color: S.colour("ink-muted") } }] : []),
|
|
3029
|
+
];
|
|
3030
|
+
let at = ruleY + 18;
|
|
3031
|
+
if (runs.length) {
|
|
3032
|
+
// A frame as tall as an office renderer sets two lines of mixed sizes (about 1.5 of each).
|
|
3033
|
+
const hh = (meta.presenter ? S.sizes.lead * 1.5 : 0) + (place ? S.sizes.label * 1.5 : 0);
|
|
3034
|
+
slide.addText(runs, { x: IN(span.x), y: IN(at), w: IN(span.w), h: IN(hh), align: "left", valign: "top", margin: [0, 0, 0, 0], lineSpacingMultiple: 1.2 });
|
|
3035
|
+
at += hh;
|
|
3036
|
+
}
|
|
3037
|
+
if (meta.subtitle) packText(slide, S, meta.subtitle, { ...span, y: at + 18, h: G.textHeight(meta.subtitle, span.w, S.sizes.label, 1.3) }, { name: "subtitle@cover", size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.3 });
|
|
3038
|
+
packText(slide, S, spec.title, { ...span, y, h }, { face: "display", size, lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
|
|
3039
|
+
},
|
|
3040
|
+
plain(slide, spec, S, meta) {
|
|
3041
|
+
const g = S.grid;
|
|
3042
|
+
const span = g.span(1, 9);
|
|
3043
|
+
const { size, h } = coverTitle(S, spec.title, span.w, 3);
|
|
3044
|
+
if (meta.subtitle) {
|
|
3045
|
+
const sub = g.span(1, 8);
|
|
3046
|
+
packText(slide, S, meta.subtitle, { ...sub, y: 144 + h + 24, h: G.textHeight(meta.subtitle, sub.w, S.sizes.lead) }, { name: "subtitle@cover", size: S.sizes.lead, colour: S.colour("ink-muted") });
|
|
3047
|
+
}
|
|
3048
|
+
const line = metaLine(meta);
|
|
3049
|
+
if (line) packText(slide, S, line, { ...g.span(1, 6), y: 438, h: 24 }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.2 });
|
|
3050
|
+
packText(slide, S, spec.title, { ...span, y: 144, h }, { face: "display", size, lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
|
|
3051
|
+
},
|
|
3052
|
+
};
|
|
3053
|
+
|
|
3054
|
+
const COVERS = {
|
|
3055
|
+
"cover-typographic"(slide, spec, S, meta) {
|
|
3056
|
+
const device = (S.pack.display || {}).cover || "plain";
|
|
3057
|
+
const facts = spec.cover || {};
|
|
3058
|
+
if (device === "rail") return COVERS["cover-rail"](slide, spec, S, meta);
|
|
3059
|
+
if (device === "band") return COVERS["cover-band"](slide, spec, S, meta);
|
|
3060
|
+
if (device === "figures" && (facts.figures || []).length >= 2) return COVERS["cover-figures"](slide, spec, S, meta);
|
|
3061
|
+
if (device === "numeral" && facts.numeral) return COVERS["cover-numeral"](slide, spec, S, meta);
|
|
3062
|
+
return (TYPE_COVERS[device] || TYPE_COVERS.plain)(slide, spec, S, meta);
|
|
3063
|
+
},
|
|
3064
|
+
"cover-split-field"(slide, spec, S, meta) {
|
|
3065
|
+
const g = S.grid;
|
|
3066
|
+
const x = g.colX(8) - g.gutter / 2;
|
|
3067
|
+
packRect(slide, S, { x, y: 0, w: g.W - x, h: g.H }, { fill: "field" });
|
|
3068
|
+
if (spec.cover.numeral) {
|
|
3069
|
+
const s = g.span(9, 12);
|
|
3070
|
+
packText(slide, S, spec.cover.numeral, { ...s, y: 384, h: S.sizes.title * 1.15 }, { face: "numeral", size: S.sizes.title, colour: S.colour("on-field"), lineSpacing: 1.15 });
|
|
3071
|
+
}
|
|
3072
|
+
splitText(slide, spec, S, meta, 6);
|
|
3073
|
+
},
|
|
3074
|
+
"cover-split-image"(slide, spec, S, meta) {
|
|
3075
|
+
const g = S.grid;
|
|
3076
|
+
const x = g.colX(8) - g.gutter / 2;
|
|
3077
|
+
coverImage(slide, { x, y: 0, w: g.W - x, h: g.H }, spec.cover.image, spec.sourceDir);
|
|
3078
|
+
splitText(slide, spec, S, meta, 6);
|
|
3079
|
+
},
|
|
3080
|
+
"cover-full-image"(slide, spec, S, meta) {
|
|
3081
|
+
const g = S.grid;
|
|
3082
|
+
coverImage(slide, { x: 0, y: 0, w: g.W, h: g.H }, spec.cover.image, spec.sourceDir);
|
|
3083
|
+
const span = g.span(1, 8);
|
|
3084
|
+
// The panel is as tall as the title and its meta line and stands on the page foot (bleeding off it,
|
|
3085
|
+
// so the sample tag sits inside it, clear of its edge); the picture keeps the rest of the page.
|
|
3086
|
+
const { size, h } = coverTitle(S, spec.title, span.w, 2, S.sizes.display);
|
|
3087
|
+
const line = [meta.subtitle, metaLine(meta)].filter(Boolean).join(" · ");
|
|
3088
|
+
const lineH = line ? G.textHeight(line, span.w, S.sizes.label, 1.3) : 0;
|
|
3089
|
+
const y = Math.round(G.ZONE.floor - 24 - (line ? lineH + 18 : 0) - h);
|
|
3090
|
+
packRect(slide, S, { x: 0, y: y - 24, w: span.x + span.w + 24, h: g.H - y + 24 }, { fill: "field", alpha: 12 });
|
|
3091
|
+
packText(slide, S, spec.title, { ...span, y, h }, { face: "display", size, colour: S.colour("on-field"), lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
|
|
3092
|
+
if (line) packText(slide, S, line, { ...span, y: y + h + 18, h: lineH }, { size: S.sizes.label, colour: S.colour("on-field"), lineSpacing: 1.3 });
|
|
3093
|
+
},
|
|
3094
|
+
"cover-band"(slide, spec, S, meta) {
|
|
3095
|
+
const g = S.grid;
|
|
3096
|
+
packRect(slide, S, { x: 0, y: 0, w: g.W, h: 216 }, { fill: "field" });
|
|
3097
|
+
const span = g.span(1, 10);
|
|
3098
|
+
const { size, h } = coverTitle(S, spec.title, span.w, 2, S.sizes.display);
|
|
3099
|
+
packText(slide, S, spec.title, { ...span, y: Math.max(48, 180 - h), h }, { face: "display", size, colour: S.colour("on-field"), lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
|
|
3100
|
+
const subH = meta.subtitle ? G.textHeight(meta.subtitle, g.span(1, 8).w, S.sizes.lead) : 0;
|
|
3101
|
+
if (meta.subtitle) packText(slide, S, meta.subtitle, { ...g.span(1, 8), y: 252, h: subH }, { name: "subtitle@cover", size: S.sizes.lead, colour: S.colour("ink-muted") });
|
|
3102
|
+
// Under the band, the deck's parts (else its headline figures) run across the page as the
|
|
3103
|
+
// issue's contents, between a hairline and the meta line.
|
|
3104
|
+
const parts = (spec.cover.index || []).length >= 2 ? spec.cover.index.map((name, i) => [String(i + 1).padStart(2, "0"), name])
|
|
3105
|
+
: (spec.cover.figures || []).length >= 2 ? spec.cover.figures.map((f) => [f.value, f.label]) : [];
|
|
3106
|
+
if (parts.length) {
|
|
3107
|
+
const top = Math.max(300, 252 + subH + 36);
|
|
3108
|
+
const cols = equalParts(S, region(S, 1, 12, top, 420), Math.min(parts.length, 4));
|
|
3109
|
+
packRect(slide, S, { x: g.margin, y: top - 18, w: g.span(1, 12).w, h: 0.75 }, { fill: "line" });
|
|
3110
|
+
parts.slice(0, 4).forEach(([key, text], i) => {
|
|
3111
|
+
const R = cols[i];
|
|
3112
|
+
const k = [S.sizes.title, S.sizes.lead].find((z) => S.ems(key, "numeral") * z * 1.1 <= R.w) || S.sizes.lead;
|
|
3113
|
+
packText(slide, S, key, { x: R.x, y: top, w: R.w, h: k * 1.15 }, { face: "numeral", size: k, colour: S.colour("accent"), lineSpacing: 1.15 });
|
|
3114
|
+
packText(slide, S, text, { x: R.x, y: top + k * 1.15 + 8, w: R.w, h: G.textHeight(text, R.w, S.sizes.body) }, { size: S.sizes.body });
|
|
3115
|
+
});
|
|
3116
|
+
}
|
|
3117
|
+
const line = metaLine(meta);
|
|
3118
|
+
if (line) packText(slide, S, line, { ...g.span(1, 8), y: 438, h: 24 }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.2 });
|
|
3119
|
+
},
|
|
3120
|
+
"cover-numeral"(slide, spec, S, meta) {
|
|
3121
|
+
const g = S.grid;
|
|
3122
|
+
const n = spec.cover.numeral;
|
|
3123
|
+
// A number is never the display element: title size at most.
|
|
3124
|
+
const numeral = S.sizes.title;
|
|
3125
|
+
packText(slide, S, n, { ...g.span(1, 8), y: 96, h: numeral * 1.15 }, { face: "numeral", size: numeral, colour: S.colour("accent"), lineSpacing: 1.15 });
|
|
3126
|
+
const span = g.span(1, 9);
|
|
3127
|
+
const { size, h } = coverTitle(S, spec.title, span.w, 3, S.sizes.display);
|
|
3128
|
+
const y = 96 + numeral * 1.1 + 24;
|
|
3129
|
+
packText(slide, S, spec.title, { ...span, y, h }, { face: "display", size, lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
|
|
3130
|
+
const line = metaLine(meta);
|
|
3131
|
+
if (line) packText(slide, S, line, { ...g.span(1, 6), y: 438, h: 24 }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.2 });
|
|
3132
|
+
},
|
|
3133
|
+
"cover-index"(slide, spec, S, meta) {
|
|
3134
|
+
const g = S.grid;
|
|
3135
|
+
const span = g.span(1, 6);
|
|
3136
|
+
const { size, h } = coverTitle(S, spec.title, span.w, 4, S.sizes.display);
|
|
3137
|
+
packText(slide, S, spec.title, { ...span, y: 144, h }, { face: "display", size, lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
|
|
3138
|
+
const list = g.span(8, 12);
|
|
3139
|
+
// The part names run from the title's top down to the meta line, at most 96 pt apart.
|
|
3140
|
+
const pitch = Math.min(96, (438 - 144) / Math.max(1, spec.cover.index.length - 1));
|
|
3141
|
+
packRect(slide, S, { x: list.x - 12, y: 144, w: 0.75, h: pitch * spec.cover.index.length }, { fill: "line" });
|
|
3142
|
+
spec.cover.index.forEach((name, i) => {
|
|
3143
|
+
packText(slide, S, `${String(i + 1).padStart(2, "0")} ${name}`, { ...list, y: 144 + i * pitch, h: G.textHeight(name, list.w, S.sizes.lead, 1.3) }, { size: S.sizes.lead, colour: S.colour("ink"), lineSpacing: 1.3 });
|
|
3144
|
+
});
|
|
3145
|
+
const line = metaLine(meta);
|
|
3146
|
+
if (line) packText(slide, S, line, { ...span, y: 438, h: G.textHeight(line, span.w, S.sizes.label, 1.3) }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.3 });
|
|
3147
|
+
},
|
|
3148
|
+
"cover-figures"(slide, spec, S, meta) {
|
|
3149
|
+
const g = S.grid;
|
|
3150
|
+
const span = g.span(1, 9);
|
|
3151
|
+
const { size, h } = coverTitle(S, spec.title, span.w, 2, S.sizes.display);
|
|
3152
|
+
// The figures sit between the title and the meta line, with a hairline above them; the title
|
|
3153
|
+
// is drawn last, so the slide's first shape is an unnamed one that carries the family.
|
|
3154
|
+
const figs = spec.cover.figures.slice(0, 4);
|
|
3155
|
+
// The row stands in the middle of the room between the title and the meta line.
|
|
3156
|
+
const rowH = S.sizes.title * 1.3 + 6 + S.sizes.body * 1.4;
|
|
3157
|
+
const top = Math.max(240, 96 + h + 36, Math.round((96 + h + 438 - rowH) / 2 / 6) * 6);
|
|
3158
|
+
const parts = equalParts(S, region(S, 1, 12, top, top + 120), figs.length);
|
|
3159
|
+
packRect(slide, S, { x: g.margin, y: top - 18, w: g.span(1, 12).w, h: 0.75 }, { fill: "line" });
|
|
3160
|
+
figs.forEach((f, i) => {
|
|
3161
|
+
const R = parts[i];
|
|
3162
|
+
const v = [S.sizes.title, S.sizes.lead, S.sizes.body].find((s) => S.ems(f.value, "numeral") * s * 1.1 <= R.w) || S.sizes.body;
|
|
3163
|
+
packText(slide, S, f.value, { x: R.x, y: top, w: R.w, h: v * 1.15 }, { face: "numeral", size: v, colour: S.colour(i === 0 ? "accent" : "ink"), lineSpacing: 1.15 });
|
|
3164
|
+
packText(slide, S, f.label, { x: R.x, y: top + v * 1.3 + 6, w: R.w, h: S.sizes.body * 1.4 }, { size: S.sizes.body, colour: S.colour("ink-muted"), lineSpacing: 1.3 });
|
|
3165
|
+
});
|
|
3166
|
+
const line = metaLine(meta);
|
|
3167
|
+
if (line) packText(slide, S, line, { ...g.span(1, 6), y: 438, h: 24 }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.2 });
|
|
3168
|
+
packText(slide, S, spec.title, { ...span, y: 96, h }, { face: "display", size, lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
|
|
3169
|
+
},
|
|
3170
|
+
"cover-rail"(slide, spec, S, meta) {
|
|
3171
|
+
const g = S.grid;
|
|
3172
|
+
const rail = g.span(1, 4);
|
|
3173
|
+
packRect(slide, S, { x: 0, y: 0, w: rail.x + rail.w + g.gutter / 2, h: g.H }, { fill: "field" });
|
|
3174
|
+
const side = g.span(1, 3);
|
|
3175
|
+
const lines = [meta.department, meta.presenter, meta.date].filter(Boolean);
|
|
3176
|
+
lines.forEach((l, i) => packText(slide, S, l, { ...side, y: 402 + i * 24, h: 22 }, { size: S.sizes.label, colour: S.colour("on-field"), lineSpacing: 1.2 }));
|
|
3177
|
+
const span = g.span(5, 12);
|
|
3178
|
+
const { size, h } = coverTitle(S, spec.title, span.w, 3);
|
|
3179
|
+
packText(slide, S, spec.title, { ...span, y: 144, h }, { face: "display", size, lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
|
|
3180
|
+
if (meta.subtitle) packText(slide, S, meta.subtitle, { ...span, y: 144 + h + 24, h: G.textHeight(meta.subtitle, span.w, S.sizes.lead) }, { name: "subtitle@cover", size: S.sizes.lead, colour: S.colour("ink-muted") });
|
|
3181
|
+
},
|
|
3182
|
+
};
|
|
3183
|
+
|
|
3184
|
+
/** Title and meta in columns 1-k beside a field or image (the split covers). */
|
|
3185
|
+
function splitText(slide, spec, S, meta, k) {
|
|
3186
|
+
const g = S.grid;
|
|
3187
|
+
const span = g.span(1, k);
|
|
3188
|
+
const { size, h } = coverTitle(S, spec.title, span.w, 4, S.sizes.display);
|
|
3189
|
+
packText(slide, S, spec.title, { ...span, y: 144, h }, { face: "display", size, lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
|
|
3190
|
+
if (meta.subtitle) packText(slide, S, meta.subtitle, { ...span, y: 144 + h + 24, h: G.textHeight(meta.subtitle, span.w, S.sizes.body) }, { name: "subtitle@cover", colour: S.colour("ink-muted") });
|
|
3191
|
+
const line = metaLine(meta);
|
|
3192
|
+
if (line) packText(slide, S, line, { ...span, y: 438, h: G.textHeight(line, span.w, S.sizes.label, 1.3) }, { size: S.sizes.label, colour: S.colour("ink-muted"), lineSpacing: 1.3 });
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
/**
|
|
3196
|
+
* Section slides carry a part number and the part title. The title frame keeps the statement
|
|
3197
|
+
* frame (cols 1-10, centred in the body height) on every variant but the rail and the index, so a
|
|
3198
|
+
* section reads as one more statement-sized title and its frame repeats across the deck. A part
|
|
3199
|
+
* number shows only when the deck says how many parts there are; a section without one opens with
|
|
3200
|
+
* its first lines instead (see sectionOpener).
|
|
3201
|
+
*/
|
|
3202
|
+
const SECTIONS = {
|
|
3203
|
+
"section-field"(slide, spec, S) {
|
|
3204
|
+
const g = S.grid;
|
|
3205
|
+
packRect(slide, S, { x: 0, y: 0, w: g.W, h: g.H }, { fill: "field" });
|
|
3206
|
+
sectionTitle(slide, spec, S, { colour: "on-field", numeralColour: "on-field" });
|
|
3207
|
+
},
|
|
3208
|
+
"section-numeral"(slide, spec, S) {
|
|
3209
|
+
// With the agenda known, the numeral's page is the index page: the title is never alone on it.
|
|
3210
|
+
if (spec.parts && ((spec.cover || {}).index || []).length >= 2) { sectionIndex(slide, spec, S); return; }
|
|
3211
|
+
sectionTitle(slide, spec, S, { hero: true });
|
|
3212
|
+
},
|
|
3213
|
+
"section-rule"(slide, spec, S) {
|
|
3214
|
+
if (showsIndex(spec, S)) { sectionIndex(slide, spec, S); return; }
|
|
3215
|
+
if (!spec.parts) { sectionOpener(slide, spec, S, { rule: true }); return; }
|
|
3216
|
+
const full = S.grid.span(1, 12);
|
|
3217
|
+
sectionTitle(slide, spec, S, { numeral: false, under: (box) => {
|
|
3218
|
+
packRect(slide, S, { x: full.x, y: box.y + box.h + 18, w: full.w, h: 0.75 }, { fill: "line" });
|
|
3219
|
+
} });
|
|
3220
|
+
},
|
|
3221
|
+
"section-band"(slide, spec, S) {
|
|
3222
|
+
// The header band deepened to y 216; the title opens the ground under it. With the deck's part
|
|
3223
|
+
// index the band carries that index (this part marked) and the title stands without a numeral.
|
|
3224
|
+
const g = S.grid;
|
|
3225
|
+
packRect(slide, S, { x: 0, y: 0, w: g.W, h: 216 }, { fill: "field" });
|
|
3226
|
+
const index = showsIndex(spec, S);
|
|
3227
|
+
if (index) {
|
|
3228
|
+
const h = S.sizes.body * 1.4;
|
|
3229
|
+
partIndex(slide, S, spec, { ...g.span(1, 12), y: 216 - 36 - h }, true);
|
|
3230
|
+
}
|
|
3231
|
+
// Under a band that carries the index, the title is a statement over what the part opens with.
|
|
3232
|
+
if (index) sectionOpener(slide, spec, S, { top: 252, centre: 351 });
|
|
3233
|
+
else sectionTitle(slide, spec, S, { top: 240 });
|
|
3234
|
+
},
|
|
3235
|
+
"section-rail"(slide, spec, S) {
|
|
3236
|
+
const g = S.grid;
|
|
3237
|
+
const rail = g.span(1, 4);
|
|
3238
|
+
packRect(slide, S, { x: 0, y: 0, w: rail.x + rail.w + g.gutter / 2, h: g.H }, { fill: "field" });
|
|
3239
|
+
const numeral = S.sizes.title;
|
|
3240
|
+
if (spec.parts) partNumeral(slide, S, spec, { ...g.span(1, 3), y: 216, h: numeral * 1.15 }, numeral, "on-field");
|
|
3241
|
+
else if ((spec.contents || []).length) {
|
|
3242
|
+
// No part count: the rail names what the section opens with, one entry under the next at lead size
|
|
3243
|
+
// with a step between them, so the list reads as the part's contents and not as one wrapped block.
|
|
3244
|
+
const w = g.span(1, 3).w;
|
|
3245
|
+
let y = 216;
|
|
3246
|
+
for (const entry of spec.contents) {
|
|
3247
|
+
const h = G.textHeight(entry, w, S.sizes.lead, 1.3);
|
|
3248
|
+
packText(slide, S, entry, { ...g.span(1, 3), y, h }, { size: S.sizes.lead, colour: S.colour("on-field"), lineSpacing: 1.3 });
|
|
3249
|
+
y += h + S.tok.group;
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
const span = g.span(5, 12);
|
|
3253
|
+
const display = S.sizes.display;
|
|
3254
|
+
const h = Math.ceil(S.lines(plainText(spec.title), span.w, display, "display") * display * 1.15);
|
|
3255
|
+
packText(slide, S, spec.title, { ...span, y: Math.round((303 - h / 2) / 6) * 6, h }, { face: "display", size: display, lineSpacing: 1.15, title: true, balance: true, name: "title@section" });
|
|
3256
|
+
},
|
|
3257
|
+
"section-image"(slide, spec, S) {
|
|
3258
|
+
// The panel stands on the page foot (bleeding off it) and is as tall as the part number and the title.
|
|
3259
|
+
const g = S.grid;
|
|
3260
|
+
coverImage(slide, { x: 0, y: 0, w: g.W, h: g.H }, spec.cover.image, spec.sourceDir);
|
|
3261
|
+
const s = g.span(1, 7);
|
|
3262
|
+
const display = S.sizes.display;
|
|
3263
|
+
const h = Math.ceil(S.lines(plainText(spec.title), s.w, display, "display") * display * 1.15);
|
|
3264
|
+
const y = Math.round(G.ZONE.floor - 18 - h);
|
|
3265
|
+
const numY = y - 24;
|
|
3266
|
+
packRect(slide, S, { x: 0, y: (spec.parts ? numY : y) - 18, w: s.x + s.w + 24, h: g.H - (spec.parts ? numY : y) + 18 }, { fill: "field", alpha: 12 });
|
|
3267
|
+
if (spec.parts) packText(slide, S, String(spec.part).padStart(2, "0"), { ...s, y: numY, h: S.sizes.label * 1.4 }, { size: S.sizes.label, colour: S.colour("on-field"), lineSpacing: 1.3 });
|
|
3268
|
+
packText(slide, S, spec.title, { ...s, y, h }, { face: "display", size: display, colour: S.colour("on-field"), lineSpacing: 1.15, title: true, balance: true, name: "title@section" });
|
|
3269
|
+
},
|
|
3270
|
+
};
|
|
3271
|
+
|
|
3272
|
+
function sectionTitle(slide, spec, S, o = {}) {
|
|
3273
|
+
if (!spec.parts) return sectionOpener(slide, spec, S, o);
|
|
3274
|
+
const g = S.grid;
|
|
3275
|
+
const span = g.span(1, 10);
|
|
3276
|
+
const display = S.sizes.display;
|
|
3277
|
+
const h = Math.ceil(S.lines(plainText(spec.title), span.w, display, "display") * display * 1.15);
|
|
3278
|
+
// The part number is set at title size: a number never takes the display step.
|
|
3279
|
+
const size = S.sizes.title;
|
|
3280
|
+
const numeralH = o.numeral === false ? 0 : size * 1.15 + 12;
|
|
3281
|
+
// Centred in the body height like a statement, and never above `top` with its numeral.
|
|
3282
|
+
const y = Math.max(228, (o.top || 0) + numeralH, Math.round((303 - h / 2) / 6) * 6);
|
|
3283
|
+
if (o.numeral !== false) {
|
|
3284
|
+
partNumeral(slide, S, spec, { ...g.span(1, spec.parts ? 6 : 3), y: y - numeralH, h: size * 1.15 }, size, o.numeralColour || "accent");
|
|
3285
|
+
}
|
|
3286
|
+
const box = { ...span, y, h };
|
|
3287
|
+
// Anything drawn with the title (a rule under it) goes first, so the title stays the last shape.
|
|
3288
|
+
if (o.under) o.under(box);
|
|
3289
|
+
packText(slide, S, spec.title, box, { face: "display", size: display, colour: S.colour(o.colour || "ink"), lineSpacing: 1.15, title: true, balance: true, name: "title@section" });
|
|
3290
|
+
return box;
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
/**
|
|
3294
|
+
* A section the deck gives no part count (one section, no agenda): the title at the cover step (the
|
|
3295
|
+
* hero step in a hero tonality) as a statement centred in the body, and under it the titles of the
|
|
3296
|
+
* slides the section opens, so the page says what comes next instead of showing a bare count. With
|
|
3297
|
+
* `rule` the pack's hairline runs between them; `top` and `centre` place it under a band.
|
|
3298
|
+
*/
|
|
3299
|
+
function sectionOpener(slide, spec, S, o = {}) {
|
|
3300
|
+
const g = S.grid;
|
|
3301
|
+
const span = g.span(1, 10);
|
|
3302
|
+
const size = S.pack.hero ? S.sizes.hero : S.sizes.cover;
|
|
3303
|
+
const h = Math.ceil(S.lines(plainText(spec.title), span.w, size, "display") * size * 1.1);
|
|
3304
|
+
const onField = o.colour === "on-field";
|
|
3305
|
+
const digest = spec.digest || [];
|
|
3306
|
+
if (digest.some((d) => d.lines.length)) return sectionDigestPage(slide, spec, S, o, { span, size, h, digest, onField });
|
|
3307
|
+
const text = (spec.contents || []).join("\n");
|
|
3308
|
+
const lead = S.sizes.lead;
|
|
3309
|
+
const ch = text ? G.textHeight(text, span.w, lead, 1.4) : 0;
|
|
3310
|
+
const gap = text ? (o.rule ? 60 : 30) : 0;
|
|
3311
|
+
const y = Math.max(o.top || 0, Math.round(((o.centre || 303) - (h + gap + ch) / 2) / 6) * 6);
|
|
3312
|
+
if (text && o.rule) packRect(slide, S, { ...g.span(1, 12), y: y + h + 30, h: 0.75 }, { fill: "line" });
|
|
3313
|
+
if (text) packText(slide, S, text, { ...span, y: y + h + gap, h: ch }, { size: lead, colour: S.colour(onField ? "on-field" : "ink-muted"), lineSpacing: 1.4 });
|
|
3314
|
+
const box = { ...span, y, h };
|
|
3315
|
+
packText(slide, S, spec.title, box, { face: "display", size, colour: S.colour(o.colour || "ink"), lineSpacing: 1.1, title: true, balance: true, name: "title@section" });
|
|
3316
|
+
return box;
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
/**
|
|
3320
|
+
* A section with no part count whose slides carry body lines: the title on the upper body, and under
|
|
3321
|
+
* a hairline what the section opens with, one row per slide (its title, then its first lines) spread
|
|
3322
|
+
* down to the body floor, so the page previews the part instead of standing as a title alone.
|
|
3323
|
+
*/
|
|
3324
|
+
function sectionDigestPage(slide, spec, S, o, d) {
|
|
3325
|
+
const g = S.grid;
|
|
3326
|
+
const head = S.sizes.lead;
|
|
3327
|
+
const body = S.sizes.body;
|
|
3328
|
+
// The preview starts at the margin and is the widest column span within 70 % of the page and about 70
|
|
3329
|
+
// characters of body text: Korean 8 columns, English 6 on the 16:9 compact grid.
|
|
3330
|
+
const korean = G.isKorean(d.digest.map((e) => e.title + e.lines.join("")).join(""));
|
|
3331
|
+
const cap = Math.min(0.7 * g.W, 70 * body * (korean ? 0.94 : 0.5));
|
|
3332
|
+
let k = 12;
|
|
3333
|
+
while (k > 1 && g.span(1, k).w > cap) k--;
|
|
3334
|
+
const right = g.span(1, k);
|
|
3335
|
+
const ink = S.colour(d.onField ? "on-field" : "ink");
|
|
3336
|
+
const soft = S.colour(d.onField ? "on-field" : "ink-muted");
|
|
3337
|
+
// Each opened slide shows up to two of its lines (a preview, not a copy); lines and then entries
|
|
3338
|
+
// drop until the preview fits between the title and the body floor.
|
|
3339
|
+
const gap = 24;
|
|
3340
|
+
const room = G.ZONE.floor - Math.max(o.top || 0, G.ZONE.body) - d.h - 54;
|
|
3341
|
+
const build = (entries, per) => entries.map((e) => {
|
|
3342
|
+
const text = e.lines.slice(0, per).join("\n");
|
|
3343
|
+
const headH = G.textHeight(e.title, right.w, head, 1.3);
|
|
3344
|
+
const textH = text ? G.textHeight(plainText(text), right.w, body) : 0;
|
|
3345
|
+
return { ...e, text, headH, textH, h: headH + (text ? 6 + textH : 0) };
|
|
3346
|
+
});
|
|
3347
|
+
const height = (rows) => rows.reduce((a, r) => a + r.h, 0) + gap * (rows.length - 1);
|
|
3348
|
+
let rows = null;
|
|
3349
|
+
for (let n = d.digest.length; n >= 1 && !rows; n--) {
|
|
3350
|
+
for (let per = 2; per >= 1; per--) {
|
|
3351
|
+
const tried = build(d.digest.slice(0, n), per);
|
|
3352
|
+
if (height(tried) <= room) { rows = tried; break; }
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
if (!rows) rows = build(d.digest.slice(0, 1), 1);
|
|
3356
|
+
const sum = rows.reduce((a, r) => a + r.h, 0);
|
|
3357
|
+
// Title, hairline and preview form one block centred in the body (under `top` when a band sits
|
|
3358
|
+
// above), like a statement that says what the part holds.
|
|
3359
|
+
const total = d.h + 54 + sum + gap * (rows.length - 1);
|
|
3360
|
+
const y = Math.max(o.top || 0, G.ZONE.body, Math.round(((o.centre || 303) - total / 2) / 6) * 6);
|
|
3361
|
+
const listTop = y + d.h + 54;
|
|
3362
|
+
packRect(slide, S, { ...g.span(1, 12), y: listTop - 18, h: 0.75 }, { fill: d.onField ? "on-field" : "line" });
|
|
3363
|
+
let at = listTop;
|
|
3364
|
+
for (const r of rows) {
|
|
3365
|
+
packText(slide, S, r.title, { ...right, y: at, h: r.headH }, { size: head, bold: true, colour: ink, lineSpacing: 1.3 });
|
|
3366
|
+
if (r.text) packText(slide, S, r.text, { ...right, y: at + r.headH + 6, h: r.textH }, { size: body, colour: soft });
|
|
3367
|
+
at += r.h + gap;
|
|
3368
|
+
}
|
|
3369
|
+
const box = { ...d.span, y, h: d.h };
|
|
3370
|
+
packText(slide, S, spec.title, box, { face: "display", size: d.size, colour: S.colour(o.colour || "ink"), lineSpacing: 1.1, title: true, balance: true, name: "title@section" });
|
|
3371
|
+
return box;
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
/**
|
|
3375
|
+
* A section in a deck with an agenda, set as its index page: the part numeral at the hero step over
|
|
3376
|
+
* the part title on the left, and the agenda down the right columns, one part a line spread over the
|
|
3377
|
+
* body, a hairline above each and a heavier accent rule above this part, which is set in the accent.
|
|
3378
|
+
*/
|
|
3379
|
+
function sectionIndex(slide, spec, S) {
|
|
3380
|
+
const g = S.grid;
|
|
3381
|
+
const left = g.span(1, 6);
|
|
3382
|
+
const right = g.span(8, 12);
|
|
3383
|
+
const names = spec.cover.index;
|
|
3384
|
+
const lead = S.sizes.lead;
|
|
3385
|
+
const rowH = lead * 1.4;
|
|
3386
|
+
const label = (name, i) => `${String(i + 1).padStart(2, "0")} ${name}`;
|
|
3387
|
+
const heights = names.map((name, i) => G.textHeight(label(name, i), right.w, lead, 1.4));
|
|
3388
|
+
const sum = heights.reduce((a, h) => a + h, 0);
|
|
3389
|
+
const after = Math.max(24, Math.min(96 - rowH, (G.ZONE.floor - G.ZONE.body - sum) / Math.max(1, names.length - 1)));
|
|
3390
|
+
const listH = sum + (names.length - 1) * after;
|
|
3391
|
+
const listY = Math.max(G.ZONE.body, Math.round(303 - listH / 2));
|
|
3392
|
+
const quiet = S.colour("ink-muted");
|
|
3393
|
+
const runs = [];
|
|
3394
|
+
let ruleY = listY - 12;
|
|
3395
|
+
names.forEach((name, i) => {
|
|
3396
|
+
const current = i + 1 === spec.part;
|
|
3397
|
+
const colour = current ? S.colour("accent") : quiet;
|
|
3398
|
+
packRect(slide, S, { x: right.x, y: ruleY - (current ? 1 : 0), w: right.w, h: current ? 2 : 0.75 }, { fill: current ? "accent" : "line" });
|
|
3399
|
+
ruleY += heights[i] + after;
|
|
3400
|
+
runs.push({ text: `${String(i + 1).padStart(2, "0")} `, options: { fontFace: S.faces.body.font, fontSize: lead, color: colour, bold: current,
|
|
3401
|
+
...(i ? { paraSpaceBefore: +after.toFixed(1) } : {}) } });
|
|
3402
|
+
runs.push({ text: name, options: { fontFace: (current ? S.faces.title : S.faces.body).font, fontSize: lead, color: colour,
|
|
3403
|
+
bold: current || Boolean(S.faces.body.bold), breakLine: i < names.length - 1 } });
|
|
3404
|
+
});
|
|
3405
|
+
// The gap goes before each part after the first, so the frame ends on the last line.
|
|
3406
|
+
slide.addText(runs, { x: IN(right.x), y: IN(listY), w: IN(right.w), h: IN(listH), align: "left", valign: "top", margin: [0, 0, 0, 0],
|
|
3407
|
+
lineSpacing: +rowH.toFixed(1) });
|
|
3408
|
+
const size = S.sizes.title;
|
|
3409
|
+
const display = S.sizes.display;
|
|
3410
|
+
const h = Math.ceil(S.lines(plainText(spec.title), left.w, display, "display") * display * 1.15);
|
|
3411
|
+
const blockH = size * 1.15 + 24 + h;
|
|
3412
|
+
const y = Math.round((303 - blockH / 2) / 6) * 6;
|
|
3413
|
+
partNumeral(slide, S, spec, { ...left, y, h: size * 1.15 }, size, "accent");
|
|
3414
|
+
packText(slide, S, spec.title, { ...left, y: y + size * 1.15 + 24, h }, { face: "display", size: display, lineSpacing: 1.15, title: true, balance: true, name: "title@section" });
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
/** The part number as the agenda writes it ("03"), followed by the part count when it is known. */
|
|
3418
|
+
function partNumeral(slide, S, spec, box, size, colour) {
|
|
3419
|
+
const face = S.faces.numeral;
|
|
3420
|
+
const base = { fontFace: face.font, bold: Boolean(face.bold), color: S.colour(colour) };
|
|
3421
|
+
const pad = (n) => (spec.parts ? String(n).padStart(2, "0") : String(n));
|
|
3422
|
+
const tracking = S.tracking("numeral", size);
|
|
3423
|
+
const runs = [{ text: pad(spec.part), options: { ...base, fontSize: size, ...(tracking ? { charSpacing: tracking } : {}) } }];
|
|
3424
|
+
// The count is a step below the number, so the number stays the display element.
|
|
3425
|
+
if (spec.parts) runs.push({ text: ` / ${pad(spec.parts)}`, options: { ...base, fontSize: S.sizes.title, color: S.colour(colour === "on-field" ? "on-field" : "ink-muted") } });
|
|
3426
|
+
slide.addText(runs, { x: IN(box.x), y: IN(box.y), w: IN(box.w), h: IN(box.h), align: "left", valign: "bottom", margin: [0, 0, 0, 0],
|
|
3427
|
+
lineSpacing: +(size * 1.15).toFixed(1) });
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
function showsIndex(spec, S) {
|
|
3431
|
+
return Boolean((S.pack.display || {}).index && spec.parts && ((spec.cover || {}).index || []).length >= 2);
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
/** The deck's parts in one line, numbered as in the agenda; this section's part bold in the accent. */
|
|
3435
|
+
function partIndex(slide, S, spec, box, onField = false) {
|
|
3436
|
+
const size = S.sizes.body;
|
|
3437
|
+
const quiet = onField ? mix(S.P["on-field"], S.P.field, 0.3) : S.P["ink-muted"];
|
|
3438
|
+
const runs = [];
|
|
3439
|
+
spec.cover.index.forEach((name, i) => {
|
|
3440
|
+
const current = i + 1 === spec.part;
|
|
3441
|
+
if (i) runs.push({ text: " ", options: { fontFace: S.faces.body.font, fontSize: size } });
|
|
3442
|
+
runs.push({ text: `${String(i + 1).padStart(2, "0")} ${name}`, options: {
|
|
3443
|
+
fontFace: (current ? S.faces.title : S.faces.body).font, fontSize: size, bold: current || Boolean(S.faces.body.bold),
|
|
3444
|
+
color: current ? S.colour(onField ? "on-field" : "accent") : shared().hex(quiet),
|
|
3445
|
+
} });
|
|
3446
|
+
});
|
|
3447
|
+
const text = runs.map((r) => r.text).join("");
|
|
3448
|
+
const h = G.textHeight(text, box.w, size, 1.4);
|
|
3449
|
+
slide.addText(runs, { x: IN(box.x), y: IN(box.y), w: IN(box.w), h: IN(h), align: "left", valign: "top", margin: [0, 0, 0, 0],
|
|
3450
|
+
lineSpacing: +(size * 1.4).toFixed(1) });
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
/** The sample-data tag in the footer zone, on the grid's left edge, in the pack's colours. */
|
|
3454
|
+
function packNotice(slide, S, text) {
|
|
3455
|
+
const label = plainText(text);
|
|
3456
|
+
const size = S.sizes.source;
|
|
3457
|
+
// A tenth of slack over the measured width keeps a Latin notice on one line in every renderer.
|
|
3458
|
+
const w = Math.min(S.grid.span(1, 8).w, G.textEms(label) * size * 1.1 + 24);
|
|
3459
|
+
const box = { x: S.grid.margin, y: G.ZONE.footer, w, h: G.ZONE.footerH };
|
|
3460
|
+
packRect(slide, S, box, { fill: "accent-tint", radius: S.pack.radius, name: "lit-notice tag" });
|
|
3461
|
+
slide.addText(label, {
|
|
3462
|
+
objectName: "lit-notice text",
|
|
3463
|
+
x: IN(box.x + 12), y: IN(box.y), w: IN(w - 24), h: IN(box.h),
|
|
3464
|
+
fontFace: S.faces.label.font, fontSize: size, bold: true, color: S.colour("accent-deep"),
|
|
3465
|
+
align: "left", valign: "middle", margin: [0, 0, 0, 0], fit: "shrink",
|
|
3466
|
+
});
|
|
3467
|
+
}
|
|
3468
|
+
|
|
3469
|
+
async function renderPack(resolved, templateObj, outputPath) {
|
|
3470
|
+
const A = shared();
|
|
3471
|
+
const pack = templateObj.pack;
|
|
3472
|
+
const S = packStyle(pack);
|
|
3473
|
+
A.usePackFonts(S.faces.title.font, S.faces.body.font, pack.tokens);
|
|
3474
|
+
|
|
3475
|
+
const PptxGenJS = require("pptxgenjs");
|
|
3476
|
+
const pptx = new PptxGenJS();
|
|
3477
|
+
pptx.defineLayout({ name: "LIT_PACK", width: IN(S.grid.W), height: IN(S.grid.H) });
|
|
3478
|
+
pptx.layout = "LIT_PACK";
|
|
3479
|
+
pptx.title = (resolved.deck && resolved.deck.title) || "Presentation";
|
|
3480
|
+
pptx.subject = `lit-pptx tonality=${pack.id} density=${pack.dials.density} grid=${S.grid.name} variance=${pack.dials.variance}`;
|
|
3481
|
+
const meta = (resolved.deck && resolved.deck.metadata) || {};
|
|
3482
|
+
const notice = typeof meta.notice === "string" && meta.notice.trim() ? meta.notice.trim() : null;
|
|
3483
|
+
const log = [...(resolved.notes || []).map((n) => `note: ${n}`)];
|
|
3484
|
+
const ctx = { sourceDir: resolved.sourceDir, log };
|
|
3485
|
+
const placementCtx = { rich: false, P: {}, fontRoles: {}, pageH: IN(S.grid.H), sourceDir: resolved.sourceDir, layout: "content" };
|
|
3486
|
+
// No title treatment chosen for fill may cover more than about a third of the content slides.
|
|
3487
|
+
const contentSlides = (resolved.slides || []).filter((s) => s.kind === "content").length;
|
|
3488
|
+
const deck = { used: new Set(), max: pack.dials.varianceMax, count: {}, prev: null, limit: Math.max(3, Math.floor(contentSlides * 0.34)) };
|
|
3489
|
+
const chosenBy = {};
|
|
3490
|
+
const choices = new Map();
|
|
3491
|
+
// One colour per series name across the deck: a series that recedes beside the
|
|
3492
|
+
// highlighted one on a chart with several series keeps that colour when it stands alone on another chart.
|
|
3493
|
+
S.seriesRole = new Map();
|
|
3494
|
+
for (const spec of resolved.slides || []) {
|
|
3495
|
+
for (const item of spec.items || []) {
|
|
3496
|
+
const c = item.type === "kpi-table" && item.content;
|
|
3497
|
+
if (!c || !c.chart || (c.headers || []).length < 3) continue;
|
|
3498
|
+
const names = c.headers.slice(1);
|
|
3499
|
+
const primary = primarySeries(names);
|
|
3500
|
+
names.forEach((n, i) => { const key = seriesKey(n); if (!S.seriesRole.has(key) || i === primary) S.seriesRole.set(key, i === primary ? "primary" : "other"); });
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
// A picture's own proportions, so the layout reads it as drawn (contained in its box).
|
|
3504
|
+
for (const spec of resolved.slides || []) {
|
|
3505
|
+
for (const item of spec.items || []) {
|
|
3506
|
+
if (item.type !== "image" || !item.content || !item.content.src) continue;
|
|
3507
|
+
try { item.imageSize = A.readImageSize(A.resolveImagePath(item.content.src, resolved.sourceDir)); } catch { /* drawn as its box */ }
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
for (const spec of resolved.slides || []) {
|
|
3511
|
+
if (spec.kind === "cover" || spec.kind === "section") continue;
|
|
3512
|
+
const chosen = chooseTreatment(spec, S, deck);
|
|
3513
|
+
deck.used.add(chosen.t);
|
|
3514
|
+
deck.count[chosen.t] = (deck.count[chosen.t] || 0) + 1;
|
|
3515
|
+
deck.prev = chosen.t;
|
|
3516
|
+
choices.set(spec, chosen);
|
|
3517
|
+
}
|
|
3518
|
+
for (const line of spreadTreatments(choices, S, pack, (resolved.slides || []).length)) log.push(line);
|
|
3519
|
+
|
|
3520
|
+
for (const spec of resolved.slides || []) {
|
|
3521
|
+
const slide = pptx.addSlide();
|
|
3522
|
+
slide.background = { color: S.colour("ground") };
|
|
3523
|
+
const stamped = A.stampFamily(slide, spec.family);
|
|
3524
|
+
A.renderPlacements(slide, spec.placements || [], "under", placementCtx);
|
|
3525
|
+
const withDir = { ...spec, sourceDir: resolved.sourceDir };
|
|
3526
|
+
if (spec.kind === "cover") COVERS[spec.variant](slide, withDir, S, meta);
|
|
3527
|
+
else if (spec.kind === "section") SECTIONS[spec.variant](slide, withDir, S);
|
|
3528
|
+
else {
|
|
3529
|
+
const chosen = choices.get(spec);
|
|
3530
|
+
chosenBy[spec.index] = chosen.t;
|
|
3531
|
+
renderContent(slide, spec, S, ctx, chosen);
|
|
3532
|
+
}
|
|
3533
|
+
A.renderPlacements(slide, spec.placements || [], "content", placementCtx);
|
|
3534
|
+
A.renderPlacements(slide, spec.placements || [], "over", placementCtx);
|
|
3535
|
+
if (!stamped()) {
|
|
3536
|
+
// Nothing unnamed was drawn (a statement alone): an empty marker carries the family.
|
|
3537
|
+
slide.addShape("rect", { x: IN(S.grid.margin), y: IN(G.ZONE.top), w: 0, h: 0, fill: { type: "none" }, line: { type: "none" }, objectName: `family@${spec.family}` });
|
|
3538
|
+
}
|
|
3539
|
+
if (notice) packNotice(slide, S, notice);
|
|
3540
|
+
}
|
|
3541
|
+
for (const line of log) console.log(line);
|
|
3542
|
+
console.log(`Treatments: ${Object.entries(chosenBy).map(([i, t]) => `${Number(i) + 1}:${t}`).join(" ")}`);
|
|
3543
|
+
await writeWithEastAsianFaces(pptx, S.faces.body.font, outputPath);
|
|
3544
|
+
return outputPath;
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
/**
|
|
3548
|
+
* Write the deck with the pack's body face also named for East Asian text in charts and the theme.
|
|
3549
|
+
* Chart text otherwise carries only a Latin face, and a renderer sets Hangul axis labels in
|
|
3550
|
+
* whatever system font it finds. Korean runs are tagged ko-KR on the way (see writeTagged).
|
|
3551
|
+
*/
|
|
3552
|
+
async function writeWithEastAsianFaces(pptx, face, outputPath) {
|
|
3553
|
+
await shared().writeTagged(pptx, outputPath, (name, xml) => (/^ppt\/(charts\/chart\d+|theme\/theme\d+)\.xml$/u.test(name)
|
|
3554
|
+
? xml.replace(/<a:ea typeface=""\/>/gu, `<a:ea typeface="${face}"/>`).replace(/(<a:latin typeface="([^"]*)"\/>)(?!<a:ea)/gu, `$1<a:ea typeface="$2"/>`)
|
|
3555
|
+
: xml));
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
module.exports = { renderPack, PLANS, COVERS, SECTIONS };
|