pindeck 0.0.0-stage → 2.8.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.
@@ -0,0 +1,682 @@
1
+ /* PowerPoint import: turns slides read by pptx.mjs into slides of this deck, in its own layouts.
2
+
3
+ What is reliable becomes real HTML: titles, bullets, paragraphs, references and speaker notes.
4
+ Pictures are copied. Everything drawn from shapes (diagrams, tables, charts, pictures with labels on
5
+ them) becomes a picture cut from the slide as PowerPoint renders it (see pptx-render.mjs).
6
+
7
+ Two steps, so the server can render the cut-outs in between:
8
+ planSlides(model, numbers, opts) -> plans: the slide's text and layout, and per picture either
9
+ a media part to copy or a region of the slide to render
10
+ slidesHtml(deckHtml, css, plans) -> the new <section>s, in the style of the deck's own slides
11
+ buildImport() runs both, with the rendering and the figure files in between. */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { renderJobs } from "./pptx-render.mjs";
16
+ import { waveformSvg } from "./waveform.mjs";
17
+ import { ommlToTex } from "./omml.mjs";
18
+ import { parse, findSlides, walk, elementEnd, classList } from "./eids.mjs";
19
+ import { findAll as xfindAll, textOf as xtextOf } from "./xml.mjs";
20
+
21
+ /* ---- Geometry -------------------------------------------------------------------------------------- */
22
+ const right = (b) => b.x + b.w, bottom = (b) => b.y + b.h;
23
+ const union = (a, b) => { const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y); return { x, y, w: Math.max(right(a), right(b)) - x, h: Math.max(bottom(a), bottom(b)) - y }; };
24
+ const pad = (b, d) => ({ x: b.x - d, y: b.y - d, w: b.w + 2 * d, h: b.h + 2 * d });
25
+ const meets = (a, b) => a.x <= right(b) && b.x <= right(a) && a.y <= bottom(b) && b.y <= bottom(a);
26
+ const overlapArea = (a, b) => Math.max(0, Math.min(right(a), right(b)) - Math.max(a.x, b.x)) * Math.max(0, Math.min(bottom(a), bottom(b)) - Math.max(a.y, b.y));
27
+ const bboxOf = (boxes) => boxes.reduce((u, b) => (u ? union(u, b) : b), null);
28
+
29
+ /* ---- Text ------------------------------------------------------------------------------------------- */
30
+ const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
31
+ const escAttr = (s) => esc(s).replace(/"/g, "&quot;");
32
+ const paraText = (p) => p.runs.map((r) => (r.br ? " " : r.math ? mathText(r.math) : r.text || "")).join("").replace(/\s+/g, " ").trim();
33
+ const mathText = (m) => xfindAll(m, "m:t").map(xtextOf).join("");
34
+
35
+ /* Equations become TeX for the deck's KaTeX (\( \) inline, \[ \] displayed), or plain text in decks
36
+ without math; slidesHtml() sets this for the deck at hand */
37
+ let mathOutput = true;
38
+ function mathHtml(el) {
39
+ if (!mathOutput) return esc(mathText(el));
40
+ const { tex, display } = ommlToTex(el);
41
+ return display ? `\\[${esc(tex)}\\]` : `\\(${esc(tex)}\\)`;
42
+ }
43
+
44
+ function runsHtml(runs) {
45
+ let out = "";
46
+ for (const r of runs) {
47
+ if (r.br) { out += "<br>"; continue; }
48
+ if (r.math) { out += mathHtml(r.math); continue; }
49
+ let s = esc(r.text || "");
50
+ if (!s) continue;
51
+ // keep surrounding spaces outside the tags: "<strong>Goal:</strong> text"
52
+ const [, lead, core, trail] = s.match(/^(\s*)([\s\S]*?)(\s*)$/);
53
+ if (!core) { out += s; continue; }
54
+ let h = core;
55
+ if (r.sup) h = `<sup>${h}</sup>`;
56
+ if (r.sub) h = `<sub>${h}</sub>`;
57
+ if (r.i) h = `<em>${h}</em>`;
58
+ if (r.hl) h = `<span class="hl">${h}</span>`;
59
+ else if (r.b) h = `<strong>${h}</strong>`; // .hl is already bold in the decks
60
+ if (r.href) h = `<a href="${escAttr(r.href)}">${h}</a>`;
61
+ out += lead + h + trail;
62
+ }
63
+ return out.replace(/<\/strong>(\s*)<strong>/g, "$1").replace(/<\/em>(\s*)<em>/g, "$1").trim().replace(/(<br>\s*)+$/, "");
64
+ }
65
+
66
+ /* Build steps as reveal.js fragments; data-step is turned into data-fragment-index (or dropped) per slide */
67
+ const stepAttr = (st) => (!st ? "" : st.in ? ` class="fragment" data-step="${st.in}"` : st.out ? ` class="fragment fade-out" data-step="${st.out}"` : "");
68
+
69
+ /* Paragraphs as HTML: runs of bullets become one <ul class="bullets"> nested by level, others <p>.
70
+ stepOf(p) gives a paragraph's build step ({ in, out } clicks) or null. */
71
+ function parasHtml(paras, ind, stepOf = () => null) {
72
+ const out = [];
73
+ for (let i = 0; i < paras.length;) {
74
+ if (!paras[i].bullet) { out.push(`${ind}<p${stepAttr(stepOf(paras[i]))}>${runsHtml(paras[i].runs)}</p>`); i++; continue; }
75
+ // a tree of items: a deeper paragraph belongs to the item before it
76
+ const root = { level: -1, kids: [] }, stack = [root];
77
+ while (i < paras.length && paras[i].bullet) {
78
+ const p = paras[i++];
79
+ while (stack.length > 1 && stack.at(-1).level >= p.level) stack.pop();
80
+ const item = { level: p.level, p, kids: [] };
81
+ stack.at(-1).kids.push(item);
82
+ stack.push(item);
83
+ }
84
+ // an item that appears with its parent needs no step of its own
85
+ const same = (a, b) => (a?.in || 0) === (b?.in || 0) && (a?.out || 0) === (b?.out || 0);
86
+ const li = (it, up) => `<li${same(stepOf(it.p), up) ? "" : stepAttr(stepOf(it.p))}>${runsHtml(it.p.runs)}`;
87
+ // numbered items (PowerPoint's auto-numbering) become an <ol> without the deck's bullet class
88
+ const list = (items, ind2, cls, up) => {
89
+ const tag = items[0].p.numbered ? "ol" : "ul";
90
+ return [`${ind2}<${tag}${tag === "ol" ? "" : cls}>`, ...items.map((it) => it.kids.length
91
+ ? `${ind2} ${li(it, up)}\n${list(it.kids, ind2 + " ", "", stepOf(it.p) || up)}\n${ind2} </li>`
92
+ : `${ind2} ${li(it, up)}</li>`), `${ind2}</${tag}>`].join("\n");
93
+ };
94
+ out.push(list(root.kids, ind, ' class="bullets"', null));
95
+ }
96
+ return out.join("\n");
97
+ }
98
+
99
+ /* Tables: the deck's table class, a caption from the label above, header rows in <thead>.
100
+ Body cells get no rowspan: a group label sits on the group's first row, the rows below get an empty
101
+ cell, and the first row of each later group gets class "group" (booktabs themes add space above it). */
102
+ function tableHtml(t, ind, { cls = "", caption = null } = {}) {
103
+ const rows = t.rows;
104
+ const filled = (c) => c.paras.some((p) => paraText(p));
105
+ const bold = (c) => c.paras.every((p) => p.runs.every((r) => r.b || !(r.text || "").trim()));
106
+ const header = t.header || (rows[0] && rows[0].filter(filled).length > 0 && rows[0].filter(filled).every(bold));
107
+ const headRows = header ? Math.max(1, ...rows[0].map((c) => c.rowspan)) : 0;
108
+ const cell = (c, tag) => `<${tag}${c.colspan > 1 ? ` colspan="${c.colspan}"` : ""}${tag === "th" && c.rowspan > 1 ? ` rowspan="${c.rowspan}"` : ""}>` +
109
+ (c.below ? "" : c.paras.map((p) => runsHtml(tag === "th" ? p.runs.map((r) => ({ ...r, b: false })) : p.runs)).filter(Boolean).join("<br>")) + `</${tag}>`;
110
+ const body = rows.slice(headRows);
111
+ const ends = new Set(body.flatMap((r, i) => r.filter((c) => c.rowspan > 1).map((c) => i + c.rowspan - 1)));
112
+ const group = (r, i) => i > 0 && (r.some((c) => c.rowspan > 1) || ends.has(i - 1));
113
+ const tr = (r, tag, g) => `${ind} <tr${g ? ' class="group"' : ""}>` +
114
+ r.filter((c) => !c.merged || (tag === "td" && c.below)).map((c) => cell(c, tag)).join("") + "</tr>";
115
+ const lines = [`${ind}<table${cls ? ` class="${cls}"` : ""}>`];
116
+ if (caption) lines.push(`${ind} <caption>${caption}</caption>`);
117
+ if (headRows) lines.push(`${ind} <thead>`, ...rows.slice(0, headRows).map((r) => tr(r, "th")), `${ind} </thead>`);
118
+ lines.push(`${ind} <tbody>`, ...body.map((r, i) => tr(r, "td", group(r, i))), `${ind} </tbody>`, `${ind}</table>`);
119
+ return lines.join("\n");
120
+ }
121
+
122
+ /* The text of a paragraph split at its line breaks */
123
+ function lineParts(p) {
124
+ const parts = [];
125
+ let cur = "";
126
+ for (const r of p.runs) { if (r.br) { parts.push(cur); cur = ""; } else cur += r.math ? mathText(r.math) : r.text || ""; }
127
+ return [...parts, cur];
128
+ }
129
+
130
+ /* Rough size of the text itself (a placeholder box is often much larger than its text) */
131
+ function textExtent(shape) {
132
+ let h = 8, widest = 0;
133
+ for (const p of shape.paras) {
134
+ for (const t of lineParts(p)) {
135
+ const w = t.length * 0.45 * p.size + 36 * (p.level + (p.bullet ? 1 : 0));
136
+ widest = Math.max(widest, w);
137
+ h += Math.max(1, Math.ceil(w / Math.max(shape.box.w, 100))) * 1.2 * p.size;
138
+ }
139
+ h += 0.2 * p.size;
140
+ }
141
+ return { x: shape.box.x, y: shape.box.y, w: Math.min(shape.box.w, widest + 16), h: Math.min(shape.box.h, h) };
142
+ }
143
+
144
+ const AUDIO_EXT = ["wav", "mp3", "m4a", "aac", "ogg", "oga", "opus", "flac"], VIDEO_EXT = ["mp4", "m4v", "mov", "webm"];
145
+ const REF_RE = /et al\.|\b(19|20)\d\d\b|doi|arxiv|proc\.|journal|trans\.|conf\.|icassp|interspeech|eusipco|waspaa/i;
146
+ const EVENT_RE = /\b(19|20)\d\d\b|conference|workshop|symposium|meeting|congress|seminar|colloquium|tagung|konferenz|interspeech|icassp|eusipco|waspaa|\bdaga\b|\baes\b/i;
147
+ const INSTITUTE_RE = /universit|institut|laborator|\blabs?\b|department|school|college|fraunhofer|gmbh|\binc\b|\bltd\b|center|centre|faculty|fakultät/i;
148
+
149
+ /* ---- Planning ------------------------------------------------------------------------------------------ */
150
+ /* opts: { prefix: file name stem for new figures, height: slide height in px (720),
151
+ drawings: whether pictures of drawn shapes can be made (PowerPoint or Keynote is there) } */
152
+ export function planSlides(model, numbers, { prefix = "import", height = model.height, drawings = true } = {}) {
153
+ const wanted = new Set(numbers);
154
+ // the deck's usual body text size: the size of most first-level text in body placeholders
155
+ const count = new Map();
156
+ for (const s of model.slides) for (const sh of s.shapes) if (sh.ph && ["body", "obj"].includes(sh.ph.type))
157
+ for (const p of sh.paras || []) if (!p.level) count.set(Math.round(p.size), (count.get(Math.round(p.size)) || 0) + paraText(p).length);
158
+ const refSize = [...count].sort((a, b) => b[1] - a[1])[0]?.[0] || 32;
159
+ return model.slides.filter((s) => wanted.has(s.n)).map((s) => planSlide(s, { prefix, height, refSize, drawings }));
160
+ }
161
+
162
+ /* Text clearly smaller than the deck's body text (side notes, labels, tables) keeps that difference.
163
+ Body placeholders take the deck's own size: their size in PowerPoint often only reflects autofit. */
164
+ function sizeRatio(paras, refSize) {
165
+ const top = paras.filter((p) => p.level === Math.min(...paras.map((q) => q.level)));
166
+ const r = top.reduce((a, p) => a + p.size, 0) / top.length / refSize;
167
+ return r >= 0.85 ? 1 : Math.max(0.5, r);
168
+ }
169
+
170
+ function planSlide(slide, { prefix, height, refSize, drawings }) {
171
+ const plan = { n: slide.n, kind: slide.kind, hidden: slide.hidden, title: slide.title, subtitle: slide.subtitle,
172
+ notes: slide.notes, body: [], refs: [], figures: [], extra: {}, lostSteps: 0, complex: [] };
173
+ const shapes = slide.shapes.filter((s) => s.box && s.box.w > 0.5 && s.box.h > 0.5 || s.tag === "cxnSp" && s.box);
174
+ const isTitle = (s) => s.ph && ["title", "ctrTitle"].includes(s.ph.type);
175
+ const hasText = (s) => s.tag === "sp" && s.paras?.some((p) => paraText(p));
176
+
177
+ if (slide.kind === "title" || slide.kind === "section") {
178
+ // title slides: the lines below the title, top to bottom, are subtitle, authors, event, institute
179
+ const lines = shapes.filter((s) => !isTitle(s) && hasText(s) && !(s.filled && !s.ph))
180
+ .sort((a, b) => a.box.y - b.box.y || a.box.x - b.box.x)
181
+ .flatMap((s) => s.paras.map((p) => ({ p, ph: s.ph?.type })));
182
+ const pick = (pred) => { const i = lines.findIndex(pred); return i < 0 ? null : lines.splice(i, 1)[0].p; };
183
+ const sub = pick((l) => l.ph === "subTitle");
184
+ if (!plan.subtitle && (sub || slide.kind === "title" || slide.kind === "section")) {
185
+ const first = sub || (lines.length > 1 || slide.kind === "section" ? pick(() => true) : null);
186
+ if (first) plan.subtitle = paraText(first);
187
+ }
188
+ if (slide.kind === "title") {
189
+ const ev = pick((l) => EVENT_RE.test(paraText(l.p)));
190
+ const inst = pick((l) => INSTITUTE_RE.test(paraText(l.p)) && !/\band\b|,/.test(paraText(l.p)));
191
+ const auth = pick(() => true);
192
+ plan.extra = { authors: auth && runsHtml(auth.runs), event: ev && runsHtml(ev.runs), institute: inst && runsHtml(inst.runs) };
193
+ }
194
+ return plan;
195
+ }
196
+
197
+ // Build steps: per shape (or the group it is in) and per paragraph, the click it appears or goes on
198
+ const whole = new Map(), byPara = new Map();
199
+ for (const st of slide.steps || []) {
200
+ if (!st.click) continue; // plays when the slide opens: no step
201
+ const set = (m, key) => { const v = m.get(key) || {}; if (!v[st.kind]) v[st.kind] = st.click; m.set(key, v); };
202
+ if (st.from == null) set(whole, st.shape);
203
+ else for (let i = st.from; i <= st.to; i++) set(byPara, `${st.shape}/${i}`);
204
+ }
205
+ const shapeStep = (sh) => whole.get(sh.id) || (sh.group && whole.get(sh.group)) || null;
206
+ const paraStepOf = (sh) => (p) => byPara.get(`${sh.id}/${p.index}`) || null;
207
+ // a picture of several shapes appears with them only if all of them appear (or go) together
208
+ const groupStep = (members) => {
209
+ const sts = members.map(shapeStep);
210
+ if (!sts.length || sts.some((st) => !st)) { if (sts.some(Boolean)) plan.lostSteps++; return null; }
211
+ const ins = sts.map((st) => st.in), outs = sts.map((st) => st.out);
212
+ return { in: ins.every(Boolean) ? Math.min(...ins) : null, out: outs.every((o) => o && o === outs[0]) ? outs[0] : null };
213
+ };
214
+ plan.lostSteps = 0;
215
+
216
+ // Content slides. Visible shapes (pictures, frames, lines, filled or outlined shapes) that touch form
217
+ // a figure; short text boxes on or next to a figure are its labels.
218
+ const avs = shapes.filter((s) => s.tag === "pic" && s.av);
219
+ const visuals = shapes.filter((s) => !s.av && (s.tag === "pic" || s.tag === "graphicFrame" || s.tag === "cxnSp" ||
220
+ (s.tag === "sp" && !s.ph && (s.filled || s.lined))));
221
+ const texts = shapes.filter((s) => s.tag === "sp" && !isTitle(s) && s.ph?.type !== "subTitle" && hasText(s) && !visuals.includes(s));
222
+
223
+ let clusters = visuals.map((v) => ({ members: [v], box: v.box }));
224
+ const mergeAll = () => {
225
+ for (let changed = true; changed;) {
226
+ changed = false;
227
+ outer: for (let i = 0; i < clusters.length; i++) for (let j = i + 1; j < clusters.length; j++) {
228
+ if (meets(pad(clusters[i].box, 12), pad(clusters[j].box, 12))) {
229
+ clusters[i] = { members: [...clusters[i].members, ...clusters[j].members], box: union(clusters[i].box, clusters[j].box) };
230
+ clusters.splice(j, 1);
231
+ changed = true;
232
+ break outer;
233
+ }
234
+ }
235
+ }
236
+ };
237
+ mergeAll();
238
+ // a lone line or an empty frame filling most of the slide is decoration, not a figure
239
+ clusters = clusters.filter((c) => !(c.members.length === 1 && c.members[0].tag === "cxnSp") &&
240
+ !(c.members.length === 1 && c.members[0].tag === "sp" && !hasText(c.members[0]) && c.box.w * c.box.h > 0.5 * 1280 * height));
241
+ const free = new Set(texts);
242
+ for (let changed = true; changed;) {
243
+ changed = false;
244
+ for (const t of free) {
245
+ const chars = t.paras.reduce((n, p) => n + paraText(p).length, 0);
246
+ const short = !t.ph && chars <= 60 && t.paras.length <= 3;
247
+ const near = chars <= 40 ? 20 : 10; // captions sit a little further away
248
+ const c = clusters.find((c) => meets(pad(c.box, near), t.box) && (short || overlapArea(c.box, t.box) >= 0.6 * t.box.w * t.box.h));
249
+ if (!c) continue;
250
+ c.members.push(t);
251
+ c.box = union(c.box, t.box);
252
+ c.labels = [...(c.labels || []), t];
253
+ free.delete(t);
254
+ changed = true;
255
+ }
256
+ if (changed) mergeAll();
257
+ }
258
+
259
+ // references: text near the bottom that reads like a citation
260
+ const items = [];
261
+ for (const t of texts.filter((t) => free.has(t))) {
262
+ const txt = t.paras.map(paraText).join(" ");
263
+ if (!t.ph && t.box.y >= 0.8 * height && REF_RE.test(txt)) plan.refs.push(...t.paras);
264
+ else items.push({ type: "text", paras: t.paras, box: textExtent(t), real: t.box, ratio: t.ph ? 1 : sizeRatio(t.paras, refSize),
265
+ step: shapeStep(t), paraStep: paraStepOf(t) });
266
+ }
267
+ const titleShape = shapes.find(isTitle);
268
+ // where the title's text ends (its box is often taller): lines at 1.1 times the font size below the inset
269
+ const titleBottom = titleShape ? titleShape.box.y + 4.8 + titleShape.paras.reduce((h, p) => h + lineParts(p).length * 1.1 * p.size, 0) : 0;
270
+ let k = 0;
271
+ const name = (ext) => `figures/${prefix}-s${String(slide.n).padStart(2, "0")}-${++k}.${ext}`;
272
+ for (const c of clusters) {
273
+ const only = c.members.length === 1 ? c.members[0] : null;
274
+ // without a renderer, shapes that only carry text (a coloured box, a callout) keep their text
275
+ if (!drawings && c.members.every((m) => m.tag === "sp")) {
276
+ for (const m of c.members.filter(hasText).sort((a, b) => a.box.y - b.box.y || a.box.x - b.box.x))
277
+ items.push({ type: "text", paras: m.paras, box: textExtent(m), ratio: m.ph ? 1 : sizeRatio(m.paras, refSize), step: shapeStep(m), paraStep: paraStepOf(m) });
278
+ continue;
279
+ }
280
+ const step = groupStep(c.members);
281
+ // a table with nothing but labels around it becomes an HTML table, the label above it its caption
282
+ const table = c.members.find((m) => m.table);
283
+ if (table && c.members.every((m) => m === table || (c.labels || []).includes(m))) {
284
+ const above = (c.labels || []).filter((l) => bottom(l.box) <= table.box.y + 8).sort((a, b) => a.box.y - b.box.y);
285
+ const below = (c.labels || []).filter((l) => !above.includes(l));
286
+ items.push({ type: "table", table: table.table, caption: above.flatMap((l) => l.paras), box: c.box, step: shapeStep(table),
287
+ ratio: sizeRatio(table.table.rows.flat().flatMap((cell) => cell.paras), refSize) });
288
+ for (const l of below) items.push({ type: "text", paras: l.paras, box: textExtent(l), ratio: sizeRatio(l.paras, refSize), step: shapeStep(l), paraStep: paraStepOf(l) });
289
+ continue;
290
+ }
291
+ let fig;
292
+ if (only?.tag === "pic" && only.displayable && !only.cropped) fig = { file: name(only.ext), media: only.media, box: c.box, alt: `Figure from slide ${slide.n}` };
293
+ else {
294
+ // fallback when no renderer is available: the largest picture of the group, if any
295
+ const pics = c.members.filter((m) => m.tag === "pic" && m.displayable).sort((a, b) => b.box.w * b.box.h - a.box.w * a.box.h);
296
+ // the picture covers the shapes and the text in the labels (a text box is taller than its text)
297
+ // labels: from the top of their first line (inset 4.8 px plus the space above capitals)
298
+ let ink = bboxOf(c.members.map((m) => {
299
+ if (!(c.labels || []).includes(m)) return m.box;
300
+ const top = 4.8 + 0.12 * m.paras[0].size;
301
+ return { ...m.box, y: m.box.y + top, h: Math.max(1, m.box.h - top - 4) };
302
+ }));
303
+ let crop = pad(ink, 4);
304
+ if (crop.y < titleBottom + 1 && bottom(ink) > titleBottom + 40) crop = { ...crop, y: Math.max(titleBottom + 1, ink.y - 1), h: bottom(crop) - Math.max(titleBottom + 1, ink.y - 1) };
305
+ fig = { file: name("png"), crop, box: ink, fallback: pics[0] ? { media: pics[0].media, ext: pics[0].ext } : null,
306
+ alt: `${only?.table ? "Table" : only?.chart ? "Chart" : "Figure"} from slide ${slide.n}`,
307
+ shapes: c.members.length, table: !!table, labels: c.members.filter((m) => m.paras?.length).map((m) => m.paras.map(paraText).join(" / ")) };
308
+ }
309
+ plan.figures.push(fig);
310
+ items.push({ type: "fig", fig, box: c.box, step });
311
+ }
312
+ // audio goes into the deck's audio player below the rest, labelled with the nearest text;
313
+ // video stays where it is, with its poster frame
314
+ const audio = [];
315
+ const centre = (b) => ({ x: b.x + b.w / 2, y: b.y + b.h / 2 });
316
+ for (const a of avs) {
317
+ if (!a.av.media) continue;
318
+ // the extension names a file in the deck: only known media types, never one from the .pptx as it is
319
+ const ext0 = path.posix.extname(a.av.media).slice(1).toLowerCase();
320
+ const ext = (a.av.kind === "video" ? VIDEO_EXT : AUDIO_EXT).includes(ext0) ? ext0 : a.av.kind === "video" ? "mp4" : "wav";
321
+ const fig = { file: name(ext), media: a.av.media, av: a.av.kind, box: a.box };
322
+ plan.figures.push(fig);
323
+ if (a.av.kind === "video") {
324
+ if (a.displayable) { fig.poster = { file: name(a.ext), media: a.media }; plan.figures.push(fig.poster); }
325
+ items.push({ type: "fig", fig, box: a.box, step: shapeStep(a) });
326
+ continue;
327
+ }
328
+ const c = centre(a.box);
329
+ const near = shapes.filter((t) => t.tag === "sp" && hasText(t) && !isTitle(t) && t.paras.length <= 3)
330
+ .map((t) => ({ t, d: Math.hypot(centre(t.box).x - c.x, centre(t.box).y - c.y) })).filter((x) => x.d < 220).sort((x, y) => x.d - y.d)[0];
331
+ const label = near ? near.t.paras.map(paraText).join(" ") : a.name && !/^(audio|picture|media|grafik|bild)\b/i.test(a.name) ? a.name : "";
332
+ audio.push({ fig, label: label || `Audio ${audio.length + 1}`, x: a.box.x, y: a.box.y });
333
+ }
334
+
335
+ // what the import can only approximate: worth a look by Claude, with the original slide as reference
336
+ const drawn = plan.figures.filter((f) => f.crop && (f.shapes >= 6 || f.table));
337
+ if (drawn.length) plan.complex.push(drawn.map((f) => f.table ? "a table with shapes drawn over it is a picture" : `a diagram of ${f.shapes} shapes is a picture`).join(", "));
338
+ const inText = items.filter((i) => i.type === "fig" && items.some((t) => t.type === "text" && overlapArea(t.box, i.box) > 0.4 * i.box.w * i.box.h));
339
+ if (inText.length) plan.complex.push(`${inText.length} picture${inText.length > 1 ? "s" : ""} placed inside text (e.g. equations as images)`);
340
+ if (plan.lostSteps) plan.complex.push("build steps on parts of a diagram");
341
+ if (!slide.title) plan.complex.push("no title");
342
+
343
+ plan.body = layout(items);
344
+ if (audio.length) plan.body.push({ type: "demo", rows: audio.sort((p, q) => p.y - q.y || p.x - q.x), box: bboxOf(audio.map((x) => x.fig.box)) });
345
+ return plan;
346
+ }
347
+
348
+ /* XY cut: split into rows or columns where nothing crosses the gap (the cut with the wider gap wins),
349
+ recursively; what cannot be cut is read top to bottom. Small overlaps are allowed, since text sizes
350
+ are estimates and text boxes have padding. */
351
+ function layout(items) {
352
+ if (items.length <= 1) return items;
353
+ const cut = (pos, len, overlap) => {
354
+ const sorted = [...items].sort((a, b) => a.box[pos] - b.box[pos]);
355
+ const groups = [[sorted[0]]];
356
+ let end = sorted[0].box[pos] + sorted[0].box[len], gap = Infinity;
357
+ for (const it of sorted.slice(1)) {
358
+ if (it.box[pos] >= end - overlap) { gap = Math.min(gap, it.box[pos] - end); groups.push([it]); }
359
+ else groups.at(-1).push(it);
360
+ end = Math.max(end, it.box[pos] + it.box[len]);
361
+ }
362
+ return { groups, gap };
363
+ };
364
+ const rows = cut("y", "h", 8), cols = cut("x", "w", 24);
365
+ if (rows.groups.length > 1 && (cols.groups.length === 1 || rows.gap >= cols.gap)) return rows.groups.flatMap(layout);
366
+ if (cols.groups.length > 1) return [{ type: "cols", cols: cols.groups.map(layout), widths: cols.groups.map((c) => bboxOf(c.map((i) => i.box)).w) }];
367
+ return [...items].sort((a, b) => a.box.y - b.box.y || a.box.x - b.box.x);
368
+ }
369
+
370
+ /* Render jobs for the figures that are cut from the slide picture. */
371
+ export function cropJobs(plans, dir) {
372
+ return plans.flatMap((p) => p.figures.filter((f) => f.crop).map((f) => ({ page: p.n, box: [f.crop.x, f.crop.y, f.crop.w, f.crop.h], scale: 2, out: path.join(dir, path.basename(f.file)), fig: f })));
373
+ }
374
+
375
+ /* ---- HTML in the deck's style ---------------------------------------------------------------------------- */
376
+ function cssVar(css, name, fallback) {
377
+ const m = css.match(new RegExp(`--${name}\\s*:\\s*([\\d.]+)px`));
378
+ return m ? +m[1] : fallback;
379
+ }
380
+
381
+ /* The deck's own slides, used as models: content, title and section slides, and how headings look. */
382
+ function deckModels(html) {
383
+ const tree = parse(html);
384
+ const slides = findSlides(tree);
385
+ const kindOf = (s) => classList(s).find((c) => /-slide$/.test(c)) || "content";
386
+ const findIn = (node, pred) => { let hit = null; walk({ children: [node] }, (c) => { if (!hit && pred(c)) hit = c; return !hit; }); return hit; };
387
+ const content = slides.find((s) => kindOf(s) === "content" && findIn(s, (c) => classList(c).includes("slide-head")));
388
+ const out = { title: slides.find((s) => kindOf(s) === "title-slide"), section: slides.find((s) => kindOf(s) === "section-slide"),
389
+ content, sectionCount: (before) => slides.filter((s, i) => i <= before && kindOf(s) === "section-slide").length, slides };
390
+ const table = findIn(tree, (c) => c.name === "table");
391
+ out.tableClass = table ? classList(table).join(" ") : "";
392
+ const row = findIn(tree, (c) => classList(c).includes("demo-row"));
393
+ const demo = findIn(tree, (c) => classList(c).includes("demo"));
394
+ if (row && demo && /data-target="/.test(html.slice(row.start, elementEnd(html, row)))) {
395
+ out.demoTag = html.slice(demo.start, demo.tagEnd + 1).replace(/\s+data-eid="[^"]*"/g, "");
396
+ out.demoRow = html.slice(row.start, elementEnd(html, row)).replace(/\s+data-eid="[^"]*"/g, "");
397
+ }
398
+ out.ids = new Set([...html.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]));
399
+ out.head = { mode: "plain", headTag: '<div class="slide-head">', h2Tag: "<h2>", subTag: '<span class="sub">', kickerTag: '<p class="kicker">',
400
+ sectionTag: '<section class="slide">', bodyTag: '<div class="slide-body">', hasRefs: /class="[^"]*\brefs\b/.test(html) };
401
+ if (content) {
402
+ // the model's tags without what belongs to that one slide: its ids and inline styles (hand edits)
403
+ const tag = (n) => html.slice(n.start, n.tagEnd + 1).replace(/\s+(?:data-eid|id|style)=(?:"[^"]*"|'[^']*')/g, "");
404
+ out.head.sectionTag = tag(content).replace(/\s+data-visibility="[^"]*"/, "");
405
+ const head = findIn(content, (c) => classList(c).includes("slide-head"));
406
+ out.head.headTag = tag(head);
407
+ const h2 = findIn(head, (c) => /^h[1-3]$/.test(c.name));
408
+ const sub = h2 && findIn(h2, (c) => classList(c).includes("sub"));
409
+ const kicker = findIn(head, (c) => classList(c).includes("kicker"));
410
+ const body = findIn(content, (c) => classList(c).includes("slide-body"));
411
+ if (h2) out.head.h2Tag = tag(h2), out.head.h2Close = `</${h2.name}>`;
412
+ if (body) out.head.bodyTag = tag(body);
413
+ if (sub) out.head.mode = "sub", out.head.subTag = tag(sub);
414
+ else if (kicker) out.head.mode = "kicker", out.head.kickerTag = tag(kicker), out.head.kickerClose = `</${kicker.name}>`;
415
+ }
416
+ return out;
417
+ }
418
+
419
+ /* deckHtml: index.html; css: theme.css + brand.css; plans: from planSlides with figure files settled
420
+ (fig.file, or fig.missing when it could not be made); after: index of the slide they follow. */
421
+ export function slidesHtml(deckHtml, css, plans, { after = -1, indent = " " } = {}) {
422
+ const models = deckModels(deckHtml);
423
+ mathOutput = /renderMathInElement|katex|mathjax/i.test(deckHtml);
424
+ const m = cssVar(css, "m", 50), bodyTop = cssVar(css, "body-top", 150), footTop = cssVar(css, "foot-top", 650);
425
+ const rule = (sel, prop, fallback) => +((css.match(new RegExp(`${sel}\\s*\\{[^}]*?\\b${prop}:\\s*([\\d.]+)px`)) || [])[1] || fallback);
426
+ const body = { w: 1280 - 2 * m, h: footTop - 30 - bodyTop, fs: cssVar(css, "fs-body", 28),
427
+ wave: rule("\\.wave", "height", 120), demoGap: rule("\\.demo", "gap", 40) };
428
+ let section = models.sectionCount(after);
429
+ const out = [];
430
+ for (const p of plans) {
431
+ let html;
432
+ if (p.kind === "section" && models.section) html = sectionSlide(deckHtml, models.section, p, ++section);
433
+ else if (p.kind === "title" && models.title) html = titleSlide(deckHtml, models.title, p);
434
+ else html = contentSlide(models, p, body, indent);
435
+ if (p.hidden) html = html.replace(/^<section\b[^>]*/, '$& data-visibility="hidden"');
436
+ out.push(html);
437
+ }
438
+ return out;
439
+ }
440
+
441
+ function headHtml(h, p) {
442
+ const title = esc(p.title || `Slide ${p.n}`), sub = p.subtitle ? esc(p.subtitle) : "";
443
+ const close = h.h2Close || "</h2>";
444
+ if (h.mode === "sub") return `${h.headTag}${h.h2Tag}${title}${sub ? `${h.subTag}${sub}</span>` : ""}${close}</div>`;
445
+ if (h.mode === "kicker") return sub ? `${h.headTag}${h.kickerTag}${title}${h.kickerClose || "</p>"}${h.h2Tag}${sub}${close}</div>`
446
+ : `${h.headTag}${h.h2Tag}${title}${close}</div>`;
447
+ return `${h.headTag}${h.h2Tag}${title}${sub ? `: ${sub}` : ""}${close}</div>`;
448
+ }
449
+
450
+ function contentSlide(models, p, body, ind) {
451
+ const h = models.head;
452
+ const i1 = ind + " ", i2 = i1 + " ";
453
+ const { fig: scale, font } = fit(p.body, body);
454
+ if (font < 0.85 && !p.complex.some((c) => c.startsWith("text shrunk"))) p.complex.push(`text shrunk to ${Math.round(font * 100)}% to fit`);
455
+ const lines = [h.sectionTag, `${i1}${headHtml(h, p)}`];
456
+ const bodyTag = font < 1 ? h.bodyTag.replace(/>$/, ` style="font-size:${Math.round(font * 100)}%">`) : h.bodyTag;
457
+ if (p.body.length) lines.push(`${i1}${bodyTag}`, blocksHtml(p.body, i2, scale, { ...models, body }), `${i1}</div>`);
458
+ if (p.refs.length) lines.push(h.hasRefs ? `${i1}<div class="refs">${p.refs.map((r) => runsHtml(r.runs)).join("<br>\n" + i2)}</div>`
459
+ : `${i1}<p class="muted" style="font-size:.6em">${p.refs.map((r) => runsHtml(r.runs)).join("<br>")}</p>`);
460
+ if (p.notes) lines.push(`${i1}<aside class="notes">${esc(p.notes).replace(/\n+/g, "<br>")}</aside>`);
461
+ lines.push(`${ind}</section>`);
462
+ return fragmentOrder(lines.join("\n"));
463
+ }
464
+
465
+ /* Fragments appear in document order unless they carry data-fragment-index. The steps are kept as
466
+ indices only when the order on the slide differs from the click order (or several share a click). */
467
+ function fragmentOrder(html) {
468
+ const steps = [...html.matchAll(/ data-step="(\d+)"/g)].map((m) => +m[1]);
469
+ const inOrder = steps.every((s, i) => !i || s > steps[i - 1]) && steps.every((s, i) => s === i + 1);
470
+ return html.replace(/ data-step="(\d+)"/g, inOrder ? "" : ' data-fragment-index="$1"');
471
+ }
472
+
473
+ /* How tall a layout would be in the deck: text in the deck's font size, figures scaled by f, all text by k */
474
+ const GAP = 16, COL_GAP = 56;
475
+ function heightOf(items, width, f, k, env) {
476
+ const fs = env.fs;
477
+ let h = 0;
478
+ for (const it of items) {
479
+ if (it.type === "text") {
480
+ for (const p of it.paras) {
481
+ const size = fs * k * (it.ratio || 1) * (p.level ? 0.86 : 1);
482
+ const w = Math.max(80, width - 32 * (p.level + 1));
483
+ for (const t of lineParts(p)) h += Math.max(1, Math.ceil(t.length * 0.53 * size / w)) * 1.3 * size;
484
+ h += 0.5 * size;
485
+ }
486
+ } else if (it.type === "cols") {
487
+ const total = it.widths.reduce((a, b) => a + b, 0), avail = width - COL_GAP * (it.cols.length - 1);
488
+ h += Math.max(...it.cols.map((c, i) => heightOf(c, avail * it.widths[i] / total, f, k, env)));
489
+ } else if (it.type === "table") {
490
+ const size = 0.8 * fs * k * Math.min(1, it.ratio || 1);
491
+ h += (it.table.rows.length * (1.25 * size + 20)) + (it.caption?.length ? 1.6 * size : 0);
492
+ } else if (it.type === "demo") h += it.rows.length * (env.wave + env.demoGap) * f - env.demoGap * f;
493
+ else { const w = Math.min(it.box.w * f, width); h += it.box.h * w / it.box.w; }
494
+ h += GAP;
495
+ }
496
+ return h - GAP;
497
+ }
498
+ /* The largest figure scale and text size (text shrinks last) at which the slide fits its body area */
499
+ function fit(items, body) {
500
+ for (const k of [1, 0.94, 0.88, 0.82, 0.76, 0.7])
501
+ for (let f = 1; f >= 0.5; f -= 0.05) if (heightOf(items, body.w, f, k, body) <= body.h) return { fig: f, font: k };
502
+ return { fig: 0.5, font: 0.7 };
503
+ }
504
+
505
+ /* A whole block's build step, added to its first tag (joining a class attribute it already has) */
506
+ function addStep(html, st) {
507
+ if (!st || !(st.in || st.out)) return html;
508
+ if (st.in && st.out && st.out <= st.in) st = { in: st.in }; // gone on the click it came: keep it shown
509
+ // a block that already has a step (its paragraphs build), or one that comes and goes: wrapped in a
510
+ // fragment of its own, since one tag cannot carry two
511
+ const first = html.match(/^\s*<[a-z]+\b[^>]*>/)?.[0] || "";
512
+ const own = first.match(/\bdata-step="(\d+)"/)?.[1];
513
+ if (own && !st.out && +own === st.in && !/fade-out/.test(first)) return html; // it comes on that click already
514
+ if (own || (st.in && st.out)) {
515
+ const ind = html.match(/^\s*/)[0];
516
+ const inner = st.in && st.out && !own ? addStep(html, { in: st.in }) : html;
517
+ const outer = st.in && st.out ? { out: st.out } : st;
518
+ return `${ind}<div class="${outer.in ? "fragment" : "fragment fade-out"}" data-step="${outer.in || outer.out}">\n` +
519
+ inner.split("\n").map((l) => " " + l).join("\n") + `\n${ind}</div>`;
520
+ }
521
+ const cls = st.in ? "fragment" : "fragment fade-out", data = ` data-step="${st.in || st.out}"`;
522
+ return html.replace(/^(\s*<[a-z]+\b)([^>]*)>/, (m, open, attrs) => (/\bclass="/.test(attrs)
523
+ ? `${open}${attrs.replace(/\bclass="([^"]*)"/, `class="$1 ${cls}"`)}${data}>` : `${open} class="${cls}"${data}${attrs}>`));
524
+ }
525
+
526
+ function blocksHtml(items, ind, scale, models) {
527
+ return items.map((it) => {
528
+ if (it.type === "cols" && items.length > 1) return blocksHtml([{ ...it, auto: true }], ind, scale, models);
529
+ if (it.type === "text") {
530
+ let html = parasHtml(it.paras, ind, it.paraStep);
531
+ if (it.ratio && it.ratio !== 1) html = html.replace(new RegExp(`(^|\n)(${ind}<(?:ul|ol|p)\\b[^>]*)>`, "g"), `$1$2 style="font-size:${it.ratio.toFixed(2)}em">`);
532
+ // a text that appears as a whole: each of its top-level blocks carries the step
533
+ if (it.step) html = html.split(new RegExp(`\n(?=${ind}<(?:ul|ol|p)\\b)`)).map((b) => addStep(b, it.step)).join("\n");
534
+ return html;
535
+ }
536
+ if (it.type === "table") {
537
+ const caption = it.caption?.length ? it.caption.map((p) => runsHtml(p.runs)).join("<br>") : null;
538
+ const html = addStep(tableHtml(it.table, ind, { cls: models.tableClass, caption }), it.step);
539
+ return it.ratio < 1 ? html.replace(/^(\s*<table\b[^>]*)>/, `$1 style="font-size:${(it.ratio * 0.8).toFixed(2)}em">`) : html;
540
+ }
541
+ if (it.type === "demo") return demoHtml(it.rows, ind, models, scale);
542
+ if (it.type === "cols") {
543
+ const n = it.cols.length, total = it.widths.reduce((a, b) => a + b, 0);
544
+ // the columns keep their widths from the slide, unless they are about equal
545
+ const even = Math.max(...it.widths) / Math.min(...it.widths) < 1.2;
546
+ const cls = "cols";
547
+ let style = even && n === 2 ? "" : ` style="grid-template-columns:${it.widths.map((w) => `${(w / total * n).toFixed(2)}fr`).join(" ")}"`;
548
+ const cols = it.cols.map((col) => col.length === 1 && !["text", "table"].includes(col[0].type) ? blocksHtml(col, ind + " ", scale, models)
549
+ : `${ind} <div>\n${blocksHtml(col, ind + " ", scale, models)}\n${ind} </div>`);
550
+ if (it.auto) style = style ? style.replace(/"$/, '; height:auto"') : ' style="height:auto"';
551
+ return `${ind}<div class="${cls} top"${style}>\n${cols.join("\n")}\n${ind}</div>`;
552
+ }
553
+ const f = it.fig;
554
+ if (f.missing) return `${ind}<div class="callout">${esc(f.missing)}</div>`;
555
+ const fig = (inner) => addStep(`${ind}<figure class="fig">${inner}</figure>`, it.step);
556
+ if (f.av === "video") return fig(`<video controls src="${escAttr(f.file)}"${f.poster && !f.poster.missing ? ` poster="${escAttr(f.poster.file)}"` : ""} preload="metadata" style="width:${Math.round(f.box.w * scale)}px"></video>`);
557
+ return fig(`<img src="${escAttr(f.file)}" alt="${escAttr(f.alt || "")}" style="width:${Math.round(f.box.w * scale)}px">`);
558
+ }).join("\n");
559
+ }
560
+
561
+ /* Audio in the deck's own player (a copy of its first .demo-row with new ids, label, sound and
562
+ waveform), or plain players when the deck has none or no waveform could be made */
563
+ function demoHtml(rows, ind, models, scale) {
564
+ const plain = (r) => `${ind}<p>${esc(r.label)}<br><audio controls src="${escAttr(r.fig.file)}" preload="metadata"></audio></p>`;
565
+ if (!models.demoRow) return rows.map(plain).join("\n");
566
+ // smaller waveforms when the slide is full
567
+ const small = scale < 1 && models.body;
568
+ const out = [`${ind}${small ? models.demoTag.replace(/>$/, ` style="gap:${Math.round(models.body.demoGap * scale)}px">`) : models.demoTag}`];
569
+ for (const r of rows) {
570
+ if (!r.fig.wave) { out.push(plain(r).replace(/^\s*/, ind + " ")); continue; }
571
+ const old = models.demoRow.match(/data-target="([^"]+)"/)[1];
572
+ let id = path.basename(r.fig.file).replace(/\.[^.]+$/, "").replace(/^.*-(s\d+-\d+)$/, "$1"), k = 2;
573
+ while (models.ids.has(`audio-${id}`)) id = `${id}-${k++}`;
574
+ models.ids.add(`audio-${id}`);
575
+ let row = models.demoRow
576
+ .replace(new RegExp(`data-target="${old}"`, "g"), `data-target="${id}"`)
577
+ .replace(new RegExp(`id="(\\w+)-${old}"`, "g"), `id="$1-${id}"`)
578
+ .replace(/(<audio\b[^>]*\bsrc=")[^"]*"/, (m, a) => `${a}${escAttr(r.fig.file)}"`)
579
+ .replace(/(<div[^>]*class="[^"]*\bwave\b[^>]*>[\s\S]*?<img\b[^>]*\bsrc=")[^"]*"/, (m, a) => `${a}${escAttr(r.fig.wave)}"`);
580
+ // the label: the text of .who up to its first child element
581
+ row = row.replace(/(class="[^"]*\bwho\b[^"]*"[^>]*>)([^<]*)(<span\b[^>]*>[\s\S]*?<\/span>)?/, (m, open) => `${open}${esc(r.label)}`);
582
+ if (small) row = row.replace(/(<div\b[^>]*class="[^"]*\bwave\b[^"]*"[^>]*)>/, `$1 style="height:${Math.round(models.body.wave * scale)}px">`);
583
+ const rind = (models.demoRow.match(/\n(\s*)<\/div>\s*$/) || [, ""])[1];
584
+ out.push(row.split("\n").map((l, i) => (i ? ind + " " + l.slice(rind.length) : ind + " " + l)).join("\n"));
585
+ }
586
+ out.push(`${ind}</div>`);
587
+ return out.join("\n");
588
+ }
589
+
590
+ /* Title and section slides: a copy of the deck's own, with the texts replaced. */
591
+ function fillModel(html, model, fills) {
592
+ let frag = html.slice(model.start, elementEnd(html, model)).replace(/\s+data-eid="[^"]*"/g, "");
593
+ const root = parse(frag).children[0];
594
+ const edits = [];
595
+ const findIn = (pred) => { let hit = null; walk({ children: [root] }, (c) => { if (!hit && pred(c)) hit = c; return !hit; }); return hit; };
596
+ const drop = (n) => {
597
+ let a = n.start;
598
+ while (a > 0 && /[ \t]/.test(frag[a - 1])) a--;
599
+ if (frag[a - 1] === "\n") a--;
600
+ edits.push({ start: a, end: elementEnd(frag, n), text: "" });
601
+ };
602
+ for (const [pred, value] of fills) {
603
+ const n = findIn(pred);
604
+ if (!n) continue;
605
+ if (value == null || value === "") drop(n);
606
+ else edits.push({ start: n.contentStart, end: n.closeStart, text: value });
607
+ }
608
+ for (const c of root.children) if (c.name === "aside") drop(c);
609
+ edits.sort((a, b) => b.start - a.start);
610
+ for (const e of edits) frag = frag.slice(0, e.start) + e.text + frag.slice(e.end);
611
+ return frag.replace(/\s+data-visibility="[^"]*"/, "");
612
+ }
613
+ const has = (cls) => (n) => classList(n).includes(cls);
614
+
615
+ function titleSlide(html, model, p) {
616
+ const sub = p.subtitle ? esc(p.subtitle) : "";
617
+ const tplHasSub = /class="[^"]*\bsubtitle\b/.test(html.slice(model.start, elementEnd(html, model)));
618
+ const frag = fillModel(html, model, [
619
+ [(n) => n.name === "h1", esc(p.title) + (sub && !tplHasSub ? `<br>\n ${sub}` : "")],
620
+ [has("subtitle"), sub],
621
+ [has("authors"), p.extra.authors],
622
+ [has("event"), p.extra.event],
623
+ [has("institute"), p.extra.institute],
624
+ ]);
625
+ return addNotes(frag, p);
626
+ }
627
+ function sectionSlide(html, model, p, number) {
628
+ const tpl = html.slice(model.start, elementEnd(html, model));
629
+ const numWidth = (tpl.match(/class="[^"]*\bnum\b[^"]*"[^>]*>\s*([^<]*)</) || [, "00"])[1].trim().length;
630
+ const frag = fillModel(html, model, [
631
+ [has("num"), String(number).padStart(numWidth, "0")],
632
+ [(n) => /^h[12]$/.test(n.name), esc(p.title || `Section ${number}`)],
633
+ [(n) => n.name === "p" && !has("kicker")(n) && !has("num")(n), p.subtitle ? esc(p.subtitle) : null],
634
+ ]);
635
+ return addNotes(frag, p);
636
+ }
637
+ function addNotes(frag, p) {
638
+ if (!p.notes) return frag;
639
+ const close = frag.lastIndexOf("</section>");
640
+ const ind = (frag.match(/\n([ \t]*)<\/section>\s*$/) || [, ""])[1];
641
+ return frag.slice(0, close).replace(/\s*$/, "") + `\n${ind} <aside class="notes">${esc(p.notes).replace(/\n+/g, "<br>")}</aside>\n${ind}` + frag.slice(close);
642
+ }
643
+
644
+ /* ---- All together ------------------------------------------------------------------------------------------ */
645
+ /* Plans the slides, renders the cut-outs from the slide PDF (if there is one) and returns the new
646
+ slides and the figure files to write: { fragments, files: [{ rel, data }], plans }. Nothing is written
647
+ into the deck here. */
648
+ export async function buildImport({ model, numbers, pdf, workDir, deckDir, deckHtml, css, after, prefix }) {
649
+ const plans = planSlides(model, numbers, { prefix, drawings: !!pdf });
650
+ const taken = new Set();
651
+ const free = (rel) => {
652
+ const ext = path.posix.extname(rel), stem = rel.slice(0, -ext.length);
653
+ let r = rel, k = 2;
654
+ while (taken.has(r) || fs.existsSync(path.join(deckDir, r))) r = `${stem}-${k++}${ext}`;
655
+ taken.add(r);
656
+ return r;
657
+ };
658
+ for (const p of plans) for (const f of p.figures) f.file = free(f.file);
659
+ const jobs = pdf ? cropJobs(plans, workDir) : [];
660
+ if (jobs.length) await renderJobs(pdf, jobs.map(({ fig, ...j }) => j), workDir);
661
+ const files = [];
662
+ for (const p of plans) {
663
+ for (const f of p.figures) {
664
+ const job = jobs.find((j) => j.fig === f);
665
+ let data = null;
666
+ if (f.media) data = model.zip.read(f.media);
667
+ else if (job && fs.existsSync(job.out)) data = fs.readFileSync(job.out);
668
+ else if (f.fallback) {
669
+ data = model.zip.read(f.fallback.media);
670
+ taken.delete(f.file);
671
+ f.file = free(f.file.replace(/\.png$/, "." + f.fallback.ext));
672
+ }
673
+ if (data) files.push({ rel: f.file, data });
674
+ else f.missing = `${f.alt || "A figure"}: PinDeck needs PowerPoint or Keynote to make a picture of it.`;
675
+ if (data && f.av === "audio") {
676
+ const svg = await waveformSvg(data, path.extname(f.file).slice(1));
677
+ if (svg) { f.wave = free(f.file.replace(/\.[^.]+$/, "-wave.svg")); files.push({ rel: f.wave, data: Buffer.from(svg) }); }
678
+ }
679
+ }
680
+ }
681
+ return { plans, files, fragments: slidesHtml(deckHtml, css, plans, { after }) };
682
+ }