slidemind 0.1.8 → 0.2.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.
@@ -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
+ }