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.
- package/CLAUDE.md +10 -7
- package/README.md +20 -7
- package/bin/slidemind.mjs +393 -39
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +68 -16
- package/skills/search/SKILL.md +15 -2
- package/src/bridge/build-and-push.mjs +11 -0
- package/src/bridge/bundle.mjs +15 -4
- package/src/bridge/capture.mjs +20 -2
- package/src/bridge/client.mjs +99 -0
- package/src/builders/build.mjs +83 -11
- package/src/builders/gate-server.mjs +364 -0
- package/src/builders/plan-markdown-edit.mjs +120 -0
- package/src/builders/plan-md.mjs +216 -0
- package/src/builders/plan-overlay.js +1778 -0
- package/src/builders/plan.mjs +696 -0
- package/src/builders/red-thread-backbone.mjs +194 -0
- package/src/builders/resolve-skill.mjs +40 -5
- package/src/builders/review-overlay.js +191 -13
- package/src/builders/review.mjs +196 -68
- package/src/builders/storyboard-markdown.mjs +118 -0
- package/src/builders/storyboard-schema.mjs +180 -0
- package/src/builders/visual-families.mjs +32 -0
- package/src/pptx/app-props.mjs +222 -0
- package/src/pptx/hidden-ole.mjs +125 -0
- package/src/pptx/image-extensions.mjs +190 -0
- package/src/pptx/ooxml.mjs +169 -0
- package/src/pptx/read-local.mjs +206 -0
- package/src/pptx/slide-swap.mjs +429 -0
- package/src/pptx/zip.mjs +30 -0
|
@@ -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
|
+
}
|
package/src/pptx/zip.mjs
ADDED
|
@@ -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
|
+
}
|