slidemind 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -0
- package/bin/slidemind.mjs +450 -55
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +6 -5
- package/skills/search/SKILL.md +10 -4
- package/src/bridge/bundle.mjs +21 -3
- package/src/bridge/client.mjs +414 -7
- package/src/bridge/engine-download.mjs +229 -5
- package/src/bridge/image-dimensions.mjs +181 -0
- package/src/builders/resolve-skill.mjs +45 -1
- package/src/builders/review.mjs +4 -1
- package/src/pptx/fill/engine.mjs +1273 -0
- package/src/pptx/fill/freeform.mjs +156 -0
- package/src/pptx/fill/parse-slides.mjs +154 -0
- package/src/pptx/fill/xml.mjs +486 -0
- package/src/pptx/ooxml.mjs +10 -3
|
@@ -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
|
+
}
|