slidemind 0.1.9 → 0.2.1

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,1273 @@
1
+ // The fill-template deck builder — the host-free `build-pptx` path for
2
+ // proposal-style packs (mode: "fill-template" in the pack's skill.json).
3
+ //
4
+ // Assembly direction: the template .pptx is cloned WHOLE (masters, layouts,
5
+ // media, tags, embeddings), then only declared shapes inside the clone are
6
+ // patched. Slides are duplicated/dropped/reordered by the deck's file order;
7
+ // native one-slide .pptx files in slides/ (engine-search folds) are inserted
8
+ // carrying their own layout+master. Nothing is ever rendered from HTML.
9
+ //
10
+ // The pack owns every template-specific fact: fields.json (semantic name →
11
+ // shape id, {shape,paragraph} targets, {image:{anchor,z}} picture wells),
12
+ // repeats.json (member shapes + strategy + timeline), and skill.json `policy`
13
+ // (immutable tables, hygiene deletions, freeform placeholders).
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ import { unzip, zip, readText } from "../zip.mjs";
19
+ import { slideOrderPaths, relsPathFor, resolvePartPath } from "../ooxml.mjs";
20
+ import { parseSlideFile, layoutNumber } from "./parse-slides.mjs";
21
+ import {
22
+ setShapeText, setParagraphText, setShapeFill, deleteShapes, cloneShape,
23
+ getGeom, setGeom, fillTable, mergedCellViolations, insertPicture,
24
+ insertPictureAtGeom, findShape, esc, imageSize, cellText, blocks,
25
+ shapeFillXml, shapeRunRPr, setShapeFillXml,
26
+ } from "./xml.mjs";
27
+
28
+ const enc = (s) => new TextEncoder().encode(s);
29
+ const RESERVED_PROPS = new Set(["repeat", "tables", "patches", "highlight"]);
30
+ const IMAGE_PATH_RE = /\.(png|jpe?g)$/i;
31
+
32
+ export async function buildFillTemplateDeck({ deckDir, skill, outPath, onLog }) {
33
+ const log = makeLog(onLog);
34
+ const pack = loadPack(skill);
35
+ const locale = skill.locale || "en";
36
+ const fieldsByNo = pack.fields[locale] ?? {};
37
+ const repeatsByNo = pack.repeats[locale] ?? {};
38
+
39
+ if (!skill.templatePath || !fs.existsSync(skill.templatePath)) {
40
+ throw new Error(
41
+ `template "${skill.templateKey}" not found in pack ${skill.skillDir} — check plan.md frontmatter and skill.json templates`,
42
+ );
43
+ }
44
+
45
+ // ---- deck reading ---------------------------------------------------------
46
+ const slidesDir = path.join(deckDir, "slides");
47
+ const entries = fs.readdirSync(slidesDir)
48
+ .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
49
+ .sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
50
+ if (entries.length === 0) throw new Error(`no .jsx or .pptx slides in ${slidesDir}`);
51
+
52
+ const problems = [];
53
+ const warnings = [];
54
+ const sequence = []; // { kind: "master", no, props, file } | { kind: "insert", file, buf }
55
+
56
+ // In-deck freeform pairing: NN-name.jsx (create-slides source) renders to a
57
+ // sibling NN-name.pptx artifact — when both exist, the .pptx carries the
58
+ // slide and the .jsx is source only.
59
+ const renderedStems = new Set(
60
+ entries.filter((f) => f.endsWith(".pptx")).map((f) => f.replace(/\.pptx$/, "")),
61
+ );
62
+
63
+ for (const file of entries) {
64
+ if (file.endsWith(".pptx")) {
65
+ sequence.push({ kind: "insert", file, buf: fs.readFileSync(path.join(slidesDir, file)) });
66
+ continue;
67
+ }
68
+ if (renderedStems.has(file.replace(/\.jsx$/, ""))) continue;
69
+ const src = fs.readFileSync(path.join(slidesDir, file), "utf8");
70
+ let parsed;
71
+ try {
72
+ parsed = parseSlideFile(src, file);
73
+ } catch (err) {
74
+ problems.push(err.message);
75
+ continue;
76
+ }
77
+ const layout = layoutNumber(parsed.component, locale);
78
+ if (!layout) {
79
+ problems.push(`${file}: <${parsed.component}> is a freeform (create-slides) slide with no ` +
80
+ `rendered artifact next to it — 'slidemind build-pptx' renders slides/${file.replace(/\.jsx$/, "")}.pptx ` +
81
+ `automatically when a paired add-in backend is reachable`);
82
+ continue;
83
+ }
84
+ if (layout.warn) warnings.push(`${file}: ${layout.warn}`);
85
+ const no = String(layout.no);
86
+
87
+ if ((pack.policy.freeform ?? []).includes(no)) {
88
+ if (Object.keys(parsed.props).length > 0) {
89
+ problems.push(`${file}: slide ${no} is a freeform placeholder — it takes no props. ` +
90
+ `Keep it bare to keep the master placeholder, or delete the file to drop the slide.`);
91
+ continue;
92
+ }
93
+ warnings.push(`${file}: keeping the master's slide-${no} placeholder (freeform replacement pending)`);
94
+ }
95
+
96
+ validateProps(parsed.props, fieldsByNo[no] ?? {}, repeatsByNo[no] ?? {}, pack.policy, no, file, problems);
97
+ sequence.push({ kind: "master", no: Number(no), props: parsed.props, file });
98
+ }
99
+
100
+ checkIdentities(sequence, warnings, problems);
101
+ if (problems.length) {
102
+ const err = new Error(`deck has ${problems.length} authoring error(s):\n - ${problems.join("\n - ")}`);
103
+ err.problems = problems;
104
+ throw err;
105
+ }
106
+
107
+ // ---- clone the template & patch ------------------------------------------
108
+ const files = unzip(fs.readFileSync(skill.templatePath));
109
+ const order = slideOrderPaths(files);
110
+ const partOfNo = (no) => order[no - 1];
111
+
112
+ const seen = new Map(); // master no → instance count
113
+ const pristine = new Map(); // master no → untouched template slide XML
114
+ const output = []; // { part, isNew }
115
+ let insertCounter = 0;
116
+
117
+ for (const item of sequence) {
118
+ if (item.kind === "insert") {
119
+ insertCounter++;
120
+ const part = insertForeignSlide(files, item.buf, insertCounter, item.file, log, warnings);
121
+ output.push({ part, isNew: true });
122
+ continue;
123
+ }
124
+ const basePart = partOfNo(item.no);
125
+ if (!basePart) throw new Error(`${item.file}: template has no slide ${item.no}`);
126
+ const n = (seen.get(item.no) ?? 0) + 1;
127
+ seen.set(item.no, n);
128
+ if (n === 1) pristine.set(item.no, files[basePart]);
129
+
130
+ let part = basePart;
131
+ if (n > 1) {
132
+ // Copies start from the UNTOUCHED template slide, not from the patched
133
+ // first instance — otherwise fields the copy doesn't set would leak the
134
+ // first instance's content.
135
+ part = cloneSlidePart(files, basePart, pristine.get(item.no), log);
136
+ }
137
+ log.slide(`${item.file} → template slide ${item.no}${n > 1 ? ` (copy ${n})` : ""}`);
138
+ let xml = readText(files, part);
139
+ const fieldMap = fieldsByNo[String(item.no)] ?? {};
140
+ xml = applyProps(xml, item, fieldMap, repeatsByNo[String(item.no)] ?? {},
141
+ pack.policy, files, part, deckDir, log, warnings);
142
+ xml = applyDeckChrome(xml, fieldMap, skill.planMeta ?? {}, item.props, log);
143
+ xml = applyHygiene(xml, pack.policy, item.no, log);
144
+ files[part] = enc(xml);
145
+ output.push({ part, isNew: n > 1 });
146
+ }
147
+
148
+ // Hygiene also applies to untouched (clone-only) slides that made it in.
149
+ // Drop every template slide the deck never referenced.
150
+ const referenced = new Set(output.map((o) => o.part));
151
+ for (let no = 1; no <= order.length; no++) {
152
+ if (![...seen.keys()].includes(no)) {
153
+ dropSlide(files, order[no - 1], log);
154
+ }
155
+ }
156
+
157
+ rebuildPresentation(files, output, log);
158
+ rebuildAppProps(files, output, log);
159
+ // AFTER the rebuild: cloned slides are only registered in
160
+ // presentation.xml.rels by rebuildPresentation, so pruning any earlier would
161
+ // sweep them up as orphans.
162
+ pruneOrphanParts(files, log);
163
+
164
+ const integrity = checkIntegrity(files);
165
+ for (const issue of integrity) warnings.push(`integrity: ${issue}`);
166
+ if (integrity.length) {
167
+ throw new Error(`integrity check failed:\n - ${integrity.join("\n - ")}`);
168
+ }
169
+
170
+ const finalOut = outPath
171
+ ? path.resolve(outPath)
172
+ : path.join(deckDir, `${path.basename(deckDir)}.pptx`);
173
+ fs.writeFileSync(finalOut, zip(files));
174
+ return { outPath: finalOut, slideCount: output.length, warnings };
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Pack
179
+ // ---------------------------------------------------------------------------
180
+
181
+ function loadPack(skill) {
182
+ const read = (name, fallback) => {
183
+ const p = path.join(skill.skillDir, name);
184
+ if (!fs.existsSync(p)) return fallback;
185
+ return JSON.parse(fs.readFileSync(p, "utf8"));
186
+ };
187
+ const fields = read("fields.json", null);
188
+ if (!fields) throw new Error(`pack ${skill.skillDir} has no fields.json`);
189
+ return {
190
+ fields,
191
+ repeats: read("repeats.json", {}),
192
+ policy: skill.manifest.policy ?? {},
193
+ };
194
+ }
195
+
196
+ function validateProps(props, fieldMap, repeatMap, policy, no, file, problems) {
197
+ for (const [key, value] of Object.entries(props)) {
198
+ if (key === "repeat") {
199
+ for (const group of Object.keys(value ?? {})) {
200
+ if (!repeatMap[group]) {
201
+ problems.push(`${file}: repeat group "${group}" is not declared for slide ${no} in repeats.json`);
202
+ }
203
+ }
204
+ continue;
205
+ }
206
+ if (key === "tables") {
207
+ const immutable = (policy.immutableTables ?? {})[no] ?? [];
208
+ for (const frameId of Object.keys(value ?? {})) {
209
+ if (immutable.includes(frameId)) {
210
+ problems.push(`${file}: table "${frameId}" on slide ${no} is immutable (baked into the template) — never pass it`);
211
+ }
212
+ }
213
+ continue;
214
+ }
215
+ if (key === "patches") {
216
+ // Silent near-misses here destroy content (a `paragraph` key that gets
217
+ // ignored replaces the WHOLE shape) — validate the full patch shape.
218
+ for (const patch of Array.isArray(value) ? value : []) {
219
+ const keys = Object.keys(patch ?? {});
220
+ const unknown = keys.filter((k) => !["shape", "text", "fill", "paragraph"].includes(k));
221
+ if (unknown.length) {
222
+ problems.push(`${file}: patch has unknown key(s) ${unknown.join(", ")} — patches support {shape, text}, {shape, paragraph, text}, {shape, fill}`);
223
+ }
224
+ if (patch?.shape == null) {
225
+ problems.push(`${file}: every patch needs a "shape" id`);
226
+ }
227
+ if (patch?.paragraph != null && patch?.text == null) {
228
+ problems.push(`${file}: a paragraph-targeted patch needs "text"`);
229
+ }
230
+ }
231
+ continue;
232
+ }
233
+ if (RESERVED_PROPS.has(key)) continue;
234
+ if (!(key in fieldMap)) {
235
+ const near = nearest(key, Object.keys(fieldMap));
236
+ problems.push(`${file}: unknown prop "${key}" for slide ${no}${near ? ` — did you mean "${near}"?` : ""}`);
237
+ }
238
+ }
239
+ }
240
+
241
+ function nearest(key, candidates) {
242
+ let best = null;
243
+ let bestDist = 3;
244
+ for (const c of candidates) {
245
+ const d = editDistance(key.toLowerCase(), c.toLowerCase());
246
+ if (d < bestDist) { bestDist = d; best = c; }
247
+ }
248
+ return best;
249
+ }
250
+
251
+ function editDistance(a, b) {
252
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
253
+ for (let j = 0; j <= b.length; j++) dp[0][j] = j;
254
+ for (let i = 1; i <= a.length; i++) {
255
+ for (let j = 1; j <= b.length; j++) {
256
+ dp[i][j] = Math.min(
257
+ dp[i - 1][j] + 1,
258
+ dp[i][j - 1] + 1,
259
+ dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
260
+ );
261
+ }
262
+ }
263
+ return dp[a.length][b.length];
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Cross-slide identities (the catalog's consistency table, as code)
268
+ // ---------------------------------------------------------------------------
269
+
270
+ const asLines = (v) => (Array.isArray(v) ? v : String(v ?? "").split("\n")).map((s) => s.trim()).filter(Boolean);
271
+
272
+ function checkIdentities(sequence, warnings, problems) {
273
+ const slide = (no) => sequence.filter((s) => s.kind === "master" && s.no === no);
274
+ const approach = slide(10)[0];
275
+ const details = slide(11);
276
+ const gantt = slide(13)[0];
277
+ const calc = slide(16)[0];
278
+
279
+ const wpItems = approach?.props?.repeat?.workPackages ?? null;
280
+ const ganttItems = gantt?.props?.repeat?.workPackages ?? null;
281
+
282
+ if (wpItems && details.length && wpItems.length !== details.length) {
283
+ problems.push(`WP count mismatch: slide 10 declares ${wpItems.length} work packages but there are ${details.length} WP-detail files (slide 11)`);
284
+ }
285
+ // Every WP detail must carry FULL bullet lists — a section left empty (or a
286
+ // one-liner) is a defect in a client proposal, not a style choice.
287
+ details.forEach((d, i) => {
288
+ for (const section of ["services", "assumptions", "deliverables"]) {
289
+ const lines = asLines(d.props?.[section]);
290
+ if (lines.length === 0) {
291
+ problems.push(`${d.file}: WP detail ${i + 1} has no "${section}" — every WP detail needs all three bullet lists filled`);
292
+ } else if (lines.length < 3) {
293
+ warnings.push(`${d.file}: "${section}" has only ${lines.length} bullet(s) — WP details expect full lists (3–5)`);
294
+ }
295
+ }
296
+ });
297
+ if (wpItems && ganttItems && wpItems.length !== ganttItems.length) {
298
+ problems.push(`WP count mismatch: slide 10 has ${wpItems.length} work packages, the project plan (13) has ${ganttItems.length} rows`);
299
+ }
300
+ if (ganttItems && details.length === ganttItems.length) {
301
+ ganttItems.forEach((item, i) => {
302
+ const canonical = asLines(details[i]?.props?.deliverables);
303
+ const row = asLines(item.deliverable);
304
+ if (canonical.length && row.length && canonical.join("\n") !== row.join("\n")) {
305
+ problems.push(`deliverables drift: gantt row ${i + 1} must repeat WP-${i + 1}'s deliverables verbatim (slide 11 owns them)`);
306
+ }
307
+ });
308
+ }
309
+ if (wpItems && ganttItems) {
310
+ ganttItems.forEach((item, i) => {
311
+ if (item.title && wpItems[i]?.title && item.title !== wpItems[i].title) {
312
+ problems.push(`WP name drift: slide 10 card ${i + 1} is "${wpItems[i].title}" but gantt row ${i + 1} is "${item.title}"`);
313
+ }
314
+ });
315
+ }
316
+ for (const [frameId, rows] of Object.entries(calc?.props?.tables ?? {})) {
317
+ if (!Array.isArray(rows) || !wpItems) continue;
318
+ const labels = rows.slice(1, -1).map((r) => String(r[0] ?? ""));
319
+ labels.forEach((label, i) => {
320
+ if (wpItems[i]?.title && label && label !== wpItems[i].title) {
321
+ problems.push(`WP name drift: calculation table ${frameId} row ${i + 1} is "${label}" but slide 10 card ${i + 1} is "${wpItems[i].title}"`);
322
+ }
323
+ });
324
+ }
325
+ // Price echo: the contracted total should appear in the management summary.
326
+ const costRows = calc?.props?.tables?.["15"];
327
+ if (Array.isArray(costRows)) {
328
+ const grand = String(costRows[costRows.length - 1]?.slice(-1)[0] ?? "");
329
+ const digits = grand.replace(/[^\d.,]/g, "");
330
+ const summary = slide(7)[0];
331
+ if (digits && summary?.props?.approach && !String(summary.props.approach).includes(digits)) {
332
+ warnings.push(`slide 7's approach paragraph does not mention the contracted total ${grand} from table 15`);
333
+ }
334
+ }
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // Per-slide ops
339
+ // ---------------------------------------------------------------------------
340
+
341
+ function applyProps(xml, item, fieldMap, repeatMap, policy, files, part, deckDir, log, warnings) {
342
+ const { props } = item;
343
+
344
+ for (const [key, value] of Object.entries(props)) {
345
+ if (RESERVED_PROPS.has(key)) continue;
346
+ const target = fieldMap[key];
347
+ if (target == null) continue; // validated earlier
348
+ if (typeof target === "object" && target.image) {
349
+ xml = applyImage(xml, files, part, deckDir, String(value), target.image, log);
350
+ } else if (typeof target === "object" && target.shape != null) {
351
+ xml = setParagraphText(xml, target.shape, target.paragraph, String(value), log);
352
+ } else {
353
+ xml = setShapeText(xml, target, value, log);
354
+ }
355
+ }
356
+
357
+ for (const [group, items] of Object.entries(props.repeat ?? {})) {
358
+ xml = applyRepeat(xml, repeatMap[group], items, group, log, warnings, {
359
+ highlight: props.highlight,
360
+ files, part, deckDir,
361
+ });
362
+ }
363
+
364
+ for (const [frameId, rows] of Object.entries(props.tables ?? {})) {
365
+ xml = fillTable(xml, frameId, rows, log);
366
+ }
367
+
368
+ for (const patch of props.patches ?? []) {
369
+ if (patch.paragraph != null && patch.text != null) {
370
+ xml = setParagraphText(xml, patch.shape, patch.paragraph, String(patch.text), log);
371
+ } else if (patch.text != null) {
372
+ xml = setShapeText(xml, patch.shape, patch.text, log);
373
+ }
374
+ if (patch.fill != null) xml = setShapeFill(xml, patch.shape, patch.fill, log);
375
+ }
376
+
377
+ return xml;
378
+ }
379
+
380
+ function applyHygiene(xml, policy, no, log) {
381
+ const ids = (policy.deleteShapes ?? {})[String(no)] ?? [];
382
+ if (ids.length) xml = deleteShapes(xml, ids, log);
383
+ // Some template slides ship internal authoring highlights inside text that
384
+ // stays (general conditions) — the pack lists them for a wholesale strip.
385
+ if ((policy.stripHighlights ?? []).includes(String(no)) && /<a:highlight>/.test(xml)) {
386
+ xml = xml.replace(/<a:highlight>[\s\S]*?<\/a:highlight>/g, "");
387
+ log.ok("stripped template highlights");
388
+ }
389
+ return xml;
390
+ }
391
+
392
+ // Deck-wide chrome from plan.md frontmatter: `footer:` / `date:` apply to every
393
+ // master-backed slide that declares the field and wasn't explicitly authored.
394
+ // Shapes driven by live PowerPoint fields (<a:fld> — auto date, slide number)
395
+ // are never overwritten with static text.
396
+ function applyDeckChrome(xml, fieldMap, planMeta, props, log) {
397
+ for (const key of ["footer", "date"]) {
398
+ if (!planMeta[key] || props[key] != null || fieldMap[key] == null) continue;
399
+ const target = fieldMap[key];
400
+ if (typeof target === "object") continue;
401
+ const hit = findShape(xml, target, ["sp"]);
402
+ if (!hit || /<a:fld /.test(hit.block)) continue;
403
+ xml = setShapeText(xml, target, planMeta[key], log);
404
+ }
405
+ return xml;
406
+ }
407
+
408
+ // ---- repeats ---------------------------------------------------------------
409
+
410
+ function applyRepeat(xml, group, items, name, log, warnings, ctx = {}) {
411
+ if (!group) return xml;
412
+ const n = items.length;
413
+ if (n < (group.min ?? 1) || n > (group.max ?? 99)) {
414
+ throw new Error(`repeat "${name}": ${n} items is outside the template's supported range ${group.min}..${group.max}`);
415
+ }
416
+ const base = group.baseCount ?? group.members[0].shapes.length;
417
+
418
+ if (group.strategy === "ganttRow" && n > base) {
419
+ throw new Error(`repeat "${name}": the template carries ${base} rows and row cloning is not supported yet — cap at ${base}`);
420
+ }
421
+
422
+ // Extend member shape lists by cloning the last member when n > base.
423
+ const members = group.members.map((m) => ({ ...m, shapes: [...m.shapes] }));
424
+ if (n > base) {
425
+ for (const m of members) {
426
+ for (let k = base; k < n; k++) {
427
+ const res = cloneShape(xml, m.shapes[base - 1], log);
428
+ xml = res.xml;
429
+ m.shapes.push(String(res.newId));
430
+ }
431
+ }
432
+ }
433
+
434
+ // Even horizontal respacing across the ORIGINAL span (first..last member).
435
+ // Above the template's base count the slot pitch shrinks, so member widths
436
+ // scale down with it — otherwise neighbouring cards overlap.
437
+ if (group.strategy === "evenSpacedHorizontal" && n !== 1) {
438
+ const widthScale = n > base ? (base - 1) / (n - 1) : 1;
439
+ for (const m of members) {
440
+ const first = getGeom(xml, m.shapes[0]);
441
+ const last = getGeom(xml, m.shapes[base - 1]);
442
+ if (!first || !last) continue;
443
+ for (let i = 0; i < n; i++) {
444
+ const g = getGeom(xml, m.shapes[i]);
445
+ if (!g) continue;
446
+ const x = first.x + (i * (last.x - first.x)) / (n - 1);
447
+ xml = setGeom(xml, m.shapes[i], { ...g, x, cx: g.cx * widthScale }, log);
448
+ }
449
+ }
450
+ }
451
+
452
+ // Bars carry their formatting in a styled sibling when the template pill is
453
+ // empty — the pack can name that sibling explicitly (textDonor), else pick
454
+ // the first member shape that has its own run properties.
455
+ const donorFor = (m) => {
456
+ if (group.textDonor) return group.textDonor;
457
+ for (const id of m.shapes) {
458
+ const hit = findShape(xml, id, ["sp"]);
459
+ if (hit && /<a:rPr[\s>]/.test(hit.block)) return id;
460
+ }
461
+ return null;
462
+ };
463
+
464
+ // Highlight state-swap (the approach chevron "where we are" marker): the
465
+ // template's first member is the active style, the second the inactive one —
466
+ // both probed from the slide itself, never hardcoded.
467
+ const hl = Number(ctx.highlight) || 0;
468
+ const titleRole = members.find((m) => m.role === "title");
469
+ const hlStyles = hl && titleRole && titleRole.shapes.length >= 2
470
+ ? {
471
+ activeFill: shapeFillXml(xml, titleRole.shapes[0]),
472
+ inactiveFill: shapeFillXml(xml, titleRole.shapes[1]),
473
+ activeRPr: shapeRunRPr(xml, titleRole.shapes[0]),
474
+ inactiveRPr: shapeRunRPr(xml, titleRole.shapes[1]),
475
+ }
476
+ : null;
477
+
478
+ const pendingImages = [];
479
+ for (const m of members) {
480
+ const donorId = donorFor(m);
481
+ for (let i = 0; i < n; i++) {
482
+ const item = items[i] ?? {};
483
+ const value = item[m.role];
484
+ if (value == null) continue;
485
+ if (typeof value === "string" && IMAGE_PATH_RE.test(value.trim())) {
486
+ // An image path in a repeat role (e.g. illustration icons) replaces
487
+ // the placeholder box with a contain-fit picture — after respacing.
488
+ pendingImages.push({ shapeId: m.shapes[i], file: value.trim() });
489
+ continue;
490
+ }
491
+ const opts = { donorId };
492
+ if (hlStyles && m === titleRole) {
493
+ opts.rPrOverride = i === hl - 1 ? hlStyles.activeRPr : hlStyles.inactiveRPr;
494
+ }
495
+ xml = m.paragraph != null
496
+ ? setParagraphText(xml, m.shapes[i], m.paragraph, String(value), log)
497
+ : setShapeText(xml, m.shapes[i], value, log, opts);
498
+ }
499
+ }
500
+ // Surplus members disappear entirely (shape, not just text) — deduped, since
501
+ // paragraph-targeted members legitimately share one shape (name/level).
502
+ if (n < base) {
503
+ const surplus = new Set();
504
+ for (const m of members) {
505
+ for (const id of m.shapes.slice(n, base)) surplus.add(id);
506
+ }
507
+ xml = deleteShapes(xml, [...surplus], log);
508
+ }
509
+
510
+ if (hlStyles) {
511
+ for (let i = 0; i < n; i++) {
512
+ const fill = i === hl - 1 ? hlStyles.activeFill : hlStyles.inactiveFill;
513
+ xml = setShapeFillXml(xml, titleRole.shapes[i], fill, log);
514
+ }
515
+ log.ok(`highlighted work package ${hl}`);
516
+ }
517
+
518
+ for (const img of pendingImages) {
519
+ xml = replaceShapeWithImage(xml, img.shapeId, img.file, ctx, log);
520
+ }
521
+
522
+ // Gantt bars: span from startMonth/endMonth via the template's timeline.
523
+ if (group.strategy === "ganttRow" && group.timeline) {
524
+ const { leftX, monthWidth, months } = group.timeline;
525
+ const bars = members.find((m) => m.role === "bar");
526
+ items.forEach((item, i) => {
527
+ if (!bars || item.startMonth == null || item.endMonth == null) return;
528
+ const s = Math.max(1, Math.min(months, item.startMonth));
529
+ const e = Math.max(s, Math.min(months, item.endMonth));
530
+ const g = getGeom(xml, bars.shapes[i]);
531
+ if (!g) return;
532
+ xml = setGeom(xml, bars.shapes[i], {
533
+ ...g,
534
+ x: leftX + (s - 1) * monthWidth,
535
+ cx: (e - s + 1) * monthWidth,
536
+ }, log);
537
+ log.ok(`bar ${i + 1} spans ${s}..${e}`);
538
+ // A repositioned bar makes the row's fixed annotations (dashed "planned"
539
+ // extensions) stale — drop them with the move. Track lines stay.
540
+ const stale = group.annotations?.[i] ?? [];
541
+ if (stale.length) xml = deleteShapes(xml, stale, log);
542
+ });
543
+ }
544
+
545
+ // Trimmed rows lose their furniture too (track lines AND annotations) — the
546
+ // member shapes alone leave orphan rules behind otherwise.
547
+ if (n < base) {
548
+ for (let i = n; i < base; i++) {
549
+ const gone = [...(group.furniture?.[i] ?? []), ...(group.annotations?.[i] ?? [])];
550
+ if (gone.length) xml = deleteShapes(xml, gone, log);
551
+ }
552
+ }
553
+
554
+ return xml;
555
+ }
556
+
557
+ // ---- images ------------------------------------------------------------------
558
+
559
+ // Register an image file as a media part + slide relationship. Returns null
560
+ // (with a log line) when the file is missing.
561
+ function addImageMedia(files, part, deckDir, file, log) {
562
+ const imgPath = path.isAbsolute(file) ? file : path.join(deckDir, file);
563
+ if (!fs.existsSync(imgPath)) {
564
+ log.miss(`image ${file} not found (resolved ${imgPath})`);
565
+ return null;
566
+ }
567
+ const buf = fs.readFileSync(imgPath);
568
+ const ext = path.extname(imgPath).slice(1).toLowerCase().replace("jpg", "jpeg");
569
+ const mediaName = `ppt/media/fillImage${Object.keys(files).filter((p) => p.startsWith("ppt/media/fillImage")).length + 1}.${ext}`;
570
+ files[mediaName] = new Uint8Array(buf);
571
+ ensureDefaultContentType(files, ext, ext === "png" ? "image/png" : "image/jpeg");
572
+
573
+ const relsPath = relsPathFor(part);
574
+ let rels = readText(files, relsPath) ??
575
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`;
576
+ const relId = nextRelId(rels);
577
+ rels = rels.replace(
578
+ "</Relationships>",
579
+ `<Relationship Id="${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/${path.posix.basename(mediaName)}"/></Relationships>`,
580
+ );
581
+ files[relsPath] = enc(rels);
582
+ return { relId, dims: imageSize(buf) };
583
+ }
584
+
585
+ function applyImage(xml, files, part, deckDir, file, spec, log) {
586
+ const media = addImageMedia(files, part, deckDir, file, log);
587
+ if (!media) return xml;
588
+ const z = spec.z ?? "below";
589
+ if (z === "above") {
590
+ // The picture covers its anchor (e.g. the client-logo well) — blank the
591
+ // anchor's placeholder text so it cannot bleed through transparency.
592
+ xml = setShapeText(xml, spec.anchor, "", log);
593
+ }
594
+ return insertPicture(xml, spec.anchor, media.relId, media.dims, { z }, log);
595
+ }
596
+
597
+ // Replace a placeholder box (e.g. the approach "Illustration" wells) with a
598
+ // contain-fit image at the box's geometry — the box itself is removed.
599
+ function replaceShapeWithImage(xml, shapeId, file, ctx, log) {
600
+ const geom = getGeom(xml, shapeId);
601
+ if (!geom) return log.miss(`image target ${shapeId} has no geometry`), xml;
602
+ const media = addImageMedia(ctx.files, ctx.part, ctx.deckDir, file, log);
603
+ if (!media) return xml;
604
+ xml = deleteShapes(xml, [shapeId], log);
605
+ return insertPictureAtGeom(xml, geom, media.relId, media.dims, log);
606
+ }
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // Slide duplication (self-contained, the way PowerPoint's writer does it)
610
+ // ---------------------------------------------------------------------------
611
+
612
+ let clonePartCounter = 0;
613
+
614
+ // think-cell's payload on a slide: the custData tag pointer and the invisible
615
+ // TCLayout OLE frames. Both carry an object identity that must appear once per
616
+ // presentation — a second copy makes PowerPoint strip it and report "couldn't
617
+ // read some content". Prefix-tolerant: engine slices arrive with generic ns
618
+ // prefixes rather than p:.
619
+ export function stripThinkCell(xml) {
620
+ return xml
621
+ .replace(/<(?:[\w.-]+:)?custDataLst>[\s\S]*?<\/(?:[\w.-]+:)?custDataLst>/g, "")
622
+ .replace(/<((?:[\w.-]+:)?)graphicFrame>[\s\S]*?<\/\1graphicFrame>/g, (frame) =>
623
+ /progId="TCLayout/.test(frame) ? "" : frame,
624
+ );
625
+ }
626
+
627
+ // PowerPoint requires every <p:sp> to carry a <p:txBody>, even when the shape
628
+ // holds no text. The schema marks it optional and LibreOffice renders happily
629
+ // without it, but PowerPoint answers a text-free shape with "couldn't read some
630
+ // content" and repairs the file by inserting an empty body — so we insert it
631
+ // first. The add-in's translator omits it for pure-geometry boxes, which is the
632
+ // real origin; normalizing here also fixes artifacts already cached on disk and
633
+ // any slide folded in from a foreign deck.
634
+ // NB the `(?:\s[^>]*)?` — <p:sp> takes an optional useBgFill attribute, which
635
+ // PowerPoint writes whenever a user picks "background fill". Matching the bare
636
+ // literal <p:sp> would silently exempt exactly those shapes from the guard.
637
+ const SP_OPEN = String.raw`<((?:[\w.-]+:)?)sp((?:\s[^>]*)?)>`;
638
+
639
+ export function ensureShapeTextBodies(xml) {
640
+ const a = xml.match(/xmlns:([\w.-]+)="http:\/\/schemas\.openxmlformats\.org\/drawingml\/2006\/main"/)?.[1] ?? "a";
641
+ return xml.replace(
642
+ new RegExp(`${SP_OPEN}([\\s\\S]*?)</\\1sp>`, "g"),
643
+ (whole, pfx, attrs, body) => {
644
+ if (new RegExp(`<${pfx}txBody[\\s>]`).test(body)) return whole;
645
+ return (
646
+ `<${pfx}sp${attrs}>${body}` +
647
+ `<${pfx}txBody><${a}:bodyPr/><${a}:lstStyle/><${a}:p/></${pfx}txBody>` +
648
+ `</${pfx}sp>`
649
+ );
650
+ },
651
+ );
652
+ }
653
+
654
+ // Shapes live in layouts and masters too, not just slides — an inserted deck
655
+ // brings its own of each.
656
+ export function shapesWithoutTextBody(xml) {
657
+ return [...xml.matchAll(new RegExp(`${SP_OPEN}(?:(?!</\\1sp>)[\\s\\S])*</\\1sp>`, "g"))]
658
+ .filter((m) => !new RegExp(`<${m[1]}txBody[\\s>]`).test(m[0])).length;
659
+ }
660
+
661
+ function cloneSlidePart(files, basePart, srcBytes, log) {
662
+ const maxNo = Math.max(
663
+ ...Object.keys(files)
664
+ .map((p) => (p.match(/^ppt\/slides\/slide(\d+)\.xml$/) || [])[1])
665
+ .filter(Boolean)
666
+ .map(Number),
667
+ );
668
+ const newPart = `ppt/slides/slide${maxNo + 1}.xml`;
669
+
670
+ // Strip think-cell from the COPY. Its custData + TCLayout OLE frames carry an
671
+ // object identity that PowerPoint refuses to see on two slides at once: given
672
+ // a duplicate it removes the copy's tag and OLE parts on open and reports
673
+ // "couldn't read some content". The first instance keeps its payload exactly
674
+ // as the template ships it; the frames are invisible layout metadata, so the
675
+ // copies lose nothing on screen. Same treatment the insert path applies.
676
+ const xml = stripThinkCell(new TextDecoder().decode(srcBytes ?? files[basePart]));
677
+ files[newPart] = enc(xml);
678
+
679
+ // Rels: drop the notes pointer; duplicate the per-slide unique parts that
680
+ // SURVIVED the strip (a non-think-cell embedded object, a real tag list) so
681
+ // clones stay self-contained, and drop the relationships the strip orphaned.
682
+ const liveIds = new Set(
683
+ [...xml.matchAll(/\br:(?:id|embed|link)="([^"]+)"/g)].map((m) => m[1]),
684
+ );
685
+ let rels = readText(files, relsPathFor(basePart)) ?? "";
686
+ rels = rels.replace(/<Relationship [^>]*Type="[^"]*\/notesSlide"[^>]*\/>/g, "");
687
+ rels = rels.replace(/<Relationship [^>]*\/>/g, (rel) => {
688
+ const type = (rel.match(/Type="([^"]+)"/) || [])[1] ?? "";
689
+ if (!/\/(tags|oleObject)$/.test(type)) return rel;
690
+ const id = (rel.match(/Id="([^"]+)"/) || [])[1];
691
+ return id && !liveIds.has(id) ? "" : rel;
692
+ });
693
+ for (const m of [...rels.matchAll(/<Relationship [^>]*\/>/g)]) {
694
+ const rel = m[0];
695
+ const type = (rel.match(/Type="([^"]+)"/) || [])[1] ?? "";
696
+ if (!/\/(tags|oleObject)$/.test(type)) continue;
697
+ const target = (rel.match(/Target="([^"]+)"/) || [])[1];
698
+ const srcPart = resolvePartPath("ppt/slides", target);
699
+ if (!files[srcPart]) continue;
700
+ clonePartCounter++;
701
+ const extM = srcPart.match(/\.([a-z0-9]+)$/i);
702
+ const dupPart = srcPart.replace(/(\.[a-z0-9]+)$/i, `Fill${clonePartCounter}$1`);
703
+ files[dupPart] = files[srcPart];
704
+ rels = rels.replace(rel, rel.replace(`Target="${target}"`, `Target="${target.replace(/(\.[a-z0-9]+)$/i, `Fill${clonePartCounter}$1`)}"`));
705
+ duplicateOverride(files, srcPart, dupPart);
706
+ void extM;
707
+ }
708
+ files[relsPathFor(newPart)] = enc(rels);
709
+ duplicateOverride(files, basePart, newPart);
710
+ log.ok(`cloned slide part → ${newPart}`);
711
+ return newPart;
712
+ }
713
+
714
+ function duplicateOverride(files, srcPart, dupPart) {
715
+ const ct = readText(files, "[Content_Types].xml");
716
+ const m = ct.match(new RegExp(`<Override PartName="/${srcPart.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*/>`));
717
+ if (m) {
718
+ files["[Content_Types].xml"] = enc(
719
+ ct.replace("</Types>", m[0].replace(`/${srcPart}`, `/${dupPart}`) + "</Types>"),
720
+ );
721
+ }
722
+ }
723
+
724
+ // ---------------------------------------------------------------------------
725
+ // Foreign one-slide inserts (engine-search folds, later freeform renders)
726
+ // ---------------------------------------------------------------------------
727
+
728
+ // `sldMasterId` and `sldLayoutId` share ONE id space. PowerPoint allocates both
729
+ // from it and treats any collision anywhere in that space as repairable
730
+ // corruption — see the same rule already written down at app-props.mjs:9-17 for
731
+ // the add-in splice path. So an inserted master registered at "one past the
732
+ // highest sldMasterId" lands squarely on the host master's FIRST layout id
733
+ // (host master 2147483757, its layouts 2147483758…2147483771), and the deck
734
+ // repairs on open even though every id is unique within its own kind.
735
+ //
736
+ // Collect every id of both kinds, from presentation.xml and every master.
737
+ export function usedMasterLayoutIds(files, presXml) {
738
+ const used = new Set();
739
+ const pres = presXml ?? readText(files, "ppt/presentation.xml") ?? "";
740
+ for (const m of pres.matchAll(/<p:sldMasterId\b[^>]*\bid="(\d+)"/g)) used.add(Number(m[1]));
741
+ for (const part of Object.keys(files)) {
742
+ if (!/^ppt\/slideMasters\/[^/]+\.xml$/.test(part)) continue;
743
+ for (const m of (readText(files, part) ?? "").matchAll(/<p:sldLayoutId\b[^>]*\bid="(\d+)"/g)) {
744
+ used.add(Number(m[1]));
745
+ }
746
+ }
747
+ return used;
748
+ }
749
+
750
+ // Both kinds live in the >= 2^31 space (ISO-29500-4: ST_SlideMasterId and
751
+ // ST_SlideLayoutId are minInclusive 2147483648, and unsignedInt caps at 2^32-1).
752
+ //
753
+ // Allocate ABOVE everything in use rather than filling the template's gaps.
754
+ // Both are valid per the schema, but every PowerPoint-authored deck we have
755
+ // numbers layouts above their master, and this is the behaviour verified to
756
+ // open cleanly — filling gaps would put a layout id below its master's, which
757
+ // nothing in the corpus does. Headroom is ~2.1 billion, so the climb is
758
+ // theoretical; the guard is here so it fails loudly rather than emitting an
759
+ // out-of-range id if it ever is not.
760
+ export function nextMasterLayoutId(used) {
761
+ const next = Math.max(2147483647, ...used) + 1;
762
+ if (next > 4294967295) {
763
+ throw new Error("no free slide master/layout id remains in the 2^31..2^32-1 space");
764
+ }
765
+ return next;
766
+ }
767
+
768
+ // Renumber a just-inserted master's layout ids off anything the package already
769
+ // uses — masters included, not just other layouts. `used` is passed in so a
770
+ // caller mid-way through registering a master can include the id it just
771
+ // allocated, which presentation.xml does not carry yet.
772
+ export function reidLayoutIds(files, masterPart, extraClaimed = new Set()) {
773
+ const xml = readText(files, masterPart);
774
+ if (!xml || !/<p:sldLayoutId\b/.test(xml)) return;
775
+
776
+ // Ids claimed by anything OTHER than this master: every sldMasterId, the
777
+ // caller's freshly allocated ids (presentation.xml is not written back until
778
+ // the registration loop ends), and every other master's layout ids. One of
779
+ // ours appearing in here is a collision, whichever kind it collides with.
780
+ const elsewhere = new Set(extraClaimed);
781
+ const pres = readText(files, "ppt/presentation.xml") ?? "";
782
+ for (const m of pres.matchAll(/<p:sldMasterId\b[^>]*\bid="(\d+)"/g)) elsewhere.add(Number(m[1]));
783
+ for (const part of Object.keys(files)) {
784
+ if (!/^ppt\/slideMasters\/[^/]+\.xml$/.test(part) || part === masterPart) continue;
785
+ for (const m of (readText(files, part) ?? "").matchAll(/<p:sldLayoutId\b[^>]*\bid="(\d+)"/g)) {
786
+ elsewhere.add(Number(m[1]));
787
+ }
788
+ }
789
+
790
+ // Re-derive the free id per assignment: nextMasterLayoutId hands out the
791
+ // LOWEST free one, so a running counter would re-issue ids sitting in the
792
+ // template's gaps.
793
+ const out = xml.replace(
794
+ /(<p:sldLayoutId\b[^>]*\bid=")(\d+)(")/g,
795
+ (whole, pre, id, post) => {
796
+ if (!elsewhere.has(Number(id))) return whole;
797
+ const assigned = nextMasterLayoutId(elsewhere);
798
+ elsewhere.add(assigned);
799
+ return `${pre}${assigned}${post}`;
800
+ },
801
+ );
802
+ if (out !== xml) files[masterPart] = enc(out);
803
+ }
804
+
805
+ // Parts scoped to the PRESENTATION, not to a slide: OPC allows exactly one of
806
+ // each per package, and each is reached from ppt/presentation.xml rather than
807
+ // from a slide. An insert carries its source deck's copies, so copying them in
808
+ // yields N+1 parts sharing one singleton content type, referenced by nothing —
809
+ // PowerPoint answers that with "couldn't read some content ... removed it" and
810
+ // strips them on open. The host's own copy already governs the whole package,
811
+ // so the incoming ones are simply dropped.
812
+ const PRESENTATION_SCOPED = new Set([
813
+ "ppt/presProps.xml",
814
+ "ppt/viewProps.xml",
815
+ "ppt/tableStyles.xml",
816
+ "ppt/commentAuthors.xml",
817
+ ]);
818
+ const PRESENTATION_SCOPED_DIRS = [
819
+ "ppt/changesInfos/",
820
+ "ppt/notesMasters/", // notesSlides are dropped, so a notes master would orphan
821
+ "ppt/handoutMasters/",
822
+ ];
823
+ export function isPresentationScoped(part) {
824
+ return (
825
+ PRESENTATION_SCOPED.has(part) ||
826
+ PRESENTATION_SCOPED_DIRS.some((d) => part.startsWith(d))
827
+ );
828
+ }
829
+
830
+ // The ONLY unbounded surface in a fill-template build. Master-backed slides are
831
+ // clones of a known-good template patched through declared shape ids; an insert
832
+ // is whatever OOXML some other renderer produced for arbitrary authored
833
+ // content, so a renderer regression arrives here rather than being caught by
834
+ // the pack's own validation. Check the incoming package on its own terms BEFORE
835
+ // splicing, so the error names the artifact to re-render instead of surfacing
836
+ // later as a corrupt 30-slide deck — or worse, as a repair prompt in front of a
837
+ // client. Everything here is a property the artifact must already satisfy as a
838
+ // standalone .pptx; the splice cannot repair a broken input.
839
+ export function checkIncomingInsert(inc, file) {
840
+ const problems = [];
841
+ const slides = slideOrderPaths(inc);
842
+ if (slides.length !== 1) {
843
+ problems.push(`expected a one-slide .pptx fold, found ${slides.length} slides`);
844
+ return problems; // everything below assumes the single-slide shape
845
+ }
846
+
847
+ const names = new Set(Object.keys(inc));
848
+ for (const p of names) {
849
+ if (!p.endsWith(".rels")) continue;
850
+ const dir = p.replace(/_rels\/[^/]+$/, "").replace(/\/$/, "");
851
+ for (const m of (readText(inc, p) ?? "").matchAll(/<Relationship [^>]*Target="([^"]+)"[^>]*\/>/g)) {
852
+ if (/TargetMode="External"/.test(m[0]) || /^(https?|mailto):/i.test(m[1])) continue;
853
+ const resolved = resolvePartPath(dir, m[1]);
854
+ if (!names.has(resolved)) problems.push(`dangling relationship ${m[1]} in ${p}`);
855
+ }
856
+ }
857
+
858
+ const ct = readText(inc, "[Content_Types].xml") ?? "";
859
+ if (!ct) problems.push("no [Content_Types].xml");
860
+ for (const m of ct.matchAll(/<Override PartName="\/([^"]+)"/g)) {
861
+ if (!names.has(m[1])) problems.push(`content-type override for missing part ${m[1]}`);
862
+ }
863
+
864
+ // A slide must reach exactly one layout, and that layout exactly one master —
865
+ // the chain the splice re-parents into the host.
866
+ const relsOf = (part) => {
867
+ const d = part.includes("/") ? part.slice(0, part.lastIndexOf("/")) : "";
868
+ const b = part.slice(part.lastIndexOf("/") + 1);
869
+ return readText(inc, d ? `${d}/_rels/${b}.rels` : `_rels/${b}.rels`) ?? "";
870
+ };
871
+ const countType = (part, type) =>
872
+ [...relsOf(part).matchAll(/Type="[^"]*\/([^"/]+)"/g)].filter((m) => m[1] === type).length;
873
+ if (countType(slides[0], "slideLayout") !== 1) {
874
+ problems.push(`the slide must reference exactly one slideLayout`);
875
+ }
876
+ for (const p of [...names].filter((x) => /^ppt\/slideLayouts\/[^/]+\.xml$/.test(x))) {
877
+ if (countType(p, "slideMaster") !== 1) problems.push(`${p} must reference exactly one slideMaster`);
878
+ }
879
+ return problems.map((p) => `${file}: ${p}`);
880
+ }
881
+
882
+ function insertForeignSlide(files, buf, i, file, log, warnings) {
883
+ const inc = unzip(buf);
884
+ const incoming = checkIncomingInsert(inc, file);
885
+ if (incoming.length) {
886
+ throw new Error(
887
+ `the rendered artifact for this freeform slide is malformed — delete ` +
888
+ `slides/${path.posix.basename(file)} and rebuild to re-render it:\n - ${incoming.join("\n - ")}`,
889
+ );
890
+ }
891
+ const incOrder = slideOrderPaths(inc);
892
+
893
+ // Rename every ppt/ part with an insert-scoped basename, folders preserved
894
+ // (mislocated parts are a known PowerPoint-repair class).
895
+ const rename = new Map();
896
+ for (const p of Object.keys(inc)) {
897
+ if (!p.startsWith("ppt/")) continue;
898
+ if (p === "ppt/presentation.xml" || p.startsWith("ppt/_rels/")) continue;
899
+ if (p.includes("/_rels/")) continue; // carried along with their owner part
900
+ if (p.startsWith("ppt/notesSlides/")) continue; // dropped
901
+ if (isPresentationScoped(p)) continue; // the host already has the one copy
902
+ const dir = path.posix.dirname(p);
903
+ const base = path.posix.basename(p);
904
+ rename.set(p, p === incOrder[0] ? `ppt/slides/slideIns${i}.xml` : `${dir}/ins${i}_${base}`);
905
+ }
906
+
907
+ const renameTargetBase = (fromDir, target) => {
908
+ const resolved = resolvePartPath(fromDir, target);
909
+ const to = rename.get(resolved);
910
+ if (!to) return null;
911
+ // Folders are preserved, so only the basename changes.
912
+ return target.replace(path.posix.basename(resolved), path.posix.basename(to));
913
+ };
914
+
915
+ for (const [from, to] of rename) {
916
+ let bytes = inc[from];
917
+ // Carry the part's rels along, rewriting targets to renamed basenames and
918
+ // dropping notes pointers.
919
+ const fromRels = relsPathFor(from);
920
+ if (inc[fromRels]) {
921
+ let rels = readText(inc, fromRels)
922
+ .replace(/<Relationship [^>]*Type="[^"]*\/notesSlide"[^>]*\/>/g, "");
923
+ // A relationship pointing at a part we deliberately did NOT copy (a
924
+ // presentation-scoped singleton, a notes slide) would dangle in the host
925
+ // and is itself a repair trigger — drop the whole relationship.
926
+ rels = rels.replace(/<Relationship\b[^>]*?\/>/g, (rel) => {
927
+ const target = rel.match(/\bTarget="([^"]+)"/)?.[1];
928
+ if (!target || /^(https?|mailto):/i.test(target)) return rel;
929
+ if (/\bTargetMode="External"/.test(rel)) return rel;
930
+ const resolved = resolvePartPath(path.posix.dirname(from), target);
931
+ if (inc[resolved] !== undefined && !rename.has(resolved)) return "";
932
+ return rel;
933
+ });
934
+ rels = rels.replace(/Target="([^"]+)"/g, (whole, target) => {
935
+ if (/^https?:/.test(target)) return whole;
936
+ const next = renameTargetBase(path.posix.dirname(from), target);
937
+ return next ? `Target="${next}"` : whole;
938
+ });
939
+ files[relsPathFor(to)] = enc(rels);
940
+ }
941
+ if (from === incOrder[0]) {
942
+ // Normalize the incoming slide: think-cell custData and invisible
943
+ // TCLayout OLE frames are the known cross-package repair class, and a
944
+ // text-free <p:sp> is the known foreign-renderer one.
945
+ bytes = enc(ensureShapeTextBodies(stripThinkCell(readText(inc, from))));
946
+ } else if (/^ppt\/(slideLayouts|slideMasters)\//.test(from)) {
947
+ // The insert brings its own layout and master, and those carry shapes
948
+ // under the same rule.
949
+ bytes = enc(ensureShapeTextBodies(readText(inc, from)));
950
+ }
951
+ files[to] = bytes;
952
+ }
953
+
954
+ // Content types: merge Defaults, clone Overrides for renamed parts.
955
+ const hostCtPath = "[Content_Types].xml";
956
+ let hostCt = readText(files, hostCtPath);
957
+ const incCt = readText(inc, hostCtPath) ?? "";
958
+ for (const d of incCt.matchAll(/<Default Extension="([^"]+)" ContentType="([^"]+)"\/>/g)) {
959
+ if (!new RegExp(`<Default Extension="${d[1]}"`).test(hostCt)) {
960
+ hostCt = hostCt.replace(/<Default /, `<Default Extension="${d[1]}" ContentType="${d[2]}"/><Default `);
961
+ }
962
+ }
963
+ for (const [from, to] of rename) {
964
+ const m = incCt.match(new RegExp(`<Override PartName="/${from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*/>`));
965
+ if (m) hostCt = hostCt.replace("</Types>", m[0].replace(`/${from}`, `/${to}`) + "</Types>");
966
+ }
967
+ files[hostCtPath] = enc(hostCt);
968
+
969
+ // Register the incoming master(s) with the host presentation.
970
+ const presPath = "ppt/presentation.xml";
971
+ const presRelsPath = "ppt/_rels/presentation.xml.rels";
972
+ let pres = readText(files, presPath);
973
+ let presRels = readText(files, presRelsPath);
974
+ for (const [from, to] of rename) {
975
+ if (!/^ppt\/slideMasters\/[^/]+\.xml$/.test(from)) continue;
976
+ const relId = nextRelId(presRels);
977
+ presRels = presRels.replace(
978
+ "</Relationships>",
979
+ `<Relationship Id="${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="${to.replace("ppt/", "")}"/></Relationships>`,
980
+ );
981
+ // Allocate out of the SHARED master/layout id space — scanning only
982
+ // sldMasterId lands on the host master's first layout id.
983
+ const used = usedMasterLayoutIds(files, pres);
984
+ const masterId = nextMasterLayoutId(used);
985
+ used.add(masterId);
986
+ pres = pres.replace(
987
+ "</p:sldMasterIdLst>",
988
+ `<p:sldMasterId id="${masterId}" r:id="${relId}"/></p:sldMasterIdLst>`,
989
+ );
990
+ // `used` carries the id just allocated; presentation.xml is not written
991
+ // back until the loop ends, so reidLayoutIds cannot re-derive it.
992
+ reidLayoutIds(files, to, new Set([masterId]));
993
+ log.ok(`registered inserted master ${path.posix.basename(to)}`);
994
+ }
995
+ files[presPath] = enc(pres);
996
+ files[presRelsPath] = enc(presRels);
997
+
998
+ warnings.push(`${file}: inserted as a native slide with its own layout/master (think-cell payload stripped)`);
999
+ log.slide(`${file} → inserted native slide`);
1000
+ return `ppt/slides/slideIns${i}.xml`;
1001
+ }
1002
+
1003
+ // ---------------------------------------------------------------------------
1004
+ // Presentation & app parts
1005
+ // ---------------------------------------------------------------------------
1006
+
1007
+ // Delete every ppt/ part no relationship points at, and its content-type
1008
+ // override with it. Dropping a template slide orphans the parts only that slide
1009
+ // referenced — its think-cell tags above all — and orphans are NOT tolerated:
1010
+ // PowerPoint reports "couldn't read some content" and strips them on open. Runs
1011
+ // to a fixed point, since removing a part can orphan the parts it referenced.
1012
+ // Shared parts are safe: they stay referenced by whichever slide survived.
1013
+ export function pruneOrphanParts(files, log = { ok() {} }) {
1014
+ let removed = 0;
1015
+ for (;;) {
1016
+ const targeted = new Set();
1017
+ for (const p of Object.keys(files).filter((x) => x.endsWith(".rels"))) {
1018
+ const dir = p.replace(/_rels\/[^/]+$/, "").replace(/\/$/, "");
1019
+ for (const m of (readText(files, p) ?? "").matchAll(/<Relationship [^>]*Target="([^"]+)"[^>]*\/>/g)) {
1020
+ if (/TargetMode="External"/.test(m[0]) || /^(https?|mailto):/i.test(m[1])) continue;
1021
+ targeted.add(resolvePartPath(dir, m[1]));
1022
+ }
1023
+ }
1024
+ const orphans = Object.keys(files).filter(
1025
+ (p) => p.startsWith("ppt/") && !p.endsWith(".rels") && !targeted.has(p),
1026
+ );
1027
+ if (orphans.length === 0) break;
1028
+ for (const p of orphans) {
1029
+ delete files[p];
1030
+ delete files[relsPathFor(p)];
1031
+ removeOverride(files, p);
1032
+ removed++;
1033
+ }
1034
+ }
1035
+ if (removed) log.ok(`pruned ${removed} orphaned part(s)`);
1036
+ return removed;
1037
+ }
1038
+
1039
+ function dropSlide(files, part, log) {
1040
+ const rels = readText(files, relsPathFor(part)) ?? "";
1041
+ // Also remove this slide's notes part; anything else the slide alone
1042
+ // referenced is swept up by pruneOrphanParts once every drop has happened.
1043
+ const notes = rels.match(/<Relationship [^>]*Type="[^"]*\/notesSlide"[^>]*Target="([^"]+)"[^>]*\/>/);
1044
+ if (notes) {
1045
+ const notesPart = resolvePartPath("ppt/slides", notes[1]);
1046
+ delete files[notesPart];
1047
+ delete files[relsPathFor(notesPart)];
1048
+ removeOverride(files, notesPart);
1049
+ }
1050
+ delete files[part];
1051
+ delete files[relsPathFor(part)];
1052
+ removeOverride(files, part);
1053
+ log.ok(`dropped template slide ${path.posix.basename(part)}`);
1054
+ }
1055
+
1056
+ function removeOverride(files, part) {
1057
+ const ct = readText(files, "[Content_Types].xml");
1058
+ files["[Content_Types].xml"] = enc(
1059
+ ct.replace(new RegExp(`<Override PartName="/${part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*/>`), ""),
1060
+ );
1061
+ }
1062
+
1063
+ function rebuildPresentation(files, output, log) {
1064
+ const presPath = "ppt/presentation.xml";
1065
+ const presRelsPath = "ppt/_rels/presentation.xml.rels";
1066
+ let pres = readText(files, presPath);
1067
+ let presRels = readText(files, presRelsPath);
1068
+
1069
+ // Existing slide rels by target part.
1070
+ const relByPart = new Map();
1071
+ for (const m of presRels.matchAll(/<Relationship Id="([^"]+)"[^>]*Type="[^"]*\/slide"[^>]*Target="([^"]+)"[^>]*\/>/g)) {
1072
+ relByPart.set(resolvePartPath("ppt", m[2]), m[1]);
1073
+ }
1074
+ // Drop slide rels pointing at parts that no longer exist.
1075
+ presRels = presRels.replace(/<Relationship [^>]*Type="[^"]*\/slide"[^>]*\/>/g, (rel) => {
1076
+ const target = (rel.match(/Target="([^"]+)"/) || [])[1];
1077
+ return files[resolvePartPath("ppt", target)] ? rel : "";
1078
+ });
1079
+
1080
+ const oldIds = [...pres.matchAll(/<p:sldId id="(\d+)"/g)].map((m) => Number(m[1]));
1081
+ let nextSldId = Math.max(255, ...oldIds) + 1;
1082
+
1083
+ const entries = [];
1084
+ for (const o of output) {
1085
+ let relId = relByPart.get(o.part);
1086
+ if (!relId) {
1087
+ relId = nextRelId(presRels);
1088
+ presRels = presRels.replace(
1089
+ "</Relationships>",
1090
+ `<Relationship Id="${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="${o.part.replace("ppt/", "")}"/></Relationships>`,
1091
+ );
1092
+ }
1093
+ entries.push(`<p:sldId id="${nextSldId++}" r:id="${relId}"/>`);
1094
+ }
1095
+ // Fresh ids for the WHOLE list keeps the id space dense and far from the
1096
+ // 2^31 ceiling some authoring tools have already crowded.
1097
+ pres = pres.replace(/<p:sldIdLst>[\s\S]*?<\/p:sldIdLst>/, `<p:sldIdLst>${entries.join("")}</p:sldIdLst>`);
1098
+ files[presPath] = enc(pres);
1099
+ files[presRelsPath] = enc(presRels);
1100
+ log.ok(`slide list rebuilt (${output.length} slides)`);
1101
+ }
1102
+
1103
+ function rebuildAppProps(files, output, log) {
1104
+ const appPath = "docProps/app.xml";
1105
+ let app = readText(files, appPath);
1106
+ if (!app) return;
1107
+
1108
+ const titles = output.map((o) => {
1109
+ const xml = readText(files, o.part) ?? "";
1110
+ for (const hit of blocks(xml, "sp")) {
1111
+ if (/<p:ph type="(title|ctrTitle)"/.test(hit.block)) {
1112
+ const text = [...hit.block.matchAll(/<a:t>([^<]*)<\/a:t>/g)].map((m) => m[1]).join("");
1113
+ return text || "PowerPoint Presentation";
1114
+ }
1115
+ }
1116
+ return "PowerPoint Presentation";
1117
+ });
1118
+
1119
+ app = app.replace(/<Slides>\d+<\/Slides>/, `<Slides>${output.length}</Slides>`);
1120
+
1121
+ // TitlesOfParts = [non-slide entries…, slide titles…]; HeadingPairs' LAST
1122
+ // pair is the slide-title bucket in these templates.
1123
+ const tp = app.match(/<TitlesOfParts><vt:vector size="(\d+)"[^>]*>([\s\S]*?)<\/vt:vector><\/TitlesOfParts>/);
1124
+ const hpCounts = [...app.matchAll(/<vt:i4>(\d+)<\/vt:i4>/g)];
1125
+ if (tp && hpCounts.length) {
1126
+ const oldSlideCount = Number(hpCounts[hpCounts.length - 1][1]);
1127
+ const lpstrs = [...tp[2].matchAll(/<vt:lpstr>[\s\S]*?<\/vt:lpstr>/g)].map((m) => m[0]);
1128
+ const lead = lpstrs.slice(0, lpstrs.length - oldSlideCount);
1129
+ const next = [...lead, ...titles.map((t) => `<vt:lpstr>${esc(t)}</vt:lpstr>`)];
1130
+ app = app.replace(tp[0],
1131
+ `<TitlesOfParts><vt:vector size="${next.length}" baseType="lpstr">${next.join("")}</vt:vector></TitlesOfParts>`);
1132
+ const last = hpCounts[hpCounts.length - 1];
1133
+ app = app.slice(0, last.index) + `<vt:i4>${titles.length}</vt:i4>` + app.slice(last.index + last[0].length);
1134
+ }
1135
+ files[appPath] = enc(app);
1136
+ log.ok(`app.xml reconciled (${output.length} slides)`);
1137
+ }
1138
+
1139
+ // ---------------------------------------------------------------------------
1140
+ // Integrity
1141
+ // ---------------------------------------------------------------------------
1142
+
1143
+ export function checkIntegrity(files) {
1144
+ const issues = [];
1145
+ const ct = readText(files, "[Content_Types].xml");
1146
+ for (const m of ct.matchAll(/<Override PartName="\/([^"]+)"/g)) {
1147
+ if (!files[m[1]]) issues.push(`content-type override for missing part ${m[1]}`);
1148
+ }
1149
+ for (const p of Object.keys(files).filter((p) => p.endsWith(".rels"))) {
1150
+ const dir = p.replace(/_rels\/[^/]+$/, "").replace(/\/$/, "");
1151
+ for (const m of readText(files, p).matchAll(/<Relationship [^>]*Target="([^"]+)"[^>]*\/>/g)) {
1152
+ if (/TargetMode="External"/.test(m[0]) || /^https?:/.test(m[1])) continue;
1153
+ const resolved = resolvePartPath(dir, m[1]);
1154
+ if (!files[resolved]) issues.push(`dangling relationship in ${p}: ${m[1]}`);
1155
+ }
1156
+ }
1157
+ const pres = readText(files, "ppt/presentation.xml");
1158
+ const sldCount = [...pres.matchAll(/<p:sldId /g)].length;
1159
+ const partCount = Object.keys(files).filter((p) => /^ppt\/slides\/[^/]+\.xml$/.test(p)).length;
1160
+ if (sldCount !== partCount) issues.push(`sldIdLst has ${sldCount} entries but ${partCount} slide parts exist`);
1161
+ for (const p of Object.keys(files).filter((p) => /^ppt\/slides\/[^/]+\.xml$/.test(p))) {
1162
+ const n = mergedCellViolations(readText(files, p));
1163
+ if (n) issues.push(`${p}: ${n} merged-away table cell(s) carry text`);
1164
+ }
1165
+
1166
+ // Orphans. In OPC every part is reachable from the package root through
1167
+ // relationships; a part that nothing targets is content PowerPoint cannot
1168
+ // place, and it repairs the file by removing it. (.rels sidecars and the
1169
+ // content-type map are not relationship targets and are exempt.)
1170
+ const targeted = new Set();
1171
+ for (const p of Object.keys(files).filter((x) => x.endsWith(".rels"))) {
1172
+ const dir = p.replace(/_rels\/[^/]+$/, "").replace(/\/$/, "");
1173
+ for (const m of readText(files, p).matchAll(/<Relationship [^>]*Target="([^"]+)"[^>]*\/>/g)) {
1174
+ if (/TargetMode="External"/.test(m[0]) || /^(https?|mailto):/i.test(m[1])) continue;
1175
+ targeted.add(resolvePartPath(dir, m[1]));
1176
+ }
1177
+ }
1178
+ for (const p of Object.keys(files)) {
1179
+ if (p.endsWith(".rels") || p === "[Content_Types].xml") continue;
1180
+ if (!targeted.has(p)) issues.push(`orphan part referenced by no relationship: ${p}`);
1181
+ }
1182
+
1183
+ // Presentation-scoped singletons. Exactly one part per package may carry each
1184
+ // of these content types; an insert that brings its own is invalid even when
1185
+ // the extra copy is harmlessly ignored.
1186
+ const byType = new Map();
1187
+ for (const m of ct.matchAll(/<Override PartName="\/([^"]+)" ContentType="([^"]+)"/g)) {
1188
+ if (!/\b(presProps|viewProps|tableStyles|commentAuthors)\+xml$/.test(m[2])) continue;
1189
+ if (!byType.has(m[2])) byType.set(m[2], []);
1190
+ byType.get(m[2]).push(m[1]);
1191
+ }
1192
+ for (const [type, parts] of byType) {
1193
+ if (parts.length > 1) {
1194
+ issues.push(
1195
+ `${parts.length} parts declare the singleton content type ${type.split(".").pop()}: ${parts.join(", ")}`,
1196
+ );
1197
+ }
1198
+ }
1199
+
1200
+ // A <p:sp> with no <p:txBody>. Both shipped masters have zero of these; a
1201
+ // foreign renderer that omits it makes PowerPoint repair the file on open.
1202
+ for (const p of Object.keys(files).filter((x) =>
1203
+ /^ppt\/(slides|slideLayouts|slideMasters)\/[^/]+\.xml$/.test(x),
1204
+ )) {
1205
+ const n = shapesWithoutTextBody(readText(files, p) ?? "");
1206
+ if (n) issues.push(`${p}: ${n} shape(s) have no <p:txBody> — PowerPoint repairs those`);
1207
+ }
1208
+
1209
+ // Hard id ranges from ISO-IEC-29500-4 pml.xsd: ST_SlideId is [256,2^31),
1210
+ // ST_SlideMasterId and ST_SlideLayoutId are >= 2^31. The allocators satisfy
1211
+ // these by construction today; asserting them here keeps a future allocator
1212
+ // from drifting out of range silently.
1213
+ for (const [re, label, lo, hi] of [
1214
+ [/<p:sldId\b[^>]*\bid="(\d+)"/g, "sldId", 256, 2147483647],
1215
+ [/<p:sldMasterId\b[^>]*\bid="(\d+)"/g, "sldMasterId", 2147483648, 4294967295],
1216
+ ]) {
1217
+ for (const m of pres.matchAll(re)) {
1218
+ const v = Number(m[1]);
1219
+ if (v < lo || v > hi) issues.push(`${label} ${v} outside the legal range ${lo}..${hi}`);
1220
+ }
1221
+ }
1222
+ for (const p of Object.keys(files).filter((x) => /^ppt\/slideMasters\/[^/]+\.xml$/.test(x))) {
1223
+ for (const m of (readText(files, p) ?? "").matchAll(/<p:sldLayoutId\b[^>]*\bid="(\d+)"/g)) {
1224
+ const v = Number(m[1]);
1225
+ if (v < 2147483648 || v > 4294967295) {
1226
+ issues.push(`${p}: sldLayoutId ${v} outside the legal range 2147483648..4294967295`);
1227
+ }
1228
+ }
1229
+ }
1230
+
1231
+ // sldMasterId and sldLayoutId share ONE id space across the whole
1232
+ // presentation. Uniqueness within each kind is NOT enough — a master id equal
1233
+ // to some layout id repairs on open (app-props.mjs:9-17).
1234
+ const idOwner = new Map();
1235
+ const claim = (id, where) => {
1236
+ if (idOwner.has(id)) {
1237
+ issues.push(`id ${id} used twice in the shared master/layout id space: ${idOwner.get(id)} and ${where}`);
1238
+ } else {
1239
+ idOwner.set(id, where);
1240
+ }
1241
+ };
1242
+ for (const m of pres.matchAll(/<p:sldMasterId\b[^>]*\bid="(\d+)"/g)) {
1243
+ claim(m[1], "presentation.xml sldMasterId");
1244
+ }
1245
+ for (const p of Object.keys(files).filter((x) => /^ppt\/slideMasters\/[^/]+\.xml$/.test(x))) {
1246
+ for (const m of readText(files, p).matchAll(/<p:sldLayoutId\b[^>]*\bid="(\d+)"/g)) {
1247
+ claim(m[1], `${p} sldLayoutId`);
1248
+ }
1249
+ }
1250
+ return issues;
1251
+ }
1252
+
1253
+ // ---------------------------------------------------------------------------
1254
+
1255
+ function nextRelId(rels) {
1256
+ let max = 0;
1257
+ for (const m of rels.matchAll(/Id="rId(\d+)"/g)) max = Math.max(max, Number(m[1]));
1258
+ return `rId${max + 1}`;
1259
+ }
1260
+
1261
+ function ensureDefaultContentType(files, ext, type) {
1262
+ const ct = readText(files, "[Content_Types].xml");
1263
+ if (new RegExp(`<Default Extension="${ext}"`).test(ct)) return;
1264
+ files["[Content_Types].xml"] = enc(ct.replace(/<Default /, `<Default Extension="${ext}" ContentType="${type}"/><Default `));
1265
+ }
1266
+
1267
+ function makeLog(onLog = () => {}) {
1268
+ return {
1269
+ slide: (msg) => onLog(`${msg}`),
1270
+ ok: (msg) => onLog(` ✓ ${msg}`),
1271
+ miss: (msg) => onLog(` !! ${msg}`),
1272
+ };
1273
+ }