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,32 @@
|
|
|
1
|
+
import { splitPlanMarkdownIntoSegments } from "./storyboard-markdown.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Split a per-slide concept batch into the dependency waves expected by the
|
|
5
|
+
* add-in: independent slides and family anchors first, family variants second.
|
|
6
|
+
*/
|
|
7
|
+
export function conceptGenerationWaves(planMarkdown, slideCount) {
|
|
8
|
+
const groups = new Map();
|
|
9
|
+
for (const segment of splitPlanMarkdownIntoSegments(planMarkdown)) {
|
|
10
|
+
if (segment.type !== "storyboard" || !segment.data) continue;
|
|
11
|
+
const family = segment.data.visualFamily?.trim();
|
|
12
|
+
const slide = segment.data.slide;
|
|
13
|
+
if (!family || !Number.isFinite(slide)) continue;
|
|
14
|
+
groups.set(family, [...(groups.get(family) ?? []), slide]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const variants = new Set();
|
|
18
|
+
for (const unsorted of groups.values()) {
|
|
19
|
+
const members = [...new Set(unsorted)].sort((a, b) => a - b);
|
|
20
|
+
if (members.length < 2) continue;
|
|
21
|
+
for (const slide of members.slice(1)) variants.add(slide);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const remaining = Array.from(
|
|
25
|
+
{ length: Math.max(0, slideCount - 1) },
|
|
26
|
+
(_, index) => index + 2,
|
|
27
|
+
);
|
|
28
|
+
return {
|
|
29
|
+
firstWave: remaining.filter((slide) => !variants.has(slide)),
|
|
30
|
+
variants: remaining.filter((slide) => variants.has(slide)),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Normalize PowerPoint package metadata after server-side assembly.
|
|
2
|
+
//
|
|
3
|
+
// The render backend can assemble authored JSX slides plus reused native .pptx
|
|
4
|
+
// slides correctly while leaving docProps/app.xml counted from the JSX-only
|
|
5
|
+
// source deck. PowerPoint treats stale <Slides>, HeadingPairs, or TitlesOfParts
|
|
6
|
+
// metadata as repairable corruption on open. This post-process keeps those
|
|
7
|
+
// extended properties aligned with the actual slide order in ppt/presentation.xml.
|
|
8
|
+
//
|
|
9
|
+
// Reused native slides can also bring duplicate copies of a source deck's slide
|
|
10
|
+
// master tree. Those copies legitimately need distinct relationship targets, but
|
|
11
|
+
// PowerPoint allocates <p:sldMasterId id> and <p:sldLayoutId id> from a single
|
|
12
|
+
// shared id space and treats any collision across that whole space as repairable
|
|
13
|
+
// corruption on open. A splicer that assigns new master ids from a watermark that
|
|
14
|
+
// only scanned presentation.xml can hand a spliced master the same numeric id as
|
|
15
|
+
// an existing brand layout. Treat the sldMasterId values as fixed anchors and
|
|
16
|
+
// renumber only the colliding sldLayoutId values, keeping every r:id pointer
|
|
17
|
+
// untouched and never rewriting presentation.xml.
|
|
18
|
+
|
|
19
|
+
import { unzip, zip, readText } from "./zip.mjs";
|
|
20
|
+
import { slideOrderPaths, decodeXmlEntities } from "./ooxml.mjs";
|
|
21
|
+
import { slideText } from "./read-local.mjs";
|
|
22
|
+
import { stripHiddenOleGraphicFrames } from "./hidden-ole.mjs";
|
|
23
|
+
import { stripRiskyImageExtensions } from "./image-extensions.mjs";
|
|
24
|
+
|
|
25
|
+
const APP_PROPS = "docProps/app.xml";
|
|
26
|
+
|
|
27
|
+
export function normalizePptxAppProperties(buffer) {
|
|
28
|
+
let files;
|
|
29
|
+
try {
|
|
30
|
+
files = unzip(buffer);
|
|
31
|
+
} catch {
|
|
32
|
+
return { bytes: buffer, changed: false, slideCount: null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!files["ppt/presentation.xml"]) {
|
|
36
|
+
return { bytes: buffer, changed: false, slideCount: null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const order = slideOrderPaths(files);
|
|
40
|
+
const slideCount = order.length;
|
|
41
|
+
let entries = files;
|
|
42
|
+
let changed = false;
|
|
43
|
+
|
|
44
|
+
const before = readText(files, APP_PROPS);
|
|
45
|
+
if (before && slideCount > 0) {
|
|
46
|
+
let after = replaceElementText(before, "Slides", String(slideCount));
|
|
47
|
+
after = replaceSlideTitleHeadingCount(after, slideCount);
|
|
48
|
+
after = replaceTitlesOfParts(after, slideTitles(files, order));
|
|
49
|
+
if (after !== before) {
|
|
50
|
+
entries = { ...entries, [APP_PROPS]: new TextEncoder().encode(after) };
|
|
51
|
+
changed = true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const idResult = normalizeMasterAndLayoutIds(entries);
|
|
56
|
+
if (idResult.changed) {
|
|
57
|
+
entries = idResult.entries;
|
|
58
|
+
changed = true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (stripHiddenOleGraphicFrames(entries)) {
|
|
62
|
+
changed = true;
|
|
63
|
+
}
|
|
64
|
+
if (stripRiskyImageExtensions(entries)) {
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
bytes: changed ? zip(entries) : buffer,
|
|
70
|
+
changed,
|
|
71
|
+
slideCount,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function normalizePptxAppPropertiesBase64(base64) {
|
|
76
|
+
const normalized = normalizePptxAppProperties(Buffer.from(base64, "base64"));
|
|
77
|
+
if (!normalized.changed) {
|
|
78
|
+
return { ...normalized, base64 };
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
...normalized,
|
|
82
|
+
base64: Buffer.from(normalized.bytes).toString("base64"),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function slideTitles(files, order) {
|
|
87
|
+
return order.map((slidePath, i) => {
|
|
88
|
+
const xml = readText(files, slidePath) ?? "";
|
|
89
|
+
const { title, subtitle } = slideText(xml);
|
|
90
|
+
return title || subtitle || `Slide ${i + 1}`;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const LAYOUT_ID_FLOOR = 2147483648;
|
|
95
|
+
|
|
96
|
+
function normalizeMasterAndLayoutIds(files) {
|
|
97
|
+
const masterParts = Object.keys(files)
|
|
98
|
+
.filter((part) => /^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(part))
|
|
99
|
+
.sort(numericPartSort);
|
|
100
|
+
if (masterParts.length === 0) return { entries: files, changed: false };
|
|
101
|
+
|
|
102
|
+
// sldMasterId values live in presentation.xml and are the fixed anchors of the
|
|
103
|
+
// shared id space — never renumbered here (that would mean rewriting
|
|
104
|
+
// presentation.xml, and a master-vs-master collision is the server's to fix).
|
|
105
|
+
const presentationXml = readText(files, "ppt/presentation.xml") ?? "";
|
|
106
|
+
const masterIds = [];
|
|
107
|
+
for (const tag of presentationXml.matchAll(/<p:sldMasterId\b[^>]*>/g)) {
|
|
108
|
+
const id = attr(tag[0], "id");
|
|
109
|
+
if (id !== null) masterIds.push(id);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const layoutIds = [];
|
|
113
|
+
for (const part of masterParts) {
|
|
114
|
+
const xml = readText(files, part) ?? "";
|
|
115
|
+
for (const tag of xml.matchAll(/<p:sldLayoutId\b[^>]*>/g)) {
|
|
116
|
+
const id = attr(tag[0], "id");
|
|
117
|
+
if (id !== null) layoutIds.push(id);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Inert unless the master and layout ids collide somewhere across the union —
|
|
122
|
+
// keeps well-formed (and server-fixed) output byte-stable.
|
|
123
|
+
const union = [...masterIds, ...layoutIds];
|
|
124
|
+
if (new Set(union).size === union.length) {
|
|
125
|
+
return { entries: files, changed: false };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const numericIds = union.map((id) => Number(id)).filter(Number.isFinite);
|
|
129
|
+
let nextId = numericIds.length ? Math.max(...numericIds) + 1 : LAYOUT_ID_FLOOR;
|
|
130
|
+
|
|
131
|
+
// Seed with the master anchors so a layout id equal to a master id is treated
|
|
132
|
+
// as a collision and moved off it. Then walk masters/layouts in document order,
|
|
133
|
+
// renumbering only ids already claimed.
|
|
134
|
+
const seen = new Set(masterIds);
|
|
135
|
+
let entries = files;
|
|
136
|
+
let changed = false;
|
|
137
|
+
|
|
138
|
+
for (const part of masterParts) {
|
|
139
|
+
const before = readText(files, part) ?? "";
|
|
140
|
+
const after = before.replace(/<p:sldLayoutId\b[^>]*>/g, (tag) => {
|
|
141
|
+
const id = attr(tag, "id");
|
|
142
|
+
if (id === null || !seen.has(id)) {
|
|
143
|
+
if (id !== null) seen.add(id);
|
|
144
|
+
return tag;
|
|
145
|
+
}
|
|
146
|
+
while (seen.has(String(nextId))) nextId += 1;
|
|
147
|
+
const replacement = String(nextId);
|
|
148
|
+
nextId += 1;
|
|
149
|
+
seen.add(replacement);
|
|
150
|
+
return replaceAttr(tag, "id", replacement);
|
|
151
|
+
});
|
|
152
|
+
if (after !== before) {
|
|
153
|
+
entries = { ...entries, [part]: new TextEncoder().encode(after) };
|
|
154
|
+
changed = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { entries, changed };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function replaceElementText(xml, tagName, value) {
|
|
162
|
+
return xml.replace(
|
|
163
|
+
new RegExp(`(<${tagName}>)[\\s\\S]*?(</${tagName}>)`),
|
|
164
|
+
`$1${value}$2`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function attr(tag, name) {
|
|
169
|
+
const m = new RegExp(`(^|\\s)${name}="([^"]*)"`).exec(tag);
|
|
170
|
+
return m ? m[2] : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function replaceAttr(tag, name, value) {
|
|
174
|
+
return tag.replace(
|
|
175
|
+
new RegExp(`(^|\\s)${name}="[^"]*"`),
|
|
176
|
+
`$1${name}="${value}"`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function numericPartSort(a, b) {
|
|
181
|
+
const na = Number(a.match(/(\d+)\.xml$/)?.[1] ?? 0);
|
|
182
|
+
const nb = Number(b.match(/(\d+)\.xml$/)?.[1] ?? 0);
|
|
183
|
+
return na - nb || a.localeCompare(b);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function replaceSlideTitleHeadingCount(xml, slideCount) {
|
|
187
|
+
return xml.replace(
|
|
188
|
+
/(<vt:variant>\s*<vt:lpstr>Slide Titles<\/vt:lpstr>\s*<\/vt:variant>\s*<vt:variant>\s*<vt:i4>)-?\d+(<\/vt:i4>\s*<\/vt:variant>)/,
|
|
189
|
+
`$1${slideCount}$2`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function replaceTitlesOfParts(xml, titles) {
|
|
194
|
+
return xml.replace(
|
|
195
|
+
/<TitlesOfParts>\s*<vt:vector\b[^>]*>[\s\S]*?<\/vt:vector>\s*<\/TitlesOfParts>/,
|
|
196
|
+
(match) => {
|
|
197
|
+
const themeName = firstLpstr(match) || "Theme";
|
|
198
|
+
const values = [themeName, ...titles]
|
|
199
|
+
.map((title) => `<vt:lpstr>${escapeXml(title)}</vt:lpstr>`)
|
|
200
|
+
.join("");
|
|
201
|
+
return (
|
|
202
|
+
`<TitlesOfParts><vt:vector size="${titles.length + 1}" baseType="lpstr">` +
|
|
203
|
+
values +
|
|
204
|
+
`</vt:vector></TitlesOfParts>`
|
|
205
|
+
);
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function firstLpstr(xml) {
|
|
211
|
+
const m = /<vt:lpstr>([\s\S]*?)<\/vt:lpstr>/.exec(xml);
|
|
212
|
+
return m ? decodeXmlEntities(m[1]) : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function escapeXml(value) {
|
|
216
|
+
return String(value)
|
|
217
|
+
.replace(/&/g, "&")
|
|
218
|
+
.replace(/</g, "<")
|
|
219
|
+
.replace(/>/g, ">")
|
|
220
|
+
.replace(/"/g, """)
|
|
221
|
+
.replace(/'/g, "'");
|
|
222
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readText } from "./zip.mjs";
|
|
2
|
+
import { relsFor, relsPathFor, resolvePartPath } from "./ooxml.mjs";
|
|
3
|
+
|
|
4
|
+
const CONTENT_TYPES = "[Content_Types].xml";
|
|
5
|
+
|
|
6
|
+
export function stripHiddenOleGraphicFrames(files) {
|
|
7
|
+
let changed = false;
|
|
8
|
+
const removedTargets = new Set();
|
|
9
|
+
const candidates = Object.keys(files).filter((part) =>
|
|
10
|
+
/^ppt\/(?:slides|slideMasters|slideLayouts)\/[^/]+\.xml$/.test(part),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
for (const part of candidates) {
|
|
14
|
+
const before = readText(files, part);
|
|
15
|
+
if (!before) continue;
|
|
16
|
+
|
|
17
|
+
const removedIds = new Set();
|
|
18
|
+
const after = before.replace(
|
|
19
|
+
/<p:graphicFrame\b[\s\S]*?<\/p:graphicFrame>/g,
|
|
20
|
+
(frame) => {
|
|
21
|
+
if (!/<p:oleObj\b/.test(frame) || !/\bhidden="1"/.test(frame)) return frame;
|
|
22
|
+
for (const id of relationshipIdsIn(frame)) removedIds.add(id);
|
|
23
|
+
return "";
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
if (after === before) continue;
|
|
27
|
+
|
|
28
|
+
files[part] = new TextEncoder().encode(after);
|
|
29
|
+
changed = true;
|
|
30
|
+
|
|
31
|
+
const relsPath = relsPathFor(part);
|
|
32
|
+
const relsXml = readText(files, relsPath);
|
|
33
|
+
if (!relsXml) continue;
|
|
34
|
+
|
|
35
|
+
const partDir = part.includes("/") ? part.slice(0, part.lastIndexOf("/")) : "";
|
|
36
|
+
const relsById = new Map(relsFor(files, part).map((rel) => [rel.id, rel]));
|
|
37
|
+
const remainingIds = relationshipIdsIn(after);
|
|
38
|
+
let nextRels = relsXml;
|
|
39
|
+
for (const id of removedIds) {
|
|
40
|
+
if (remainingIds.has(id)) continue;
|
|
41
|
+
nextRels = removeRelationshipById(nextRels, id);
|
|
42
|
+
const rel = relsById.get(id);
|
|
43
|
+
if (rel && rel.targetMode !== "External") {
|
|
44
|
+
removedTargets.add(resolvePartPath(partDir, rel.target));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (nextRels !== relsXml) {
|
|
48
|
+
files[relsPath] = new TextEncoder().encode(nextRels);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const deleted = deleteUnreferencedTargets(files, removedTargets);
|
|
53
|
+
if (deleted.size > 0) {
|
|
54
|
+
removeContentTypeOverrides(files, deleted);
|
|
55
|
+
changed = true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return changed;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function deleteUnreferencedTargets(files, removedTargets) {
|
|
62
|
+
const remainingRefs = referencedInternalTargets(files);
|
|
63
|
+
const deleted = new Set();
|
|
64
|
+
for (const part of removedTargets) {
|
|
65
|
+
if (!part || remainingRefs.has(part) || files[part] === undefined) continue;
|
|
66
|
+
delete files[part];
|
|
67
|
+
const sidecar = relsPathFor(part);
|
|
68
|
+
if (files[sidecar] !== undefined) delete files[sidecar];
|
|
69
|
+
deleted.add(part);
|
|
70
|
+
}
|
|
71
|
+
return deleted;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function referencedInternalTargets(files) {
|
|
75
|
+
const refs = new Set();
|
|
76
|
+
for (const relsPath of Object.keys(files)) {
|
|
77
|
+
if (!relsPath.endsWith(".rels")) continue;
|
|
78
|
+
const dir = relsPath.replace(/\/?_rels\/[^/]+$/, "");
|
|
79
|
+
const owner =
|
|
80
|
+
dir === ""
|
|
81
|
+
? relsPath.replace(/^_rels\/(.*)\.rels$/, "$1")
|
|
82
|
+
: `${dir}/${relsPath.replace(/^.*_rels\/(.*)\.rels$/, "$1")}`;
|
|
83
|
+
for (const rel of relsFor(files, owner)) {
|
|
84
|
+
if (rel.targetMode === "External") continue;
|
|
85
|
+
refs.add(resolvePartPath(dir, rel.target));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return refs;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function removeContentTypeOverrides(files, deletedParts) {
|
|
92
|
+
const before = readText(files, CONTENT_TYPES);
|
|
93
|
+
if (!before) return;
|
|
94
|
+
let after = before;
|
|
95
|
+
for (const part of deletedParts) {
|
|
96
|
+
after = after.replace(
|
|
97
|
+
new RegExp(
|
|
98
|
+
`<Override\\b(?=[^>]*\\bPartName="/${escapeRe(part)}")[^>]*?/>`,
|
|
99
|
+
"g",
|
|
100
|
+
),
|
|
101
|
+
"",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (after !== before) files[CONTENT_TYPES] = new TextEncoder().encode(after);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function relationshipIdsIn(xml) {
|
|
108
|
+
const ids = new Set();
|
|
109
|
+
for (const m of String(xml).matchAll(/\br:(?:id|embed|link)\s*=\s*"([^"]+)"/g)) {
|
|
110
|
+
ids.add(m[1]);
|
|
111
|
+
}
|
|
112
|
+
return ids;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function removeRelationshipById(xml, id) {
|
|
116
|
+
return xml.replace(
|
|
117
|
+
new RegExp(
|
|
118
|
+
`<Relationship\\b(?=[^>]*\\bId="${escapeRe(id)}")[^>]*?(?:/>|>[\\s\\S]*?</Relationship>)`,
|
|
119
|
+
"g",
|
|
120
|
+
),
|
|
121
|
+
"",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { readText } from "./zip.mjs";
|
|
2
|
+
import { relsFor, relsPathFor, resolvePartPath } from "./ooxml.mjs";
|
|
3
|
+
|
|
4
|
+
const CONTENT_TYPES = "[Content_Types].xml";
|
|
5
|
+
|
|
6
|
+
export function stripRiskyImageExtensions(files) {
|
|
7
|
+
let changed = false;
|
|
8
|
+
const removedTargets = new Set();
|
|
9
|
+
const candidates = Object.keys(files).filter((part) =>
|
|
10
|
+
/^ppt\/(?:slides|slideMasters|slideLayouts)\/[^/]+\.xml$/.test(part),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
for (const part of candidates) {
|
|
14
|
+
const before = readText(files, part);
|
|
15
|
+
if (!before) continue;
|
|
16
|
+
|
|
17
|
+
const removedIds = new Set();
|
|
18
|
+
const relsById = new Map(relsFor(files, part).map((rel) => [rel.id, rel]));
|
|
19
|
+
const partDir = part.includes("/") ? part.slice(0, part.lastIndexOf("/")) : "";
|
|
20
|
+
let after = before.replace(
|
|
21
|
+
/<a:ext\b[\s\S]*?<\/a:ext>/g,
|
|
22
|
+
(ext) => {
|
|
23
|
+
if (!/<(?:asvg:svgBlip|a14:imgLayer)\b/.test(ext)) return ext;
|
|
24
|
+
for (const id of relationshipIdsIn(ext)) removedIds.add(id);
|
|
25
|
+
return "";
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
after = after.replace(/<a:extLst\b[^>]*>\s*<\/a:extLst>/g, "");
|
|
29
|
+
after = after.replace(
|
|
30
|
+
/<p:pic\b[\s\S]*?<\/p:pic>/g,
|
|
31
|
+
(pic) => {
|
|
32
|
+
if (!/<a:blip\b(?![^>]*\br:(?:embed|link)=)[^>]*>\s*<\/a:blip>/.test(pic)) {
|
|
33
|
+
return pic;
|
|
34
|
+
}
|
|
35
|
+
for (const id of relationshipIdsIn(pic)) removedIds.add(id);
|
|
36
|
+
return "";
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
after = after.replace(
|
|
40
|
+
/<p:pic\b[\s\S]*?<\/p:pic>/g,
|
|
41
|
+
(pic) => {
|
|
42
|
+
for (const id of relationshipIdsIn(pic)) {
|
|
43
|
+
const rel = relsById.get(id);
|
|
44
|
+
if (
|
|
45
|
+
rel &&
|
|
46
|
+
rel.targetMode !== "External" &&
|
|
47
|
+
/\.svg(?:$|[?#])/i.test(rel.target ?? "")
|
|
48
|
+
) {
|
|
49
|
+
removedIds.add(id);
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return pic;
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
const relsPath = relsPathFor(part);
|
|
57
|
+
const relsXml = readText(files, relsPath);
|
|
58
|
+
const remainingIds = relationshipIdsIn(after);
|
|
59
|
+
if (relsXml) {
|
|
60
|
+
for (const rel of relsById.values()) {
|
|
61
|
+
if (
|
|
62
|
+
rel.targetMode !== "External" &&
|
|
63
|
+
!remainingIds.has(rel.id) &&
|
|
64
|
+
/\.(?:svg|wdp)(?:$|[?#])/i.test(rel.target ?? "")
|
|
65
|
+
) {
|
|
66
|
+
removedIds.add(rel.id);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (after === before && removedIds.size === 0) continue;
|
|
72
|
+
|
|
73
|
+
if (after !== before) {
|
|
74
|
+
files[part] = new TextEncoder().encode(after);
|
|
75
|
+
changed = true;
|
|
76
|
+
}
|
|
77
|
+
if (!relsXml) continue;
|
|
78
|
+
|
|
79
|
+
let nextRels = relsXml;
|
|
80
|
+
for (const id of removedIds) {
|
|
81
|
+
if (remainingIds.has(id)) continue;
|
|
82
|
+
nextRels = removeRelationshipById(nextRels, id);
|
|
83
|
+
const rel = relsById.get(id);
|
|
84
|
+
if (rel && rel.targetMode !== "External") {
|
|
85
|
+
removedTargets.add(resolvePartPath(partDir, rel.target));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (nextRels !== relsXml) {
|
|
89
|
+
files[relsPath] = new TextEncoder().encode(nextRels);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const deleted = deleteUnreferencedTargets(files, removedTargets);
|
|
94
|
+
if (deleted.size > 0) {
|
|
95
|
+
removeContentTypeOverrides(files, deleted);
|
|
96
|
+
changed = true;
|
|
97
|
+
}
|
|
98
|
+
if (removeUnusedMediaDefaults(files, ["svg", "wdp"])) {
|
|
99
|
+
changed = true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return changed;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function deleteUnreferencedTargets(files, removedTargets) {
|
|
106
|
+
const remainingRefs = referencedInternalTargets(files);
|
|
107
|
+
const deleted = new Set();
|
|
108
|
+
for (const part of removedTargets) {
|
|
109
|
+
if (!part || remainingRefs.has(part) || files[part] === undefined) continue;
|
|
110
|
+
delete files[part];
|
|
111
|
+
const sidecar = relsPathFor(part);
|
|
112
|
+
if (files[sidecar] !== undefined) delete files[sidecar];
|
|
113
|
+
deleted.add(part);
|
|
114
|
+
}
|
|
115
|
+
return deleted;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function referencedInternalTargets(files) {
|
|
119
|
+
const refs = new Set();
|
|
120
|
+
for (const relsPath of Object.keys(files)) {
|
|
121
|
+
if (!relsPath.endsWith(".rels")) continue;
|
|
122
|
+
const dir = relsPath.replace(/\/?_rels\/[^/]+$/, "");
|
|
123
|
+
const owner =
|
|
124
|
+
dir === ""
|
|
125
|
+
? relsPath.replace(/^_rels\/(.*)\.rels$/, "$1")
|
|
126
|
+
: `${dir}/${relsPath.replace(/^.*_rels\/(.*)\.rels$/, "$1")}`;
|
|
127
|
+
for (const rel of relsFor(files, owner)) {
|
|
128
|
+
if (rel.targetMode === "External") continue;
|
|
129
|
+
refs.add(resolvePartPath(dir, rel.target));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return refs;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function removeContentTypeOverrides(files, deletedParts) {
|
|
136
|
+
const before = readText(files, CONTENT_TYPES);
|
|
137
|
+
if (!before) return;
|
|
138
|
+
let after = before;
|
|
139
|
+
for (const part of deletedParts) {
|
|
140
|
+
after = after.replace(
|
|
141
|
+
new RegExp(
|
|
142
|
+
`<Override\\b(?=[^>]*\\bPartName="/${escapeRe(part)}")[^>]*?/>`,
|
|
143
|
+
"g",
|
|
144
|
+
),
|
|
145
|
+
"",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (after !== before) files[CONTENT_TYPES] = new TextEncoder().encode(after);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function removeUnusedMediaDefaults(files, exts) {
|
|
152
|
+
const before = readText(files, CONTENT_TYPES);
|
|
153
|
+
if (!before) return false;
|
|
154
|
+
let after = before;
|
|
155
|
+
for (const ext of exts) {
|
|
156
|
+
const hasPart = Object.keys(files).some((part) =>
|
|
157
|
+
part.toLowerCase().endsWith(`.${ext}`),
|
|
158
|
+
);
|
|
159
|
+
if (hasPart) continue;
|
|
160
|
+
after = after.replace(
|
|
161
|
+
new RegExp(`<Default\\b(?=[^>]*\\bExtension="${escapeRe(ext)}")[^>]*?/>`, "g"),
|
|
162
|
+
"",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (after !== before) {
|
|
166
|
+
files[CONTENT_TYPES] = new TextEncoder().encode(after);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function relationshipIdsIn(xml) {
|
|
173
|
+
const ids = new Set();
|
|
174
|
+
for (const m of String(xml).matchAll(/\br:(?:id|embed|link)\s*=\s*"([^"]+)"/g)) {
|
|
175
|
+
ids.add(m[1]);
|
|
176
|
+
}
|
|
177
|
+
return ids;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function removeRelationshipById(xml, id) {
|
|
181
|
+
return xml.replace(
|
|
182
|
+
new RegExp(
|
|
183
|
+
`<Relationship\\b(?=[^>]*\\bId="${escapeRe(id)}")[^>]*?(?:/>|>[\\s\\S]*?</Relationship>)`,
|
|
184
|
+
"g",
|
|
185
|
+
),
|
|
186
|
+
"",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|