@lutrin/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/design/layouts/before-after.json +6 -0
- package/design/layouts/funnel.json +6 -0
- package/design/layouts/journey.json +5 -0
- package/design/layouts/key-message.json +5 -0
- package/design/layouts/portfolio.json +8 -0
- package/design/layouts/priority-matrix.json +7 -0
- package/design/layouts/pros-cons.json +6 -0
- package/design/layouts/pyramid.json +6 -0
- package/design/layouts/risk-map.json +8 -0
- package/design/layouts/roadmap.json +6 -0
- package/design/themes/default.json +159 -0
- package/package.json +57 -0
- package/src/cli.mjs +1114 -0
- package/src/deck/assets.mjs +910 -0
- package/src/deck/chart.mjs +424 -0
- package/src/deck/context.mjs +62 -0
- package/src/deck/highlight.mjs +206 -0
- package/src/deck/kit.mjs +342 -0
- package/src/deck/layout.mjs +1599 -0
- package/src/deck/parse.mjs +776 -0
- package/src/deck/suggest.mjs +33 -0
- package/src/deck/theme.mjs +1135 -0
- package/src/deck/tokens.mjs +268 -0
- package/src/deck/validate.mjs +737 -0
- package/src/html/render.mjs +1586 -0
- package/src/kit/archive.mjs +448 -0
- package/src/pptx/anim.mjs +196 -0
- package/src/pptx/fonts.mjs +204 -0
- package/src/pptx/morph.mjs +103 -0
- package/src/pptx/render.mjs +1379 -0
- package/src/vendor.mjs +281 -0
- package/src/worker/protocol.d.ts +82 -0
- package/src/worker/worker.mjs +145 -0
|
@@ -0,0 +1,1599 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout engine: IR → scenes.
|
|
3
|
+
*
|
|
4
|
+
* Three passes, like a compiler:
|
|
5
|
+
* 1. analysis — infer each slide's layout from its content (title alone
|
|
6
|
+
* → section; bullets + diagram → split; table → table;
|
|
7
|
+
* etc.);
|
|
8
|
+
* 2. placement — assign each block to a slot (a region in px on the
|
|
9
|
+
* 1280 × 720 surface, aligned to the 8 px grid);
|
|
10
|
+
* 3. pagination — estimate heights and split what overflows into
|
|
11
|
+
* "(cont.)" continuation slides.
|
|
12
|
+
*
|
|
13
|
+
* The scene produced is purely geometric: the renderer has no decision left
|
|
14
|
+
* to make.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import {
|
|
21
|
+
CHROME,
|
|
22
|
+
COLORS,
|
|
23
|
+
LAYER_SHADES,
|
|
24
|
+
PAGE,
|
|
25
|
+
SEMANTIC,
|
|
26
|
+
SPACE,
|
|
27
|
+
TYPE,
|
|
28
|
+
LINE_HEIGHT,
|
|
29
|
+
contentArea,
|
|
30
|
+
} from './tokens.mjs';
|
|
31
|
+
import { ALERT_BLOCK_TYPES, animateFlag, animatePreset, runsToText } from './parse.mjs';
|
|
32
|
+
import { closest } from './suggest.mjs';
|
|
33
|
+
|
|
34
|
+
const PT_TO_PX = 96 / 72;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Height estimation (px)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
function textHeight(text, widthPx, pt, lineHeight = LINE_HEIGHT) {
|
|
41
|
+
const avgChar = pt * PT_TO_PX * 0.52;
|
|
42
|
+
const cpl = Math.max(8, Math.floor(widthPx / avgChar));
|
|
43
|
+
const lines = Math.max(1, Math.ceil((text.length || 1) / cpl));
|
|
44
|
+
return lines * pt * PT_TO_PX * lineHeight;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function blockHeight(block, widthPx) {
|
|
48
|
+
switch (block.type) {
|
|
49
|
+
case 'para':
|
|
50
|
+
return textHeight(runsToText(block.runs), widthPx, TYPE.body) + SPACE.xs;
|
|
51
|
+
case 'bullets':
|
|
52
|
+
return block.items.reduce(
|
|
53
|
+
(h, it) =>
|
|
54
|
+
h +
|
|
55
|
+
textHeight(
|
|
56
|
+
runsToText(it.runs),
|
|
57
|
+
widthPx - 32 - it.level * 24,
|
|
58
|
+
it.level ? TYPE.bulletNested : TYPE.bullet,
|
|
59
|
+
) +
|
|
60
|
+
6,
|
|
61
|
+
SPACE.xs,
|
|
62
|
+
);
|
|
63
|
+
case 'code': {
|
|
64
|
+
const lines = block.source.split('\n').length;
|
|
65
|
+
return lines * TYPE.code * PT_TO_PX * 1.35 + SPACE.lg;
|
|
66
|
+
}
|
|
67
|
+
case 'table': {
|
|
68
|
+
const rowH = (cells) =>
|
|
69
|
+
Math.max(
|
|
70
|
+
...cells.map((c) =>
|
|
71
|
+
textHeight(runsToText(c), (widthPx - 16 * cells.length) / cells.length, TYPE.tableBody),
|
|
72
|
+
),
|
|
73
|
+
TYPE.tableBody * PT_TO_PX * LINE_HEIGHT,
|
|
74
|
+
) + 14;
|
|
75
|
+
return (
|
|
76
|
+
rowH(block.header.length ? block.header : [[]]) +
|
|
77
|
+
block.rows.reduce((h, r) => h + rowH(r), 0) +
|
|
78
|
+
SPACE.xs
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
case 'alert': {
|
|
82
|
+
// only the blocks the renderers actually render count: reserving the
|
|
83
|
+
// height of an ignored block would dig a visual hole in the callout
|
|
84
|
+
const inner = block.blocks
|
|
85
|
+
.filter((b) => ALERT_BLOCK_TYPES.has(b.type))
|
|
86
|
+
.reduce((h, b) => h + blockHeight(b, widthPx - 2 * SPACE.sm), 0);
|
|
87
|
+
return inner + 2 * SPACE.sm + TYPE.small * PT_TO_PX * LINE_HEIGHT;
|
|
88
|
+
}
|
|
89
|
+
case 'metric':
|
|
90
|
+
return 160;
|
|
91
|
+
case 'quote':
|
|
92
|
+
return textHeight(runsToText(block.runs), widthPx - 2 * SPACE.xl, TYPE.quote) + SPACE.xl;
|
|
93
|
+
case 'image':
|
|
94
|
+
case 'mermaid':
|
|
95
|
+
case 'chart':
|
|
96
|
+
return 280; // visuals adapt to their slot; default flow value
|
|
97
|
+
case 'math':
|
|
98
|
+
// one equation "line" per \\ separator (multiline environments)
|
|
99
|
+
return block.source.split('\\\\').length * 56 + SPACE.sm;
|
|
100
|
+
case 'icon':
|
|
101
|
+
return 112;
|
|
102
|
+
case 'heading':
|
|
103
|
+
// imposed size (key message of the focus layout): the text may flow
|
|
104
|
+
// over several lines — the estimate follows; otherwise one title line
|
|
105
|
+
if (block.size) return textHeight(runsToText(block.runs), widthPx, block.size) + SPACE.xs;
|
|
106
|
+
return TYPE.sectionHeading * PT_TO_PX * LINE_HEIGHT + SPACE.xs;
|
|
107
|
+
default:
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Pass 1 — analysis: layout inference
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const flat = (slide) => slide.sections.flatMap((s) => s.blocks);
|
|
117
|
+
const count = (blocks, type) => blocks.filter((b) => b.type === type).length;
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Layout registry (review §3.3, step 2)
|
|
121
|
+
//
|
|
122
|
+
// The built-in layouts are registered here at load time; USER layouts
|
|
123
|
+
// (`layouts/*.json` files next to the deck) are added to it per compilation
|
|
124
|
+
// via loadUserLayouts(). A user layout is a named ALIAS of a built-in
|
|
125
|
+
// layout: `{ "name": "before-after", "base": "comparison", "sections":
|
|
126
|
+
// { "min": 2, "max": 2 } }` — it inherits the placement of its `base`, may
|
|
127
|
+
// tighten the section bounds, and validation ("did you mean",
|
|
128
|
+
// LAYOUT_SECTIONS) as well as capabilities() know about it for free, since
|
|
129
|
+
// they read the registry.
|
|
130
|
+
//
|
|
131
|
+
// LAYOUTS and LAYOUT_SECTIONS stay exported: they are LIVE VIEWS of the
|
|
132
|
+
// registry (same object references — consumers bound through ESM follow the
|
|
133
|
+
// registrations without changing).
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
const REGISTRY = new Map(); // name → { name, base?, sections?, description?, params?, paramSchema?, builtin?, official? }
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Generator parameters (review §3.3, step 3, phase A)
|
|
140
|
+
//
|
|
141
|
+
// Every built-in layout is a GENERATOR: its registry def declares a
|
|
142
|
+
// `paramSchema` (name → { type, domain, default, description }) — the single
|
|
143
|
+
// source of truth. A layouts/*.json file (user) or design/layouts/ file
|
|
144
|
+
// (official) sets parameters AT THE TOP LEVEL of the JSON, exactly like
|
|
145
|
+
// `sections`; registerLayout validates them (types, domains, "did you mean"),
|
|
146
|
+
// capabilities() publishes them. Semantic values reference design TOKENS
|
|
147
|
+
// (panelStyle variants, SEMANTIC tints, LAYER_SHADES shades), never raw
|
|
148
|
+
// colors; the defaults reproduce the historical behaviour exactly (an alias
|
|
149
|
+
// with no parameters stays a pure alias).
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/** Admissible values of the `panels` parameters: the neutral variants of
|
|
153
|
+
* panelStyle() + the four semantic tints — the layout picks the variant,
|
|
154
|
+
* the theme defines its color. */
|
|
155
|
+
const PANEL_VARIANTS = ['muted', 'highlight', 'pillar', 'info', 'success', 'warning', 'danger'];
|
|
156
|
+
const SEMANTIC_KINDS = ['info', 'success', 'warning', 'danger'];
|
|
157
|
+
|
|
158
|
+
const panelsParam = (dflt, what) => ({
|
|
159
|
+
type: 'enum-list',
|
|
160
|
+
values: PANEL_VARIANTS,
|
|
161
|
+
default: dflt,
|
|
162
|
+
description: `panel variant per ${what}, cycling: muted, highlight, pillar or a semantic tint`,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/** Validates a parameter value against its spec; returns the value (copied
|
|
166
|
+
* if a list), throws an Error otherwise. */
|
|
167
|
+
function checkParam(layoutName, key, spec, value) {
|
|
168
|
+
const fail = (why) => {
|
|
169
|
+
throw new Error(`parameter "${key}" of "${layoutName}": ${why}`);
|
|
170
|
+
};
|
|
171
|
+
switch (spec.type) {
|
|
172
|
+
case 'boolean':
|
|
173
|
+
if (typeof value !== 'boolean') fail('true or false expected');
|
|
174
|
+
return value;
|
|
175
|
+
case 'number':
|
|
176
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) fail('number expected');
|
|
177
|
+
if (spec.integer && !Number.isInteger(value)) fail('integer expected');
|
|
178
|
+
if (value < spec.min || value > spec.max)
|
|
179
|
+
fail(`${value} outside the domain ${spec.min}–${spec.max}`);
|
|
180
|
+
return value;
|
|
181
|
+
case 'enum': {
|
|
182
|
+
if (typeof value !== 'string' || !spec.values.includes(value)) {
|
|
183
|
+
const hint = typeof value === 'string' ? closest(value, spec.values) : null;
|
|
184
|
+
fail(
|
|
185
|
+
`"${value}" invalid${hint ? ` — did you mean "${hint}"?` : ''} (values: ${spec.values.join(', ')})`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
case 'enum-list': {
|
|
191
|
+
if (!Array.isArray(value) || !value.length)
|
|
192
|
+
fail(`non-empty list expected (values: ${spec.values.join(', ')})`);
|
|
193
|
+
if (value.length > 16) fail('list too long (16 values maximum)');
|
|
194
|
+
for (const v of value) {
|
|
195
|
+
if (typeof v !== 'string' || !spec.values.includes(v)) {
|
|
196
|
+
const hint = typeof v === 'string' ? closest(v, spec.values) : null;
|
|
197
|
+
fail(
|
|
198
|
+
`"${v}" invalid${hint ? ` — did you mean "${hint}"?` : ''} (values: ${spec.values.join(', ')})`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return [...value];
|
|
203
|
+
}
|
|
204
|
+
case 'number-list': {
|
|
205
|
+
if (!Array.isArray(value) || !value.length) fail('non-empty list of numbers expected');
|
|
206
|
+
if (value.length > 16) fail('list too long (16 values maximum)');
|
|
207
|
+
if (spec.items && value.length !== spec.items) fail(`exactly ${spec.items} values expected`);
|
|
208
|
+
for (const v of value) {
|
|
209
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) fail('numbers expected');
|
|
210
|
+
if (spec.integer && !Number.isInteger(v)) fail('integers expected');
|
|
211
|
+
if (v < spec.min || v > spec.max) fail(`${v} outside the domain ${spec.min}–${spec.max}`);
|
|
212
|
+
}
|
|
213
|
+
if (spec.sumMax && value.reduce((s, v) => s + v, 0) > spec.sumMax)
|
|
214
|
+
fail(`the sum exceeds ${spec.sumMax}`);
|
|
215
|
+
return [...value];
|
|
216
|
+
}
|
|
217
|
+
default:
|
|
218
|
+
return fail(`unknown spec type "${spec.type}" (inconsistent built-in schema)`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Parameter schema of a layout's BASE (the built-in generator) — {} if the
|
|
223
|
+
* base has no parameters, null if the layout is unknown. */
|
|
224
|
+
export function layoutParamSchema(name) {
|
|
225
|
+
const def = REGISTRY.get(name);
|
|
226
|
+
if (!def) return null;
|
|
227
|
+
const root = def.builtin ? def : REGISTRY.get(def.base);
|
|
228
|
+
return root?.paramSchema ?? {};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Effective parameters of a layout: the generator's defaults, overridden by
|
|
232
|
+
* the def (official or user alias). {} if the layout is unknown. */
|
|
233
|
+
export function layoutParams(name) {
|
|
234
|
+
const def = REGISTRY.get(name);
|
|
235
|
+
if (!def) return {};
|
|
236
|
+
const root = def.builtin ? def : REGISTRY.get(def.base);
|
|
237
|
+
const out = {};
|
|
238
|
+
for (const [k, s] of Object.entries(root?.paramSchema ?? {})) {
|
|
239
|
+
out[k] = Array.isArray(s.default) ? [...s.default] : s.default;
|
|
240
|
+
}
|
|
241
|
+
return Object.assign(out, def.params);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Layouts that `<!-- layout: … -->` can impose (a live view of the
|
|
245
|
+
* registry, the source of truth for validation and `capabilities()`). The
|
|
246
|
+
* "structured" layouts (comparison, pillars, timeline, layers, swot) — and
|
|
247
|
+
* user layouts — are never inferred: they express an intent (to compare, to
|
|
248
|
+
* mark milestones, to stack…) that the content alone does not reveal — they
|
|
249
|
+
* are asked for explicitly. */
|
|
250
|
+
export const LAYOUTS = [];
|
|
251
|
+
|
|
252
|
+
/** Number of `##` sections expected by the column-based or structured
|
|
253
|
+
* layouts (a live view of the registry, the source of truth for validation
|
|
254
|
+
* AND for the placement bounds of the buildScenes switch): the surplus is
|
|
255
|
+
* removed without any other trace — validation is the only place to
|
|
256
|
+
* report it. */
|
|
257
|
+
export const LAYOUT_SECTIONS = {};
|
|
258
|
+
|
|
259
|
+
const LAYOUT_NAME_RE = /^[a-z][a-z0-9-]{1,31}$/;
|
|
260
|
+
|
|
261
|
+
/** Keys common to every definition — any other key name is a parameter of
|
|
262
|
+
* the base generator (validated against its paramSchema). */
|
|
263
|
+
const RESERVED_KEYS = ['name', 'base', 'sections', 'description'];
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Registers a layout. Non-built-in definitions (official or user) require a
|
|
267
|
+
* built-in or official `base` whose placement they inherit; their `sections`
|
|
268
|
+
* bounds must fit inside those of the base, and their other keys are
|
|
269
|
+
* parameters validated against the generator's paramSchema. An alias of an
|
|
270
|
+
* official layout is FLATTENED at registration (base = built-in generator,
|
|
271
|
+
* the official's parameters merged underneath its own). Throws an Error on
|
|
272
|
+
* the first invalid definition — loadUserLayouts() and the official catalog
|
|
273
|
+
* loader turn it into a diagnostic.
|
|
274
|
+
*/
|
|
275
|
+
export function registerLayout(def) {
|
|
276
|
+
if (!def || typeof def !== 'object' || Array.isArray(def))
|
|
277
|
+
throw new Error('layout definition expected (JSON object)');
|
|
278
|
+
const {
|
|
279
|
+
name,
|
|
280
|
+
base,
|
|
281
|
+
sections,
|
|
282
|
+
description,
|
|
283
|
+
builtin = false,
|
|
284
|
+
official = false,
|
|
285
|
+
origin = null,
|
|
286
|
+
paramSchema = null,
|
|
287
|
+
} = def;
|
|
288
|
+
if (typeof name !== 'string' || !LAYOUT_NAME_RE.test(name))
|
|
289
|
+
throw new Error(`name "${name}" invalid (lowercase, digits and hyphens, e.g. "before-after")`);
|
|
290
|
+
if (REGISTRY.has(name)) {
|
|
291
|
+
const prior = REGISTRY.get(name);
|
|
292
|
+
const what = prior.builtin
|
|
293
|
+
? 'built-in layout'
|
|
294
|
+
: prior.official
|
|
295
|
+
? 'official catalog layout'
|
|
296
|
+
: prior.origin
|
|
297
|
+
? `layout provided by the ${prior.origin} theme`
|
|
298
|
+
: 'user layout already loaded';
|
|
299
|
+
throw new Error(
|
|
300
|
+
`the layout "${name}" already exists (${what})${
|
|
301
|
+
prior.official
|
|
302
|
+
? ' — remove or rename the local definition: the official definition already applies'
|
|
303
|
+
: ''
|
|
304
|
+
}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
let baseDef = null;
|
|
308
|
+
if (!builtin) {
|
|
309
|
+
if (typeof base !== 'string' || !REGISTRY.has(base)) {
|
|
310
|
+
const hint = typeof base === 'string' ? closest(base, [...REGISTRY.keys()]) : null;
|
|
311
|
+
throw new Error(
|
|
312
|
+
`unknown base "${base ?? '(missing)'}"${hint ? ` — did you mean "${hint}"?` : ''} (built-in layouts: ${[...REGISTRY.keys()].filter((n) => REGISTRY.get(n).builtin).join(', ')})`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
baseDef = REGISTRY.get(base);
|
|
316
|
+
if (!baseDef.builtin && !baseDef.official)
|
|
317
|
+
throw new Error(
|
|
318
|
+
`base "${base}" is a user layout — inherit from a built-in or official layout`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
// ultimate built-in generator (an official one already points at its own)
|
|
322
|
+
const rootDef = baseDef ? (baseDef.builtin ? baseDef : REGISTRY.get(baseDef.base)) : null;
|
|
323
|
+
let bounds = null;
|
|
324
|
+
if (sections != null) {
|
|
325
|
+
const { min, max } = sections;
|
|
326
|
+
if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || max < min)
|
|
327
|
+
throw new Error(`sections of "${name}" invalid (integer min/max, 1 ≤ min ≤ max)`);
|
|
328
|
+
bounds = { min, max };
|
|
329
|
+
const ref = baseDef?.sections;
|
|
330
|
+
if (ref && (min < ref.min || max > ref.max))
|
|
331
|
+
throw new Error(
|
|
332
|
+
`sections of "${name}" (${min}–${max}) outside the bounds of base "${base}" (${ref.min}–${ref.max})`,
|
|
333
|
+
);
|
|
334
|
+
} else if (baseDef?.sections) {
|
|
335
|
+
bounds = { ...baseDef.sections }; // inherited from the base
|
|
336
|
+
}
|
|
337
|
+
let params = null;
|
|
338
|
+
if (!builtin) {
|
|
339
|
+
const schema = rootDef?.paramSchema ?? {};
|
|
340
|
+
const paramNames = Object.keys(schema);
|
|
341
|
+
for (const [key, value] of Object.entries(def)) {
|
|
342
|
+
if (
|
|
343
|
+
RESERVED_KEYS.includes(key) ||
|
|
344
|
+
key === 'builtin' ||
|
|
345
|
+
key === 'official' ||
|
|
346
|
+
key === 'origin'
|
|
347
|
+
)
|
|
348
|
+
continue;
|
|
349
|
+
const spec = schema[key];
|
|
350
|
+
if (!spec) {
|
|
351
|
+
const hint = closest(key, paramNames);
|
|
352
|
+
throw new Error(
|
|
353
|
+
`unknown parameter "${key}" for base "${rootDef?.name ?? base}"${hint ? ` — did you mean "${hint}"?` : ''}${
|
|
354
|
+
paramNames.length
|
|
355
|
+
? ` (parameters: ${paramNames.join(', ')})`
|
|
356
|
+
: ' (this base has no parameters)'
|
|
357
|
+
}`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
(params ??= {})[key] = checkParam(name, key, spec, value);
|
|
361
|
+
}
|
|
362
|
+
// alias of an official one: its inherited settings, its own on top
|
|
363
|
+
if (baseDef && !baseDef.builtin && baseDef.params) params = { ...baseDef.params, ...params };
|
|
364
|
+
}
|
|
365
|
+
const entry = {
|
|
366
|
+
name,
|
|
367
|
+
...(builtin ? { builtin: true } : { base: rootDef.name }),
|
|
368
|
+
...(official ? { official: true } : {}),
|
|
369
|
+
...(origin ? { origin } : {}),
|
|
370
|
+
...(bounds ? { sections: bounds } : {}),
|
|
371
|
+
...(params ? { params } : {}),
|
|
372
|
+
...(typeof description === 'string' && description.trim()
|
|
373
|
+
? { description: description.trim() }
|
|
374
|
+
: {}),
|
|
375
|
+
};
|
|
376
|
+
if (builtin && paramSchema) entry.paramSchema = paramSchema;
|
|
377
|
+
REGISTRY.set(name, entry);
|
|
378
|
+
LAYOUTS.push(name);
|
|
379
|
+
if (bounds) LAYOUT_SECTIONS[name] = bounds;
|
|
380
|
+
return entry;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// built-in layouts — placement lives in the buildScenes switch; each one's
|
|
384
|
+
// paramSchema exposes its settings to the JSON layouts (default = historical
|
|
385
|
+
// behaviour, the switch's literals turned into parameters)
|
|
386
|
+
[
|
|
387
|
+
{ name: 'cover' },
|
|
388
|
+
{ name: 'section' },
|
|
389
|
+
{ name: 'hero' },
|
|
390
|
+
{ name: 'quote' },
|
|
391
|
+
{
|
|
392
|
+
name: 'metrics',
|
|
393
|
+
paramSchema: {
|
|
394
|
+
max: {
|
|
395
|
+
type: 'number',
|
|
396
|
+
min: 1,
|
|
397
|
+
max: 6,
|
|
398
|
+
integer: true,
|
|
399
|
+
default: 4,
|
|
400
|
+
description: 'cap on displayed cards (the surplus is reported by METRICS_DROPPED)',
|
|
401
|
+
},
|
|
402
|
+
cardHeight: {
|
|
403
|
+
type: 'number',
|
|
404
|
+
min: 120,
|
|
405
|
+
max: 320,
|
|
406
|
+
integer: true,
|
|
407
|
+
default: 176,
|
|
408
|
+
description: 'card height (px)',
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
name: 'split',
|
|
414
|
+
paramSchema: {
|
|
415
|
+
ratio: {
|
|
416
|
+
type: 'number',
|
|
417
|
+
min: 0.2,
|
|
418
|
+
max: 0.8,
|
|
419
|
+
default: 0.42,
|
|
420
|
+
description: 'share of the width taken by the text (the visual takes the rest)',
|
|
421
|
+
},
|
|
422
|
+
side: {
|
|
423
|
+
type: 'enum',
|
|
424
|
+
values: ['right', 'left'],
|
|
425
|
+
default: 'right',
|
|
426
|
+
description: 'side of the visual ( forces it image by image)',
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
{ name: 'two-columns', sections: { min: 2, max: 2 } },
|
|
431
|
+
{ name: 'three-columns', sections: { min: 3, max: 3 } },
|
|
432
|
+
{
|
|
433
|
+
name: 'comparison',
|
|
434
|
+
sections: { min: 2, max: 2 },
|
|
435
|
+
paramSchema: {
|
|
436
|
+
panels: panelsParam(['muted', 'highlight'], 'column'),
|
|
437
|
+
pad: {
|
|
438
|
+
type: 'number',
|
|
439
|
+
min: 0,
|
|
440
|
+
max: 48,
|
|
441
|
+
integer: true,
|
|
442
|
+
default: 16,
|
|
443
|
+
description: 'inner padding of the panels (px)',
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
name: 'pillars',
|
|
449
|
+
sections: { min: 2, max: 4 },
|
|
450
|
+
paramSchema: {
|
|
451
|
+
panels: panelsParam(['pillar'], 'pillar'),
|
|
452
|
+
accent: { type: 'boolean', default: true, description: 'accent bar at the top of pillars' },
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
name: 'timeline',
|
|
457
|
+
sections: { min: 2, max: 6 },
|
|
458
|
+
paramSchema: {
|
|
459
|
+
dot: {
|
|
460
|
+
type: 'number',
|
|
461
|
+
min: 20,
|
|
462
|
+
max: 48,
|
|
463
|
+
integer: true,
|
|
464
|
+
default: 28,
|
|
465
|
+
description: 'diameter of the dots (px)',
|
|
466
|
+
},
|
|
467
|
+
arrow: { type: 'boolean', default: true, description: 'arrowhead at the end of the axis' },
|
|
468
|
+
numbered: {
|
|
469
|
+
type: 'boolean',
|
|
470
|
+
default: true,
|
|
471
|
+
description: 'numbered dots (otherwise solid)',
|
|
472
|
+
},
|
|
473
|
+
orientation: {
|
|
474
|
+
type: 'enum',
|
|
475
|
+
values: ['horizontal', 'vertical'],
|
|
476
|
+
default: 'horizontal',
|
|
477
|
+
description: 'horizontal axis, or vertical on the left (roadmap in a column)',
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
name: 'layers',
|
|
483
|
+
sections: { min: 2, max: 5 },
|
|
484
|
+
paramSchema: {
|
|
485
|
+
ratios: {
|
|
486
|
+
type: 'number-list',
|
|
487
|
+
min: 0.1,
|
|
488
|
+
max: 0.9,
|
|
489
|
+
items: 2,
|
|
490
|
+
sumMax: 1,
|
|
491
|
+
default: [0.3, 0.68],
|
|
492
|
+
description: 'width shares of a band, title / body',
|
|
493
|
+
},
|
|
494
|
+
shades: {
|
|
495
|
+
type: 'number-list',
|
|
496
|
+
min: 0,
|
|
497
|
+
max: 4,
|
|
498
|
+
integer: true,
|
|
499
|
+
default: null,
|
|
500
|
+
description: 'LAYER_SHADES indices per layer, cycling (default: dark to light)',
|
|
501
|
+
},
|
|
502
|
+
shape: {
|
|
503
|
+
type: 'enum',
|
|
504
|
+
values: ['stack', 'funnel', 'pyramid'],
|
|
505
|
+
default: 'stack',
|
|
506
|
+
description: 'full-width bands (stack), a funnel (narrowing) or a pyramid (widening)',
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
name: 'swot',
|
|
512
|
+
sections: { min: 4, max: 4 },
|
|
513
|
+
paramSchema: {
|
|
514
|
+
kinds: {
|
|
515
|
+
type: 'enum-list',
|
|
516
|
+
values: SEMANTIC_KINDS,
|
|
517
|
+
default: ['success', 'danger', 'info', 'warning'],
|
|
518
|
+
description: 'semantic tint per quadrant (cycling)',
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
name: 'grid',
|
|
524
|
+
sections: { min: 2, max: 8 },
|
|
525
|
+
paramSchema: {
|
|
526
|
+
cols: {
|
|
527
|
+
type: 'number',
|
|
528
|
+
min: 1,
|
|
529
|
+
max: 4,
|
|
530
|
+
integer: true,
|
|
531
|
+
default: 2,
|
|
532
|
+
description: 'number of columns in the mosaic',
|
|
533
|
+
},
|
|
534
|
+
panels: panelsParam(['muted'], 'cell'),
|
|
535
|
+
kinds: {
|
|
536
|
+
type: 'enum-list',
|
|
537
|
+
values: SEMANTIC_KINDS,
|
|
538
|
+
default: null,
|
|
539
|
+
description: 'semantic tints per cell, cycling (takes precedence over panels)',
|
|
540
|
+
},
|
|
541
|
+
headed: {
|
|
542
|
+
type: 'boolean',
|
|
543
|
+
default: false,
|
|
544
|
+
description: 'detached header per cell (title + rule)',
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
name: 'steps',
|
|
550
|
+
sections: { min: 2, max: 6 },
|
|
551
|
+
paramSchema: {
|
|
552
|
+
connector: {
|
|
553
|
+
type: 'enum',
|
|
554
|
+
values: ['arrow', 'line', 'none'],
|
|
555
|
+
default: 'arrow',
|
|
556
|
+
description: 'link between steps: arrow, line or nothing',
|
|
557
|
+
},
|
|
558
|
+
panels: panelsParam(['muted'], 'step'),
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
name: 'focus',
|
|
563
|
+
paramSchema: {
|
|
564
|
+
align: {
|
|
565
|
+
type: 'enum',
|
|
566
|
+
values: ['center', 'left'],
|
|
567
|
+
default: 'center',
|
|
568
|
+
description: 'alignment of the key message',
|
|
569
|
+
},
|
|
570
|
+
accent: {
|
|
571
|
+
type: 'boolean',
|
|
572
|
+
default: true,
|
|
573
|
+
description: 'accent bar above the message',
|
|
574
|
+
},
|
|
575
|
+
scale: {
|
|
576
|
+
type: 'number',
|
|
577
|
+
min: 0.5,
|
|
578
|
+
max: 2.5,
|
|
579
|
+
default: 1,
|
|
580
|
+
description: 'size factor of the key message',
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
{ name: 'table' },
|
|
585
|
+
{ name: 'code' },
|
|
586
|
+
{ name: 'diagram' },
|
|
587
|
+
{ name: 'chart' },
|
|
588
|
+
{ name: 'content' },
|
|
589
|
+
].forEach((def) => registerLayout({ ...def, builtin: true }));
|
|
590
|
+
|
|
591
|
+
// ---------------------------------------------------------------------------
|
|
592
|
+
// OFFICIAL layouts (review §3.3, step 3, phase C): a pure-data catalog
|
|
593
|
+
// shipped with the product (design/layouts/*.json), built on the bases —
|
|
594
|
+
// registered at load time, never reset per deck. Each file follows the user
|
|
595
|
+
// layout schema (base + sections + parameters): the catalog documents the
|
|
596
|
+
// bases by example and serves as living test fixtures.
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
const OFFICIAL_DIR = path.join(
|
|
600
|
+
path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'),
|
|
601
|
+
'design',
|
|
602
|
+
'layouts',
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
/** Problems loading the official catalog (a broken installation) — surfaced
|
|
606
|
+
* as diagnostics by prepareDeckContext on every compilation. */
|
|
607
|
+
export const OFFICIAL_LAYOUT_DIAGS = [];
|
|
608
|
+
|
|
609
|
+
try {
|
|
610
|
+
for (const f of fs
|
|
611
|
+
.readdirSync(OFFICIAL_DIR)
|
|
612
|
+
.filter((x) => x.toLowerCase().endsWith('.json'))
|
|
613
|
+
.sort()) {
|
|
614
|
+
try {
|
|
615
|
+
// builtin/official forced: a data file never decides its own level of
|
|
616
|
+
// privilege in the registry
|
|
617
|
+
registerLayout({
|
|
618
|
+
...JSON.parse(fs.readFileSync(path.join(OFFICIAL_DIR, f), 'utf8')),
|
|
619
|
+
builtin: false,
|
|
620
|
+
official: true,
|
|
621
|
+
});
|
|
622
|
+
} catch (e) {
|
|
623
|
+
OFFICIAL_LAYOUT_DIAGS.push({
|
|
624
|
+
severity: 'warning',
|
|
625
|
+
code: 'LAYOUT_DEF_INVALID',
|
|
626
|
+
message: `Official layout design/layouts/${f}: ${e?.message ?? e} — ignored.`,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
} catch {
|
|
631
|
+
// no catalog (partial installation): the built-in bases are enough
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Definition of a registered layout (null if unknown). */
|
|
635
|
+
export const layoutDef = (name) => REGISTRY.get(name) ?? null;
|
|
636
|
+
|
|
637
|
+
/** User layouts currently registered (for capabilities()). */
|
|
638
|
+
export const userLayouts = () => [...REGISTRY.values()].filter((d) => !d.builtin && !d.official);
|
|
639
|
+
|
|
640
|
+
/** Official layouts of the design/layouts/ catalog (for capabilities()). */
|
|
641
|
+
export const officialLayouts = () => [...REGISTRY.values()].filter((d) => d.official);
|
|
642
|
+
|
|
643
|
+
/** Removes every user layout — called at the head of each compilation: a
|
|
644
|
+
* warm worker must never serve one deck the layouts of another. The
|
|
645
|
+
* built-in AND the official ones stay. */
|
|
646
|
+
export function resetUserLayouts() {
|
|
647
|
+
for (const [name, def] of REGISTRY) {
|
|
648
|
+
if (def.builtin || def.official) continue;
|
|
649
|
+
REGISTRY.delete(name);
|
|
650
|
+
delete LAYOUT_SECTIONS[name];
|
|
651
|
+
}
|
|
652
|
+
LAYOUTS.splice(0, LAYOUTS.length, ...REGISTRY.keys());
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Tolerant loader for a directory of JSON layouts (one file = one
|
|
657
|
+
* definition). Never throws: every invalid file becomes a diagnostic and is
|
|
658
|
+
* ignored. `describe(f)` labels the file in the messages; `origin` (the name
|
|
659
|
+
* of the theme package) is set on the registry entry so collision messages
|
|
660
|
+
* can be attributed.
|
|
661
|
+
*/
|
|
662
|
+
function loadLayoutDir(dir, describe, origin = null) {
|
|
663
|
+
const diags = [];
|
|
664
|
+
let files;
|
|
665
|
+
try {
|
|
666
|
+
files = fs
|
|
667
|
+
.readdirSync(dir)
|
|
668
|
+
.filter((f) => f.toLowerCase().endsWith('.json'))
|
|
669
|
+
.sort();
|
|
670
|
+
} catch {
|
|
671
|
+
return diags; // no directory: nothing to load
|
|
672
|
+
}
|
|
673
|
+
for (const f of files) {
|
|
674
|
+
const file = path.join(dir, f);
|
|
675
|
+
let def;
|
|
676
|
+
try {
|
|
677
|
+
def = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
678
|
+
} catch (e) {
|
|
679
|
+
diags.push({
|
|
680
|
+
severity: 'warning',
|
|
681
|
+
code: 'LAYOUT_DEF_INVALID',
|
|
682
|
+
message: `Layout ${describe(f)} could not be read (invalid JSON: ${e?.message ?? e}) — ignored.`,
|
|
683
|
+
});
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (def && typeof def === 'object' && !Array.isArray(def)) {
|
|
687
|
+
// accepted keys: the four common ones + the parameters of the base's
|
|
688
|
+
// generator — if the base does not resolve, nothing is filtered (it is
|
|
689
|
+
// the base itself that registerLayout will report)
|
|
690
|
+
const schema = typeof def.base === 'string' ? layoutParamSchema(def.base) : null;
|
|
691
|
+
if (schema) {
|
|
692
|
+
const known = new Set([...RESERVED_KEYS, ...Object.keys(schema)]);
|
|
693
|
+
for (const key of Object.keys(def)) {
|
|
694
|
+
if (known.has(key)) continue;
|
|
695
|
+
diags.push({
|
|
696
|
+
severity: 'warning',
|
|
697
|
+
code: 'LAYOUT_DEF_ADJUSTED',
|
|
698
|
+
message: `Layout ${describe(f)}: unknown key "${key}" — ignored.`,
|
|
699
|
+
...(closest(key, [...known]) ? { suggestion: closest(key, [...known]) } : {}),
|
|
700
|
+
});
|
|
701
|
+
delete def[key];
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
// builtin/official/origin forced: a data file cannot register a layout
|
|
707
|
+
// that would survive the reset (a ghost leaking between the decks of a
|
|
708
|
+
// warm host), pass itself off as an official one, or claim a
|
|
709
|
+
// provenance of its own
|
|
710
|
+
registerLayout({ ...def, builtin: false, official: false, origin });
|
|
711
|
+
} catch (e) {
|
|
712
|
+
diags.push({
|
|
713
|
+
severity: 'warning',
|
|
714
|
+
code: 'LAYOUT_DEF_INVALID',
|
|
715
|
+
message: `Layout ${describe(f)}: ${e?.message ?? e} — ignored.`,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return diags;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Loads the user layouts from the `layouts/` directory next to the deck.
|
|
724
|
+
*
|
|
725
|
+
* @returns {Array<{severity, code, message, suggestion?}>}
|
|
726
|
+
*/
|
|
727
|
+
export function loadUserLayouts(baseDir) {
|
|
728
|
+
return loadLayoutDir(path.join(baseDir, 'layouts'), (f) => `layouts/${f}`);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Loads the layouts provided by a KIT (the layouts/ directory resolved by
|
|
733
|
+
* resolveTheme from the kit.json). Registered at user level — so reloaded on
|
|
734
|
+
* every compilation by prepareDeckContext, never leaking between the decks of
|
|
735
|
+
* a warm host — but attributed to the kit (`origin`) so collisions are
|
|
736
|
+
* explicit. Loaded BEFORE the deck's own layouts: on a duplicate, it is the
|
|
737
|
+
* deck's definition that is reported and ignored.
|
|
738
|
+
*
|
|
739
|
+
* @returns {Array<{severity, code, message, suggestion?}>}
|
|
740
|
+
*/
|
|
741
|
+
export function loadThemeLayouts(dir, kitName) {
|
|
742
|
+
return loadLayoutDir(dir, (f) => `${kitName}/layouts/${f}`, kitName);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
export function inferLayout(slide, index) {
|
|
746
|
+
if (slide.layout) return slide.layout;
|
|
747
|
+
const blocks = flat(slide);
|
|
748
|
+
const visuals = blocks.filter(
|
|
749
|
+
(b) =>
|
|
750
|
+
b.type === 'mermaid' || b.type === 'chart' || (b.type === 'image' && b.role !== 'background'),
|
|
751
|
+
);
|
|
752
|
+
const textual = blocks.filter((b) => ['bullets', 'para'].includes(b.type));
|
|
753
|
+
|
|
754
|
+
if (blocks.some((b) => b.type === 'image' && (b.role === 'cover' || b.role === 'background')))
|
|
755
|
+
return 'hero';
|
|
756
|
+
// cover/section with no content — but NOT when the slide sketches an outline
|
|
757
|
+
// in columns: two (or three) `##` headings without a body are content that
|
|
758
|
+
// the early return silently threw away (elements=0). We now let it fall
|
|
759
|
+
// through to two-columns/three-columns, which do render those titles. A
|
|
760
|
+
// single heading under a cover stays a cover (the section subtitle is
|
|
761
|
+
// decorative there).
|
|
762
|
+
if (!blocks.length && slide.sections.filter((s) => s.heading).length < 2)
|
|
763
|
+
return index === 0 ? 'cover' : 'section';
|
|
764
|
+
if (
|
|
765
|
+
index === 0 &&
|
|
766
|
+
!slide.sections.some((s) => s.heading) &&
|
|
767
|
+
blocks.every((b) => b.type === 'para') &&
|
|
768
|
+
blocks.length <= 2
|
|
769
|
+
)
|
|
770
|
+
return 'cover';
|
|
771
|
+
if (count(blocks, 'metric') >= 2 && count(blocks, 'metric') >= blocks.length - 1)
|
|
772
|
+
return 'metrics';
|
|
773
|
+
if (blocks.length === 1 && blocks[0].type === 'quote') return 'quote';
|
|
774
|
+
const sections = slide.sections.filter((s) => s.heading || s.blocks.length);
|
|
775
|
+
if (visuals.length && textual.length) return 'split';
|
|
776
|
+
if (count(blocks, 'table') && blocks.length <= 2) return 'table';
|
|
777
|
+
if (sections.filter((s) => s.heading).length === 2) return 'two-columns';
|
|
778
|
+
if (sections.filter((s) => s.heading).length === 3) return 'three-columns';
|
|
779
|
+
if (blocks.length === 1 && blocks[0].type === 'code') return 'code';
|
|
780
|
+
if (blocks.length === 1 && blocks[0].type === 'mermaid') return 'diagram';
|
|
781
|
+
if (blocks.length === 1 && blocks[0].type === 'chart') return 'chart';
|
|
782
|
+
return 'content';
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ---------------------------------------------------------------------------
|
|
786
|
+
// Passes 2 and 3 — placement + pagination
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
/** Flows blocks into a region; returns pages of placed elements. */
|
|
790
|
+
function flowBlocks(blocks, region, { paginate = true } = {}) {
|
|
791
|
+
const pages = [];
|
|
792
|
+
let page = [];
|
|
793
|
+
let y = region.y;
|
|
794
|
+
|
|
795
|
+
const place = (block, h) => {
|
|
796
|
+
page.push({ block, region: { x: region.x, y, w: region.w, h } });
|
|
797
|
+
y += h + SPACE.sm;
|
|
798
|
+
};
|
|
799
|
+
const breakPage = () => {
|
|
800
|
+
if (page.length) pages.push(page);
|
|
801
|
+
page = [];
|
|
802
|
+
y = region.y;
|
|
803
|
+
};
|
|
804
|
+
const fits = (h) => y + h <= region.y + region.h;
|
|
805
|
+
|
|
806
|
+
for (const block of blocks) {
|
|
807
|
+
const rawH = blockHeight(block, region.w);
|
|
808
|
+
|
|
809
|
+
if (!fits(rawH) && paginate) {
|
|
810
|
+
// fine-grained splitting for divisible blocks
|
|
811
|
+
// working copy: shift() must never empty the block held by the IR
|
|
812
|
+
// (the deck has to stay reusable — inspect, a second render)
|
|
813
|
+
if (block.type === 'bullets') {
|
|
814
|
+
const rest = [...block.items];
|
|
815
|
+
// A numbered list that gets split must carry on its numbering on the
|
|
816
|
+
// next slide: without a starting rank, the audience reads "1." twice.
|
|
817
|
+
// Only top-level items count — sub-lists have their own numbering,
|
|
818
|
+
// which does restart at 1 under each parent. The starting rank may
|
|
819
|
+
// already be set: `parse` also splits lists, at the point where a
|
|
820
|
+
// table or a code block comes in between. Starting from 1 here would
|
|
821
|
+
// shift the whole pagination of a chunk cut that way.
|
|
822
|
+
let rank = block.startAt ?? 1;
|
|
823
|
+
while (rest.length) {
|
|
824
|
+
const taken = [];
|
|
825
|
+
while (rest.length) {
|
|
826
|
+
const trial = { ...block, items: [...taken, rest[0]] };
|
|
827
|
+
if (!fits(blockHeight(trial, region.w)) && taken.length) break;
|
|
828
|
+
taken.push(rest.shift());
|
|
829
|
+
}
|
|
830
|
+
const chunk = { ...block, items: taken };
|
|
831
|
+
if (block.ordered && rank > 1) chunk.startAt = rank;
|
|
832
|
+
rank += taken.filter((it) => !it.level).length;
|
|
833
|
+
place(chunk, blockHeight(chunk, region.w));
|
|
834
|
+
if (rest.length) breakPage();
|
|
835
|
+
}
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
if (block.type === 'table') {
|
|
839
|
+
const rest = [...block.rows];
|
|
840
|
+
while (rest.length) {
|
|
841
|
+
const taken = [];
|
|
842
|
+
while (rest.length) {
|
|
843
|
+
const trial = { ...block, rows: [...taken, rest[0]] };
|
|
844
|
+
if (!fits(blockHeight(trial, region.w)) && taken.length) break;
|
|
845
|
+
taken.push(rest.shift());
|
|
846
|
+
}
|
|
847
|
+
place({ ...block, rows: taken }, blockHeight({ ...block, rows: taken }, region.w));
|
|
848
|
+
if (rest.length) breakPage();
|
|
849
|
+
}
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
breakPage();
|
|
853
|
+
}
|
|
854
|
+
place(block, Math.min(rawH, region.h));
|
|
855
|
+
}
|
|
856
|
+
breakPage();
|
|
857
|
+
return pages.length ? pages : [[]];
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Assigns the animation steps (appear on click) to the elements of a scene.
|
|
862
|
+
* One step = one click. Rules:
|
|
863
|
+
* - elements sharing a `group` (a column, a `##` section) appear together,
|
|
864
|
+
* one step per group;
|
|
865
|
+
* - a bullet list appears point by point (one step per item);
|
|
866
|
+
* - any other block is a step of its own.
|
|
867
|
+
* The chrome (title, background hero image) is never animated.
|
|
868
|
+
*/
|
|
869
|
+
function assignAnimSteps(scene) {
|
|
870
|
+
let step = 0;
|
|
871
|
+
let prevGroup = null;
|
|
872
|
+
for (const el of scene.elements) {
|
|
873
|
+
const group = el.group ?? el.block.group;
|
|
874
|
+
if (group != null) {
|
|
875
|
+
if (prevGroup === null || group !== prevGroup) step++;
|
|
876
|
+
prevGroup = group;
|
|
877
|
+
el.step = step - 1;
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
prevGroup = null;
|
|
881
|
+
if (el.block.type === 'bullets' && el.block.items.length > 1) {
|
|
882
|
+
el.step = step;
|
|
883
|
+
el.stepCount = el.block.items.length;
|
|
884
|
+
step += el.stepCount;
|
|
885
|
+
} else {
|
|
886
|
+
el.step = step++;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
if (step) scene.animSteps = step;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** A last visual alone at the end of the flow stretches to the region's
|
|
893
|
+
* bottom. */
|
|
894
|
+
function stretchTrailingVisual(elements, region) {
|
|
895
|
+
const last = elements[elements.length - 1];
|
|
896
|
+
if (last && ['image', 'mermaid', 'chart'].includes(last.block.type)) {
|
|
897
|
+
last.region.h = Math.max(last.region.h, region.y + region.h - last.region.y);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Compiles the deck into scenes ready to render.
|
|
903
|
+
* @returns {Array<{master:string, layout:string, title:string|null, titleRuns:any, notes:string[], elements:any[]}>}
|
|
904
|
+
*/
|
|
905
|
+
export function buildScenes(deck) {
|
|
906
|
+
const scenes = [];
|
|
907
|
+
const meta = deck.meta ?? {};
|
|
908
|
+
const deckAnimate = meta.animate != null && animateFlag(meta.animate);
|
|
909
|
+
const deckPreset = meta.animate != null ? animatePreset(meta.animate) : null;
|
|
910
|
+
|
|
911
|
+
// Implicit cover slide from the frontmatter
|
|
912
|
+
if (meta.title) {
|
|
913
|
+
scenes.push({
|
|
914
|
+
master: 'cover',
|
|
915
|
+
layout: 'cover',
|
|
916
|
+
title: meta.title,
|
|
917
|
+
subtitle: meta.subtitle ?? '',
|
|
918
|
+
byline: [meta.author, meta.date].filter(Boolean).join(' · '),
|
|
919
|
+
notes: [],
|
|
920
|
+
elements: [],
|
|
921
|
+
sourceLine: 1,
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
deck.slides.forEach((slide, idx) => {
|
|
926
|
+
const layout = inferLayout(slide, scenes.length === 0 ? 0 : idx + 1);
|
|
927
|
+
// user layout (registry): the scene keeps its name, the placement is that
|
|
928
|
+
// of the built-in `base` layout; the section bounds come from the registry
|
|
929
|
+
// (a single source, shared with validation)
|
|
930
|
+
const kind = REGISTRY.get(layout)?.base ?? layout;
|
|
931
|
+
const bounds = LAYOUT_SECTIONS[layout] ?? null;
|
|
932
|
+
// effective generator parameters: the built-in layout's defaults,
|
|
933
|
+
// overridden by the def (official or user) — phase A of §3.3
|
|
934
|
+
const P = layoutParams(layout);
|
|
935
|
+
const area = contentArea();
|
|
936
|
+
const blocks = flat(slide);
|
|
937
|
+
const base = {
|
|
938
|
+
layout,
|
|
939
|
+
title: slide.title,
|
|
940
|
+
titleRuns: slide.titleRuns,
|
|
941
|
+
notes: slide.notes,
|
|
942
|
+
sourceLine: slide.line,
|
|
943
|
+
};
|
|
944
|
+
const animate = slide.animate ?? deckAnimate;
|
|
945
|
+
// effect imposed for the slide (otherwise anim.mjs picks by block type)
|
|
946
|
+
const preset = slide.animatePreset ?? deckPreset;
|
|
947
|
+
const push = (extra) => {
|
|
948
|
+
const scene = { master: 'content', ...base, ...extra };
|
|
949
|
+
if (animate) {
|
|
950
|
+
assignAnimSteps(scene);
|
|
951
|
+
if (scene.animSteps && preset) scene.animPreset = preset;
|
|
952
|
+
}
|
|
953
|
+
scenes.push(scene);
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
switch (kind) {
|
|
957
|
+
case 'cover': {
|
|
958
|
+
const paras = blocks.filter((b) => b.type === 'para');
|
|
959
|
+
scenes.push({
|
|
960
|
+
master: 'cover',
|
|
961
|
+
layout,
|
|
962
|
+
title: slide.title ?? meta.title ?? '',
|
|
963
|
+
subtitle: paras[0] ? runsToText(paras[0].runs) : (meta.subtitle ?? ''),
|
|
964
|
+
byline: paras[1]
|
|
965
|
+
? runsToText(paras[1].runs)
|
|
966
|
+
: [meta.author, meta.date].filter(Boolean).join(' · '),
|
|
967
|
+
notes: slide.notes,
|
|
968
|
+
elements: [],
|
|
969
|
+
sourceLine: slide.line,
|
|
970
|
+
});
|
|
971
|
+
break;
|
|
972
|
+
}
|
|
973
|
+
case 'section': {
|
|
974
|
+
scenes.push({
|
|
975
|
+
master: 'section',
|
|
976
|
+
layout,
|
|
977
|
+
title: slide.title ?? '',
|
|
978
|
+
notes: slide.notes,
|
|
979
|
+
elements: [],
|
|
980
|
+
sourceLine: slide.line,
|
|
981
|
+
});
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
case 'hero': {
|
|
985
|
+
const img = blocks.find((b) => b.type === 'image');
|
|
986
|
+
const rest = blocks.filter((b) => b !== img);
|
|
987
|
+
push({
|
|
988
|
+
master: 'hero',
|
|
989
|
+
image: img,
|
|
990
|
+
elements: flowBlocks(rest, area, { paginate: false })[0],
|
|
991
|
+
});
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
case 'quote': {
|
|
995
|
+
// a slide forced into `quote` may have no block at all (title only,
|
|
996
|
+
// or only content the layout does not place): we emit an EMPTY scene
|
|
997
|
+
// rather than an element without a `block` — both renderers
|
|
998
|
+
// dereference `el.block.type` without a guard and used to crash on it
|
|
999
|
+
push({ elements: blocks.length ? [{ block: blocks[0], region: { ...area } }] : [] });
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
1002
|
+
case 'metrics': {
|
|
1003
|
+
const metrics = blocks.filter((b) => b.type === 'metric');
|
|
1004
|
+
const rest = blocks.filter((b) => b.type !== 'metric');
|
|
1005
|
+
const max = P.max ?? 4;
|
|
1006
|
+
const cols = Math.min(metrics.length, max);
|
|
1007
|
+
const cardW = (area.w - (cols - 1) * PAGE.gutter) / cols;
|
|
1008
|
+
const cardH = P.cardHeight ?? 176;
|
|
1009
|
+
const elements = metrics.slice(0, max).map((m, k) => ({
|
|
1010
|
+
block: m,
|
|
1011
|
+
region: {
|
|
1012
|
+
x: area.x + k * (cardW + PAGE.gutter),
|
|
1013
|
+
y: area.y + SPACE.sm,
|
|
1014
|
+
w: cardW,
|
|
1015
|
+
h: cardH,
|
|
1016
|
+
},
|
|
1017
|
+
}));
|
|
1018
|
+
// the content under the cards stops at the bottom of the usable area:
|
|
1019
|
+
// the height is DERIVED from the region's real top, it is not
|
|
1020
|
+
// recomputed from area.h (which forgot the SPACE.sm offsetting the
|
|
1021
|
+
// cards and overflowed 16 px below the footer)
|
|
1022
|
+
const belowY = area.y + cardH + SPACE.lg + SPACE.sm;
|
|
1023
|
+
const below = { x: area.x, y: belowY, w: area.w, h: Math.max(0, area.y + area.h - belowY) };
|
|
1024
|
+
elements.push(...flowBlocks(rest, below, { paginate: false })[0]);
|
|
1025
|
+
push({ elements });
|
|
1026
|
+
break;
|
|
1027
|
+
}
|
|
1028
|
+
case 'split': {
|
|
1029
|
+
const isVisual = (b) =>
|
|
1030
|
+
b.type === 'mermaid' ||
|
|
1031
|
+
b.type === 'chart' ||
|
|
1032
|
+
(b.type === 'image' && b.role !== 'background');
|
|
1033
|
+
const visuals = blocks.filter(isVisual);
|
|
1034
|
+
const text = blocks.filter((b) => !isVisual(b));
|
|
1035
|
+
const flip = P.side === 'left' || visuals.some((b) => b.role === 'left'); // visual left
|
|
1036
|
+
const leftW = Math.round((area.w - PAGE.gutter) * (P.ratio ?? 0.42));
|
|
1037
|
+
const rightW = area.w - PAGE.gutter - leftW;
|
|
1038
|
+
const textRegion = flip
|
|
1039
|
+
? { x: area.x + rightW + PAGE.gutter, y: area.y, w: leftW, h: area.h }
|
|
1040
|
+
: { x: area.x, y: area.y, w: leftW, h: area.h };
|
|
1041
|
+
const visRegion = flip
|
|
1042
|
+
? { x: area.x, y: area.y, w: rightW, h: area.h }
|
|
1043
|
+
: { x: area.x + leftW + PAGE.gutter, y: area.y, w: rightW, h: area.h };
|
|
1044
|
+
const elements = flowBlocks(text, textRegion, { paginate: false })[0];
|
|
1045
|
+
const visH = (visRegion.h - (visuals.length - 1) * PAGE.gutter) / visuals.length;
|
|
1046
|
+
visuals.forEach((v, k) =>
|
|
1047
|
+
elements.push({
|
|
1048
|
+
block: v,
|
|
1049
|
+
region: { ...visRegion, y: visRegion.y + k * (visH + PAGE.gutter), h: visH },
|
|
1050
|
+
}),
|
|
1051
|
+
);
|
|
1052
|
+
push({ elements });
|
|
1053
|
+
break;
|
|
1054
|
+
}
|
|
1055
|
+
case 'two-columns':
|
|
1056
|
+
case 'three-columns': {
|
|
1057
|
+
const sections = slide.sections.filter((s) => s.heading || s.blocks.length);
|
|
1058
|
+
// LEAD: what is written BEFORE the first "##" is not a column — it is
|
|
1059
|
+
// an opening. It flows full width above, and the columns start again
|
|
1060
|
+
// underneath it. Without this it consumed a column and the LAST
|
|
1061
|
+
// titled section vanished without a word: the engine only drops
|
|
1062
|
+
// content at the bounds the registry announces (LAYOUT_SECTIONS,
|
|
1063
|
+
// which validation reports), never by an accident of writing. A slide
|
|
1064
|
+
// forced into columns WITHOUT any "##" keeps its original placement:
|
|
1065
|
+
// its single anonymous section stays a column.
|
|
1066
|
+
const lead =
|
|
1067
|
+
sections.length && !sections[0].heading && sections.some((s) => s.heading)
|
|
1068
|
+
? sections.shift()
|
|
1069
|
+
: null;
|
|
1070
|
+
const nCols = bounds?.max ?? (kind === 'two-columns' ? 2 : 3);
|
|
1071
|
+
const colW = (area.w - (nCols - 1) * PAGE.gutter) / nCols;
|
|
1072
|
+
const elements = [];
|
|
1073
|
+
let top = area.y;
|
|
1074
|
+
if (lead) {
|
|
1075
|
+
const flowed = flowBlocks(lead.blocks, { ...area }, { paginate: false })[0];
|
|
1076
|
+
flowed.forEach((el) => {
|
|
1077
|
+
el.group = 0;
|
|
1078
|
+
}); // animation: the lead = one step
|
|
1079
|
+
elements.push(...flowed);
|
|
1080
|
+
// the lead is NOT bounded in height: if it eats the slide, it is
|
|
1081
|
+
// BLOCK_OVERFLOW (validate) that says so — the engine does not
|
|
1082
|
+
// silently trim what the author wrote
|
|
1083
|
+
top = flowed.reduce((m, el) => Math.max(m, el.region.y + el.region.h), area.y) + SPACE.md;
|
|
1084
|
+
}
|
|
1085
|
+
const colH = Math.max(0, area.y + area.h - top);
|
|
1086
|
+
sections.slice(0, nCols).forEach((sec, k) => {
|
|
1087
|
+
const col = { x: area.x + k * (colW + PAGE.gutter), y: top, w: colW, h: colH };
|
|
1088
|
+
const colBlocks = sec.heading
|
|
1089
|
+
? [{ type: 'heading', depth: 2, runs: sec.heading }, ...sec.blocks]
|
|
1090
|
+
: sec.blocks;
|
|
1091
|
+
const flowed = flowBlocks(colBlocks, col, { paginate: false })[0];
|
|
1092
|
+
stretchTrailingVisual(flowed, col);
|
|
1093
|
+
// animation: one column = one step, after the lead's
|
|
1094
|
+
flowed.forEach((el) => {
|
|
1095
|
+
el.group = lead ? k + 1 : k;
|
|
1096
|
+
});
|
|
1097
|
+
elements.push(...flowed);
|
|
1098
|
+
});
|
|
1099
|
+
push({ elements });
|
|
1100
|
+
break;
|
|
1101
|
+
}
|
|
1102
|
+
case 'comparison':
|
|
1103
|
+
case 'pillars': {
|
|
1104
|
+
// panels side by side: a comparison (current state understated /
|
|
1105
|
+
// target highlighted) or pillars (architecture principles, accent on
|
|
1106
|
+
// top) — per-column variants configurable (`panels`, cycling)
|
|
1107
|
+
const secs = slide.sections.filter((s) => s.heading || s.blocks.length);
|
|
1108
|
+
const nCols =
|
|
1109
|
+
kind === 'comparison'
|
|
1110
|
+
? (bounds?.max ?? 2)
|
|
1111
|
+
: Math.min(Math.max(secs.length, bounds?.min ?? 2), bounds?.max ?? 4);
|
|
1112
|
+
const colW = (area.w - (nCols - 1) * PAGE.gutter) / nCols;
|
|
1113
|
+
const pad = P.pad ?? SPACE.sm;
|
|
1114
|
+
const panels = P.panels ?? (kind === 'comparison' ? ['muted', 'highlight'] : ['pillar']);
|
|
1115
|
+
const elements = [];
|
|
1116
|
+
secs.slice(0, nCols).forEach((sec, k) => {
|
|
1117
|
+
const col = {
|
|
1118
|
+
x: area.x + k * (colW + PAGE.gutter),
|
|
1119
|
+
y: area.y + SPACE.xs,
|
|
1120
|
+
w: colW,
|
|
1121
|
+
h: area.h - SPACE.xs,
|
|
1122
|
+
};
|
|
1123
|
+
const spec = panels[k % panels.length];
|
|
1124
|
+
const panel = SEMANTIC_KINDS.includes(spec)
|
|
1125
|
+
? { variant: 'semantic', kind: spec }
|
|
1126
|
+
: { variant: spec };
|
|
1127
|
+
const accented = panel.variant === 'pillar' && P.accent !== false;
|
|
1128
|
+
elements.push({
|
|
1129
|
+
block: {
|
|
1130
|
+
type: 'panel',
|
|
1131
|
+
...panel,
|
|
1132
|
+
...(panel.variant === 'pillar' && P.accent === false ? { accent: false } : {}),
|
|
1133
|
+
},
|
|
1134
|
+
region: { ...col },
|
|
1135
|
+
group: k,
|
|
1136
|
+
});
|
|
1137
|
+
const padTop = accented ? SPACE.md : pad; // room for the accent
|
|
1138
|
+
const inner = {
|
|
1139
|
+
x: col.x + pad,
|
|
1140
|
+
y: col.y + padTop,
|
|
1141
|
+
w: col.w - 2 * pad,
|
|
1142
|
+
h: col.h - padTop - pad,
|
|
1143
|
+
};
|
|
1144
|
+
const ink = panel.variant === 'semantic' ? SEMANTIC[panel.kind].text : null;
|
|
1145
|
+
const colBlocks = sec.heading
|
|
1146
|
+
? [
|
|
1147
|
+
{ type: 'heading', depth: 2, runs: sec.heading, ...(ink ? { color: ink } : {}) },
|
|
1148
|
+
...sec.blocks,
|
|
1149
|
+
]
|
|
1150
|
+
: sec.blocks;
|
|
1151
|
+
const flowed = flowBlocks(colBlocks, inner, { paginate: false })[0];
|
|
1152
|
+
flowed.forEach((el) => {
|
|
1153
|
+
el.group = k;
|
|
1154
|
+
}); // animation: one panel = one step
|
|
1155
|
+
elements.push(...flowed);
|
|
1156
|
+
});
|
|
1157
|
+
push({ elements });
|
|
1158
|
+
break;
|
|
1159
|
+
}
|
|
1160
|
+
case 'timeline': {
|
|
1161
|
+
// milestones on an axis: each `##` section is a step (a phase, a
|
|
1162
|
+
// date); a dot on the axis, content beside it — horizontal axis
|
|
1163
|
+
// (default) or vertical on the left (roadmap in a column)
|
|
1164
|
+
const secs = slide.sections.filter((s) => s.heading || s.blocks.length);
|
|
1165
|
+
// lower bound at 1 (not the registry min): a single section still
|
|
1166
|
+
// renders one milestone — validation reports the shortfall, no blank
|
|
1167
|
+
const n = Math.min(Math.max(secs.length, 1), bounds?.max ?? 6);
|
|
1168
|
+
const dotR = P.dot ?? 28;
|
|
1169
|
+
// only add the attributes for non-default values: the scenes of decks
|
|
1170
|
+
// without parameters stay identical (goldens intact)
|
|
1171
|
+
const dotBlock = (k) => ({
|
|
1172
|
+
type: 'timeline-dot',
|
|
1173
|
+
index: k + 1,
|
|
1174
|
+
...(P.numbered === false ? { numbered: false } : {}),
|
|
1175
|
+
});
|
|
1176
|
+
const axisBlock = { type: 'timeline-axis', ...(P.arrow === false ? { arrow: false } : {}) };
|
|
1177
|
+
const headed = (sec) =>
|
|
1178
|
+
sec.heading
|
|
1179
|
+
? [
|
|
1180
|
+
{ type: 'heading', depth: 2, runs: sec.heading, color: COLORS.primaryDarker },
|
|
1181
|
+
...sec.blocks,
|
|
1182
|
+
]
|
|
1183
|
+
: sec.blocks;
|
|
1184
|
+
const elements = [];
|
|
1185
|
+
if (P.orientation === 'vertical') {
|
|
1186
|
+
const axisX = area.x + dotR / 2;
|
|
1187
|
+
const rowGap = SPACE.xs;
|
|
1188
|
+
const rowH = (area.h - SPACE.xs - (n - 1) * rowGap) / n;
|
|
1189
|
+
elements.push({
|
|
1190
|
+
block: { ...axisBlock, vertical: true },
|
|
1191
|
+
region: { x: axisX - 1, y: area.y + SPACE.xs, w: 2, h: area.h - SPACE.xs },
|
|
1192
|
+
});
|
|
1193
|
+
secs.slice(0, n).forEach((sec, k) => {
|
|
1194
|
+
const rowY = area.y + SPACE.xs + k * (rowH + rowGap);
|
|
1195
|
+
const grp = [
|
|
1196
|
+
{ block: dotBlock(k), region: { x: axisX - dotR / 2, y: rowY, w: dotR, h: dotR } },
|
|
1197
|
+
];
|
|
1198
|
+
const rightX = axisX + dotR / 2 + SPACE.md;
|
|
1199
|
+
grp.push(
|
|
1200
|
+
...flowBlocks(
|
|
1201
|
+
headed(sec),
|
|
1202
|
+
{ x: rightX, y: rowY, w: area.x + area.w - rightX, h: rowH },
|
|
1203
|
+
{ paginate: false },
|
|
1204
|
+
)[0],
|
|
1205
|
+
);
|
|
1206
|
+
grp.forEach((el) => {
|
|
1207
|
+
el.group = k;
|
|
1208
|
+
}); // animation: one milestone = one step
|
|
1209
|
+
elements.push(...grp);
|
|
1210
|
+
});
|
|
1211
|
+
push({ elements });
|
|
1212
|
+
break;
|
|
1213
|
+
}
|
|
1214
|
+
const colW = (area.w - (n - 1) * PAGE.gutter) / n;
|
|
1215
|
+
const axisY = area.y + SPACE.md;
|
|
1216
|
+
elements.push({ block: axisBlock, region: { x: area.x, y: axisY - 1, w: area.w, h: 2 } });
|
|
1217
|
+
secs.slice(0, n).forEach((sec, k) => {
|
|
1218
|
+
const colX = area.x + k * (colW + PAGE.gutter);
|
|
1219
|
+
const grp = [
|
|
1220
|
+
{ block: dotBlock(k), region: { x: colX, y: axisY - dotR / 2, w: dotR, h: dotR } },
|
|
1221
|
+
];
|
|
1222
|
+
const below = {
|
|
1223
|
+
x: colX,
|
|
1224
|
+
y: axisY + dotR / 2 + SPACE.sm,
|
|
1225
|
+
w: colW,
|
|
1226
|
+
h: area.y + area.h - axisY - dotR / 2 - SPACE.sm,
|
|
1227
|
+
};
|
|
1228
|
+
grp.push(...flowBlocks(headed(sec), below, { paginate: false })[0]);
|
|
1229
|
+
grp.forEach((el) => {
|
|
1230
|
+
el.group = k;
|
|
1231
|
+
}); // animation: one milestone = one step
|
|
1232
|
+
elements.push(...grp);
|
|
1233
|
+
});
|
|
1234
|
+
push({ elements });
|
|
1235
|
+
break;
|
|
1236
|
+
}
|
|
1237
|
+
case 'layers': {
|
|
1238
|
+
// architecture layers: full-width bands stacked, from the base (dark
|
|
1239
|
+
// shade) to the surface — `##` sections, or the items of a bullet
|
|
1240
|
+
// list if the slide has no sections
|
|
1241
|
+
const secs = slide.sections.filter((s) => s.heading);
|
|
1242
|
+
const bulletsOnly = !secs.length && blocks.length === 1 && blocks[0].type === 'bullets';
|
|
1243
|
+
// surplus dropped at the registry bounds — that is what validation
|
|
1244
|
+
// (LAYOUT_SECTIONS) promises; before, the extra bands were silently
|
|
1245
|
+
// crushed one on top of another
|
|
1246
|
+
const items = (
|
|
1247
|
+
bulletsOnly
|
|
1248
|
+
? blocks[0].items.map((it) => ({ runs: it.runs, blocks: [] }))
|
|
1249
|
+
: secs.map((s) => ({ runs: s.heading, blocks: s.blocks }))
|
|
1250
|
+
).slice(0, bounds?.max ?? 5);
|
|
1251
|
+
const n = Math.max(items.length, 1);
|
|
1252
|
+
const gap = SPACE.xs;
|
|
1253
|
+
const bandH = (area.h - SPACE.xs - (n - 1) * gap) / n;
|
|
1254
|
+
const headH = TYPE.sectionHeading * PT_TO_PX * LINE_HEIGHT;
|
|
1255
|
+
const [titleRatio, bodyRatio] = P.ratios ?? [0.3, 0.68];
|
|
1256
|
+
// body start: title + a little slack — historical literal 0.32 when
|
|
1257
|
+
// nothing overrides it (0.3 + 0.02 is not 0.32 in floating point, and
|
|
1258
|
+
// the goldens must stay intact to the bit)
|
|
1259
|
+
const bodyStart = P.ratios ? titleRatio + 0.02 : 0.32;
|
|
1260
|
+
// funnel / pyramid: relative band width, linear between 1 and 0.45,
|
|
1261
|
+
// centered — stack (default): full width
|
|
1262
|
+
const minW = 0.45;
|
|
1263
|
+
const widthAt = (k) =>
|
|
1264
|
+
P.shape === 'funnel'
|
|
1265
|
+
? 1 - (k * (1 - minW)) / Math.max(n - 1, 1)
|
|
1266
|
+
: P.shape === 'pyramid'
|
|
1267
|
+
? minW + (k * (1 - minW)) / Math.max(n - 1, 1)
|
|
1268
|
+
: 1;
|
|
1269
|
+
const elements = [];
|
|
1270
|
+
items.forEach((it, k) => {
|
|
1271
|
+
// shades spread over the palette: 3 layers → dark, medium, light
|
|
1272
|
+
// (or imposed by the `shades` parameter, cycling)
|
|
1273
|
+
// the index is CLAMPED to the last available shade: a kit that
|
|
1274
|
+
// provides fewer shades than the `shades` asked for (or than there
|
|
1275
|
+
// are layers) keeps a monotonic gradient instead of crashing the
|
|
1276
|
+
// layout on an `.ink` of undefined. It is the CLAMPED index that
|
|
1277
|
+
// goes into the `panel` block: panelStyle() and the ink must point
|
|
1278
|
+
// at the same shade, or the text is computed for a background it
|
|
1279
|
+
// does not have.
|
|
1280
|
+
const lastShade = LAYER_SHADES.length - 1;
|
|
1281
|
+
const wanted = P.shades
|
|
1282
|
+
? P.shades[k % P.shades.length]
|
|
1283
|
+
: n > 1
|
|
1284
|
+
? Math.round((k * Math.max(lastShade, 0)) / (n - 1))
|
|
1285
|
+
: 0;
|
|
1286
|
+
const shade = Math.min(Math.max(wanted, 0), Math.max(lastShade, 0));
|
|
1287
|
+
// empty palette (a theme overwriting LAYER_SHADES): readable neutral ink
|
|
1288
|
+
const ink = LAYER_SHADES[shade]?.ink ?? COLORS.neutralPrimary;
|
|
1289
|
+
const bandW = area.w * widthAt(k);
|
|
1290
|
+
const band = {
|
|
1291
|
+
x: area.x + (area.w - bandW) / 2,
|
|
1292
|
+
y: area.y + SPACE.xs + k * (bandH + gap),
|
|
1293
|
+
w: bandW,
|
|
1294
|
+
h: bandH,
|
|
1295
|
+
};
|
|
1296
|
+
elements.push({
|
|
1297
|
+
block: { type: 'panel', variant: 'layer', shade },
|
|
1298
|
+
region: band,
|
|
1299
|
+
group: k,
|
|
1300
|
+
});
|
|
1301
|
+
const hasBody = it.blocks.length > 0;
|
|
1302
|
+
const headW = hasBody ? band.w * titleRatio - SPACE.md : band.w - 2 * SPACE.md;
|
|
1303
|
+
elements.push({
|
|
1304
|
+
block: { type: 'heading', depth: 2, runs: it.runs, color: ink },
|
|
1305
|
+
region: { x: band.x + SPACE.md, y: band.y + (band.h - headH) / 2, w: headW, h: headH },
|
|
1306
|
+
group: k,
|
|
1307
|
+
});
|
|
1308
|
+
if (hasBody) {
|
|
1309
|
+
const body = {
|
|
1310
|
+
x: band.x + band.w * bodyStart,
|
|
1311
|
+
y: band.y + SPACE.xs,
|
|
1312
|
+
w: band.w * bodyRatio - SPACE.md,
|
|
1313
|
+
h: band.h - 2 * SPACE.xs,
|
|
1314
|
+
};
|
|
1315
|
+
const flowed = flowBlocks(
|
|
1316
|
+
it.blocks.map((b) => ({ ...b, color: ink })),
|
|
1317
|
+
body,
|
|
1318
|
+
{ paginate: false },
|
|
1319
|
+
)[0];
|
|
1320
|
+
// description centered vertically in the band, like the title
|
|
1321
|
+
const bottom = flowed.reduce((m, el) => Math.max(m, el.region.y + el.region.h), body.y);
|
|
1322
|
+
const shift = Math.max(0, (body.h - (bottom - body.y)) / 2);
|
|
1323
|
+
flowed.forEach((el) => {
|
|
1324
|
+
el.region.y += shift;
|
|
1325
|
+
el.group = k;
|
|
1326
|
+
});
|
|
1327
|
+
elements.push(...flowed);
|
|
1328
|
+
}
|
|
1329
|
+
});
|
|
1330
|
+
push({ elements });
|
|
1331
|
+
break;
|
|
1332
|
+
}
|
|
1333
|
+
case 'swot': {
|
|
1334
|
+
// 2 × 2 matrix: sections in the order Strengths, Weaknesses,
|
|
1335
|
+
// Opportunities, Threats — panels in the semantic tints
|
|
1336
|
+
// (configurable through `kinds`, cycling)
|
|
1337
|
+
const secs = slide.sections.filter((s) => s.heading || s.blocks.length);
|
|
1338
|
+
const kinds = P.kinds ?? ['success', 'danger', 'info', 'warning'];
|
|
1339
|
+
const maxCells = bounds?.max ?? 4;
|
|
1340
|
+
const rows = Math.max(Math.ceil(Math.min(secs.length, maxCells) / 2), 1);
|
|
1341
|
+
const cellW = (area.w - PAGE.gutter) / 2;
|
|
1342
|
+
const cellH = (area.h - SPACE.xs - (rows - 1) * PAGE.gutter) / rows;
|
|
1343
|
+
const elements = [];
|
|
1344
|
+
secs.slice(0, maxCells).forEach((sec, k) => {
|
|
1345
|
+
const cell = {
|
|
1346
|
+
x: area.x + (k % 2) * (cellW + PAGE.gutter),
|
|
1347
|
+
y: area.y + SPACE.xs + Math.floor(k / 2) * (cellH + PAGE.gutter),
|
|
1348
|
+
w: cellW,
|
|
1349
|
+
h: cellH,
|
|
1350
|
+
};
|
|
1351
|
+
const kindK = kinds[k % kinds.length];
|
|
1352
|
+
elements.push({
|
|
1353
|
+
block: { type: 'panel', variant: 'semantic', kind: kindK },
|
|
1354
|
+
region: cell,
|
|
1355
|
+
group: k,
|
|
1356
|
+
});
|
|
1357
|
+
const inner = {
|
|
1358
|
+
x: cell.x + SPACE.sm,
|
|
1359
|
+
y: cell.y + SPACE.sm,
|
|
1360
|
+
w: cell.w - 2 * SPACE.sm,
|
|
1361
|
+
h: cell.h - 2 * SPACE.sm,
|
|
1362
|
+
};
|
|
1363
|
+
const colBlocks = sec.heading
|
|
1364
|
+
? [
|
|
1365
|
+
{ type: 'heading', depth: 2, runs: sec.heading, color: SEMANTIC[kindK].text },
|
|
1366
|
+
...sec.blocks,
|
|
1367
|
+
]
|
|
1368
|
+
: sec.blocks;
|
|
1369
|
+
const flowed = flowBlocks(colBlocks, inner, { paginate: false })[0];
|
|
1370
|
+
flowed.forEach((el) => {
|
|
1371
|
+
el.group = k;
|
|
1372
|
+
}); // animation: one quadrant = one step
|
|
1373
|
+
elements.push(...flowed);
|
|
1374
|
+
});
|
|
1375
|
+
push({ elements });
|
|
1376
|
+
break;
|
|
1377
|
+
}
|
|
1378
|
+
case 'grid': {
|
|
1379
|
+
// R × C mosaic of panels (review §3.3, phase B): a portfolio of
|
|
1380
|
+
// projects, offerings, a team, 2 × 2 matrices — one `##` section =
|
|
1381
|
+
// one cell; `kinds` (semantic) takes precedence over `panels`
|
|
1382
|
+
const secs = slide.sections.filter((s) => s.heading || s.blocks.length);
|
|
1383
|
+
const maxCells = bounds?.max ?? 8;
|
|
1384
|
+
const n = Math.min(Math.max(secs.length, 1), maxCells);
|
|
1385
|
+
const cols = Math.min(P.cols ?? 2, n);
|
|
1386
|
+
const rows = Math.ceil(n / cols);
|
|
1387
|
+
const cellW = (area.w - (cols - 1) * PAGE.gutter) / cols;
|
|
1388
|
+
const cellH = (area.h - SPACE.xs - (rows - 1) * PAGE.gutter) / rows;
|
|
1389
|
+
const headH = TYPE.sectionHeading * PT_TO_PX * LINE_HEIGHT;
|
|
1390
|
+
const panels = P.panels ?? ['muted'];
|
|
1391
|
+
const elements = [];
|
|
1392
|
+
secs.slice(0, n).forEach((sec, k) => {
|
|
1393
|
+
const cell = {
|
|
1394
|
+
x: area.x + (k % cols) * (cellW + PAGE.gutter),
|
|
1395
|
+
y: area.y + SPACE.xs + Math.floor(k / cols) * (cellH + PAGE.gutter),
|
|
1396
|
+
w: cellW,
|
|
1397
|
+
h: cellH,
|
|
1398
|
+
};
|
|
1399
|
+
const spec = P.kinds ? P.kinds[k % P.kinds.length] : panels[k % panels.length];
|
|
1400
|
+
const panel = SEMANTIC_KINDS.includes(spec)
|
|
1401
|
+
? { variant: 'semantic', kind: spec }
|
|
1402
|
+
: { variant: spec };
|
|
1403
|
+
elements.push({ block: { type: 'panel', ...panel }, region: cell, group: k });
|
|
1404
|
+
const inner = {
|
|
1405
|
+
x: cell.x + SPACE.sm,
|
|
1406
|
+
y: cell.y + SPACE.sm,
|
|
1407
|
+
w: cell.w - 2 * SPACE.sm,
|
|
1408
|
+
h: cell.h - 2 * SPACE.sm,
|
|
1409
|
+
};
|
|
1410
|
+
const ink = panel.variant === 'semantic' ? SEMANTIC[panel.kind].text : null;
|
|
1411
|
+
const heading = sec.heading
|
|
1412
|
+
? { type: 'heading', depth: 2, runs: sec.heading, ...(ink ? { color: ink } : {}) }
|
|
1413
|
+
: null;
|
|
1414
|
+
let flowRegion = inner;
|
|
1415
|
+
let cellBlocks = sec.blocks;
|
|
1416
|
+
if (P.headed && heading) {
|
|
1417
|
+
// detached header: title at the top of the cell, rule, content below
|
|
1418
|
+
elements.push({ block: heading, region: { ...inner, h: headH }, group: k });
|
|
1419
|
+
elements.push({
|
|
1420
|
+
block: { type: 'timeline-axis', arrow: false },
|
|
1421
|
+
region: { x: inner.x, y: inner.y + headH + SPACE.xs, w: inner.w, h: 2 },
|
|
1422
|
+
group: k,
|
|
1423
|
+
});
|
|
1424
|
+
const contentY = inner.y + headH + SPACE.xs + SPACE.sm;
|
|
1425
|
+
// height clamped at 0: a dense mosaic (cols: 1 × 8 rows) must
|
|
1426
|
+
// never emit a negative region — the overflow stays visible
|
|
1427
|
+
// through BLOCK_OVERFLOW
|
|
1428
|
+
flowRegion = {
|
|
1429
|
+
x: inner.x,
|
|
1430
|
+
y: contentY,
|
|
1431
|
+
w: inner.w,
|
|
1432
|
+
h: Math.max(0, inner.h - (contentY - inner.y)),
|
|
1433
|
+
};
|
|
1434
|
+
} else if (heading) {
|
|
1435
|
+
cellBlocks = [heading, ...sec.blocks];
|
|
1436
|
+
}
|
|
1437
|
+
const flowed = flowBlocks(cellBlocks, flowRegion, { paginate: false })[0];
|
|
1438
|
+
flowed.forEach((el) => {
|
|
1439
|
+
el.group = k;
|
|
1440
|
+
}); // animation: one cell = one step
|
|
1441
|
+
elements.push(...flowed);
|
|
1442
|
+
});
|
|
1443
|
+
push({ elements });
|
|
1444
|
+
break;
|
|
1445
|
+
}
|
|
1446
|
+
case 'steps': {
|
|
1447
|
+
// sequential process (review §3.3, phase B): step panels joined by
|
|
1448
|
+
// connectors (arrow, line or nothing) — a citizen journey, the path
|
|
1449
|
+
// of a request, a "how it works"
|
|
1450
|
+
const secs = slide.sections.filter((s) => s.heading || s.blocks.length);
|
|
1451
|
+
const n = Math.min(Math.max(secs.length, 1), bounds?.max ?? 6);
|
|
1452
|
+
const connector = P.connector ?? 'arrow';
|
|
1453
|
+
const gap = connector === 'none' ? PAGE.gutter : 40;
|
|
1454
|
+
const stepW = (area.w - (n - 1) * gap) / n;
|
|
1455
|
+
const panels = P.panels ?? ['muted'];
|
|
1456
|
+
const elements = [];
|
|
1457
|
+
secs.slice(0, n).forEach((sec, k) => {
|
|
1458
|
+
const col = {
|
|
1459
|
+
x: area.x + k * (stepW + gap),
|
|
1460
|
+
y: area.y + SPACE.xs,
|
|
1461
|
+
w: stepW,
|
|
1462
|
+
h: area.h - SPACE.xs,
|
|
1463
|
+
};
|
|
1464
|
+
if (k && connector !== 'none') {
|
|
1465
|
+
// the connector appears with the step it introduces (group k)
|
|
1466
|
+
elements.push({
|
|
1467
|
+
block: { type: 'timeline-axis', ...(connector === 'line' ? { arrow: false } : {}) },
|
|
1468
|
+
region: { x: col.x - gap + (gap - 28) / 2, y: col.y + col.h / 2 - 1, w: 28, h: 2 },
|
|
1469
|
+
group: k,
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
const spec = panels[k % panels.length];
|
|
1473
|
+
const panel = SEMANTIC_KINDS.includes(spec)
|
|
1474
|
+
? { variant: 'semantic', kind: spec }
|
|
1475
|
+
: { variant: spec };
|
|
1476
|
+
elements.push({ block: { type: 'panel', ...panel }, region: { ...col }, group: k });
|
|
1477
|
+
const padTop = panel.variant === 'pillar' ? SPACE.md : SPACE.sm; // room for the accent
|
|
1478
|
+
const inner = {
|
|
1479
|
+
x: col.x + SPACE.sm,
|
|
1480
|
+
y: col.y + padTop,
|
|
1481
|
+
w: col.w - 2 * SPACE.sm,
|
|
1482
|
+
h: col.h - padTop - SPACE.sm,
|
|
1483
|
+
};
|
|
1484
|
+
const ink = panel.variant === 'semantic' ? SEMANTIC[panel.kind].text : null;
|
|
1485
|
+
const stepBlocks = sec.heading
|
|
1486
|
+
? [
|
|
1487
|
+
{ type: 'heading', depth: 2, runs: sec.heading, ...(ink ? { color: ink } : {}) },
|
|
1488
|
+
...sec.blocks,
|
|
1489
|
+
]
|
|
1490
|
+
: sec.blocks;
|
|
1491
|
+
const flowed = flowBlocks(stepBlocks, inner, { paginate: false })[0];
|
|
1492
|
+
flowed.forEach((el) => {
|
|
1493
|
+
el.group = k;
|
|
1494
|
+
}); // animation: one step at a time
|
|
1495
|
+
elements.push(...flowed);
|
|
1496
|
+
});
|
|
1497
|
+
push({ elements });
|
|
1498
|
+
break;
|
|
1499
|
+
}
|
|
1500
|
+
case 'focus': {
|
|
1501
|
+
// ONE message (review §3.3, phase B): a large figure or key sentence
|
|
1502
|
+
// filling the frame (the slide's first paragraph), context
|
|
1503
|
+
// underneath — the weapon against the overloaded slide. Any `##`
|
|
1504
|
+
// titles remain content (heading blocks), never thrown away.
|
|
1505
|
+
const withHeads = slide.sections.flatMap((s) =>
|
|
1506
|
+
s.heading ? [{ type: 'heading', depth: 2, runs: s.heading }, ...s.blocks] : s.blocks,
|
|
1507
|
+
);
|
|
1508
|
+
const msgIdx = withHeads.findIndex((b) => b.type === 'para');
|
|
1509
|
+
if (msgIdx < 0) {
|
|
1510
|
+
// no paragraph: plain flow, no pagination
|
|
1511
|
+
push({ elements: flowBlocks(withHeads, area, { paginate: false })[0] });
|
|
1512
|
+
break;
|
|
1513
|
+
}
|
|
1514
|
+
const msg = withHeads[msgIdx];
|
|
1515
|
+
const rest = withHeads.filter((_, i) => i !== msgIdx);
|
|
1516
|
+
const align = P.align ?? 'center';
|
|
1517
|
+
const size = Math.round(TYPE.coverTitle * (P.scale ?? 1));
|
|
1518
|
+
const msgBlock = {
|
|
1519
|
+
type: 'heading',
|
|
1520
|
+
depth: 1,
|
|
1521
|
+
runs: msg.runs,
|
|
1522
|
+
size,
|
|
1523
|
+
...(align === 'center' ? { align } : {}),
|
|
1524
|
+
};
|
|
1525
|
+
const msgH = blockHeight(msgBlock, area.w);
|
|
1526
|
+
// message area: the whole content area, or its upper half if context
|
|
1527
|
+
// follows — the message is centered vertically in it; a message taller
|
|
1528
|
+
// than its area PUSHES the context down rather than overlapping it
|
|
1529
|
+
// (an overflow past the bottom of the page is still reported by
|
|
1530
|
+
// BLOCK_OVERFLOW)
|
|
1531
|
+
const contextY = rest.length
|
|
1532
|
+
? area.y +
|
|
1533
|
+
Math.min(area.h, Math.max(Math.round(area.h * 0.58), SPACE.lg + msgH + SPACE.md))
|
|
1534
|
+
: area.y + area.h;
|
|
1535
|
+
const msgY = area.y + Math.max(SPACE.lg, (contextY - area.y - msgH) / 2);
|
|
1536
|
+
const elements = [];
|
|
1537
|
+
if (P.accent !== false) {
|
|
1538
|
+
const barW = CHROME.cover.barW;
|
|
1539
|
+
elements.push({
|
|
1540
|
+
block: { type: 'panel', variant: 'accent' },
|
|
1541
|
+
region: {
|
|
1542
|
+
x: align === 'center' ? area.x + (area.w - barW) / 2 : area.x,
|
|
1543
|
+
y: msgY - SPACE.md,
|
|
1544
|
+
w: barW,
|
|
1545
|
+
h: CHROME.cover.barH,
|
|
1546
|
+
},
|
|
1547
|
+
group: 0,
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
elements.push({
|
|
1551
|
+
block: msgBlock,
|
|
1552
|
+
region: { x: area.x, y: msgY, w: area.w, h: msgH },
|
|
1553
|
+
group: 0,
|
|
1554
|
+
});
|
|
1555
|
+
if (rest.length) {
|
|
1556
|
+
const ctx = {
|
|
1557
|
+
x: area.x,
|
|
1558
|
+
y: contextY,
|
|
1559
|
+
w: area.w,
|
|
1560
|
+
h: Math.max(0, area.y + area.h - contextY),
|
|
1561
|
+
};
|
|
1562
|
+
elements.push(...flowBlocks(rest, ctx, { paginate: false })[0]);
|
|
1563
|
+
}
|
|
1564
|
+
push({ elements });
|
|
1565
|
+
break;
|
|
1566
|
+
}
|
|
1567
|
+
case 'table':
|
|
1568
|
+
case 'code':
|
|
1569
|
+
case 'diagram':
|
|
1570
|
+
case 'chart':
|
|
1571
|
+
case 'content':
|
|
1572
|
+
default: {
|
|
1573
|
+
// a single vertical flow, with pagination; if the slide has several
|
|
1574
|
+
// `##` sections, each section becomes an animation group
|
|
1575
|
+
const secs = slide.sections.filter((s) => s.heading || s.blocks.length);
|
|
1576
|
+
const grouped = animate && secs.filter((s) => s.heading).length >= 2;
|
|
1577
|
+
const withHeadings = secs.flatMap((s, si) => {
|
|
1578
|
+
const blocks = s.heading
|
|
1579
|
+
? [{ type: 'heading', depth: 2, runs: s.heading }, ...s.blocks]
|
|
1580
|
+
: s.blocks;
|
|
1581
|
+
return grouped ? blocks.map((b) => ({ ...b, group: si })) : blocks;
|
|
1582
|
+
});
|
|
1583
|
+
const pages = flowBlocks(withHeadings, area);
|
|
1584
|
+
pages.forEach((elements, p) => {
|
|
1585
|
+
stretchTrailingVisual(elements, area);
|
|
1586
|
+
push({
|
|
1587
|
+
elements,
|
|
1588
|
+
title: p === 0 ? slide.title : slide.title ? `${slide.title} (cont.)` : null,
|
|
1589
|
+
notes: p === 0 ? slide.notes : [],
|
|
1590
|
+
continued: p > 0 || undefined,
|
|
1591
|
+
});
|
|
1592
|
+
});
|
|
1593
|
+
break;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
return scenes;
|
|
1599
|
+
}
|