slidemind 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,429 @@
1
+ // Host-free `slidemind add-slide --from <file.pptx>`: fold picked slide(s) from a
2
+ // local .pptx into one-slide native packages the deck render splices in. The
3
+ // add-in's render-time splicer copies only the LOWEST-numbered slide part's
4
+ // dependency tree (see firstSlidePath in ooxml.mjs): slide → layout → master →
5
+ // the master's relationships, skipping External targets and dropping notesSlide
6
+ // rels. So to reuse slide N we:
7
+ // 1. SWAP slide N (+ its rels) into the lowest-numbered slot (firstSlidePath),
8
+ // 2. strip the notesSlide rel (the splicer drops it anyway),
9
+ // 3. trim the slide master to the picked slide's actual layout,
10
+ // 4. PRUNE the package to that slide's dependency closure + a trimmed carrier
11
+ // skeleton — dropping the OTHER slides, unused master layouts, and every
12
+ // part only they reference.
13
+ //
14
+ // Pruning to the closure cannot break the render: it is exactly the set of parts
15
+ // the splicer copies. It also shrinks a folded slice from the whole source deck
16
+ // (a media-heavy 70MB deck × several picks blew the backend's 256MB body limit)
17
+ // to just what the one slide needs, and makes the folded file a valid standalone
18
+ // one-slide deck (opens clean; `slidemind read` reports 1 slide).
19
+ //
20
+ // The output of foldPickedSlides feeds reusedSlideTargets in
21
+ // src/lib/reused-slides.mjs (which turns each pick's slideId into an NN-reused-
22
+ // <shortid>.pptx filename). We only supply { selection, slideId, bytes } here.
23
+
24
+ import { unzip, zip, readText } from "./zip.mjs";
25
+ import {
26
+ slideOrderPaths,
27
+ firstSlidePath,
28
+ relsFor,
29
+ relsPathFor,
30
+ resolvePartPath,
31
+ } from "./ooxml.mjs";
32
+ import { stripHiddenOleGraphicFrames } from "./hidden-ole.mjs";
33
+ import { stripRiskyImageExtensions } from "./image-extensions.mjs";
34
+
35
+ // The four package parts we always keep and rewrite for standalone validity.
36
+ const SKELETON = [
37
+ "[Content_Types].xml",
38
+ "_rels/.rels",
39
+ "ppt/presentation.xml",
40
+ "ppt/_rels/presentation.xml.rels",
41
+ ];
42
+
43
+ // Fold each pick (1-based slide numbers in presentation order) into its own
44
+ // complete .pptx. Returns `[{ selection, slideId, bytes }]`, aligned 1:1 with
45
+ // `picks`. Bytes is a Uint8Array of a one-slide package; for a single-slide
46
+ // source that needs no swap, no notes strip, and no prune, the ORIGINAL buffer
47
+ // is returned untouched (no re-zip, best fidelity).
48
+ export function foldPickedSlides(buffer, picks, { sourceName = "reused" } = {}) {
49
+ const files = unzip(buffer);
50
+ const order = slideOrderPaths(files);
51
+ const count = order.length;
52
+ const entries = sldIdEntries(files);
53
+
54
+ // Validate EVERY pick before doing any work, so a bad batch fails cleanly.
55
+ for (const n of picks) {
56
+ if (!Number.isInteger(n) || n < 1 || n > count) {
57
+ throw new Error(
58
+ `--from source has ${count} slide(s); --pick ${n} is out of range`,
59
+ );
60
+ }
61
+ }
62
+
63
+ const lowest = firstSlidePath(files);
64
+ const lowestRels = relsPathFor(lowest);
65
+
66
+ return picks.map((n) => {
67
+ const picked = order[n - 1];
68
+ const sldId = entries[n - 1] ? entries[n - 1].sldId : null;
69
+ const slideId = makeSlideId(sourceName, sldId, n);
70
+
71
+ // Build the post-swap package on a fresh copy. The picked slide's body + rels
72
+ // must occupy the lowest slot (what the splicer reads); its notesSlide rel is
73
+ // stripped so the folded file carries no orphan notes/notesMaster.
74
+ const copy = { ...files };
75
+ const swapped = picked !== lowest;
76
+ if (swapped) {
77
+ copy[lowest] = files[picked];
78
+ copy[picked] = files[lowest];
79
+ const pickedRels = relsPathFor(picked);
80
+ const pickedRelsBytes = files[pickedRels];
81
+ const lowestRelsBytes = files[lowestRels];
82
+ if (pickedRelsBytes !== undefined) copy[lowestRels] = pickedRelsBytes;
83
+ else delete copy[lowestRels];
84
+ if (lowestRelsBytes !== undefined) copy[pickedRels] = lowestRelsBytes;
85
+ else delete copy[pickedRels];
86
+ }
87
+ let notesStripped = false;
88
+ if (copy[lowestRels] !== undefined && hasNotesRel(copy[lowestRels])) {
89
+ copy[lowestRels] = stripNotesRel(copy[lowestRels]);
90
+ notesStripped = true;
91
+ }
92
+ const hiddenOleStripped = stripHiddenOleGraphicFrames(copy);
93
+ const riskyImagesStripped = stripRiskyImageExtensions(copy);
94
+
95
+ // Prune to the lowest slide's dependency closure + trimmed skeleton.
96
+ const { entries: pruned, changed } = pruneToLowest(copy);
97
+
98
+ // Identity fast-path: an already-lowest, notes-free, nothing-to-prune source
99
+ // (e.g. a single-slide file) returns verbatim — no re-zip, byte-for-byte.
100
+ if (
101
+ !swapped &&
102
+ !notesStripped &&
103
+ !hiddenOleStripped &&
104
+ !riskyImagesStripped &&
105
+ !changed
106
+ ) {
107
+ return { selection: n, slideId, bytes: buffer };
108
+ }
109
+ return { selection: n, slideId, bytes: zip(pruned) };
110
+ });
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Dependency closure + prune
115
+ // ---------------------------------------------------------------------------
116
+
117
+ const SLIDE_PART_RE = /^ppt\/slides\/slide\d+\.xml$/;
118
+
119
+ // BFS the internal-rel closure reachable from `start`, including each visited
120
+ // part's own `_rels/<base>.rels`. Mirrors the splicer's walk: skip
121
+ // TargetMode="External" and Type ".../notesSlide" so the closure is exactly the
122
+ // set of parts the render-time splice copies. NEVER traverse INTO another slide
123
+ // part (a slide is only ever added as an explicit `start`): otherwise walking
124
+ // from e.g. presentation.xml — or a co-authoring revisionInfo part — would drag
125
+ // every other slide's whole tree back in and defeat the prune.
126
+ function dependencyClosure(files, start, { slideMasterLayouts = new Map() } = {}) {
127
+ const keep = new Set();
128
+ const queue = [];
129
+ const visit = (part, isStart) => {
130
+ if (!part || files[part] === undefined || keep.has(part)) return;
131
+ if (!isStart && SLIDE_PART_RE.test(part)) return; // slides are roots only
132
+ keep.add(part);
133
+ queue.push(part);
134
+ };
135
+ visit(start, true);
136
+ while (queue.length) {
137
+ const part = queue.shift();
138
+ const dir = part.includes("/") ? part.slice(0, part.lastIndexOf("/")) : "";
139
+ // Keep the part's own rels file, and follow its internal targets.
140
+ const relsPath = relsPathFor(part);
141
+ if (files[relsPath] !== undefined) keep.add(relsPath);
142
+ for (const rel of relsFor(files, part)) {
143
+ if (rel.targetMode === "External") continue;
144
+ if (rel.type && rel.type.endsWith("/notesSlide")) continue;
145
+ const target = resolvePartPath(dir, rel.target);
146
+ if (rel.type && rel.type.endsWith("/slideLayout")) {
147
+ const allowedLayouts = slideMasterLayouts.get(part);
148
+ if (allowedLayouts && !allowedLayouts.has(target)) continue;
149
+ }
150
+ visit(target, false);
151
+ }
152
+ }
153
+ return keep;
154
+ }
155
+
156
+ // Prune a post-swap package to the lowest slide's closure plus a carrier skeleton
157
+ // rewritten for standalone validity. Returns { entries, changed } where `changed`
158
+ // is false only when nothing was dropped AND no skeleton bytes were rewritten.
159
+ function pruneToLowest(files) {
160
+ const lowest = firstSlidePath(files);
161
+ const slideMasterLayouts = pickedSlideMasterLayouts(files, lowest);
162
+ const keep = dependencyClosure(files, lowest, { slideMasterLayouts });
163
+
164
+ // Classify the presentation's own rels. Keep slide→lowest, masters in the
165
+ // closure, and every other rel type broadly (presProps/viewProps/tableStyles/
166
+ // theme/tags/authors/commentAuthors/revisionInfo/customXml) — folding each
167
+ // kept target's closure in so their sidecars (e.g. customXml itemProps) ride
168
+ // along. Drop the other slides, and notes/handout masters.
169
+ const presRelsPath = "ppt/_rels/presentation.xml.rels";
170
+ const lowestRid = new Set(); // r:id of the kept slide
171
+ const keptMasterRids = new Set();
172
+ const droppedPresRelIds = new Set();
173
+ for (const rel of relsFor(files, "ppt/presentation.xml")) {
174
+ const type = rel.type || "";
175
+ if (rel.targetMode === "External") continue;
176
+ const target = resolvePartPath("ppt", rel.target);
177
+ if (type.endsWith("/slide")) {
178
+ if (target === lowest) {
179
+ if (rel.id) lowestRid.add(rel.id);
180
+ } else if (rel.id) {
181
+ droppedPresRelIds.add(rel.id);
182
+ }
183
+ } else if (type.endsWith("/slideMaster")) {
184
+ if (keep.has(target)) {
185
+ if (rel.id) keptMasterRids.add(rel.id);
186
+ } else if (rel.id) {
187
+ droppedPresRelIds.add(rel.id);
188
+ }
189
+ } else if (type.endsWith("/notesMaster") || type.endsWith("/handoutMaster")) {
190
+ if (rel.id) droppedPresRelIds.add(rel.id);
191
+ } else {
192
+ // Keep broadly — pull the target and its own closure in.
193
+ for (const p of dependencyClosure(files, target)) keep.add(p);
194
+ }
195
+ }
196
+
197
+ // _rels/.rels: keep the document (presentation.xml — skeleton) + doc-properties
198
+ // (core/custom), drop the stale extended props (app.xml <Slides> count is a
199
+ // repair trigger), thumbnail, and webext taskpanes pointer. Just add the kept
200
+ // targets — do NOT walk presentation.xml's closure here (it fans out to every
201
+ // slide); presentation.xml's own useful parts are covered by the classification
202
+ // loop above and the lowest slide's closure.
203
+ const droppedRootRelIds = new Set();
204
+ for (const rel of relsFor(files, "")) {
205
+ const type = rel.type || "";
206
+ if (
207
+ type.endsWith("/extended-properties") ||
208
+ type.endsWith("/thumbnail") ||
209
+ type.endsWith("/webextensiontaskpanes")
210
+ ) {
211
+ if (rel.id) droppedRootRelIds.add(rel.id);
212
+ continue;
213
+ }
214
+ if (rel.targetMode === "External") continue;
215
+ const target = resolvePartPath("", rel.target);
216
+ keep.add(target);
217
+ const sidecar = relsPathFor(target);
218
+ if (files[sidecar] !== undefined) keep.add(sidecar);
219
+ }
220
+
221
+ for (const s of SKELETON) keep.add(s);
222
+
223
+ // Everything not in the closure is dropped.
224
+ const dropped = [];
225
+ for (const part of Object.keys(files)) if (!keep.has(part)) dropped.push(part);
226
+
227
+ // Assemble kept parts verbatim, then overwrite the skeleton with rewrites.
228
+ const out = {};
229
+ for (const part of keep) if (files[part] !== undefined) out[part] = files[part];
230
+
231
+ let skeletonChanged = false;
232
+ const rewrite = (part, fn) => {
233
+ if (files[part] === undefined) return;
234
+ const before = readText(files, part);
235
+ const after = fn(before);
236
+ if (after !== before) skeletonChanged = true;
237
+ out[part] = new TextEncoder().encode(after);
238
+ };
239
+
240
+ rewrite("[Content_Types].xml", (ct) => {
241
+ for (const d of dropped) ct = removeOverrideByPartName(ct, "/" + d);
242
+ return ct;
243
+ });
244
+ rewrite("_rels/.rels", (xml) => {
245
+ for (const id of droppedRootRelIds) xml = removeRelationshipById(xml, id);
246
+ return xml;
247
+ });
248
+ rewrite(presRelsPath, (xml) => {
249
+ for (const id of droppedPresRelIds) xml = removeRelationshipById(xml, id);
250
+ return xml;
251
+ });
252
+ rewrite("ppt/presentation.xml", (pres) => {
253
+ pres = pres.replace(/<p:sldIdLst\b[\s\S]*?<\/p:sldIdLst>/, (b) =>
254
+ trimEntryList(b, "p:sldId", "r:id", lowestRid),
255
+ );
256
+ pres = pres.replace(/<p:sldMasterIdLst\b[\s\S]*?<\/p:sldMasterIdLst>/, (b) =>
257
+ trimEntryList(b, "p:sldMasterId", "r:id", keptMasterRids),
258
+ );
259
+ pres = removeListEntirely(pres, "p:notesMasterIdLst");
260
+ pres = removeListEntirely(pres, "p:handoutMasterIdLst");
261
+ return pres;
262
+ });
263
+
264
+ rewritePickedMasters(out, files, slideMasterLayouts, rewrite);
265
+
266
+ return { entries: out, changed: dropped.length > 0 || skeletonChanged };
267
+ }
268
+
269
+ function pickedSlideMasterLayouts(files, slidePart) {
270
+ const out = new Map();
271
+ const slideDir = slidePart.includes("/") ? slidePart.slice(0, slidePart.lastIndexOf("/")) : "";
272
+ for (const rel of relsFor(files, slidePart)) {
273
+ if (rel.targetMode === "External" || !rel.type?.endsWith("/slideLayout")) continue;
274
+ const layout = resolvePartPath(slideDir, rel.target);
275
+ const layoutDir = layout.includes("/") ? layout.slice(0, layout.lastIndexOf("/")) : "";
276
+ for (const layoutRel of relsFor(files, layout)) {
277
+ if (
278
+ layoutRel.targetMode === "External" ||
279
+ !layoutRel.type?.endsWith("/slideMaster")
280
+ ) {
281
+ continue;
282
+ }
283
+ const master = resolvePartPath(layoutDir, layoutRel.target);
284
+ if (!out.has(master)) out.set(master, new Set());
285
+ out.get(master).add(layout);
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+
291
+ function rewritePickedMasters(out, files, slideMasterLayouts, rewrite) {
292
+ for (const [master, allowedLayouts] of slideMasterLayouts) {
293
+ if (out[master] === undefined) continue;
294
+ const masterDir = master.includes("/") ? master.slice(0, master.lastIndexOf("/")) : "";
295
+ const keepRids = new Set();
296
+ const dropRids = new Set();
297
+ for (const rel of relsFor(files, master)) {
298
+ if (!rel.type?.endsWith("/slideLayout")) continue;
299
+ const target = resolvePartPath(masterDir, rel.target);
300
+ if (allowedLayouts.has(target)) {
301
+ if (rel.id) keepRids.add(rel.id);
302
+ } else if (rel.id) {
303
+ dropRids.add(rel.id);
304
+ }
305
+ }
306
+
307
+ rewrite(relsPathFor(master), (xml) => {
308
+ for (const id of dropRids) xml = removeRelationshipById(xml, id);
309
+ return xml;
310
+ });
311
+ rewrite(master, (xml) =>
312
+ xml.replace(/<p:sldLayoutIdLst\b[\s\S]*?<\/p:sldLayoutIdLst>/, (b) =>
313
+ trimEntryList(b, "p:sldLayoutId", "r:id", keepRids),
314
+ ),
315
+ );
316
+ }
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // XML element removal — operate on the ORIGINAL text, never reconstruct a doc.
321
+ // Each helper tolerates both self-closing `<X …/>` and paired `<X …>…</X>`.
322
+ // ---------------------------------------------------------------------------
323
+
324
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
325
+
326
+ // Remove the <Relationship> with the given Id (self-closing or paired form).
327
+ function removeRelationshipById(xml, id) {
328
+ return xml.replace(
329
+ new RegExp(
330
+ `<Relationship\\b(?=[^>]*\\bId="${escapeRe(id)}")[^>]*?(?:/>|>[\\s\\S]*?</Relationship>)`,
331
+ "g",
332
+ ),
333
+ "",
334
+ );
335
+ }
336
+
337
+ // Remove the <Override> for a PartName (always self-closing; leading slash).
338
+ function removeOverrideByPartName(xml, partName) {
339
+ return xml.replace(
340
+ new RegExp(
341
+ `<Override\\b(?=[^>]*\\bPartName="${escapeRe(partName)}")[^>]*?/>`,
342
+ "g",
343
+ ),
344
+ "",
345
+ );
346
+ }
347
+
348
+ // Drop <tag … idAttr="X" …> entries whose id is not in keepIds (self-closing or
349
+ // paired — sldId can carry a child <p:extLst>).
350
+ function trimEntryList(xml, tag, idAttr, keepIds) {
351
+ const re = new RegExp(`<${escapeRe(tag)}\\b[^>]*?(?:/>|>[\\s\\S]*?</${escapeRe(tag)}>)`, "g");
352
+ const idRe = new RegExp(`\\b${escapeRe(idAttr)}\\s*=\\s*"([^"]+)"`);
353
+ return xml.replace(re, (m) => {
354
+ const id = idRe.exec(m)?.[1];
355
+ return id && keepIds.has(id) ? m : "";
356
+ });
357
+ }
358
+
359
+ // Remove a list element entirely (e.g. notesMasterIdLst / handoutMasterIdLst).
360
+ function removeListEntirely(xml, tag) {
361
+ return xml.replace(
362
+ new RegExp(`<${escapeRe(tag)}\\b[^>]*?(?:/>|>[\\s\\S]*?</${escapeRe(tag)}>)`, "g"),
363
+ "",
364
+ );
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // sldId / slideId helpers (unchanged behaviour)
369
+ // ---------------------------------------------------------------------------
370
+
371
+ // The `<p:sldId>` entries of presentation.xml in document order, each resolved to
372
+ // its slide part plus the sldId "id" attribute — parallel to slideOrderPaths, but
373
+ // carrying the id we key the folded slide by.
374
+ function sldIdEntries(files) {
375
+ const presXml = readText(files, "ppt/presentation.xml");
376
+ if (!presXml) return [];
377
+ const listMatch = /<p:sldIdLst[\s\S]*?<\/p:sldIdLst>/.exec(presXml);
378
+ if (!listMatch) return [];
379
+
380
+ const rels = {};
381
+ for (const rel of relsFor(files, "ppt/presentation.xml")) {
382
+ if (rel.id) rels[rel.id] = rel;
383
+ }
384
+
385
+ const out = [];
386
+ const re = /<p:sldId\b([^>]*)>/g;
387
+ let m;
388
+ while ((m = re.exec(listMatch[0])) !== null) {
389
+ const attrs = m[1];
390
+ const idM = /\bid\s*=\s*"([^"]+)"/.exec(attrs);
391
+ const ridM = /\br:id\s*=\s*"([^"]+)"/.exec(attrs);
392
+ if (!ridM) continue;
393
+ const rel = rels[ridM[1]];
394
+ if (!rel) continue;
395
+ const path = resolvePartPath("ppt", rel.target);
396
+ if (!/^ppt\/slides\/slide\d+\.xml$/.test(path)) continue;
397
+ out.push({ path, sldId: idM ? idM[1] : null });
398
+ }
399
+ return out;
400
+ }
401
+
402
+ // A deterministic, non-empty id for the folded slide: `<source>-<sldId|index>`.
403
+ // It feeds shortIdFor (which lowercases, strips non-alnum, truncates to 8), so a
404
+ // light sanitize here is enough — the exact shape is flexible.
405
+ function makeSlideId(sourceName, sldId, index) {
406
+ const base =
407
+ String(sourceName)
408
+ .replace(/^.*[\\/]/, "") // drop any directory
409
+ .replace(/\.[^.]+$/, "") // drop the extension
410
+ .replace(/[^a-z0-9]/gi, "") || "reused";
411
+ return `${base}-${sldId != null ? sldId : index}`;
412
+ }
413
+
414
+ // Whether a rels part's bytes declare a notesSlide relationship.
415
+ function hasNotesRel(bytes) {
416
+ return /\bType="[^"]*\/notesSlide"/.test(
417
+ new TextDecoder("utf-8").decode(bytes),
418
+ );
419
+ }
420
+
421
+ // Remove any `Type="…/notesSlide"` Relationship element from a rels part's bytes.
422
+ function stripNotesRel(bytes) {
423
+ const xml = new TextDecoder("utf-8").decode(bytes);
424
+ const stripped = xml.replace(
425
+ /<Relationship\b[^>]*?Type="[^"]*\/notesSlide"[^>]*?>/g,
426
+ "",
427
+ );
428
+ return new TextEncoder().encode(stripped);
429
+ }
@@ -0,0 +1,30 @@
1
+ // The single fflate seam for the local-.pptx modules. A .pptx is an OPC zip, so
2
+ // every read/write of one funnels through here — the rest of src/pptx/ stays
3
+ // dependency-free and works on the plain `Record<path, Uint8Array>` entry map
4
+ // this module produces. Keeping fflate behind one file means the packaging
5
+ // guard (test/packaging.test.mjs) only has to bless one import site.
6
+
7
+ import { unzipSync, zipSync } from "fflate";
8
+
9
+ // Unzip a .pptx buffer to its entry map (zip path → raw bytes). `buffer` may be
10
+ // a Node Buffer or a Uint8Array; fflate reads either. Non-zip bytes make fflate
11
+ // throw ("invalid zip data") — callers wrap that into a clear .pptx message.
12
+ export function unzip(buffer) {
13
+ return unzipSync(
14
+ buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer),
15
+ );
16
+ }
17
+
18
+ // Zip an entry map (zip path → Uint8Array) back into a .pptx byte buffer.
19
+ // Level 6 matches the OOXML-friendly middle ground the add-in uses.
20
+ export function zip(entries) {
21
+ return zipSync(entries, { level: 6 });
22
+ }
23
+
24
+ // Decode one zip entry as UTF-8 text, or null when the part is absent. The
25
+ // workhorse for OOXML parsing — every XML part is read through here.
26
+ export function readText(files, partPath) {
27
+ const bytes = files[partPath];
28
+ if (!bytes) return null;
29
+ return new TextDecoder("utf-8").decode(bytes);
30
+ }