slidemind 0.3.20 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +591 -15
- package/README.md +202 -7
- package/bin/slidemind.mjs +50 -2
- package/package.json +2 -2
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
}
|