slidemind 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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, "&amp;")
218
+ .replace(/</g, "&lt;")
219
+ .replace(/>/g, "&gt;")
220
+ .replace(/"/g, "&quot;")
221
+ .replace(/'/g, "&apos;");
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, "\\$&");