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,486 @@
1
+ // DrawingML surgery for the fill-template engine. Everything here operates on
2
+ // slide-part XML strings from the cloned template package — the engine never
3
+ // generates a slide from scratch, it only patches declared shapes, so every
4
+ // helper is written to preserve the run/paragraph/cell formatting it finds.
5
+ //
6
+ // Two hard rules learned from PowerPoint's repair dialog (2026-07-30):
7
+ // - never leave text inside hMerge/vMerge continuation cells;
8
+ // - when a template run carries no <a:rPr>, the intended formatting usually
9
+ // sits in <a:endParaRPr> — fall back to it, else the theme font leaks in.
10
+
11
+ export const esc = (s) =>
12
+ String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13
+
14
+ export const decode = (s) =>
15
+ String(s)
16
+ .replace(/&lt;/g, "<")
17
+ .replace(/&gt;/g, ">")
18
+ .replace(/&quot;/g, '"')
19
+ .replace(/&apos;/g, "'")
20
+ .replace(/&amp;/g, "&");
21
+
22
+ // Authoring-guidance highlights (yellow/green "fill me in" markers) must not
23
+ // survive into patched runs.
24
+ const stripHighlight = (rPr) =>
25
+ rPr.replace(/<a:highlight>[\s\S]*?<\/a:highlight>/g, "");
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Shape blocks
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const SHAPE_TAGS = ["sp", "pic", "cxnSp", "graphicFrame"];
32
+
33
+ // Top-level shape blocks of one kind. The proposal masters keep all editable
34
+ // shapes at spTree top level (verified by inventory); grouped shapes are not
35
+ // addressed by the pack metadata.
36
+ export function* blocks(xml, tag) {
37
+ const re = new RegExp(`<p:${tag}>[\\s\\S]*?</p:${tag}>`, "g");
38
+ let m;
39
+ while ((m = re.exec(xml))) yield { block: m[0], index: m.index };
40
+ }
41
+
42
+ export function findShape(xml, id, tags = SHAPE_TAGS) {
43
+ for (const tag of tags) {
44
+ for (const hit of blocks(xml, tag)) {
45
+ const idm = hit.block.match(/<p:cNvPr id="(\d+)"[ />]/);
46
+ if (idm && idm[1] === String(id)) return { ...hit, tag };
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+
52
+ export function maxShapeId(xml) {
53
+ let max = 1;
54
+ for (const m of xml.matchAll(/<p:cNvPr id="(\d+)"/g)) {
55
+ max = Math.max(max, Number(m[1]));
56
+ }
57
+ return max;
58
+ }
59
+
60
+ const replaceAt = (xml, hit, next) =>
61
+ xml.slice(0, hit.index) + next + xml.slice(hit.index + hit.block.length);
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Paragraphs & runs
65
+ // ---------------------------------------------------------------------------
66
+
67
+ // Formatting template for new runs, with the endParaRPr fallback: empty
68
+ // template cells/paragraphs park their run properties there, and dropping them
69
+ // changes fonts (the "table fonts changed" defect class).
70
+ function paraTemplate(paraXml) {
71
+ const pPr = (paraXml.match(/<a:pPr[^>]*\/>|<a:pPr[\s\S]*?<\/a:pPr>/) || [""])[0];
72
+ let rPr = (paraXml.match(/<a:rPr[^>]*\/>|<a:rPr[\s\S]*?<\/a:rPr>/) || [""])[0];
73
+ if (!rPr) {
74
+ const end = (paraXml.match(/<a:endParaRPr[^>]*\/>|<a:endParaRPr[\s\S]*?<\/a:endParaRPr>/) || [""])[0];
75
+ if (end) rPr = end.replace(/endParaRPr/g, "rPr");
76
+ }
77
+ return { pPr, rPr: stripHighlight(rPr) };
78
+ }
79
+
80
+ function buildParas(lines, tmpl) {
81
+ return lines
82
+ .map((line) =>
83
+ line === ""
84
+ ? `<a:p>${tmpl.pPr}</a:p>`
85
+ : `<a:p>${tmpl.pPr}<a:r>${tmpl.rPr}<a:t>${esc(line)}</a:t></a:r></a:p>`,
86
+ )
87
+ .join("");
88
+ }
89
+
90
+ function firstPara(body) {
91
+ return (body.match(/<a:p>[\s\S]*?<\/a:p>|<a:p\/>/) || [""])[0];
92
+ }
93
+
94
+ // Replace every paragraph of a txBody, preserving the template's PER-PARAGRAPH
95
+ // formatting: line i takes paragraph i's pPr/rPr, extra lines reuse the last
96
+ // paragraph's (uniform bullet shapes behave exactly as before; structured
97
+ // boxes — bold name over regular level, labeled profile lines — keep their
98
+ // per-line styling instead of being flattened to the first paragraph's look).
99
+ function replaceTxBodyParas(block, lines, donorTmpl = null, rPrOverride = null) {
100
+ const bodyM = block.match(/<p:txBody>([\s\S]*?)<\/p:txBody>/);
101
+ if (!bodyM) return null;
102
+ const body = bodyM[1];
103
+ const paras = [...body.matchAll(/<a:p>[\s\S]*?<\/a:p>|<a:p\/>/g)].map((m) => m[0]);
104
+ if (paras.length === 0) return null;
105
+ const tmpls = paras.map((p) => paraTemplate(p));
106
+ const pick = (i) => {
107
+ let t = tmpls[Math.min(i, tmpls.length - 1)];
108
+ if (!t.rPr) {
109
+ const styled = tmpls.find((x) => x.rPr);
110
+ if (styled) t = { pPr: t.pPr, rPr: styled.rPr };
111
+ }
112
+ if (!t.rPr && donorTmpl?.rPr) t = { pPr: t.pPr || donorTmpl.pPr, rPr: donorTmpl.rPr };
113
+ if (rPrOverride) t = { pPr: t.pPr, rPr: rPrOverride };
114
+ return t;
115
+ };
116
+ const rebuilt = lines.map((line, i) => buildParas([line], pick(i))).join("");
117
+ const parasStart = body.search(/<a:p[\s>/]/);
118
+ return block.replace(
119
+ bodyM[0],
120
+ `<p:txBody>${body.slice(0, parasStart)}${rebuilt}</p:txBody>`,
121
+ );
122
+ }
123
+
124
+ export function setShapeText(xml, id, value, log, opts = {}) {
125
+ const lines = Array.isArray(value) ? value : String(value).split("\n");
126
+ const hit = findShape(xml, id, ["sp"]);
127
+ if (!hit) return log.miss(`shape ${id} not found`), xml;
128
+ let donorTmpl = null;
129
+ if (opts.donorId) {
130
+ const donor = findShape(xml, opts.donorId, ["sp"]);
131
+ if (donor) donorTmpl = paraTemplate(firstPara(donor.block));
132
+ }
133
+ const patched = replaceTxBodyParas(hit.block, lines, donorTmpl, opts.rPrOverride ?? null);
134
+ if (!patched) return log.miss(`shape ${id} has no txBody`), xml;
135
+ log.ok(`shape ${id}`);
136
+ return replaceAt(xml, hit, patched);
137
+ }
138
+
139
+ // Style probes for state-swaps (e.g. the approach chevron highlight): the
140
+ // template's own shapes are the source of truth for both states.
141
+ export function shapeFillXml(xml, id) {
142
+ const hit = findShape(xml, id, ["sp"]);
143
+ const spPr = hit?.block.match(/<p:spPr>[\s\S]*?<\/p:spPr>/)?.[0] ?? "";
144
+ return (spPr.match(/<a:solidFill>[\s\S]*?<\/a:solidFill>/) || [null])[0];
145
+ }
146
+
147
+ export function shapeRunRPr(xml, id) {
148
+ const hit = findShape(xml, id, ["sp"]);
149
+ if (!hit) return null;
150
+ return paraTemplate(firstPara(hit.block)).rPr || null;
151
+ }
152
+
153
+ export function setShapeFillXml(xml, id, fillXml, log) {
154
+ const hit = findShape(xml, id, ["sp"]);
155
+ if (!hit || !fillXml) return xml;
156
+ const spPr = hit.block.match(/<p:spPr>[\s\S]*?<\/p:spPr>/);
157
+ if (!spPr) return xml;
158
+ let next = spPr[0];
159
+ next = /<a:solidFill>[\s\S]*?<\/a:solidFill>/.test(next)
160
+ ? next.replace(/<a:solidFill>[\s\S]*?<\/a:solidFill>/, fillXml)
161
+ : next.replace(/(<\/a:prstGeom>|<\/a:custGeom>)/, `$1${fillXml}`);
162
+ log.ok(`shape ${id} restyled`);
163
+ return replaceAt(xml, hit, hit.block.replace(spPr[0], next));
164
+ }
165
+
166
+ // Rewrite only paragraph <index> of a shape (slide 7's baked-header layout).
167
+ export function setParagraphText(xml, id, index, text, log) {
168
+ const hit = findShape(xml, id, ["sp"]);
169
+ if (!hit) return log.miss(`shape ${id} not found`), xml;
170
+ const paras = [...hit.block.matchAll(/<a:p>[\s\S]*?<\/a:p>/g)];
171
+ if (index >= paras.length) return log.miss(`shape ${id} ¶${index} out of range`), xml;
172
+ const tmpl = paraTemplate(paras[index][0]);
173
+ const rebuilt = `<a:p>${tmpl.pPr}<a:r>${tmpl.rPr}<a:t>${esc(text)}</a:t></a:r></a:p>`;
174
+ const block =
175
+ hit.block.slice(0, paras[index].index) +
176
+ rebuilt +
177
+ hit.block.slice(paras[index].index + paras[index][0].length);
178
+ log.ok(`shape ${id} ¶${index}`);
179
+ return replaceAt(xml, hit, block);
180
+ }
181
+
182
+ export function setShapeFill(xml, id, hex, log) {
183
+ const hit = findShape(xml, id, ["sp"]);
184
+ if (!hit) return log.miss(`shape ${id} not found`), xml;
185
+ const fill = `<a:solidFill><a:srgbClr val="${String(hex).replace(/^#/, "")}"/></a:solidFill>`;
186
+ let block = hit.block;
187
+ const spPr = block.match(/<p:spPr>[\s\S]*?<\/p:spPr>/);
188
+ if (!spPr) return log.miss(`shape ${id} has no spPr`), xml;
189
+ let next = spPr[0];
190
+ if (/<a:solidFill>[\s\S]*?<\/a:solidFill>/.test(next)) {
191
+ next = next.replace(/<a:solidFill>[\s\S]*?<\/a:solidFill>/, fill);
192
+ } else {
193
+ // Insert after geometry so the fill lands in spPr's property order.
194
+ next = next.replace(/(<\/a:prstGeom>|<\/a:custGeom>)/, `$1${fill}`);
195
+ }
196
+ log.ok(`shape ${id} fill #${hex}`);
197
+ return replaceAt(xml, hit, block.replace(spPr[0], next));
198
+ }
199
+
200
+ export function deleteShapes(xml, ids, log) {
201
+ let out = xml;
202
+ for (const id of ids) {
203
+ const hit = findShape(out, id);
204
+ if (!hit) { log.miss(`delete: shape ${id} not found`); continue; }
205
+ out = replaceAt(out, hit, "");
206
+ log.ok(`deleted shape ${id}`);
207
+ }
208
+ return out;
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Geometry (EMU) — repeats reposition member shapes
213
+ // ---------------------------------------------------------------------------
214
+
215
+ export function getGeom(xml, id) {
216
+ const hit = findShape(xml, id);
217
+ if (!hit) return null;
218
+ const m = hit.block.match(/<a:off x="(-?\d+)" y="(-?\d+)"\/><a:ext cx="(\d+)" cy="(\d+)"\/>/);
219
+ if (!m) return null;
220
+ return { x: Number(m[1]), y: Number(m[2]), cx: Number(m[3]), cy: Number(m[4]) };
221
+ }
222
+
223
+ export function setGeom(xml, id, geom, log) {
224
+ const hit = findShape(xml, id);
225
+ if (!hit) return log.miss(`geom: shape ${id} not found`), xml;
226
+ const block = hit.block.replace(
227
+ /<a:off x="-?\d+" y="-?\d+"\/><a:ext cx="\d+" cy="\d+"\/>/,
228
+ `<a:off x="${Math.round(geom.x)}" y="${Math.round(geom.y)}"/><a:ext cx="${Math.round(geom.cx)}" cy="${Math.round(geom.cy)}"/>`,
229
+ );
230
+ return replaceAt(xml, hit, block);
231
+ }
232
+
233
+ // Clone a top-level shape with a fresh id, appended just after the original so
234
+ // z-order stays local. Returns { xml, newId }.
235
+ export function cloneShape(xml, id, log) {
236
+ const hit = findShape(xml, id);
237
+ if (!hit) return log.miss(`clone: shape ${id} not found`), { xml, newId: null };
238
+ const newId = maxShapeId(xml) + 1;
239
+ const clone = hit.block.replace(
240
+ /(<p:cNvPr id=")\d+(")/,
241
+ `$1${newId}$2`,
242
+ );
243
+ log.ok(`cloned shape ${id} → ${newId}`);
244
+ return {
245
+ xml: xml.slice(0, hit.index + hit.block.length) + clone + xml.slice(hit.index + hit.block.length),
246
+ newId,
247
+ };
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Native tables (a:tbl in a p:graphicFrame)
252
+ // ---------------------------------------------------------------------------
253
+
254
+ export function cellText(tc) {
255
+ return [...tc.matchAll(/<a:t>([^<]*)<\/a:t>/g)].map((m) => decode(m[1])).join("");
256
+ }
257
+
258
+ function setCell(tc, text, donorTmpl = null) {
259
+ const bodyM = tc.match(/<a:txBody>([\s\S]*?)<\/a:txBody>/);
260
+ if (!bodyM) return tc;
261
+ let tmpl = paraTemplate(firstPara(bodyM[1]));
262
+ if (!tmpl.rPr && donorTmpl?.rPr) tmpl = { pPr: tmpl.pPr || donorTmpl.pPr, rPr: donorTmpl.rPr };
263
+ const parasStart = bodyM[1].search(/<a:p[\s>/]/);
264
+ if (parasStart < 0) return tc;
265
+ return tc.replace(
266
+ bodyM[0],
267
+ `<a:txBody>${bodyM[1].slice(0, parasStart)}${buildParas([String(text)], tmpl)}</a:txBody>`,
268
+ );
269
+ }
270
+
271
+ const isContinuation = (tc) => /\bhMerge="1"|\bvMerge="1"/.test(tc.slice(0, tc.indexOf(">")));
272
+
273
+ function parseTable(block) {
274
+ const grid = [...block.matchAll(/<a:gridCol w="(\d+)"[^>]*\/>|<a:gridCol w="(\d+)"[^>]*>[\s\S]*?<\/a:gridCol>/g)];
275
+ const rows = [...block.matchAll(/<a:tr [\s\S]*?<\/a:tr>/g)].map((m) => ({
276
+ xml: m[0],
277
+ cells: [...m[0].matchAll(/<a:tc[^>]*\/>|<a:tc[^>]*>[\s\S]*?<\/a:tc>/g)].map((c) => c[0]),
278
+ }));
279
+ return { grid, rows };
280
+ }
281
+
282
+ // Rebuild ONE row's cells by POSITION. Never replace by content needle:
283
+ // after reshaping, sibling cells can be byte-identical, and String.replace
284
+ // then patches the wrong cell (the silently-wrong-price defect class).
285
+ function rebuildRow(rowXml, transform) {
286
+ const rowOpen = rowXml.slice(0, rowXml.indexOf(">") + 1);
287
+ const cells = [...rowXml.matchAll(/<a:tc[^>]*\/>|<a:tc[^>]*>[\s\S]*?<\/a:tc>/g)].map((m) => m[0]);
288
+ return rowOpen + cells.map((tc, i) => transform(tc, i)).join("") + "</a:tr>";
289
+ }
290
+
291
+ // Fill a native table from a full positional array-of-rows, with structural
292
+ // edits: the FIRST author row maps to the first table row, the LAST to the
293
+ // last, middle rows map in order (surplus template middle rows are deleted,
294
+ // extra author middle rows clone the last template middle row). Columns get
295
+ // the same first/last anchoring: middle columns are cloned/removed to match
296
+ // the author's column count, redistributing the freed/needed width across the
297
+ // middle so total table width is stable. Continuation cells of merges are
298
+ // always emptied. The table is reassembled from arrays end-to-end — no
299
+ // content-based replacement anywhere.
300
+ export function fillTable(xml, frameId, rowsIn, log) {
301
+ const hit = findShape(xml, frameId, ["graphicFrame"]);
302
+ if (!hit) return log.miss(`table ${frameId} not found`), xml;
303
+ let block = hit.block;
304
+ let { grid, rows } = parseTable(block);
305
+ if (rows.length < 2 || rowsIn.length < 2) {
306
+ return log.miss(`table ${frameId}: need header+body rows`), xml;
307
+ }
308
+
309
+ let rowXmls = rows.map((r) => r.xml);
310
+ const colsWanted = Math.max(...rowsIn.map((r) => r.length));
311
+ const colsHave = grid.length;
312
+
313
+ // --- column structure -------------------------------------------------------
314
+ if (colsWanted !== colsHave) {
315
+ if (colsWanted < 3 || colsHave < 3) {
316
+ return log.miss(`table ${frameId}: column reshape needs ≥3 columns`), xml;
317
+ }
318
+ const widths = grid.map((g) => Number(g[1] ?? g[2]));
319
+ const total = widths.reduce((a, b) => a + b, 0);
320
+ const midCount = colsWanted - 2;
321
+ const midW = Math.floor((total - widths[0] - widths[widths.length - 1]) / midCount);
322
+ const newWidths = [widths[0], ...Array(midCount).fill(midW), widths[widths.length - 1]];
323
+ newWidths[newWidths.length - 2] += total - newWidths.reduce((a, b) => a + b, 0);
324
+ block = block.replace(
325
+ /<a:tblGrid>[\s\S]*?<\/a:tblGrid>/,
326
+ `<a:tblGrid>${newWidths.map((w) => `<a:gridCol w="${w}"/>`).join("")}</a:tblGrid>`,
327
+ );
328
+
329
+ rowXmls = rowXmls.map((rowXml) => {
330
+ const cells = [...rowXml.matchAll(/<a:tc[^>]*\/>|<a:tc[^>]*>[\s\S]*?<\/a:tc>/g)].map((m) => m[0]);
331
+ const rowOpen = rowXml.slice(0, rowXml.indexOf(">") + 1);
332
+ // A merged row still carries colsHave physical tcs (continuations are
333
+ // real cells) — detect merges by attributes, never by cell count.
334
+ const merged = cells.some((c) => /gridSpan="/.test(c) || isContinuation(c));
335
+ if (!merged) {
336
+ const mids = Array(midCount).fill(cells[1]);
337
+ return rowOpen + [cells[0], ...mids, cells[cells.length - 1]].join("") + "</a:tr>";
338
+ }
339
+ // Merged row: re-anchor the span to the new middle count and rebuild the
340
+ // matching number of continuation cells. gridSpan="1" is never written —
341
+ // PowerPoint's writer omits the attribute at span 1.
342
+ const spanAttr = midCount > 1 ? ` gridSpan="${midCount}"` : "";
343
+ const anchorIdx = cells.findIndex((c) => /gridSpan="/.test(c));
344
+ if (anchorIdx < 0) return rowXml;
345
+ const contTemplate = cells.find(isContinuation) ?? "";
346
+ const keep = cells
347
+ .filter((c) => !isContinuation(c))
348
+ .map((c) => c.replace(/ gridSpan="\d+"/, spanAttr));
349
+ keep.splice(anchorIdx + 1, 0, ...Array(Math.max(0, midCount - 1)).fill(contTemplate));
350
+ return rowOpen + keep.join("") + "</a:tr>";
351
+ });
352
+ log.ok(`table ${frameId}: columns ${colsHave} → ${colsWanted}`);
353
+ }
354
+
355
+ // --- row structure -----------------------------------------------------------
356
+ const wantMid = rowsIn.length - 2;
357
+ const haveMid = rowXmls.length - 2;
358
+ if (wantMid < haveMid) {
359
+ rowXmls.splice(1 + wantMid, haveMid - wantMid);
360
+ log.ok(`table ${frameId}: trimmed ${haveMid - wantMid} template row(s)`);
361
+ } else if (wantMid > haveMid) {
362
+ const template = rowXmls[rowXmls.length - 2];
363
+ rowXmls.splice(rowXmls.length - 1, 0, ...Array(wantMid - haveMid).fill(template));
364
+ log.ok(`table ${frameId}: cloned ${wantMid - haveMid} row(s)`);
365
+ }
366
+
367
+ // --- content -------------------------------------------------------------------
368
+ // Donor formatting for cells whose template carries neither rPr nor
369
+ // endParaRPr: the same column's header-adjacent body cell.
370
+ const donorCells = [...(rowXmls[1] ?? "").matchAll(/<a:tc[^>]*\/>|<a:tc[^>]*>[\s\S]*?<\/a:tc>/g)].map((m) => m[0]);
371
+ rowXmls = rowXmls.map((rowXml, r) => {
372
+ const values = rowsIn[r] ?? [];
373
+ return rebuildRow(rowXml, (tc, i) => {
374
+ if (isContinuation(tc)) return setCell(tc, "");
375
+ if (values[i] === undefined) return tc;
376
+ const donorTmpl = donorCells[i] ? paraTemplate(firstPara(donorCells[i])) : null;
377
+ return setCell(tc, values[i], donorTmpl);
378
+ });
379
+ });
380
+
381
+ const bodyStart = block.indexOf("<a:tr ");
382
+ const bodyEnd = block.lastIndexOf("</a:tr>") + "</a:tr>".length;
383
+ block = block.slice(0, bodyStart) + rowXmls.join("") + block.slice(bodyEnd);
384
+
385
+ log.ok(`table ${frameId}: ${rowsIn.length} rows written`);
386
+ return replaceAt(xml, hit, block);
387
+ }
388
+
389
+ // Guard used by the integrity pass: no text may survive inside continuation
390
+ // cells anywhere in the package.
391
+ export function mergedCellViolations(xml) {
392
+ let n = 0;
393
+ for (const m of xml.matchAll(/<a:tc[^>]*(?:hMerge="1"|vMerge="1")[^>]*>([\s\S]*?)<\/a:tc>/g)) {
394
+ if (cellText(m[1]).trim()) n++;
395
+ }
396
+ return n;
397
+ }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // Pictures
401
+ // ---------------------------------------------------------------------------
402
+
403
+ // Minimal raster header probe (png/jpeg) for cover-crop math.
404
+ export function imageSize(buf) {
405
+ if (buf[0] === 0x89 && buf[1] === 0x50) {
406
+ return { w: readU32(buf, 16), h: readU32(buf, 20) };
407
+ }
408
+ if (buf[0] === 0xff && buf[1] === 0xd8) {
409
+ let i = 2;
410
+ while (i < buf.length - 9) {
411
+ if (buf[i] !== 0xff) { i++; continue; }
412
+ const marker = buf[i + 1];
413
+ const len = (buf[i + 2] << 8) | buf[i + 3];
414
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
415
+ return { h: (buf[i + 5] << 8) | buf[i + 6], w: (buf[i + 7] << 8) | buf[i + 8] };
416
+ }
417
+ i += 2 + len;
418
+ }
419
+ }
420
+ return null;
421
+ }
422
+ const readU32 = (b, o) => (b[o] << 24 | b[o + 1] << 16 | b[o + 2] << 8 | b[o + 3]) >>> 0;
423
+
424
+ // Center-crop srcRect (per-mille) so the image COVERS the target box.
425
+ function coverSrcRect(imgW, imgH, boxW, boxH) {
426
+ const scale = Math.max(boxW / imgW, boxH / imgH);
427
+ const cropW = boxW / scale / imgW;
428
+ const cropH = boxH / scale / imgH;
429
+ const l = Math.round(((1 - cropW) / 2) * 100000);
430
+ const t = Math.round(((1 - cropH) / 2) * 100000);
431
+ return `<a:srcRect l="${l}" t="${t}" r="${l}" b="${t}"/>`;
432
+ }
433
+
434
+ // Build a <p:pic> for a target box. fit "cover" center-crops via srcRect;
435
+ // fit "contain" shrinks the drawn extents to the image aspect, centered.
436
+ function picXml(newId, relId, imgDims, box, fit) {
437
+ let { x, y, cx, cy } = box;
438
+ let src = "";
439
+ if (imgDims) {
440
+ if (fit === "contain") {
441
+ const scale = Math.min(cx / imgDims.w, cy / imgDims.h);
442
+ const w = Math.round(imgDims.w * scale);
443
+ const h = Math.round(imgDims.h * scale);
444
+ x = Math.round(x + (cx - w) / 2);
445
+ y = Math.round(y + (cy - h) / 2);
446
+ cx = w;
447
+ cy = h;
448
+ } else {
449
+ src = coverSrcRect(imgDims.w, imgDims.h, cx, cy);
450
+ }
451
+ }
452
+ return (
453
+ `<p:pic><p:nvPicPr><p:cNvPr id="${newId}" name="Picture ${newId}"/>` +
454
+ `<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr>` +
455
+ `<p:blipFill><a:blip r:embed="${relId}"/>${src}<a:stretch><a:fillRect/></a:stretch></p:blipFill>` +
456
+ `<p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm>` +
457
+ `<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`
458
+ );
459
+ }
460
+
461
+ // Insert a contain-fit picture at a free box (the anchor shape is gone).
462
+ export function insertPictureAtGeom(xml, geom, relId, imgDims, log) {
463
+ const newId = maxShapeId(xml) + 1;
464
+ const pic = picXml(newId, relId, imgDims, geom, "contain");
465
+ const end = xml.lastIndexOf("</p:spTree>");
466
+ if (end < 0) return log.miss("no spTree for picture"), xml;
467
+ log.ok("picture placed (contain-fit)");
468
+ return xml.slice(0, end) + pic + xml.slice(end);
469
+ }
470
+
471
+ // Insert a new <p:pic> covering an anchor shape's box, z-ordered directly
472
+ // below ("behind") or above it. relId/media part are the caller's business.
473
+ export function insertPicture(xml, anchorId, relId, imgDims, opts, log) {
474
+ const hit = findShape(xml, anchorId);
475
+ if (!hit) return log.miss(`image anchor ${anchorId} not found`), xml;
476
+ const geom = getGeom(xml, anchorId);
477
+ if (!geom) return log.miss(`image anchor ${anchorId} has no geometry`), xml;
478
+ const newId = maxShapeId(xml) + 1;
479
+ const pic = picXml(newId, relId, imgDims, geom, opts.fit ?? "cover");
480
+ const out =
481
+ opts.z === "above"
482
+ ? xml.slice(0, hit.index + hit.block.length) + pic + xml.slice(hit.index + hit.block.length)
483
+ : xml.slice(0, hit.index) + pic + xml.slice(hit.index);
484
+ log.ok(`picture inserted ${opts.z === "above" ? "over" : "behind"} shape ${anchorId}`);
485
+ return out;
486
+ }
@@ -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, "\\$&");