slidemind 0.3.20 → 0.4.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.
Files changed (187) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +592 -16
  4. package/LICENSE +4 -0
  5. package/README.md +203 -8
  6. package/THIRD-PARTY-NOTICES.md +201 -0
  7. package/bin/slidemind.mjs +50 -2
  8. package/package.json +5 -3
  9. package/skills/create-slides/SKILL.md +484 -886
  10. package/skills/search/SKILL.md +46 -23
  11. package/src/bridge/build-and-push.mjs +548 -73
  12. package/src/bridge/bundle.mjs +100 -31
  13. package/src/bridge/capture.mjs +14 -17
  14. package/src/bridge/client/brands.mjs +157 -18
  15. package/src/bridge/client/engine-routing.mjs +48 -13
  16. package/src/bridge/client/engine-search.mjs +24 -1
  17. package/src/bridge/client/render.mjs +27 -1
  18. package/src/bridge/client/transport.mjs +499 -23
  19. package/src/bridge/client.mjs +10 -0
  20. package/src/bridge/config.mjs +67 -6
  21. package/src/bridge/engine-download.mjs +0 -10
  22. package/src/bridge/graph-auth.mjs +55 -3
  23. package/src/bridge/image-intent-envelope.mjs +239 -0
  24. package/src/bridge/package-preview.mjs +67 -0
  25. package/src/bridge/sharepoint.mjs +914 -0
  26. package/src/builders/asset-fetch.mjs +203 -0
  27. package/src/builders/block-dividers.mjs +83 -0
  28. package/src/builders/build.mjs +171 -87
  29. package/src/builders/card-height.mjs +294 -0
  30. package/src/builders/chart-hole.mjs +183 -0
  31. package/src/builders/chart-jsx.mjs +410 -0
  32. package/src/builders/chart-labels.mjs +326 -0
  33. package/src/builders/concept-geometry.mjs +370 -0
  34. package/src/builders/concept-intent.mjs +200 -0
  35. package/src/builders/concept-layout.mjs +79 -0
  36. package/src/builders/concept-lines.mjs +509 -0
  37. package/src/builders/concept-lists.mjs +270 -0
  38. package/src/builders/concept-marks.mjs +84 -0
  39. package/src/builders/concept-relationships.mjs +12 -0
  40. package/src/builders/concept-spec.mjs +645 -0
  41. package/src/builders/concept-text.mjs +140 -0
  42. package/src/builders/concept-waves.mjs +57 -0
  43. package/src/builders/copy-fit.mjs +139 -0
  44. package/src/builders/design-tokens.mjs +347 -0
  45. package/src/builders/draft-check.mjs +552 -340
  46. package/src/builders/draft-lines.mjs +369 -0
  47. package/src/builders/edit-route.mjs +29 -0
  48. package/src/builders/figure-sketch.mjs +375 -0
  49. package/src/builders/fold-sets.mjs +639 -0
  50. package/src/builders/gate-server.mjs +9 -4
  51. package/src/builders/gate-session.js +273 -12
  52. package/src/builders/gate-shell.js +1494 -0
  53. package/src/builders/image-intent-check.mjs +308 -0
  54. package/src/builders/image-use-reconcile.mjs +107 -0
  55. package/src/builders/kit-helpers.mjs +121 -0
  56. package/src/builders/layout-names.mjs +32 -0
  57. package/src/builders/legacy-reference-uses.mjs +179 -0
  58. package/src/builders/line-fit.mjs +224 -0
  59. package/src/builders/line-native.mjs +895 -0
  60. package/src/builders/line-relations.mjs +230 -0
  61. package/src/builders/lint-fix.mjs +117 -9
  62. package/src/builders/lint.mjs +25 -20
  63. package/src/builders/list-jsx.mjs +88 -0
  64. package/src/builders/placement.mjs +133 -0
  65. package/src/builders/plan-checks.mjs +135 -0
  66. package/src/builders/plan-comments.mjs +19 -0
  67. package/src/builders/plan-handlers.mjs +95 -0
  68. package/src/builders/plan-markdown-edit.mjs +3 -3
  69. package/src/builders/plan-overlay.js +1530 -1608
  70. package/src/builders/plan.mjs +689 -186
  71. package/src/builders/preset-definitions.json +1 -0
  72. package/src/builders/preset-eval.mjs +244 -0
  73. package/src/builders/preview.html +51 -120
  74. package/src/builders/red-thread-backbone.mjs +135 -36
  75. package/src/builders/repair.mjs +205 -0
  76. package/src/builders/repeat-groups.mjs +540 -0
  77. package/src/builders/resolve-skill.mjs +5 -4
  78. package/src/builders/review-overlay.js +296 -293
  79. package/src/builders/review.mjs +305 -84
  80. package/src/builders/rule-shift.mjs +96 -0
  81. package/src/builders/seat.mjs +354 -0
  82. package/src/builders/settle-driver.mjs +236 -0
  83. package/src/builders/settle.mjs +1448 -0
  84. package/src/builders/slide-prep.mjs +67 -0
  85. package/src/builders/spec-assets.mjs +236 -0
  86. package/src/builders/spec-match.mjs +56 -0
  87. package/src/builders/stack.mjs +413 -0
  88. package/src/builders/storyboard-markdown.mjs +70 -27
  89. package/src/builders/storyboard-schema.mjs +54 -114
  90. package/src/builders/text-blocks.mjs +212 -0
  91. package/src/builders/text-card-fit.mjs +552 -0
  92. package/src/builders/text-stack.mjs +276 -0
  93. package/src/builders/transcribe.mjs +1069 -0
  94. package/src/cli/assets.mjs +164 -0
  95. package/src/cli/common.mjs +173 -12
  96. package/src/cli/concept-spec-support.mjs +76 -0
  97. package/src/cli/concepts.mjs +1002 -103
  98. package/src/cli/deck.mjs +401 -65
  99. package/src/cli/diagnose.mjs +4 -2
  100. package/src/cli/engine-helpers.mjs +17 -10
  101. package/src/cli/engine-results.mjs +76 -3
  102. package/src/cli/help.mjs +232 -28
  103. package/src/cli/install.mjs +9 -0
  104. package/src/cli/maintenance.mjs +104 -0
  105. package/src/cli/read.mjs +2 -1
  106. package/src/cli/ready.mjs +6 -3
  107. package/src/cli/record.mjs +522 -0
  108. package/src/cli/remote.mjs +818 -0
  109. package/src/cli/revision.mjs +686 -0
  110. package/src/cli/search.mjs +30 -11
  111. package/src/cli/session.mjs +56 -13
  112. package/src/cli/setup.mjs +46 -2
  113. package/src/cli/status.mjs +142 -33
  114. package/src/cli/tasks.mjs +246 -0
  115. package/src/cli/transcribe.mjs +854 -0
  116. package/src/cli/workspace-ui.mjs +260 -0
  117. package/src/cli/workspace.mjs +212 -34
  118. package/src/install-channel.mjs +64 -7
  119. package/src/lib/adopt.mjs +346 -25
  120. package/src/lib/concept-spec-binding.mjs +96 -0
  121. package/src/lib/conversions.mjs +70 -6
  122. package/src/lib/convert-folded.mjs +107 -0
  123. package/src/lib/deck-paths.mjs +418 -0
  124. package/src/lib/durable-ids.mjs +47 -0
  125. package/src/lib/edit-operations.mjs +316 -0
  126. package/src/lib/edit-state.mjs +1244 -0
  127. package/src/lib/edit-transitions.mjs +120 -0
  128. package/src/lib/extensions.mjs +501 -0
  129. package/src/lib/image-assets.mjs +465 -0
  130. package/src/lib/image-derivatives.mjs +234 -0
  131. package/src/lib/image-intent-fingerprint.mjs +70 -0
  132. package/src/lib/image-intent-tags.mjs +176 -0
  133. package/src/lib/image-intent.mjs +802 -0
  134. package/src/lib/image-use-overrides.mjs +279 -0
  135. package/src/lib/inline-components.mjs +613 -0
  136. package/src/lib/lint-converted.mjs +68 -4
  137. package/src/lib/merge-json.mjs +163 -0
  138. package/src/lib/overwrite-guard.mjs +3 -3
  139. package/src/lib/presentation-context.mjs +531 -0
  140. package/src/lib/preservation-tags.mjs +157 -0
  141. package/src/lib/preservation-verdict.mjs +1342 -0
  142. package/src/lib/publish.mjs +1557 -0
  143. package/src/lib/rebase.mjs +945 -0
  144. package/src/lib/reconcile-ack.mjs +4 -6
  145. package/src/lib/reconcile.mjs +62 -24
  146. package/src/lib/record-client.mjs +175 -0
  147. package/src/lib/record-state.mjs +151 -0
  148. package/src/lib/remote.mjs +661 -0
  149. package/src/lib/revision.mjs +1210 -0
  150. package/src/lib/slide-guids.mjs +4 -6
  151. package/src/lib/slide-preservation.mjs +423 -0
  152. package/src/lib/slide-selection.mjs +9 -0
  153. package/src/lib/workspace-transaction.mjs +117 -0
  154. package/src/pptx/content-digest.mjs +334 -0
  155. package/src/pptx/deck-assemble.mjs +1278 -0
  156. package/src/pptx/fill/engine.mjs +2 -3
  157. package/src/pptx/fill/freeform.mjs +6 -3
  158. package/src/pptx/ooxml.mjs +20 -0
  159. package/src/pptx/package-evidence.mjs +383 -0
  160. package/src/pptx/tags.mjs +163 -11
  161. package/src/update-check.mjs +22 -4
  162. package/src/workspace/adapters.mjs +222 -0
  163. package/src/workspace/auth.mjs +256 -0
  164. package/src/workspace/autostart.mjs +204 -0
  165. package/src/workspace/daemon.mjs +360 -0
  166. package/src/workspace/discovery.mjs +180 -0
  167. package/src/workspace/edit.mjs +167 -0
  168. package/src/workspace/events.mjs +52 -0
  169. package/src/workspace/gate-client.mjs +290 -0
  170. package/src/workspace/handback.mjs +73 -0
  171. package/src/workspace/lifecycle.mjs +635 -0
  172. package/src/workspace/operations.mjs +200 -0
  173. package/src/workspace/pick-folder.mjs +152 -0
  174. package/src/workspace/registry.mjs +359 -0
  175. package/src/workspace/remote-links.mjs +397 -0
  176. package/src/workspace/remote-status.mjs +359 -0
  177. package/src/workspace/routes.mjs +1331 -0
  178. package/src/workspace/server.mjs +159 -0
  179. package/src/workspace/sync.mjs +1119 -0
  180. package/src/workspace/task-actions.mjs +215 -0
  181. package/src/workspace/tasks.mjs +507 -0
  182. package/src/workspace/ui/app.js +1886 -0
  183. package/src/workspace/ui/index.html +141 -0
  184. package/src/workspace/ui/plan-bridge.js +120 -0
  185. package/src/workspace/ui/sharepoint.svg +59 -0
  186. package/src/workspace/ui/styles.css +423 -0
  187. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,1278 @@
1
+ // Targeted assembly: apply a handful of changed slides to an ACCEPTED BASE
2
+ // package, leaving every part the change does not touch byte-identical.
3
+ //
4
+ // The whole-deck render throws the user's package away and builds a new one from
5
+ // the design — which is right for a deck slidemind authored and wrong for a deck
6
+ // it adopted: the notes, comments, animations, embeddings, sections and native
7
+ // objects that were never in the projection do not come back. So a build against
8
+ // a base renders ONLY the dirty slides and splices them in here.
9
+ //
10
+ // What this module is careful about, in the order it bites:
11
+ // • identity, never a name — a layout is reused only when its content (and its
12
+ // master's, and its theme's) is identical; two layouts called "Title" from
13
+ // different masters are two layouts;
14
+ // • an edited slide keeps the relationships the edit does not touch (its
15
+ // notes, its comments, foreign tag parts) and keeps its slide id + GUID;
16
+ // • a construct that cannot survive a body replacement (animation timing, an
17
+ // OLE object, a chart) BLOCKS that slide's edit by name instead of silently
18
+ // dropping it;
19
+ // • structure follows: the slide list, the section list, custom shows and the
20
+ // internal links that point at a slide that is going away;
21
+ // • only parts THIS operation imported may be garbage-collected, so ten cycles
22
+ // neither grow the package nor delete someone else's media.
23
+ //
24
+ // Regex-over-XML like the rest of src/pptx/ — the constructs touched here
25
+ // (sldIdLst, sectionLst, custShowLst, Relationship, Override) are flat.
26
+
27
+ import { createHash } from "node:crypto";
28
+
29
+ import { unzip, zip, readText } from "./zip.mjs";
30
+ import { relsFor, relsPathFor, resolvePartPath, slideOrderPaths, tagElements } from "./ooxml.mjs";
31
+ import { TAG_GUID, readSlideTags } from "./tags.mjs";
32
+
33
+ const enc = (s) => new TextEncoder().encode(s);
34
+ const sha256 = (b) => createHash("sha256").update(Buffer.from(b)).digest("hex");
35
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
36
+ const xmlEsc = (s) =>
37
+ String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
38
+
39
+ const REL_BASE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
40
+ const RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships";
41
+ const SLIDE_PART_RE = /^ppt\/slides\/slide\d+\.xml$/;
42
+ const MASTER_PART_RE = /^ppt\/slideMasters\/slideMaster\d+\.xml$/;
43
+
44
+ // Parts whose life is tied to ONE slide, so an operation that replaced or
45
+ // deleted that slide may collect them once nothing else points at them. The
46
+ // design parts (layouts, masters, themes) are deliberately absent: PowerPoint
47
+ // keeps unused layouts on purpose, and deleting one is how a deck loses a
48
+ // layout someone was about to use again.
49
+ const COLLECTIBLE_RE = /^ppt\/(media|embeddings|charts|drawings|diagrams|tags|notesSlides)\//;
50
+
51
+ // Relationship types an EDITED slide keeps from the base: the edit replaced the
52
+ // slide body, not the conversation around it. Everything the replaced body used
53
+ // to reference (its own images, its own external links) comes from the render.
54
+ const PRESERVED_REL_TYPES = [
55
+ "/notesSlide",
56
+ "/comments",
57
+ "/modernComments",
58
+ "/commentAuthors",
59
+ ];
60
+
61
+ // Constructs that live INSIDE the slide part, so replacing the body destroys
62
+ // them. Each is detected on the base slide and looked for on the replacement;
63
+ // a base-only construct blocks that slide's edit by name.
64
+ const FRAGILE = [
65
+ { id: "animation", label: "slide animation/timing", test: (xml) => /<p:timing\b/.test(xml) },
66
+ {
67
+ id: "oleObject",
68
+ label: "an embedded OLE object",
69
+ test: (xml) => /<p:oleObj\b/.test(xml) || /<mc:AlternateContent\b[\s\S]*?<p:oleObj\b/.test(xml),
70
+ },
71
+ {
72
+ id: "chart",
73
+ label: "a native chart",
74
+ test: (xml) => /graphicData\s+uri="[^"]*\/chart(?:Ex)?"/.test(xml),
75
+ },
76
+ {
77
+ id: "smartArt",
78
+ label: "a SmartArt diagram",
79
+ test: (xml) => /graphicData\s+uri="[^"]*\/diagram"/.test(xml),
80
+ },
81
+ ];
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // package primitives
85
+ // ---------------------------------------------------------------------------
86
+
87
+ function contentTypeFor(files, part) {
88
+ const ct = readText(files, "[Content_Types].xml") ?? "";
89
+ const override = new RegExp(
90
+ `<Override\\b[^>]*\\bPartName="/${escapeRe(part)}"[^>]*\\bContentType="([^"]*)"`,
91
+ ).exec(ct);
92
+ if (override) return { kind: "override", value: override[1] };
93
+ const ext = part.includes(".") ? part.slice(part.lastIndexOf(".") + 1) : "";
94
+ const def = new RegExp(
95
+ `<Default\\b[^>]*\\bExtension="${escapeRe(ext)}"[^>]*\\bContentType="([^"]*)"`,
96
+ "i",
97
+ ).exec(ct);
98
+ return def ? { kind: "default", value: def[1], ext } : null;
99
+ }
100
+
101
+ function relativeTarget(fromPart, toPart) {
102
+ const from = fromPart.split("/").slice(0, -1);
103
+ const to = toPart.split("/");
104
+ let i = 0;
105
+ while (i < from.length && i < to.length - 1 && from[i] === to[i]) i++;
106
+ return [...Array(from.length - i).fill(".."), ...to.slice(i)].join("/");
107
+ }
108
+
109
+ function writeRels(rels) {
110
+ return (
111
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n' +
112
+ `<Relationships xmlns="${RELS_NS}">` +
113
+ rels
114
+ .map(
115
+ (r) =>
116
+ `<Relationship Id="${xmlEsc(r.id)}" Type="${xmlEsc(r.type)}" Target="${xmlEsc(r.target)}"` +
117
+ (r.targetMode ? ` TargetMode="${xmlEsc(r.targetMode)}"` : "") +
118
+ "/>",
119
+ )
120
+ .join("") +
121
+ "</Relationships>"
122
+ );
123
+ }
124
+
125
+ function nextFreeName(taken, part) {
126
+ const m = /^(.*?)(\d+)(\.[^.]+)$/.exec(part);
127
+ if (m) {
128
+ const [, stem, , ext] = m;
129
+ for (let n = 1; n < 100000; n++) {
130
+ const candidate = `${stem}${n}${ext}`;
131
+ if (!taken.has(candidate)) return candidate;
132
+ }
133
+ }
134
+ const dot = part.lastIndexOf(".");
135
+ const stem = dot === -1 ? part : part.slice(0, dot);
136
+ const ext = dot === -1 ? "" : part.slice(dot);
137
+ for (let n = 1; n < 100000; n++) {
138
+ const candidate = `${stem}-sm${n}${ext}`;
139
+ if (!taken.has(candidate)) return candidate;
140
+ }
141
+ throw new Error(`deck-assemble: could not find a free part name for ${part}`);
142
+ }
143
+
144
+ // The sldIdLst entries of a package, in play order.
145
+ function sldIdEntries(files) {
146
+ const pres = readText(files, "ppt/presentation.xml");
147
+ if (!pres) return [];
148
+ const list = /<(?:[\w.-]+:)?sldIdLst[\s\S]*?<\/(?:[\w.-]+:)?sldIdLst>/.exec(pres);
149
+ if (!list) return [];
150
+ const byRid = {};
151
+ for (const rel of relsFor(files, "ppt/presentation.xml")) {
152
+ if (rel.id) byRid[rel.id] = resolvePartPath("ppt", rel.target);
153
+ }
154
+ const out = [];
155
+ const re = /<(?:[\w.-]+:)?sldId\b([^>]*?)(?:\/>|>[\s\S]*?<\/(?:[\w.-]+:)?sldId>)/g;
156
+ let m;
157
+ while ((m = re.exec(list[0])) !== null) {
158
+ const rId = /\b[\w.-]+:id\s*=\s*"([^"]+)"/.exec(m[1])?.[1];
159
+ const id = /\bid\s*=\s*"([^"]+)"/.exec(m[1])?.[1];
160
+ const part = rId ? byRid[rId] : null;
161
+ if (part && SLIDE_PART_RE.test(part)) out.push({ id, rId, part });
162
+ }
163
+ return out;
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // layout / master identity — content, never the display name
168
+ // ---------------------------------------------------------------------------
169
+
170
+ // WHICH layouts a master lists is package bookkeeping — this module rewrites it
171
+ // itself, and a slice of a deck carries a trimmed copy. Two masters that differ
172
+ // only there are the same master, so the list is normalized out before hashing.
173
+ // Everything that decides how a slide LOOKS (the master's own shapes, its
174
+ // colour map, its theme) still counts.
175
+ function normalizedMaster(xml) {
176
+ return xml
177
+ .replace(/<p:sldLayoutIdLst\b[\s\S]*?<\/p:sldLayoutIdLst>/g, "")
178
+ .replace(/<p:sldLayoutIdLst\b[^>]*\/>/g, "");
179
+ }
180
+
181
+ // A layout's identity is its own bytes PLUS its master's (normalized) and that
182
+ // master's theme's. Two layouts named "Title" on different masters are different
183
+ // layouts; the same layout re-serialized identically is the same one. A NAME is
184
+ // never part of it.
185
+ function layoutSignature(files, layoutPart) {
186
+ const bits = [files[layoutPart] ? sha256(files[layoutPart]) : "-"];
187
+ const dir = layoutPart.slice(0, layoutPart.lastIndexOf("/"));
188
+ for (const rel of relsFor(files, layoutPart)) {
189
+ if (rel.targetMode === "External" || !(rel.type ?? "").endsWith("/slideMaster")) continue;
190
+ const master = resolvePartPath(dir, rel.target);
191
+ bits.push(files[master] ? sha256(normalizedMaster(readText(files, master) ?? "")) : "-");
192
+ const mdir = master.slice(0, master.lastIndexOf("/"));
193
+ for (const mrel of relsFor(files, master)) {
194
+ if (mrel.targetMode === "External" || !(mrel.type ?? "").endsWith("/theme")) continue;
195
+ const theme = resolvePartPath(mdir, mrel.target);
196
+ bits.push(files[theme] ? sha256(files[theme]) : "-");
197
+ }
198
+ }
199
+ return sha256(bits.join("|"));
200
+ }
201
+
202
+ // A master's identity, on the same terms: what it looks like plus its theme,
203
+ // minus the layout list this module maintains itself.
204
+ function masterSignature(files, masterPart) {
205
+ const bits = [sha256(normalizedMaster(readText(files, masterPart) ?? ""))];
206
+ const dir = masterPart.slice(0, masterPart.lastIndexOf("/"));
207
+ for (const rel of relsFor(files, masterPart)) {
208
+ if (rel.targetMode === "External" || !(rel.type ?? "").endsWith("/theme")) continue;
209
+ const theme = resolvePartPath(dir, rel.target);
210
+ bits.push(files[theme] ? sha256(files[theme]) : "-");
211
+ }
212
+ return sha256(bits.join("|"));
213
+ }
214
+
215
+ function partIndex(files, match, signature) {
216
+ const index = new Map();
217
+ for (const part of Object.keys(files)) {
218
+ if (!match.test(part)) continue;
219
+ const sig = signature(files, part);
220
+ if (!index.has(sig)) index.set(sig, part);
221
+ }
222
+ return index;
223
+ }
224
+
225
+ function baseLayoutIndex(files) {
226
+ return partIndex(files, /^ppt\/slideLayouts\/slideLayout\d+\.xml$/, layoutSignature);
227
+ }
228
+
229
+ function baseMediaIndex(files) {
230
+ const index = new Map();
231
+ for (const part of Object.keys(files)) {
232
+ if (!/^ppt\/media\//.test(part)) continue;
233
+ const digest = sha256(files[part]);
234
+ if (!index.has(digest)) index.set(digest, part);
235
+ }
236
+ return index;
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // importing one slide's dependency closure
241
+ // ---------------------------------------------------------------------------
242
+
243
+ class Importer {
244
+ constructor(files) {
245
+ this.files = files;
246
+ this.taken = new Set(Object.keys(files));
247
+ this.layouts = baseLayoutIndex(files);
248
+ this.masters = partIndex(files, MASTER_PART_RE, masterSignature);
249
+ this.media = baseMediaIndex(files);
250
+ this.owned = new Set(); // parts THIS operation added — the only GC candidates
251
+ this.contentTypes = { overrides: [], defaults: new Map() };
252
+ }
253
+
254
+ claim(name) {
255
+ this.taken.add(name);
256
+ return name;
257
+ }
258
+
259
+ // Copy `part` (and everything it reaches) out of `src` into the base package,
260
+ // reusing an identical layout or an identical media part where one exists.
261
+ // Returns the part path it now lives at.
262
+ importPart(src, part, mapped = new Map()) {
263
+ if (mapped.has(part)) return mapped.get(part);
264
+ if (src[part] === undefined) return null;
265
+
266
+ // Design parts resolve by SIGNATURE, which deliberately looks past their own
267
+ // bytes (a layout is its master and theme too) — so they must not fall
268
+ // through to the byte-identity fast path below, or an unchanged layout on a
269
+ // changed master would silently reuse the wrong design.
270
+ const design = /^ppt\/(slideLayouts\/slideLayout|slideMasters\/slideMaster)\d+\.xml$/.test(part);
271
+ if (/^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(part)) {
272
+ const reuse = this.layouts.get(layoutSignature(src, part));
273
+ if (reuse) {
274
+ mapped.set(part, reuse);
275
+ return reuse;
276
+ }
277
+ }
278
+ if (MASTER_PART_RE.test(part)) {
279
+ const reuse = this.masters.get(masterSignature(src, part));
280
+ if (reuse) {
281
+ mapped.set(part, reuse);
282
+ return reuse;
283
+ }
284
+ }
285
+ if (/^ppt\/media\//.test(part)) {
286
+ const reuse = this.media.get(sha256(src[part]));
287
+ if (reuse) {
288
+ mapped.set(part, reuse);
289
+ return reuse;
290
+ }
291
+ }
292
+ // The same part, byte for byte, already at that path: reuse it. Without
293
+ // this a re-render of an unchanged tag/drawing/embedding part clones itself
294
+ // on every build, and the package doubles rather than grows.
295
+ if (!design && this.files[part] !== undefined && samePart(this.files, src, part)) {
296
+ mapped.set(part, part);
297
+ return part;
298
+ }
299
+
300
+ const target = this.taken.has(part) ? nextFreeName(this.taken, part) : part;
301
+ this.claim(target);
302
+ mapped.set(part, target);
303
+ this.files[target] = src[part];
304
+ this.owned.add(target);
305
+ if (/^ppt\/media\//.test(target)) this.media.set(sha256(src[part]), target);
306
+
307
+ const ct = contentTypeFor(src, part);
308
+ if (ct?.kind === "override") this.contentTypes.overrides.push([target, ct.value]);
309
+ else if (ct?.kind === "default") this.contentTypes.defaults.set(ct.ext.toLowerCase(), ct.value);
310
+
311
+ // Follow this part's own relationships, rewriting each target to where it
312
+ // landed. Slides are never traversed into — an internal slide link is a
313
+ // reference, not an instruction to import the whole presentation.
314
+ const srcRels = relsFor(src, part);
315
+ if (srcRels.length > 0 || src[relsPathFor(part)] !== undefined) {
316
+ const dir = part.includes("/") ? part.slice(0, part.lastIndexOf("/")) : "";
317
+ const rewritten = [];
318
+ for (const rel of srcRels) {
319
+ if (rel.targetMode === "External") {
320
+ rewritten.push(rel);
321
+ continue;
322
+ }
323
+ const absolute = resolvePartPath(dir, rel.target);
324
+ if (SLIDE_PART_RE.test(absolute)) continue; // dropped: mapped by the caller
325
+ const landed = this.importPart(src, absolute, mapped);
326
+ if (!landed) continue;
327
+ rewritten.push({ ...rel, target: relativeTarget(target, landed) });
328
+ }
329
+ const relsPath = relsPathFor(target);
330
+ this.files[relsPath] = enc(writeRels(rewritten));
331
+ this.owned.add(relsPath);
332
+ const relsCt = contentTypeFor(src, relsPath);
333
+ if (relsCt?.kind === "default") {
334
+ this.contentTypes.defaults.set(relsCt.ext.toLowerCase(), relsCt.value);
335
+ }
336
+ }
337
+
338
+ // A freshly imported layout must be reachable from its master's layout list,
339
+ // or PowerPoint repairs the file.
340
+ if (/^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(target)) {
341
+ this.layouts.set(layoutSignature(this.files, target), target);
342
+ this.linkLayoutToMaster(target);
343
+ } else if (MASTER_PART_RE.test(target)) {
344
+ this.masters.set(masterSignature(this.files, target), target);
345
+ }
346
+ return target;
347
+ }
348
+
349
+ linkLayoutToMaster(layoutPart) {
350
+ const dir = layoutPart.slice(0, layoutPart.lastIndexOf("/"));
351
+ for (const rel of relsFor(this.files, layoutPart)) {
352
+ if (rel.targetMode === "External" || !(rel.type ?? "").endsWith("/slideMaster")) continue;
353
+ const master = resolvePartPath(dir, rel.target);
354
+ if (this.files[master] === undefined) continue;
355
+ const masterRelsPath = relsPathFor(master);
356
+ const masterRels = relsFor(this.files, master);
357
+ const mdir = master.slice(0, master.lastIndexOf("/"));
358
+ if (
359
+ masterRels.some(
360
+ (r) =>
361
+ (r.type ?? "").endsWith("/slideLayout") &&
362
+ resolvePartPath(mdir, r.target) === layoutPart,
363
+ )
364
+ ) {
365
+ return;
366
+ }
367
+ const rId = freeRid(masterRels);
368
+ masterRels.push({
369
+ id: rId,
370
+ type: `${REL_BASE}/slideLayout`,
371
+ target: relativeTarget(master, layoutPart),
372
+ });
373
+ this.files[masterRelsPath] = enc(writeRels(masterRels));
374
+ const xml = readText(this.files, master) ?? "";
375
+ const nextId = nextLayoutId(xml);
376
+ const entry = `<p:sldLayoutId id="${nextId}" r:id="${rId}"/>`;
377
+ const withEntry = /<p:sldLayoutIdLst\b[^>]*\/>/.test(xml)
378
+ ? xml.replace(/<p:sldLayoutIdLst\b[^>]*\/>/, `<p:sldLayoutIdLst>${entry}</p:sldLayoutIdLst>`)
379
+ : /<p:sldLayoutIdLst\b/.test(xml)
380
+ ? xml.replace(/(<p:sldLayoutIdLst\b[^>]*>)/, `$1${entry}`)
381
+ : xml.replace(/(<\/p:cSld>)/, `$1<p:sldLayoutIdLst>${entry}</p:sldLayoutIdLst>`);
382
+ this.files[master] = enc(withEntry);
383
+ return;
384
+ }
385
+ }
386
+
387
+ applyContentTypes() {
388
+ let ct = readText(this.files, "[Content_Types].xml");
389
+ if (!ct) return;
390
+ for (const [ext, value] of this.contentTypes.defaults) {
391
+ if (new RegExp(`<Default\\b[^>]*\\bExtension="${escapeRe(ext)}"`, "i").test(ct)) continue;
392
+ ct = ct.replace(/(<Types\b[^>]*>)/, `$1<Default Extension="${xmlEsc(ext)}" ContentType="${xmlEsc(value)}"/>`);
393
+ }
394
+ for (const [part, value] of this.contentTypes.overrides) {
395
+ if (new RegExp(`<Override\\b[^>]*\\bPartName="/${escapeRe(part)}"`).test(ct)) continue;
396
+ ct = ct.replace(
397
+ /(<\/Types>)/,
398
+ `<Override PartName="/${xmlEsc(part)}" ContentType="${xmlEsc(value)}"/>$1`,
399
+ );
400
+ }
401
+ this.files["[Content_Types].xml"] = enc(ct);
402
+ }
403
+
404
+ dropContentType(part) {
405
+ const ct = readText(this.files, "[Content_Types].xml");
406
+ if (!ct) return;
407
+ this.files["[Content_Types].xml"] = enc(
408
+ ct.replace(
409
+ new RegExp(`<Override\\b(?=[^>]*\\bPartName="/${escapeRe(part)}")[^>]*?/>`, "g"),
410
+ "",
411
+ ),
412
+ );
413
+ }
414
+ }
415
+
416
+ // The same part path in two packages holding identical bytes AND identical
417
+ // relationships — the one case where reusing rather than cloning is provably
418
+ // lossless.
419
+ function samePart(a, b, part) {
420
+ if (a[part] === undefined || b[part] === undefined) return false;
421
+ if (sha256(a[part]) !== sha256(b[part])) return false;
422
+ const rels = relsPathFor(part);
423
+ const ar = a[rels] === undefined ? null : sha256(a[rels]);
424
+ const br = b[rels] === undefined ? null : sha256(b[rels]);
425
+ return ar === br;
426
+ }
427
+
428
+ function freeRid(rels) {
429
+ let max = 0;
430
+ for (const r of rels) {
431
+ const n = /^rId(\d+)$/.exec(r.id ?? "")?.[1];
432
+ if (n) max = Math.max(max, Number(n));
433
+ }
434
+ return `rId${max + 1}`;
435
+ }
436
+
437
+ function nextLayoutId(masterXml) {
438
+ let max = 2147483648;
439
+ for (const m of masterXml.matchAll(/<p:sldLayoutId\b[^>]*\bid="(\d+)"/g)) {
440
+ max = Math.max(max, Number(m[1]));
441
+ }
442
+ return max + 1;
443
+ }
444
+
445
+ function nextSldId(entries) {
446
+ let max = 255;
447
+ for (const e of entries) {
448
+ const n = Number(e.id);
449
+ if (Number.isFinite(n)) max = Math.max(max, n);
450
+ }
451
+ return max + 1;
452
+ }
453
+
454
+ // Everything ONE slide reaches that belongs to it alone: its media, its
455
+ // embeddings, its chart parts, its notes. Shared design parts and other slides
456
+ // are never followed — this set exists to be garbage-collected, and a layout in
457
+ // it would take the whole master down with it.
458
+ function slideOwnedClosure(files, slidePart) {
459
+ const out = new Set();
460
+ const queue = [slidePart];
461
+ const seen = new Set([slidePart]);
462
+ while (queue.length) {
463
+ const part = queue.shift();
464
+ const dir = part.includes("/") ? part.slice(0, part.lastIndexOf("/")) : "";
465
+ for (const rel of relsFor(files, part)) {
466
+ if (rel.targetMode === "External") continue;
467
+ const target = resolvePartPath(dir, rel.target);
468
+ if (seen.has(target) || files[target] === undefined) continue;
469
+ if (SLIDE_PART_RE.test(target)) continue;
470
+ if (!COLLECTIBLE_RE.test(target)) continue;
471
+ seen.add(target);
472
+ out.add(target);
473
+ queue.push(target);
474
+ }
475
+ }
476
+ return out;
477
+ }
478
+
479
+ // A slide master reachable only through a layout is a repair prompt in
480
+ // PowerPoint: the presentation's own master list is what makes it part of the
481
+ // deck. Every master this operation imported is registered here.
482
+ function linkMastersToPresentation(files, presRels, masters) {
483
+ if (masters.length === 0) return presRels;
484
+ let xml = readText(files, "ppt/presentation.xml") ?? "";
485
+ let nextId = 2147483648;
486
+ for (const m of xml.matchAll(/<p:sldMasterId\b[^>]*\bid="(\d+)"/g)) {
487
+ nextId = Math.max(nextId, Number(m[1]) + 1);
488
+ }
489
+ let rels = presRels;
490
+ let added = "";
491
+ for (const master of masters) {
492
+ const dir = "ppt";
493
+ if (
494
+ rels.some(
495
+ (r) =>
496
+ (r.type ?? "").endsWith("/slideMaster") &&
497
+ resolvePartPath(dir, r.target) === master,
498
+ )
499
+ ) {
500
+ continue;
501
+ }
502
+ const rId = freeRid(rels);
503
+ rels = [
504
+ ...rels,
505
+ {
506
+ id: rId,
507
+ type: `${REL_BASE}/slideMaster`,
508
+ target: relativeTarget("ppt/presentation.xml", master),
509
+ },
510
+ ];
511
+ added += `<p:sldMasterId id="${nextId++}" r:id="${rId}"/>`;
512
+ }
513
+ if (added) {
514
+ xml = /<p:sldMasterIdLst\b[^>]*\/>/.test(xml)
515
+ ? xml.replace(/<p:sldMasterIdLst\b[^>]*\/>/, () => `<p:sldMasterIdLst>${added}</p:sldMasterIdLst>`)
516
+ : /<\/p:sldMasterIdLst>/.test(xml)
517
+ ? xml.replace(/<\/p:sldMasterIdLst>/, (m) => added + m)
518
+ : xml.replace(/<p:sldIdLst\b/, (m) => `<p:sldMasterIdLst>${added}</p:sldMasterIdLst>${m}`);
519
+ files["ppt/presentation.xml"] = enc(xml);
520
+ }
521
+ return rels;
522
+ }
523
+
524
+ // Layout ids share ONE id space across the whole presentation (ST_SlideLayoutId,
525
+ // unique with the master ids too). A master imported from another package —
526
+ // the render's second design master, a folded library slide's master — brings
527
+ // its own `sldLayoutIdLst` verbatim, so two masters end up numbering their
528
+ // layouts identically, and PowerPoint answers with the repair prompt. Re-mint
529
+ // every duplicate above everything already in use; the r:id links are what
530
+ // bind a layout, so the id itself is free to move.
531
+ // A master may list each layout part ONCE. The add-in's render lists the
532
+ // design master's layouts twice (observed 2026-09-17: 13 duplicate rows, each
533
+ // through its own r:id, on a fresh two-slide deck as much as on a 43-slide
534
+ // pack deck), and PowerPoint answers with the repair prompt. Keep the first
535
+ // row per part and drop the later rows with their relationships.
536
+ export function dedupeLayoutLists(files) {
537
+ let dropped = 0;
538
+ for (const master of Object.keys(files).filter((p) => /^ppt\/slideMasters\/[^/]+\.xml$/.test(p))) {
539
+ const xml = readText(files, master) ?? "";
540
+ const dir = master.slice(0, master.lastIndexOf("/"));
541
+ const rels = relsFor(files, master);
542
+ const byId = new Map(rels.map((r) => [r.id, r]));
543
+ const seen = new Set();
544
+ const kept = new Set();
545
+ // Both spellings of an empty element. `validatePackage` matches the row
546
+ // without requiring `/>`, so a `<p:sldLayoutId …></p:sldLayoutId>` row used to
547
+ // be reported as an error this function could never fix.
548
+ const fixed = xml.replace(/<p:sldLayoutId\b[^>]*\br:id="([^"]+)"[^>]*?(?:\/>|><\/p:sldLayoutId>)/g, (row, rId) => {
549
+ const rel = byId.get(rId);
550
+ // A row must point, through a slideLayout relationship, at a layout part
551
+ // that exists and whose own relationships name THIS master. The render's
552
+ // second master (observed 2026-09-17) lists the first master's layouts —
553
+ // each twice — next to its own single layout; every such row goes.
554
+ if (!rel || !(rel.type ?? "").endsWith("/slideLayout")) {
555
+ dropped += 1;
556
+ return "";
557
+ }
558
+ const part = resolvePartPath(dir, rel.target);
559
+ if (files[part] === undefined || layoutOwner(files, part) !== master || seen.has(part)) {
560
+ dropped += 1;
561
+ return "";
562
+ }
563
+ seen.add(part);
564
+ kept.add(rId);
565
+ return row;
566
+ });
567
+ if (fixed === xml) continue;
568
+ files[master] = enc(fixed);
569
+ // Relationships no kept row uses any more go with the rows.
570
+ const remaining = rels.filter((r) => !(r.type ?? "").endsWith("/slideLayout") || kept.has(r.id));
571
+ if (remaining.length !== rels.length) files[relsPathFor(master)] = enc(writeRels(remaining));
572
+ }
573
+ return dropped;
574
+ }
575
+
576
+ // The master a layout part belongs to, by the layout's own relationships.
577
+ function layoutOwner(files, layoutPart) {
578
+ const dir = layoutPart.slice(0, layoutPart.lastIndexOf("/"));
579
+ for (const rel of relsFor(files, layoutPart)) {
580
+ if (rel.targetMode === "External" || !(rel.type ?? "").endsWith("/slideMaster")) continue;
581
+ return resolvePartPath(dir, rel.target);
582
+ }
583
+ return null;
584
+ }
585
+
586
+ // A theme part belongs to ONE master. The render's second master points at the
587
+ // first master's theme (observed 2026-09-17), and PowerPoint repairs that by
588
+ // cloning the theme — so clone it here, before the file is anyone's problem.
589
+ export function uniqueThemes(files) {
590
+ const owners = new Map();
591
+ let cloned = 0;
592
+ let next = 1;
593
+ for (const p of Object.keys(files)) {
594
+ const m = p.match(/^ppt\/theme\/theme(\d+)\.xml$/);
595
+ if (m) next = Math.max(next, Number(m[1]) + 1);
596
+ }
597
+ for (const master of Object.keys(files).filter((p) => /^ppt\/slideMasters\/[^/]+\.xml$/.test(p)).sort()) {
598
+ const dir = master.slice(0, master.lastIndexOf("/"));
599
+ const rels = relsFor(files, master);
600
+ let changed = false;
601
+ const rewritten = rels.map((rel) => {
602
+ if (rel.targetMode === "External" || !(rel.type ?? "").endsWith("/theme")) return rel;
603
+ const theme = resolvePartPath(dir, rel.target);
604
+ if (files[theme] === undefined) return rel;
605
+ if (!owners.has(theme)) {
606
+ owners.set(theme, master);
607
+ return rel;
608
+ }
609
+ const clone = `ppt/theme/theme${next++}.xml`;
610
+ files[clone] = files[theme];
611
+ // The theme's own relationships travel with it. A theme carrying a picture
612
+ // fill points at `../media/…` through `ppt/theme/_rels/themeN.xml.rels`, and a
613
+ // clone without that part leaves its `r:embed` ids resolving to nothing —
614
+ // exactly the repair prompt this function exists to prevent, and invisible to
615
+ // validatePackage (a part with no rels file yields no relationships to check).
616
+ // The clone sits in the same directory, so the relative targets still resolve.
617
+ const themeRels = relsPathFor(theme);
618
+ if (files[themeRels] !== undefined) files[relsPathFor(clone)] = files[themeRels];
619
+ files["[Content_Types].xml"] = enc(
620
+ (readText(files, "[Content_Types].xml") ?? "").replace(
621
+ "</Types>",
622
+ `<Override PartName="/${clone}" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/></Types>`,
623
+ ),
624
+ );
625
+ owners.set(clone, master);
626
+ cloned += 1;
627
+ changed = true;
628
+ return { ...rel, target: `../theme/${clone.slice("ppt/theme/".length)}` };
629
+ });
630
+ if (changed) files[relsPathFor(master)] = enc(writeRels(rewritten));
631
+ }
632
+ return cloned;
633
+ }
634
+
635
+ // One p:tags per customer-data list. Files written before the tag writers
636
+ // learned to merge (every deck published up to 2026-09-17) carry two on the
637
+ // presentation — ours next to another add-in's — and an accepted base keeps
638
+ // that shape forever unless it is repaired here. Later parts' tags are merged
639
+ // into the first part; their rows, relationships, parts and overrides go.
640
+ export function mergeTagLists(files) {
641
+ let merged = 0;
642
+ const parts = Object.keys(files).filter((p) => /^ppt\/(presentation\.xml|(slides|slideLayouts|slideMasters)\/[^/]+\.xml)$/.test(p));
643
+ for (const part of parts) {
644
+ const xml = readText(files, part) ?? "";
645
+ if ((xml.match(/<p:tags\b/g) ?? []).length < 2) continue;
646
+ const dir = part.slice(0, part.lastIndexOf("/"));
647
+ const rels = relsFor(files, part);
648
+ const byId = new Map(rels.map((r) => [r.id, r]));
649
+ const dropRels = new Set();
650
+ const fixed = xml.replace(/<p:custDataLst\b[^>]*>([\s\S]*?)<\/p:custDataLst>/g, (list, body) => {
651
+ // Same widening as the layout rows: the validator counts `<p:tags` however it
652
+ // is closed, so a non-self-closing one left the list over the limit forever.
653
+ const rows = [...body.matchAll(/<p:tags\b[^>]*\br:id="([^"]+)"[^>]*?(?:\/>|><\/p:tags>)/g)];
654
+ if (rows.length < 2) return list;
655
+ const targets = rows.map((r) => byId.get(r[1])).map((rel) => (rel ? resolvePartPath(dir, rel.target) : null));
656
+ const keep = targets.findIndex((t) => t && files[t] !== undefined);
657
+ if (keep < 0) return list;
658
+ let keptXml = readText(files, targets[keep]) ?? "";
659
+ let next = body;
660
+ rows.forEach((row, i) => {
661
+ if (i === keep) return;
662
+ const target = targets[i];
663
+ const extra = target && files[target] !== undefined ? [...tagElements(readText(files, target))].map((m) => m[0]) : [];
664
+ if (extra.length) {
665
+ // An EMPTY kept list is written `<p:tagLst/>`: there is no closing tag to
666
+ // insert before, so appending silently did nothing while the other part —
667
+ // another add-in's tags — was deleted in the same breath. Expand the
668
+ // self-closing form instead, and if neither form is there leave this row
669
+ // alone rather than merge its tags into nothing.
670
+ const expanded = keptXml.replace(/<p:tagLst\b[^>]*\/>/, `<p:tagLst>${extra.join("")}</p:tagLst>`);
671
+ const appended = expanded !== keptXml ? expanded : keptXml.replace("</p:tagLst>", `${extra.join("")}</p:tagLst>`);
672
+ if (appended === keptXml) return;
673
+ keptXml = appended;
674
+ }
675
+ if (target && files[target] !== undefined) {
676
+ delete files[target];
677
+ files["[Content_Types].xml"] = enc((readText(files, "[Content_Types].xml") ?? "").replace(new RegExp(`<Override\\b[^>]*PartName="/${target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*/>`), ""));
678
+ }
679
+ dropRels.add(row[1]);
680
+ next = next.replace(row[0], "");
681
+ merged += 1;
682
+ });
683
+ files[targets[keep]] = enc(keptXml);
684
+ return list.replace(body, next);
685
+ });
686
+ if (fixed === xml) continue;
687
+ files[part] = enc(fixed);
688
+ if (dropRels.size) files[relsPathFor(part)] = enc(writeRels(rels.filter((r) => !dropRels.has(r.id))));
689
+ }
690
+ return merged;
691
+ }
692
+
693
+ // Every fix a rendered or assembled package needs before it is a candidate.
694
+ export function normalizeMasters(files) {
695
+ return {
696
+ droppedLayoutRows: dedupeLayoutLists(files),
697
+ remintedLayoutIds: uniqueLayoutIds(files),
698
+ clonedThemes: uniqueThemes(files),
699
+ mergedTagLists: mergeTagLists(files),
700
+ };
701
+ }
702
+
703
+ export function uniqueLayoutIds(files) {
704
+ const presXml = readText(files, "ppt/presentation.xml") ?? "";
705
+ const used = new Set([...presXml.matchAll(/<p:sldMasterId\b[^>]*\bid="(\d+)"/g)].map((m) => m[1]));
706
+ let next = 2147483648;
707
+ const masters = Object.keys(files).filter((p) => /^ppt\/slideMasters\/[^/]+\.xml$/.test(p)).sort();
708
+ for (const master of masters) {
709
+ for (const m of (readText(files, master) ?? "").matchAll(/<p:sldLayoutId\b[^>]*\bid="(\d+)"/g)) {
710
+ next = Math.max(next, Number(m[1]) + 1);
711
+ }
712
+ }
713
+ for (const id of used) next = Math.max(next, Number(id) + 1);
714
+ let reminted = 0;
715
+ for (const master of masters) {
716
+ const xml = readText(files, master) ?? "";
717
+ const fixed = xml.replace(/(<p:sldLayoutId\b[^>]*\bid=")(\d+)(")/g, (whole, open, id, close) => {
718
+ if (!used.has(id)) {
719
+ used.add(id);
720
+ return whole;
721
+ }
722
+ const fresh = String(next++);
723
+ used.add(fresh);
724
+ reminted += 1;
725
+ return `${open}${fresh}${close}`;
726
+ });
727
+ if (fixed !== xml) files[master] = enc(fixed);
728
+ }
729
+ return reminted;
730
+ }
731
+
732
+ // ---------------------------------------------------------------------------
733
+ // structure: slide list, sections, custom shows, internal links
734
+ // ---------------------------------------------------------------------------
735
+
736
+ function rewriteSldIdLst(presXml, entries) {
737
+ const body = entries.map((e) => `<p:sldId id="${e.id}" r:id="${e.rId}"/>`).join("");
738
+ if (/<p:sldIdLst\b[^>]*\/>/.test(presXml)) {
739
+ return presXml.replace(/<p:sldIdLst\b[^>]*\/>/, `<p:sldIdLst>${body}</p:sldIdLst>`);
740
+ }
741
+ if (/<p:sldIdLst\b/.test(presXml)) {
742
+ return presXml.replace(/<p:sldIdLst\b[\s\S]*?<\/p:sldIdLst>/, `<p:sldIdLst>${body}</p:sldIdLst>`);
743
+ }
744
+ return presXml.replace(/(<p:sldMasterIdLst\b[\s\S]*?<\/p:sldMasterIdLst>)/, `$1<p:sldIdLst>${body}</p:sldIdLst>`);
745
+ }
746
+
747
+ // Sections carry their own copy of the slide ids. A deleted slide leaves its
748
+ // section; an inserted slide joins the section of the slide it follows (or none
749
+ // when the deck has no sections); a section left empty is removed.
750
+ function rewriteSections(presXml, { removedIds, insertions, report }) {
751
+ const listMatch = /<p14:sectionLst\b[\s\S]*?<\/p14:sectionLst>/.exec(presXml);
752
+ if (!listMatch) return presXml;
753
+ const list = listMatch[0];
754
+ const open = /^<p14:sectionLst\b[^>]*>/.exec(list)?.[0] ?? "<p14:sectionLst>";
755
+ const sectionRe = /<p14:section\b[^>]*(?:\/>|>[\s\S]*?<\/p14:section>)/g;
756
+ const sections = list.match(sectionRe) ?? [];
757
+
758
+ const next = [];
759
+ for (const original of sections) {
760
+ let section = original;
761
+ for (const id of removedIds) {
762
+ section = section.replace(
763
+ new RegExp(`<p14:sldId\\b[^>]*\\bid="${escapeRe(String(id))}"[^>]*/>`, "g"),
764
+ "",
765
+ );
766
+ }
767
+ for (const { id, afterId } of insertions) {
768
+ if (afterId == null) continue;
769
+ const anchor = new RegExp(`<p14:sldId\\b[^>]*\\bid="${escapeRe(String(afterId))}"[^>]*/>`);
770
+ if (!anchor.test(section)) continue;
771
+ section = section.replace(anchor, (m) => `${m}<p14:sldId id="${id}"/>`);
772
+ report.sectionsUpdated++;
773
+ }
774
+ // A section is removed only when THIS operation emptied it. One that arrived
775
+ // empty is the user's own divider and stays exactly where it was.
776
+ if (/<p14:sldId\b/.test(original) && !/<p14:sldId\b/.test(section)) {
777
+ report.sectionsRemoved++;
778
+ continue;
779
+ }
780
+ next.push(section);
781
+ }
782
+
783
+ // Nothing left to section: drop the list rather than deliver an empty one. The
784
+ // now-empty <p:ext> wrapper is valid and stays, which beats guessing which of
785
+ // several extensions in presentation.xml was ours.
786
+ const rebuilt = next.length === 0 ? "" : `${open}${next.join("")}</p14:sectionLst>`;
787
+ return presXml.replace(list, () => rebuilt);
788
+ }
789
+
790
+ function rewriteCustomShows(presXml, removedRids, report) {
791
+ if (!/<p:custShowLst\b/.test(presXml)) return presXml;
792
+ return presXml.replace(/<p:custShowLst\b[\s\S]*?<\/p:custShowLst>/, (list) => {
793
+ let next = list;
794
+ for (const rId of removedRids) {
795
+ next = next.replace(
796
+ new RegExp(`<p:sld\\b[^>]*\\br:id="${escapeRe(rId)}"[^>]*/>`, "g"),
797
+ () => {
798
+ report.customShowsUpdated++;
799
+ return "";
800
+ },
801
+ );
802
+ }
803
+ // A custom show with no slides left is dropped, not delivered empty.
804
+ next = next.replace(
805
+ /<p:custShow\b[^>]*>[\s\S]*?<\/p:custShow>/g,
806
+ (show) => (/<p:sld\b/.test(show) ? show : ""),
807
+ );
808
+ return /<p:custShow\b/.test(next) ? next : "";
809
+ });
810
+ }
811
+
812
+ function removeRelationshipById(xml, id) {
813
+ return xml.replace(
814
+ new RegExp(
815
+ `<Relationship\\b(?=[^>]*\\bId="${escapeRe(id)}")[^>]*?(?:/>|>[\\s\\S]*?</Relationship>)`,
816
+ "g",
817
+ ),
818
+ "",
819
+ );
820
+ }
821
+
822
+ // ---------------------------------------------------------------------------
823
+ // validation
824
+ // ---------------------------------------------------------------------------
825
+
826
+ /**
827
+ * Structural checks before delivery: every internal relationship resolves,
828
+ * every part has a content type, the slide list is coherent and no two slides
829
+ * claim one identity. Returns `{ ok, errors }`.
830
+ */
831
+ export function validatePackage(files) {
832
+ const errors = [];
833
+ const ct = readText(files, "[Content_Types].xml") ?? "";
834
+ const defaults = new Set(
835
+ [...ct.matchAll(/<Default\b[^>]*\bExtension="([^"]*)"/gi)].map((m) => m[1].toLowerCase()),
836
+ );
837
+ const overrides = new Set(
838
+ [...ct.matchAll(/<Override\b[^>]*\bPartName="\/([^"]*)"/g)].map((m) => m[1]),
839
+ );
840
+
841
+ for (const part of Object.keys(files)) {
842
+ if (part === "[Content_Types].xml") continue;
843
+ // A zip directory entry ("ppt/") is not an OPC part; the server's render
844
+ // emits them and fflate keeps them as empty entries. `[trash]/*` is Office's
845
+ // and SharePoint's scratch space, carried without a content type by both.
846
+ if (part.endsWith("/") || part.startsWith("[trash]/")) continue;
847
+ if (!part.endsWith(".rels")) {
848
+ const ext = part.includes(".") ? part.slice(part.lastIndexOf(".") + 1).toLowerCase() : "";
849
+ if (!overrides.has(part) && !defaults.has(ext)) {
850
+ errors.push(`no content type for part ${part}`);
851
+ }
852
+ }
853
+ const dir = part.includes("/") ? part.slice(0, part.lastIndexOf("/")) : "";
854
+ for (const rel of relsFor(files, part)) {
855
+ if (rel.targetMode === "External") continue;
856
+ const target = resolvePartPath(dir, rel.target);
857
+ if (files[target] === undefined) {
858
+ errors.push(`${part}: relationship ${rel.id} points at missing part ${target}`);
859
+ }
860
+ }
861
+ }
862
+
863
+ // One p:tags per customer-data list (CT_CustomerDataList): a second one is
864
+ // a PowerPoint repair prompt, and PowerPoint keeps whichever it likes.
865
+ for (const part of Object.keys(files)) {
866
+ if (!/^ppt\/(presentation\.xml|(slides|slideLayouts|slideMasters)\/[^/]+\.xml)$/.test(part)) continue;
867
+ for (const list of (readText(files, part) ?? "").matchAll(/<p:custDataLst\b[^>]*>([\s\S]*?)<\/p:custDataLst>/g)) {
868
+ const count = (list[1].match(/<p:tags\b/g) ?? []).length;
869
+ if (count > 1) errors.push(`${part}: a customer-data list carries ${count} p:tags elements (one is allowed)`);
870
+ }
871
+ }
872
+
873
+ // One id space for masters and layouts, one row per layout part in a
874
+ // master's list, every row backed by a layout relationship, one theme per
875
+ // master: each violation is a PowerPoint repair prompt.
876
+ const layoutIds = new Map();
877
+ const themeOwners = new Map();
878
+ for (const part of Object.keys(files)) {
879
+ if (!/^ppt\/slideMasters\/[^/]+\.xml$/.test(part)) continue;
880
+ const xml = readText(files, part) ?? "";
881
+ for (const m of xml.matchAll(/<p:sldLayoutId\b[^>]*\bid="(\d+)"/g)) {
882
+ if (layoutIds.has(m[1])) errors.push(`layout id ${m[1]} is used by both ${layoutIds.get(m[1])} and ${part}`);
883
+ else layoutIds.set(m[1], part);
884
+ }
885
+ const dir = part.slice(0, part.lastIndexOf("/"));
886
+ const byId = new Map(relsFor(files, part).map((r) => [r.id, r]));
887
+ const listed = new Set();
888
+ for (const m of xml.matchAll(/<p:sldLayoutId\b[^>]*\br:id="([^"]+)"/g)) {
889
+ const rel = byId.get(m[1]);
890
+ if (!rel || !(rel.type ?? "").endsWith("/slideLayout")) {
891
+ errors.push(`${part}: layout row ${m[1]} has no slideLayout relationship`);
892
+ continue;
893
+ }
894
+ const target = resolvePartPath(dir, rel.target);
895
+ if (listed.has(target)) errors.push(`${part} lists ${target} twice in its layout list`);
896
+ listed.add(target);
897
+ const owner = files[target] === undefined ? null : layoutOwner(files, target);
898
+ if (owner && owner !== part) errors.push(`${part} lists ${target}, which belongs to ${owner}`);
899
+ }
900
+ for (const rel of byId.values()) {
901
+ if (rel.targetMode === "External" || !(rel.type ?? "").endsWith("/theme")) continue;
902
+ const theme = resolvePartPath(dir, rel.target);
903
+ if (themeOwners.has(theme) && themeOwners.get(theme) !== part) errors.push(`${theme} is the theme of both ${themeOwners.get(theme)} and ${part}`);
904
+ themeOwners.set(theme, part);
905
+ }
906
+ }
907
+
908
+ const entries = sldIdEntries(files);
909
+ const order = slideOrderPaths(files);
910
+ if (entries.length !== order.length) {
911
+ errors.push(`slide list has ${entries.length} entries but ${order.length} resolve to slides`);
912
+ }
913
+ const ids = new Set();
914
+ for (const e of entries) {
915
+ if (ids.has(e.id)) errors.push(`duplicate slide id ${e.id} in the slide list`);
916
+ ids.add(e.id);
917
+ }
918
+ const guids = new Map();
919
+ for (const part of order) {
920
+ const guid = readSlideTags(files, part)[TAG_GUID];
921
+ if (!guid) continue;
922
+ if (guids.has(guid)) {
923
+ errors.push(`slides ${guids.get(guid)} and ${part} both claim GUID ${guid}`);
924
+ }
925
+ guids.set(guid, part);
926
+ }
927
+ return { ok: errors.length === 0, errors };
928
+ }
929
+
930
+ // ---------------------------------------------------------------------------
931
+ // assemble
932
+ // ---------------------------------------------------------------------------
933
+
934
+ /**
935
+ * Build the next deck from an accepted base plus the slides that changed.
936
+ *
937
+ * @param {object} args
938
+ * @param {Buffer} args.base the accepted base .pptx
939
+ * @param {Buffer} [args.rendered] a .pptx carrying ONLY the changed/new slides
940
+ * @param {Array} args.plan the FINAL deck, in play order. Each entry is
941
+ * `{ guid, source: "base" }` (keep this base
942
+ * slide) or `{ guid, source: "rendered",
943
+ * index: <1-based play order in `rendered`> }`.
944
+ * A base slide absent from the plan is deleted.
945
+ * @param {object} [args.options] `{ repairLinks }` — drop internal links whose
946
+ * target slide is being deleted instead of
947
+ * refusing. `{ allowFragileLoss }` — replace a
948
+ * slide even when a construct cannot survive.
949
+ * @returns {{ bytes: Uint8Array|null, report: object }}
950
+ */
951
+ export function assembleDeck({ base, rendered = null, plan, options = {} }) {
952
+ const { repairLinks = false, allowFragileLoss = false } = options;
953
+ const files = { ...unzip(base) };
954
+ // Office (Web and desktop) parks discarded parts under `[trash]/` with no
955
+ // content type. They are scratch space, not the document: every host drops
956
+ // them on its next save, and the OPC validator below rightly refuses them.
957
+ for (const part of Object.keys(files)) if (part.startsWith("[trash]/")) delete files[part];
958
+ const src = rendered ? unzip(rendered) : null;
959
+ const srcOrder = src ? slideOrderPaths(src) : [];
960
+
961
+ const report = {
962
+ kept: [],
963
+ replaced: [],
964
+ inserted: [],
965
+ deleted: [],
966
+ blocked: [],
967
+ reusedLayouts: 0,
968
+ importedParts: [],
969
+ collectedParts: [],
970
+ sectionsUpdated: 0,
971
+ sectionsRemoved: 0,
972
+ customShowsUpdated: 0,
973
+ warnings: [],
974
+ errors: [],
975
+ };
976
+
977
+ const baseEntries = sldIdEntries(files);
978
+ const byGuid = new Map();
979
+ for (const entry of baseEntries) {
980
+ const guid = readSlideTags(files, entry.part)[TAG_GUID];
981
+ if (guid && !byGuid.has(guid)) byGuid.set(guid, entry);
982
+ }
983
+ const byPart = new Map(baseEntries.map((e) => [e.part, e]));
984
+
985
+ // --- preflight: what would a replacement destroy? -----------------------
986
+ for (const item of plan) {
987
+ if (item.source !== "rendered") continue;
988
+ const existing = byGuid.get(item.guid) ?? (item.part ? byPart.get(item.part) : null);
989
+ if (!existing) continue;
990
+ const baseXml = readText(files, existing.part) ?? "";
991
+ const srcPart = srcOrder[(item.index ?? 0) - 1];
992
+ const newXml = srcPart ? (readText(src, srcPart) ?? "") : "";
993
+ const lost = FRAGILE.filter((f) => f.test(baseXml) && !f.test(newXml));
994
+ if (lost.length > 0 && !allowFragileLoss) {
995
+ report.blocked.push({
996
+ guid: item.guid,
997
+ part: existing.part,
998
+ reasons: lost.map(
999
+ (f) =>
1000
+ `${f.label} on this slide cannot survive the edit — slidemind would have to re-author it, and it has no source for it`,
1001
+ ),
1002
+ });
1003
+ }
1004
+ }
1005
+ if (report.blocked.length > 0) {
1006
+ report.errors.push(
1007
+ ...report.blocked.flatMap((b) => b.reasons.map((r) => `${b.part}: ${r}`)),
1008
+ );
1009
+ return { bytes: null, report };
1010
+ }
1011
+
1012
+ const importer = new Importer(files);
1013
+ const planned = new Set();
1014
+ const finalEntries = [];
1015
+ let presRels = relsFor(files, "ppt/presentation.xml");
1016
+ const removedRids = [];
1017
+ const insertions = [];
1018
+ // What the slides this operation replaces or deletes used to own. Anything in
1019
+ // here that nothing points at afterwards is this operation's own litter and
1020
+ // may go; without it ten build cycles leave ten copies of one picture behind.
1021
+ const displaced = new Set();
1022
+
1023
+ for (const item of plan) {
1024
+ if (item.source === "base") {
1025
+ const entry = byGuid.get(item.guid) ?? byPart.get(item.part);
1026
+ if (!entry) {
1027
+ report.errors.push(`plan names base slide ${item.guid ?? item.part}, which the base does not have`);
1028
+ continue;
1029
+ }
1030
+ planned.add(entry.part);
1031
+ finalEntries.push(entry);
1032
+ report.kept.push(entry.part);
1033
+ continue;
1034
+ }
1035
+
1036
+ const srcPart = srcOrder[(item.index ?? 0) - 1];
1037
+ if (!srcPart) {
1038
+ report.errors.push(
1039
+ `plan wants rendered slide ${item.index} for ${item.guid}, but the render has ${srcOrder.length} slide(s)`,
1040
+ );
1041
+ continue;
1042
+ }
1043
+ const existing = byGuid.get(item.guid) ?? (item.part ? byPart.get(item.part) : null);
1044
+ if (existing) for (const p of slideOwnedClosure(files, existing.part)) displaced.add(p);
1045
+ const mapped = new Map();
1046
+
1047
+ // The slide's own dependency closure, minus the slide part itself (that one
1048
+ // is placed by hand so a replacement keeps the base slide's part path).
1049
+ const srcRels = relsFor(src, srcPart);
1050
+ const srcDir = srcPart.slice(0, srcPart.lastIndexOf("/"));
1051
+ const importedRels = [];
1052
+ for (const rel of srcRels) {
1053
+ if (rel.targetMode === "External") {
1054
+ importedRels.push({ ...rel });
1055
+ continue;
1056
+ }
1057
+ const absolute = resolvePartPath(srcDir, rel.target);
1058
+ if (SLIDE_PART_RE.test(absolute)) {
1059
+ report.errors.push(`Cannot safely remap internal slide link from ${srcPart} to ${absolute}.`);
1060
+ continue;
1061
+ }
1062
+ const landed = importer.importPart(src, absolute, mapped);
1063
+ if (!landed) continue;
1064
+ if (/^ppt\/slideLayouts\//.test(landed) && !importer.owned.has(landed)) {
1065
+ report.reusedLayouts++;
1066
+ }
1067
+ importedRels.push({ ...rel, landed });
1068
+ }
1069
+
1070
+ const targetPart = existing
1071
+ ? existing.part
1072
+ : importer.claim(nextFreeName(importer.taken, "ppt/slides/slide1.xml"));
1073
+
1074
+ // Relationship ids: the ones the base slide keeps come first and keep their
1075
+ // ids; the imported ones are renumbered around them and the slide body is
1076
+ // rewritten to match, so a preserved notes rel can never collide with an
1077
+ // imported image rel.
1078
+ const preserved = existing
1079
+ ? relsFor(files, existing.part).filter((r) =>
1080
+ PRESERVED_REL_TYPES.some((t) => (r.type ?? "").endsWith(t)),
1081
+ )
1082
+ : [];
1083
+ if (existing) {
1084
+ // A foreign tag part (another add-in's) is content, not slidemind's — it
1085
+ // stays. slidemind's own tag part comes fresh with the render.
1086
+ for (const rel of relsFor(files, existing.part)) {
1087
+ if (!(rel.type ?? "").endsWith("/tags")) continue;
1088
+ const part = resolvePartPath(existing.part.slice(0, existing.part.lastIndexOf("/")), rel.target);
1089
+ const xml = readText(files, part) ?? "";
1090
+ if (!/name="SLIDEMIND_/.test(xml)) preserved.push(rel);
1091
+ }
1092
+ }
1093
+ const usedIds = new Set(preserved.map((r) => r.id));
1094
+ const remap = new Map();
1095
+ const finalRels = [...preserved];
1096
+ // A preserved relationship and an imported one can name the SAME part (the
1097
+ // render re-emitted a tag part the base already carries). Two relationships
1098
+ // to one part is how a slide accumulates a second copy of everything on
1099
+ // every cycle — so the import folds into the preserved id instead.
1100
+ const preservedByTarget = new Map();
1101
+ const targetDir = targetPart.slice(0, targetPart.lastIndexOf("/"));
1102
+ for (const rel of preserved) {
1103
+ if (rel.targetMode === "External") continue;
1104
+ preservedByTarget.set(`${rel.type}|${resolvePartPath(targetDir, rel.target)}`, rel.id);
1105
+ }
1106
+ let n = 1;
1107
+ for (const rel of importedRels) {
1108
+ const key = rel.landed ? `${rel.type}|${rel.landed}` : null;
1109
+ const already = key ? preservedByTarget.get(key) : null;
1110
+ if (already) {
1111
+ if (rel.id) remap.set(rel.id, already);
1112
+ continue;
1113
+ }
1114
+ let id = `rId${n++}`;
1115
+ while (usedIds.has(id)) id = `rId${n++}`;
1116
+ usedIds.add(id);
1117
+ if (rel.id) remap.set(rel.id, id);
1118
+ finalRels.push({
1119
+ id,
1120
+ type: rel.type,
1121
+ target: rel.landed ? relativeTarget(targetPart, rel.landed) : rel.target,
1122
+ targetMode: rel.targetMode,
1123
+ });
1124
+ }
1125
+
1126
+ let slideXml = readText(src, srcPart) ?? "";
1127
+ // Rewrite every r:id/r:embed/r:link in one pass off a placeholder map, so a
1128
+ // renumber can never land on an id a later substitution then re-reads.
1129
+ slideXml = slideXml.replace(
1130
+ /\b(r:(?:id|embed|link|pict|dm|lo|qs|cs))="([^"]+)"/g,
1131
+ (whole, attr, id) => (remap.has(id) ? `${attr}="${remap.get(id)}"` : whole),
1132
+ );
1133
+ files[targetPart] = enc(slideXml);
1134
+ files[relsPathFor(targetPart)] = enc(writeRels(finalRels));
1135
+ const ct = contentTypeFor(src, srcPart);
1136
+ if (ct?.kind === "override") importer.contentTypes.overrides.push([targetPart, ct.value]);
1137
+
1138
+ if (existing) {
1139
+ planned.add(existing.part);
1140
+ finalEntries.push(existing);
1141
+ report.replaced.push(existing.part);
1142
+ } else {
1143
+ importer.owned.add(targetPart);
1144
+ importer.owned.add(relsPathFor(targetPart));
1145
+ const rId = freeRid(presRels);
1146
+ presRels = [
1147
+ ...presRels,
1148
+ { id: rId, type: `${REL_BASE}/slide`, target: relativeTarget("ppt/presentation.xml", targetPart) },
1149
+ ];
1150
+ const id = String(nextSldId([...baseEntries, ...finalEntries]));
1151
+ const entry = { id, rId, part: targetPart };
1152
+ finalEntries.push(entry);
1153
+ insertions.push({ id, afterId: finalEntries[finalEntries.length - 2]?.id ?? null });
1154
+ report.inserted.push(targetPart);
1155
+ }
1156
+ report.importedParts.push(...[...mapped.values()].filter((p) => importer.owned.has(p)));
1157
+ }
1158
+
1159
+ // --- deletions -----------------------------------------------------------
1160
+ const removedIds = [];
1161
+ const deletedParts = new Set();
1162
+ for (const entry of baseEntries) {
1163
+ if (planned.has(entry.part)) continue;
1164
+ deletedParts.add(entry.part);
1165
+ removedIds.push(entry.id);
1166
+ removedRids.push(entry.rId);
1167
+ report.deleted.push(entry.part);
1168
+ for (const p of slideOwnedClosure(files, entry.part)) displaced.add(p);
1169
+ }
1170
+
1171
+ // An internal link from a SURVIVING slide to a slide that is going away is a
1172
+ // dangling reference. Repair it explicitly or refuse — never deliver it.
1173
+ if (deletedParts.size > 0) {
1174
+ for (const entry of finalEntries) {
1175
+ const dir = entry.part.slice(0, entry.part.lastIndexOf("/"));
1176
+ const rels = relsFor(files, entry.part);
1177
+ const broken = rels.filter(
1178
+ (r) =>
1179
+ r.targetMode !== "External" &&
1180
+ (r.type ?? "").endsWith("/slide") &&
1181
+ deletedParts.has(resolvePartPath(dir, r.target)),
1182
+ );
1183
+ if (broken.length === 0) continue;
1184
+ if (!repairLinks) {
1185
+ report.errors.push(
1186
+ `${entry.part} links to ${broken.length} slide(s) being deleted ` +
1187
+ `(${broken.map((r) => resolvePartPath(dir, r.target)).join(", ")}). ` +
1188
+ `Re-point or remove the link, or pass repairLinks to drop it.`,
1189
+ );
1190
+ continue;
1191
+ }
1192
+ let xml = readText(files, relsPathFor(entry.part)) ?? "";
1193
+ for (const rel of broken) {
1194
+ xml = removeRelationshipById(xml, rel.id);
1195
+ report.warnings.push(`${entry.part}: dropped the link to deleted slide ${resolvePartPath(dir, rel.target)}`);
1196
+ }
1197
+ files[relsPathFor(entry.part)] = enc(xml);
1198
+ }
1199
+ }
1200
+ if (report.errors.length > 0) return { bytes: null, report };
1201
+
1202
+ for (const part of deletedParts) {
1203
+ delete files[part];
1204
+ delete files[relsPathFor(part)];
1205
+ importer.dropContentType(part);
1206
+ presRels = presRels.filter((r) => r.id !== byPart.get(part)?.rId);
1207
+ // A notes slide belongs to exactly one slide; it goes with it.
1208
+ for (const notes of Object.keys(files)) {
1209
+ if (!/^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(notes)) continue;
1210
+ const ndir = notes.slice(0, notes.lastIndexOf("/"));
1211
+ const backlinks = relsFor(files, notes).filter(
1212
+ (r) => (r.type ?? "").endsWith("/slide") && resolvePartPath(ndir, r.target) === part,
1213
+ );
1214
+ if (backlinks.length === 0) continue;
1215
+ delete files[notes];
1216
+ delete files[relsPathFor(notes)];
1217
+ importer.dropContentType(notes);
1218
+ }
1219
+ }
1220
+
1221
+ // --- presentation.xml ----------------------------------------------------
1222
+ presRels = linkMastersToPresentation(
1223
+ files,
1224
+ presRels,
1225
+ [...importer.owned].filter((p) => MASTER_PART_RE.test(p)),
1226
+ );
1227
+ files["ppt/_rels/presentation.xml.rels"] = enc(writeRels(presRels));
1228
+ let presXml = readText(files, "ppt/presentation.xml") ?? "";
1229
+ presXml = rewriteSldIdLst(presXml, finalEntries);
1230
+ presXml = rewriteSections(presXml, { removedIds, insertions, report });
1231
+ presXml = rewriteCustomShows(presXml, removedRids, report);
1232
+ files["ppt/presentation.xml"] = enc(presXml);
1233
+ importer.applyContentTypes();
1234
+
1235
+ // --- garbage collection: only what THIS operation imported ---------------
1236
+ const referenced = new Set();
1237
+ for (const part of Object.keys(files)) {
1238
+ const dir = part.includes("/") ? part.slice(0, part.lastIndexOf("/")) : "";
1239
+ for (const rel of relsFor(files, part)) {
1240
+ if (rel.targetMode === "External") continue;
1241
+ referenced.add(resolvePartPath(dir, rel.target));
1242
+ }
1243
+ }
1244
+ for (const part of new Set([...importer.owned, ...displaced])) {
1245
+ if (part.endsWith(".rels") || referenced.has(part)) continue;
1246
+ // Only what this operation brought in, or what the slides it replaced owned
1247
+ // — never a part that was simply sitting in the base unreferenced.
1248
+ if (!importer.owned.has(part) && !COLLECTIBLE_RE.test(part)) continue;
1249
+ delete files[part];
1250
+ delete files[relsPathFor(part)];
1251
+ importer.dropContentType(part);
1252
+ report.collectedParts.push(part);
1253
+ }
1254
+
1255
+ report.masters = normalizeMasters(files);
1256
+ const validation = validatePackage(files);
1257
+ report.errors.push(...validation.errors);
1258
+ if (!validation.ok) return { bytes: null, report };
1259
+ return { bytes: zip(files), report };
1260
+ }
1261
+
1262
+ /**
1263
+ * The plan `assembleDeck` takes, worked out from a deck's slide files and the
1264
+ * dirty set. `slides` is the deck's slide file order; `guidFor` maps a file to
1265
+ * its GUID; `dirty` is the set of slide files that must be rendered. Returns
1266
+ * `{ plan, render }` where `render` is the ordered list of slide files the
1267
+ * render request must carry (and therefore the index each plan entry points at).
1268
+ */
1269
+ export function planFromDeck({ slides, guidFor, dirty, partFor = () => null }) {
1270
+ const render = slides.filter((f) => dirty.has(f));
1271
+ const indexOf = new Map(render.map((f, i) => [f, i + 1]));
1272
+ const plan = slides.map((file) =>
1273
+ dirty.has(file)
1274
+ ? { guid: guidFor(file), part: partFor(file), source: "rendered", index: indexOf.get(file), file }
1275
+ : { guid: guidFor(file), part: partFor(file), source: "base", file },
1276
+ );
1277
+ return { plan, render };
1278
+ }