@entropicwarrior/sdoc 0.2.17 → 0.2.19
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/README.md +5 -2
- package/docs/reference/sdoc-authoring.sdoc +1 -1
- package/docs/reference/slide-authoring.sdoc +441 -20
- package/package.json +4 -1
- package/src/sdoc.js +4 -1
- package/src/slide-geometry.js +470 -0
- package/src/slide-layouts.js +514 -0
- package/src/slide-pdf.js +9 -3
- package/src/slide-pptx.js +599 -0
- package/src/slide-renderer.js +135 -55
- package/src/theme.js +141 -0
- package/src/zip.js +102 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
// SDOC Slide Layouts — structured layout builders.
|
|
2
|
+
//
|
|
3
|
+
// A slide's `config:` line names a layout. The default, `center` and
|
|
4
|
+
// `two-column` layouts need no structure beyond the content itself; the
|
|
5
|
+
// layouts in this module consume the slide's *child scopes* and arrange them
|
|
6
|
+
// into a known shape (columns, stats, a pipeline, a comparison matrix, and so
|
|
7
|
+
// on). Every builder emits plain semantic HTML with stable class names; all
|
|
8
|
+
// visual decisions belong to the theme.
|
|
9
|
+
//
|
|
10
|
+
// Layouts compose. `split` renders two child scopes as panes and `stack`
|
|
11
|
+
// renders each child scope as a block, and in both cases the child scope may
|
|
12
|
+
// name its own layout with its own `config:` line. That is how a slide carries
|
|
13
|
+
// a pipeline above a set of numbered columns without a bespoke layout for the
|
|
14
|
+
// combination.
|
|
15
|
+
//
|
|
16
|
+
// The renderer injects its own callbacks (renderChildren, renderInline,
|
|
17
|
+
// escapeHtml) so this module stays free of parser and KaTeX dependencies.
|
|
18
|
+
|
|
19
|
+
// Configuration is a leading run of `key: value` paragraphs. Which keys a
|
|
20
|
+
// scope actually understands depends on where it sits, and a key it does not
|
|
21
|
+
// understand stays content rather than disappearing — a slide opening with
|
|
22
|
+
// "Value: the customer keeps their data." is prose, not configuration.
|
|
23
|
+
//
|
|
24
|
+
// COMMON_KEYS every scope understands, whatever layout it names
|
|
25
|
+
// LAYOUT_KEYS a scope understands because of the layout it names itself
|
|
26
|
+
// CELL_KEYS a scope understands because of the layout its *parent* names
|
|
27
|
+
const COMMON_KEYS = new Set([
|
|
28
|
+
"config",
|
|
29
|
+
"layout",
|
|
30
|
+
"kicker",
|
|
31
|
+
"lede",
|
|
32
|
+
"footnote",
|
|
33
|
+
"accent",
|
|
34
|
+
"status",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const LAYOUT_KEYS = {
|
|
38
|
+
columns: ["numbered", "variant"],
|
|
39
|
+
"two-column": ["numbered", "variant"],
|
|
40
|
+
stats: [],
|
|
41
|
+
pipeline: ["arrow"],
|
|
42
|
+
matrix: ["highlight"],
|
|
43
|
+
rows: ["numbered", "variant"],
|
|
44
|
+
bars: [],
|
|
45
|
+
split: ["weights"],
|
|
46
|
+
stack: ["rule"],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const CELL_KEYS = {
|
|
50
|
+
columns: ["caption"],
|
|
51
|
+
"two-column": ["caption"],
|
|
52
|
+
rows: ["value"],
|
|
53
|
+
bars: ["value", "fill"],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Every key any scope might understand. A leading paragraph opening with one
|
|
57
|
+
// of these is a configuration *candidate*; whether it is kept depends on the
|
|
58
|
+
// context resolved below.
|
|
59
|
+
const CONFIG_KEYS = new Set([
|
|
60
|
+
...COMMON_KEYS,
|
|
61
|
+
...Object.values(LAYOUT_KEYS).flat(),
|
|
62
|
+
...Object.values(CELL_KEYS).flat(),
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
// Layouts that consume child scopes as structure rather than rendering them
|
|
66
|
+
// inline as nested sections.
|
|
67
|
+
const STRUCTURED_LAYOUTS = new Set(Object.keys(LAYOUT_KEYS));
|
|
68
|
+
|
|
69
|
+
function truthy(value) {
|
|
70
|
+
if (value === true) return true;
|
|
71
|
+
if (typeof value !== "string") return false;
|
|
72
|
+
const v = value.trim().toLowerCase();
|
|
73
|
+
return v === "true" || v === "yes" || v === "on" || v === "1";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function escapeAttrValue(value) {
|
|
77
|
+
return String(value).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// An author-supplied word reduced to something safe to concatenate into a
|
|
81
|
+
// class name. Anything outside [a-z0-9-] is dropped, so no configuration value
|
|
82
|
+
// can close the attribute it lands in.
|
|
83
|
+
function slug(value) {
|
|
84
|
+
if (!value) return "";
|
|
85
|
+
return String(value).trim().toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The class for a named variant of a layout, or "" when none was named.
|
|
89
|
+
function variantClass(value) {
|
|
90
|
+
const name = slug(value);
|
|
91
|
+
return name ? ` variant-${name}` : "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Splits a leading run of `key: value` paragraphs off a scope's children.
|
|
95
|
+
//
|
|
96
|
+
// Two passes, because which keys are meaningful depends on the layout the
|
|
97
|
+
// scope names and the layout its parent named, and `config:` may come after
|
|
98
|
+
// another key. Pass one collects candidates; pass two keeps the ones this
|
|
99
|
+
// context understands and hands the rest back as content, in source order.
|
|
100
|
+
// Nothing an author wrote is ever dropped on the floor.
|
|
101
|
+
//
|
|
102
|
+
// `parentLayout` is the layout of the scope this one is a cell of, or null
|
|
103
|
+
// for a slide (which is nobody's cell).
|
|
104
|
+
function extractConfig(children, parentLayout) {
|
|
105
|
+
const candidates = [];
|
|
106
|
+
const contentNodes = [];
|
|
107
|
+
let pastConfig = false;
|
|
108
|
+
|
|
109
|
+
for (const child of children || []) {
|
|
110
|
+
if (!pastConfig && child.type === "paragraph") {
|
|
111
|
+
// No `s` or `m` flag: a paragraph spanning several source lines is
|
|
112
|
+
// content, never configuration.
|
|
113
|
+
const match = child.text.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.+)$/);
|
|
114
|
+
if (match && CONFIG_KEYS.has(match[1].toLowerCase())) {
|
|
115
|
+
candidates.push({ key: match[1].toLowerCase(), value: match[2].trim(), node: child });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
pastConfig = true;
|
|
120
|
+
contentNodes.push(child);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Last one wins, the same rule the assignment loop below follows, so the
|
|
124
|
+
// keys allowed and the layout finally set can never disagree.
|
|
125
|
+
const named = candidates.filter((c) => c.key === "config" || c.key === "layout").pop();
|
|
126
|
+
const layout = named ? named.value.toLowerCase() : "";
|
|
127
|
+
|
|
128
|
+
const allowed = new Set(COMMON_KEYS);
|
|
129
|
+
for (const key of LAYOUT_KEYS[layout] || []) allowed.add(key);
|
|
130
|
+
for (const key of CELL_KEYS[parentLayout] || []) allowed.add(key);
|
|
131
|
+
|
|
132
|
+
const config = {};
|
|
133
|
+
const rejected = [];
|
|
134
|
+
for (const candidate of candidates) {
|
|
135
|
+
if (!allowed.has(candidate.key)) {
|
|
136
|
+
rejected.push(candidate.node);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (candidate.key === "config" || candidate.key === "layout") {
|
|
140
|
+
config.layout = candidate.value.toLowerCase();
|
|
141
|
+
} else {
|
|
142
|
+
config[candidate.key] = candidate.value;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { config, contentNodes: rejected.concat(contentNodes) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// A child scope, read as a structural cell of `parentLayout`: its own config,
|
|
150
|
+
// its own content.
|
|
151
|
+
function readCell(scope, parentLayout) {
|
|
152
|
+
const { config, contentNodes } = extractConfig(scope.children, parentLayout);
|
|
153
|
+
return {
|
|
154
|
+
scope,
|
|
155
|
+
config,
|
|
156
|
+
contentNodes,
|
|
157
|
+
title: scope.hasHeading !== false && scope.title ? scope.title : null,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Child scopes of a structured layout, excluding comment scopes. Speaker notes
|
|
162
|
+
// and drilldown details are removed by the renderer before a builder sees the
|
|
163
|
+
// slide, but only at slide level: a `@notes` scope nested inside a cell is an
|
|
164
|
+
// ordinary nested scope and renders visibly, as it always has.
|
|
165
|
+
function cellsOf(contentNodes, parentLayout) {
|
|
166
|
+
return contentNodes
|
|
167
|
+
.filter((n) => n.type === "scope" && n.scopeType !== "comment")
|
|
168
|
+
.map((scope) => readCell(scope, parentLayout));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function nonCellsOf(contentNodes) {
|
|
172
|
+
return contentNodes.filter((n) => n.type !== "scope" || n.scopeType === "comment");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function twoDigit(n) {
|
|
176
|
+
return n < 10 ? "0" + n : String(n);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Builders
|
|
181
|
+
//
|
|
182
|
+
// Each builder receives ({ config, contentNodes }, ctx) where ctx carries the
|
|
183
|
+
// renderer callbacks, and returns the HTML for the slide body.
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
function accentClass(value) {
|
|
187
|
+
const name = slug(value);
|
|
188
|
+
return name ? ` accent-${name}` : "";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// columns — N child scopes side by side.
|
|
192
|
+
// numbered: true prefix each column with 01, 02, 03…
|
|
193
|
+
// variant: card a bordered panel per column
|
|
194
|
+
// variant: panel a filled panel per column
|
|
195
|
+
function buildColumns({ config, contentNodes }, ctx) {
|
|
196
|
+
const cells = cellsOf(contentNodes, "columns");
|
|
197
|
+
const preamble = ctx.renderChildren(nonCellsOf(contentNodes));
|
|
198
|
+
const numbered = truthy(config.numbered);
|
|
199
|
+
const variant = variantClass(config.variant);
|
|
200
|
+
|
|
201
|
+
const columns = cells
|
|
202
|
+
.map((cell, i) => {
|
|
203
|
+
const parts = [];
|
|
204
|
+
if (numbered) {
|
|
205
|
+
parts.push(`<div class="col-index">${twoDigit(i + 1)}</div>`);
|
|
206
|
+
}
|
|
207
|
+
if (cell.config.kicker) {
|
|
208
|
+
parts.push(`<div class="col-kicker">${ctx.renderInline(cell.config.kicker)}</div>`);
|
|
209
|
+
}
|
|
210
|
+
if (cell.title) {
|
|
211
|
+
parts.push(`<h3>${ctx.renderInline(cell.title)}</h3>`);
|
|
212
|
+
}
|
|
213
|
+
parts.push(ctx.renderChildren(cell.contentNodes));
|
|
214
|
+
if (cell.config.caption) {
|
|
215
|
+
parts.push(`<div class="col-caption">${ctx.renderInline(cell.config.caption)}</div>`);
|
|
216
|
+
}
|
|
217
|
+
return `<div class="column${accentClass(cell.config.accent)}">${parts.join("\n")}</div>`;
|
|
218
|
+
})
|
|
219
|
+
.join("\n");
|
|
220
|
+
|
|
221
|
+
const body = `<div class="columns cols-${cells.length}${variant}" data-count="${cells.length}">\n${columns}\n</div>`;
|
|
222
|
+
return preamble ? preamble + "\n" + body : body;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// stats — N child scopes as figure + caption.
|
|
226
|
+
// The scope title is the figure; its content is the caption beneath it.
|
|
227
|
+
function buildStats({ config, contentNodes }, ctx) {
|
|
228
|
+
const cells = cellsOf(contentNodes, "stats");
|
|
229
|
+
const trailing = ctx.renderChildren(nonCellsOf(contentNodes));
|
|
230
|
+
|
|
231
|
+
const stats = cells
|
|
232
|
+
.map((cell) => {
|
|
233
|
+
const parts = [];
|
|
234
|
+
if (cell.title) {
|
|
235
|
+
parts.push(`<div class="stat-value">${ctx.renderInline(cell.title)}</div>`);
|
|
236
|
+
}
|
|
237
|
+
const caption = ctx.renderChildren(cell.contentNodes);
|
|
238
|
+
if (caption) parts.push(`<div class="stat-label">${caption}</div>`);
|
|
239
|
+
return `<div class="stat${accentClass(cell.config.accent)}">${parts.join("\n")}</div>`;
|
|
240
|
+
})
|
|
241
|
+
.join("\n");
|
|
242
|
+
|
|
243
|
+
const body = `<div class="stats stats-${cells.length}" data-count="${cells.length}">\n${stats}\n</div>`;
|
|
244
|
+
return trailing ? body + "\n" + trailing : body;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// pipeline — one or more chains of steps, drawn as pills joined by arrows.
|
|
248
|
+
// arrow: > the glyph between steps (default →)
|
|
249
|
+
//
|
|
250
|
+
// Each child scope is a row: its title is the row label, its bullet list the
|
|
251
|
+
// steps. A step written entirely in bold is *marked* and takes the row's
|
|
252
|
+
// accent; every other step is neutral. The distinction is semantic — where a
|
|
253
|
+
// slide sets two paths against each other, the marks are the steps a path
|
|
254
|
+
// cannot avoid — so authors mark meaning and the theme supplies the colour.
|
|
255
|
+
// A step is marked when the whole of it is one bold span. `**A** and **B**`
|
|
256
|
+
// also opens and closes with `**`, so the interior is checked too: stripping
|
|
257
|
+
// the outer pair there would leave `A** and **B`, which re-parses with its
|
|
258
|
+
// emphasis inverted.
|
|
259
|
+
function markedText(text) {
|
|
260
|
+
if (!/^\*\*[\s\S]+\*\*$/.test(text)) return null;
|
|
261
|
+
const inner = text.slice(2, -2);
|
|
262
|
+
return inner.includes("**") ? null : inner.trim();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function stepsOfRow(cell) {
|
|
266
|
+
const list = cell.contentNodes.find((n) => n.type === "list");
|
|
267
|
+
if (!list) return [];
|
|
268
|
+
return list.items.map((item) => {
|
|
269
|
+
const text = (item.title || "").trim();
|
|
270
|
+
const marked = markedText(text);
|
|
271
|
+
return { text: marked === null ? text : marked, marked: marked !== null };
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function buildPipeline({ config, contentNodes }, ctx) {
|
|
276
|
+
const cells = cellsOf(contentNodes, "pipeline");
|
|
277
|
+
const preamble = ctx.renderChildren(nonCellsOf(contentNodes));
|
|
278
|
+
// `arrow:`, not `rule:` — `rule:` is the stack's hairline flag, and one key
|
|
279
|
+
// meaning a boolean in one layout and a glyph in another put the word
|
|
280
|
+
// "true" between every step.
|
|
281
|
+
const arrow = config.arrow && config.arrow.trim() ? config.arrow.trim() : "→";
|
|
282
|
+
|
|
283
|
+
const rows = cells
|
|
284
|
+
.map((cell) => {
|
|
285
|
+
const steps = stepsOfRow(cell);
|
|
286
|
+
const pieces = [];
|
|
287
|
+
steps.forEach((step, i) => {
|
|
288
|
+
if (i > 0) pieces.push(`<div class="pipe-arrow" aria-hidden="true">${ctx.escapeHtml(arrow)}</div>`);
|
|
289
|
+
pieces.push(
|
|
290
|
+
`<div class="pipe-step${step.marked ? " is-marked" : ""}"><span>${ctx.renderInline(step.text)}</span></div>`
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
const label = cell.title
|
|
294
|
+
? `<div class="pipe-label">${ctx.renderInline(cell.title)}</div>`
|
|
295
|
+
: "";
|
|
296
|
+
// Content other than the step list (a note under the row, say) follows.
|
|
297
|
+
const rest = ctx.renderChildren(cell.contentNodes.filter((n) => n.type !== "list"));
|
|
298
|
+
const restHtml = rest ? `\n<div class="pipe-note">${rest}</div>` : "";
|
|
299
|
+
return `<div class="pipe-row${accentClass(cell.config.accent)}">${label}\n<div class="pipe-steps">${pieces.join("")}</div>${restHtml}</div>`;
|
|
300
|
+
})
|
|
301
|
+
.join("\n");
|
|
302
|
+
|
|
303
|
+
const body = `<div class="pipeline">\n${rows}\n</div>`;
|
|
304
|
+
return preamble ? preamble + "\n" + body : body;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// matrix — a comparison table whose column headers alternate accents and in
|
|
308
|
+
// which one row may be highlighted.
|
|
309
|
+
// highlight: last highlight the final row
|
|
310
|
+
// highlight: Our company highlight the row whose first cell contains this
|
|
311
|
+
function buildMatrix({ config, contentNodes }, ctx) {
|
|
312
|
+
const table = contentNodes.find((n) => n.type === "table");
|
|
313
|
+
if (!table) return ctx.renderChildren(contentNodes);
|
|
314
|
+
const rest = ctx.renderChildren(contentNodes.filter((n) => n !== table));
|
|
315
|
+
|
|
316
|
+
const highlight = (config.highlight || "").trim();
|
|
317
|
+
const highlightLast = highlight.toLowerCase() === "last";
|
|
318
|
+
const needle = highlightLast ? null : highlight.toLowerCase();
|
|
319
|
+
|
|
320
|
+
const thead = table.headers.length
|
|
321
|
+
? `<thead><tr>${table.headers
|
|
322
|
+
.map((cell, i) => `<th class="col-${i % 2 === 0 ? "a" : "b"}">${ctx.renderInline(cell)}</th>`)
|
|
323
|
+
.join("")}</tr></thead>`
|
|
324
|
+
: "";
|
|
325
|
+
|
|
326
|
+
const lastIndex = table.rows.length - 1;
|
|
327
|
+
const tbody = table.rows
|
|
328
|
+
.map((row, r) => {
|
|
329
|
+
const first = (row[0] || "").toLowerCase();
|
|
330
|
+
const isHighlight =
|
|
331
|
+
(highlightLast && r === lastIndex) || (needle && needle.length > 0 && first.includes(needle));
|
|
332
|
+
const cells = row
|
|
333
|
+
.map((cell, i) => `<td class="col-${i % 2 === 0 ? "a" : "b"}">${ctx.renderInline(cell)}</td>`)
|
|
334
|
+
.join("");
|
|
335
|
+
return `<tr${isHighlight ? ' class="is-highlight"' : ""}>${cells}</tr>`;
|
|
336
|
+
})
|
|
337
|
+
.join("\n");
|
|
338
|
+
|
|
339
|
+
const body = `<div class="matrix"><table>${thead}\n<tbody>\n${tbody}\n</tbody></table></div>`;
|
|
340
|
+
return rest ? body + "\n" + rest : body;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// rows — a stack of label / body / value rows separated by hairlines.
|
|
344
|
+
// numbered: true prefix each row with 01, 02, 03…
|
|
345
|
+
// variant: mono the label is a monospace accent (a date, a node, a stage)
|
|
346
|
+
// Each child scope: title is the label, `value:` the right-hand figure, the
|
|
347
|
+
// remaining content the description.
|
|
348
|
+
function buildRows({ config, contentNodes }, ctx) {
|
|
349
|
+
const cells = cellsOf(contentNodes, "rows");
|
|
350
|
+
const preamble = ctx.renderChildren(nonCellsOf(contentNodes));
|
|
351
|
+
const numbered = truthy(config.numbered);
|
|
352
|
+
const variant = variantClass(config.variant);
|
|
353
|
+
|
|
354
|
+
const rows = cells
|
|
355
|
+
.map((cell, i) => {
|
|
356
|
+
const parts = [];
|
|
357
|
+
if (numbered) parts.push(`<div class="row-index">${twoDigit(i + 1)}</div>`);
|
|
358
|
+
if (cell.title) parts.push(`<div class="row-label">${ctx.renderInline(cell.title)}</div>`);
|
|
359
|
+
const body = ctx.renderChildren(cell.contentNodes);
|
|
360
|
+
if (body) parts.push(`<div class="row-body">${body}</div>`);
|
|
361
|
+
if (cell.config.value) {
|
|
362
|
+
parts.push(`<div class="row-value">${ctx.renderInline(cell.config.value)}</div>`);
|
|
363
|
+
}
|
|
364
|
+
return `<div class="row${accentClass(cell.config.accent)}">${parts.join("\n")}</div>`;
|
|
365
|
+
})
|
|
366
|
+
.join("\n");
|
|
367
|
+
|
|
368
|
+
const body = `<div class="rows${variant}" data-count="${cells.length}">\n${rows}\n</div>`;
|
|
369
|
+
return preamble ? preamble + "\n" + body : body;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// bars — labelled quantities drawn as proportional bars.
|
|
373
|
+
// Each child scope: title is the label, `value:` the figure shown at the right,
|
|
374
|
+
// `fill:` the bar length as a percentage, and the content a note beneath.
|
|
375
|
+
// With no `fill:`, bars are scaled against the largest numeric `value:`.
|
|
376
|
+
function parseNumber(text) {
|
|
377
|
+
if (!text) return null;
|
|
378
|
+
const match = String(text).replace(/,/g, "").match(/-?\d+(\.\d+)?/);
|
|
379
|
+
return match ? parseFloat(match[0]) : null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function buildBars({ config, contentNodes }, ctx) {
|
|
383
|
+
const cells = cellsOf(contentNodes, "bars");
|
|
384
|
+
const preamble = ctx.renderChildren(nonCellsOf(contentNodes));
|
|
385
|
+
|
|
386
|
+
const magnitudes = cells.map((cell) => parseNumber(cell.config.value));
|
|
387
|
+
const peak = Math.max(...magnitudes.filter((n) => typeof n === "number" && isFinite(n)), 0);
|
|
388
|
+
|
|
389
|
+
const bars = cells
|
|
390
|
+
.map((cell, i) => {
|
|
391
|
+
let fill = parseNumber(cell.config.fill);
|
|
392
|
+
if (fill === null) {
|
|
393
|
+
const magnitude = magnitudes[i];
|
|
394
|
+
fill = peak > 0 && magnitude !== null ? (magnitude / peak) * 100 : 0;
|
|
395
|
+
}
|
|
396
|
+
fill = Math.max(0, Math.min(100, fill));
|
|
397
|
+
const head = [];
|
|
398
|
+
if (cell.title) head.push(`<div class="bar-label">${ctx.renderInline(cell.title)}</div>`);
|
|
399
|
+
if (cell.config.value) head.push(`<div class="bar-value">${ctx.renderInline(cell.config.value)}</div>`);
|
|
400
|
+
const note = ctx.renderChildren(cell.contentNodes);
|
|
401
|
+
return [
|
|
402
|
+
`<div class="bar${accentClass(cell.config.accent)}">`,
|
|
403
|
+
`<div class="bar-head">${head.join("")}</div>`,
|
|
404
|
+
`<div class="bar-track"><div class="bar-fill" style="width:${fill.toFixed(2)}%"></div></div>`,
|
|
405
|
+
note ? `<div class="bar-note">${note}</div>` : "",
|
|
406
|
+
`</div>`,
|
|
407
|
+
]
|
|
408
|
+
.filter(Boolean)
|
|
409
|
+
.join("\n");
|
|
410
|
+
})
|
|
411
|
+
.join("\n");
|
|
412
|
+
|
|
413
|
+
const body = `<div class="bars" data-count="${cells.length}">\n${bars}\n</div>`;
|
|
414
|
+
return preamble ? preamble + "\n" + body : body;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// split — two panes side by side, each a block with its own layout.
|
|
418
|
+
// weights: 55 45 the ratio between them (default 50 50)
|
|
419
|
+
function buildSplit({ config, contentNodes }, ctx) {
|
|
420
|
+
const cells = cellsOf(contentNodes, "split");
|
|
421
|
+
const preamble = ctx.renderChildren(nonCellsOf(contentNodes));
|
|
422
|
+
|
|
423
|
+
const weights = (config.weights || "")
|
|
424
|
+
.split(/[\s,/]+/)
|
|
425
|
+
.map((w) => parseFloat(w))
|
|
426
|
+
.filter((w) => isFinite(w) && w > 0);
|
|
427
|
+
|
|
428
|
+
const panes = cells
|
|
429
|
+
.map((cell, i) => {
|
|
430
|
+
const weight = weights[i] || 1;
|
|
431
|
+
return `<div class="pane" style="flex:${weight} 1 0">${renderBlock(cell, ctx)}</div>`;
|
|
432
|
+
})
|
|
433
|
+
.join("\n");
|
|
434
|
+
|
|
435
|
+
const body = `<div class="split" data-count="${cells.length}">\n${panes}\n</div>`;
|
|
436
|
+
return preamble ? preamble + "\n" + body : body;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// stack — child scopes rendered one under another, each with its own layout.
|
|
440
|
+
// rule: true draw a hairline between blocks
|
|
441
|
+
function buildStack({ config, contentNodes }, ctx) {
|
|
442
|
+
const cells = cellsOf(contentNodes, "stack");
|
|
443
|
+
const preamble = ctx.renderChildren(nonCellsOf(contentNodes));
|
|
444
|
+
const ruled = truthy(config.rule) ? " is-ruled" : "";
|
|
445
|
+
|
|
446
|
+
const blocks = cells
|
|
447
|
+
.map((cell) => `<div class="stack-block">${renderBlock(cell, ctx)}</div>`)
|
|
448
|
+
.join("\n");
|
|
449
|
+
|
|
450
|
+
const body = `<div class="stack${ruled}" data-count="${cells.length}">\n${blocks}\n</div>`;
|
|
451
|
+
return preamble ? preamble + "\n" + body : body;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const BUILDERS = {
|
|
455
|
+
columns: buildColumns,
|
|
456
|
+
"two-column": buildColumns,
|
|
457
|
+
stats: buildStats,
|
|
458
|
+
pipeline: buildPipeline,
|
|
459
|
+
matrix: buildMatrix,
|
|
460
|
+
rows: buildRows,
|
|
461
|
+
bars: buildBars,
|
|
462
|
+
split: buildSplit,
|
|
463
|
+
stack: buildStack,
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
// Renders a block: a heading if the scope has one, then its layout's body.
|
|
467
|
+
// Used for split panes and stack children, which are slides-within-a-slide.
|
|
468
|
+
function renderBlock(cell, ctx) {
|
|
469
|
+
const layout = cell.config.layout;
|
|
470
|
+
const parts = [];
|
|
471
|
+
|
|
472
|
+
if (cell.config.kicker) {
|
|
473
|
+
parts.push(`<div class="kicker">${ctx.renderInline(cell.config.kicker)}</div>`);
|
|
474
|
+
}
|
|
475
|
+
if (cell.title) {
|
|
476
|
+
parts.push(`<h3>${ctx.renderInline(cell.title)}</h3>`);
|
|
477
|
+
}
|
|
478
|
+
if (cell.config.lede) {
|
|
479
|
+
parts.push(`<p class="lede">${ctx.renderInline(cell.config.lede)}</p>`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
parts.push(buildBody(layout, cell, ctx));
|
|
483
|
+
|
|
484
|
+
if (cell.config.footnote) {
|
|
485
|
+
parts.push(`<div class="footnote">${ctx.renderInline(cell.config.footnote)}</div>`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const classes = ["block"];
|
|
489
|
+
const layoutSlug = slug(layout);
|
|
490
|
+
if (layoutSlug) classes.push(`layout-${layoutSlug}`);
|
|
491
|
+
const accent = accentClass(cell.config.accent).trim();
|
|
492
|
+
if (accent) classes.push(accent);
|
|
493
|
+
|
|
494
|
+
return `<div class="${classes.join(" ")}"${layout ? ` data-layout="${escapeAttrValue(layout)}"` : ""}>${parts
|
|
495
|
+
.filter(Boolean)
|
|
496
|
+
.join("\n")}</div>`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// The body of a layout: a structured builder if one is named, otherwise the
|
|
500
|
+
// content rendered as it stands.
|
|
501
|
+
function buildBody(layout, cell, ctx) {
|
|
502
|
+
const builder = layout ? BUILDERS[layout] : null;
|
|
503
|
+
if (builder) return builder(cell, ctx);
|
|
504
|
+
return ctx.renderChildren(cell.contentNodes);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
module.exports = {
|
|
508
|
+
CONFIG_KEYS,
|
|
509
|
+
STRUCTURED_LAYOUTS,
|
|
510
|
+
extractConfig,
|
|
511
|
+
buildBody,
|
|
512
|
+
accentClass,
|
|
513
|
+
slug,
|
|
514
|
+
};
|
package/src/slide-pdf.js
CHANGED
|
@@ -106,9 +106,15 @@ function chromePdf(htmlPath, pdfPath, options = {}) {
|
|
|
106
106
|
});
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
// Slide PDF:
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
// Slide PDF: landscape at the theme's page size, defaulting to the 13.333 x
|
|
110
|
+
// 7.5 in box that matches the default 1280 x 720 design box at 96 dpi. A theme
|
|
111
|
+
// declaring a different box in theme.json must print at its own size, or the
|
|
112
|
+
// slide overflows the page and Chrome clips it.
|
|
113
|
+
function exportSlidePdf(htmlPath, pdfPath, page) {
|
|
114
|
+
return chromePdf(htmlPath, pdfPath, {
|
|
115
|
+
paperWidth: (page && page.width) || 13.333,
|
|
116
|
+
paperHeight: (page && page.height) || 7.5,
|
|
117
|
+
});
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
// Document PDF: A4 portrait (8.27 x 11.69 inches)
|