slidemind 0.1.9 → 0.2.1
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/marketplace.json +25 -0
- package/.claude-plugin/plugin.json +6 -3
- package/.codex-plugin/plugin.json +29 -0
- package/CLAUDE.md +25 -5
- package/README.md +24 -0
- package/bin/slidemind +7 -0
- package/bin/slidemind.cmd +2 -0
- package/bin/slidemind.mjs +480 -60
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +6 -5
- package/skills/search/SKILL.md +10 -4
- package/src/bridge/bundle.mjs +23 -4
- package/src/bridge/client.mjs +414 -7
- package/src/bridge/engine-download.mjs +229 -5
- package/src/bridge/image-dimensions.mjs +181 -0
- package/src/builders/resolve-skill.mjs +45 -1
- package/src/builders/review.mjs +4 -1
- package/src/install-channel.mjs +61 -0
- package/src/installers/claude.mjs +22 -0
- package/src/pptx/fill/engine.mjs +1273 -0
- package/src/pptx/fill/freeform.mjs +156 -0
- package/src/pptx/fill/parse-slides.mjs +154 -0
- package/src/pptx/fill/xml.mjs +486 -0
- package/src/pptx/ooxml.mjs +10 -3
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
13
|
+
|
|
14
|
+
export const decode = (s) =>
|
|
15
|
+
String(s)
|
|
16
|
+
.replace(/</g, "<")
|
|
17
|
+
.replace(/>/g, ">")
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/'/g, "'")
|
|
20
|
+
.replace(/&/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
|
+
}
|
package/src/pptx/ooxml.mjs
CHANGED
|
@@ -20,12 +20,19 @@ export function slideOrderPaths(files) {
|
|
|
20
20
|
if (!presXml) return [];
|
|
21
21
|
|
|
22
22
|
// Isolate the sldIdLst so a stray r:id elsewhere in the part can't leak in.
|
|
23
|
-
|
|
23
|
+
// PREFIX-TOLERANT: the canonical serializers write `p:`/`r:`, but engine
|
|
24
|
+
// slide slices arrive with generic `ns0:`/`ns1:` prefixes (Python
|
|
25
|
+
// ElementTree serialization) — prefixes are arbitrary in XML, only the bound
|
|
26
|
+
// namespaces matter, and a `p:`-only match reported such packages as having
|
|
27
|
+
// zero slides.
|
|
28
|
+
const listMatch = /<(?:[\w.-]+:)?sldIdLst[\s\S]*?<\/(?:[\w.-]+:)?sldIdLst>/.exec(presXml);
|
|
24
29
|
if (!listMatch) return [];
|
|
25
30
|
|
|
26
|
-
// r:id in document order (the on-screen slide sequence).
|
|
31
|
+
// r:id in document order (the on-screen slide sequence). The relationship-id
|
|
32
|
+
// attribute is the one namespaced `…:id` attr on sldId (its OTHER attribute,
|
|
33
|
+
// the slide id, is unprefixed `id=`).
|
|
27
34
|
const rIds = [];
|
|
28
|
-
const idRe = /<
|
|
35
|
+
const idRe = /<(?:[\w.-]+:)?sldId\b[^>]*?\s[\w.-]+:id\s*=\s*"([^"]+)"/g;
|
|
29
36
|
let m;
|
|
30
37
|
while ((m = idRe.exec(listMatch[0])) !== null) rIds.push(m[1]);
|
|
31
38
|
|