slidemind 0.2.4 → 0.3.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.
@@ -0,0 +1,676 @@
1
+ // Deck-embedded source: tag format for slidemind-authored slides (CLI mirror).
2
+ //
3
+ // A rendered slide carries its own authoring source as PowerPoint tags
4
+ // (ppt/tags/*.xml referenced from p:custDataLst) so any user, machine, or
5
+ // chat can recover the slides/<name>.jsx that produced it. Format spec:
6
+ // intern-slidemind docs/deck-embedded-source-spec.md §2. The reference
7
+ // implementation is the add-in's src/ui/src/agents/services/slide-source-tags.ts
8
+ // — the two must stay format-identical (spec §9); change them in lockstep.
9
+
10
+ import { createHash, randomUUID } from "node:crypto";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+
14
+ import { readText } from "./zip.mjs";
15
+ import {
16
+ decodeXmlEntities,
17
+ firstSlidePath,
18
+ relsFor,
19
+ relsPathFor,
20
+ resolvePartPath,
21
+ } from "./ooxml.mjs";
22
+
23
+ export const CHUNK_BYTES = 12288;
24
+ export const EMBED_BUDGET = 196608;
25
+ export const SLIDEMIND_TAG_PREFIX = "SLIDEMIND_";
26
+
27
+ export const TAG_GUID = "SLIDEMIND_GUID";
28
+ export const TAG_SRC_META = "SLIDEMIND_SRC_META";
29
+ export const TAG_SRC_CHUNK = "SLIDEMIND_SRC_";
30
+ export const TAG_BASELINE_CHUNK = "SLIDEMIND_BASELINE_";
31
+ export const TAG_ASSET_CHUNK = "SLIDEMIND_ASSET_";
32
+ export const TAG_PROV = "SLIDEMIND_PROV";
33
+
34
+ const TAGS_REL_TYPE =
35
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags";
36
+ const TAGS_CONTENT_TYPE =
37
+ "application/vnd.openxmlformats-officedocument.presentationml.tags+xml";
38
+ const TAG_XML_NS =
39
+ 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"';
40
+
41
+ export function mintSlideGuid() {
42
+ return `sm-${randomUUID()}`;
43
+ }
44
+
45
+ function sha256Hex(data) {
46
+ return createHash("sha256")
47
+ .update(typeof data === "string" ? Buffer.from(data, "utf8") : data)
48
+ .digest("hex");
49
+ }
50
+
51
+ function b64(bytes) {
52
+ return Buffer.from(bytes).toString("base64");
53
+ }
54
+
55
+ function chunk(s) {
56
+ const out = [];
57
+ for (let i = 0; i < s.length; i += CHUNK_BYTES) {
58
+ out.push(s.slice(i, i + CHUNK_BYTES));
59
+ }
60
+ return out.length ? out : [""];
61
+ }
62
+
63
+ function xmlEsc(s) {
64
+ return String(s)
65
+ .replace(/&/g, "&amp;")
66
+ .replace(/</g, "&lt;")
67
+ .replace(/>/g, "&gt;")
68
+ .replace(/"/g, "&quot;");
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Encode / decode (spec §2.1/§2.2/§2.4)
73
+ // ---------------------------------------------------------------------------
74
+
75
+ // input: { name, jsx, guid, bundle:{presFP,etag}, renderer, assets:[{name,bytes}],
76
+ // baseline?, pushedAt, metaExtra? }
77
+ // `metaExtra` merges extra properties into SRC_META (e.g. kind:"template-fill");
78
+ // decoders ignore unknown properties.
79
+ export function encodeSourceTags(input) {
80
+ const tags = { [TAG_GUID]: input.guid };
81
+
82
+ const jsxBytes = Buffer.from(input.jsx, "utf8");
83
+ const srcChunks = chunk(b64(jsxBytes));
84
+ srcChunks.forEach((c, i) => {
85
+ tags[`${TAG_SRC_CHUNK}${i}`] = c;
86
+ });
87
+
88
+ let baselineFields = {};
89
+ if (input.baseline) {
90
+ const json = JSON.stringify(input.baseline);
91
+ const chunks = chunk(b64(Buffer.from(json, "utf8")));
92
+ chunks.forEach((c, i) => {
93
+ tags[`${TAG_BASELINE_CHUNK}${i}`] = c;
94
+ });
95
+ baselineFields = {
96
+ baselineSha256: sha256Hex(json),
97
+ baselineChunks: chunks.length,
98
+ };
99
+ }
100
+
101
+ // Embed smallest-first within the budget; the rest stay remote.
102
+ const assets = input.assets ?? [];
103
+ const bySize = [...assets].sort((a, b) => a.bytes.length - b.bytes.length);
104
+ let budget = EMBED_BUDGET;
105
+ const embedded = new Set();
106
+ for (const asset of bySize) {
107
+ if (asset.bytes.length <= budget) {
108
+ budget -= asset.bytes.length;
109
+ embedded.add(asset.name);
110
+ }
111
+ }
112
+ let embedIdx = 0;
113
+ const assetMetas = [];
114
+ for (const asset of assets) {
115
+ const sha = sha256Hex(asset.bytes);
116
+ if (embedded.has(asset.name)) {
117
+ const chunks = chunk(b64(asset.bytes));
118
+ const idx = embedIdx++;
119
+ chunks.forEach((c, i) => {
120
+ tags[`${TAG_ASSET_CHUNK}${idx}_${i}`] = c;
121
+ });
122
+ assetMetas.push({
123
+ name: asset.name,
124
+ sha256: sha,
125
+ size: asset.bytes.length,
126
+ mode: "embedded",
127
+ idx,
128
+ chunks: chunks.length,
129
+ });
130
+ } else {
131
+ assetMetas.push({
132
+ name: asset.name,
133
+ sha256: sha,
134
+ size: asset.bytes.length,
135
+ mode: "remote",
136
+ });
137
+ }
138
+ }
139
+
140
+ tags[TAG_SRC_META] = JSON.stringify({
141
+ v: 1,
142
+ name: input.name,
143
+ srcSha256: sha256Hex(jsxBytes),
144
+ srcChunks: srcChunks.length,
145
+ ...baselineFields,
146
+ bundle: input.bundle,
147
+ renderer: input.renderer,
148
+ assets: assetMetas,
149
+ pushedAt: input.pushedAt,
150
+ ...(input.metaExtra ?? {}),
151
+ });
152
+ return tags;
153
+ }
154
+
155
+ // Deck-relative asset references in a slide source: "assets/logo.png",
156
+ // "assets/images/photo.png". Mirrors ASSET_REF_RE in the add-in's
157
+ // build-pptx/translate-deck.ts — an extension is required so prose mentions
158
+ // don't match. Returns `[{ name: "assets/<rel>", bytes }]` for the ones that
159
+ // exist on disk, which is exactly what encodeSourceTags embeds (within the
160
+ // budget) and lists in SRC_META. Without this a locally-baked slide records
161
+ // no assets at all, so `adopt` rebuilds jsx that points at missing images.
162
+ const ASSET_REF_RE = /assets\/([A-Za-z0-9_\-.@/]+\.[A-Za-z0-9]+)/g;
163
+
164
+ export function referencedAssets(jsx, assetsDir) {
165
+ const out = [];
166
+ const seen = new Set();
167
+ for (const m of String(jsx).matchAll(ASSET_REF_RE)) {
168
+ const name = m[1];
169
+ if (seen.has(name) || name.includes("..")) continue;
170
+ seen.add(name);
171
+ const file = path.join(assetsDir, name);
172
+ try {
173
+ if (!fs.statSync(file).isFile()) continue;
174
+ out.push({ name: `assets/${name}`, bytes: fs.readFileSync(file) });
175
+ } catch {
176
+ /* referenced but not on disk — nothing to embed */
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+
182
+ function joinChunks(tags, prefix, count) {
183
+ let joined = "";
184
+ for (let i = 0; i < count; i++) {
185
+ const part = tags[`${prefix}${i}`];
186
+ if (part === undefined) return null;
187
+ joined += part;
188
+ }
189
+ return joined;
190
+ }
191
+
192
+ // Returns { guid, meta, jsx, baseline, assets } or null. Hash-verified —
193
+ // a corrupt payload is never half-trusted (the slide then counts as foreign).
194
+ export function decodeSourceTags(tags) {
195
+ const guid = tags?.[TAG_GUID];
196
+ const metaRaw = tags?.[TAG_SRC_META];
197
+ if (!guid || !metaRaw) return null;
198
+
199
+ let meta;
200
+ try {
201
+ meta = JSON.parse(metaRaw);
202
+ } catch {
203
+ return null;
204
+ }
205
+ if (meta?.v !== 1 || !meta.name || !meta.srcSha256 || !meta.srcChunks) {
206
+ return null;
207
+ }
208
+
209
+ const srcB64 = joinChunks(tags, TAG_SRC_CHUNK, meta.srcChunks);
210
+ if (srcB64 === null) return null;
211
+ const jsxBytes = Buffer.from(srcB64, "base64");
212
+ if (sha256Hex(jsxBytes) !== meta.srcSha256) return null;
213
+
214
+ let baseline = null;
215
+ if (meta.baselineSha256 && meta.baselineChunks) {
216
+ const raw = joinChunks(tags, TAG_BASELINE_CHUNK, meta.baselineChunks);
217
+ if (raw !== null) {
218
+ const json = Buffer.from(raw, "base64").toString("utf8");
219
+ if (sha256Hex(json) === meta.baselineSha256) {
220
+ try {
221
+ baseline = JSON.parse(json);
222
+ } catch {
223
+ baseline = null;
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ const assets = [];
230
+ for (const asset of meta.assets ?? []) {
231
+ if (asset.mode !== "embedded" || asset.idx === undefined || !asset.chunks) {
232
+ continue;
233
+ }
234
+ const raw = joinChunks(tags, `${TAG_ASSET_CHUNK}${asset.idx}_`, asset.chunks);
235
+ if (raw === null) continue;
236
+ const bytes = Buffer.from(raw, "base64");
237
+ if (sha256Hex(bytes) !== asset.sha256) continue;
238
+ assets.push({ name: asset.name, sha256: asset.sha256, bytes });
239
+ }
240
+
241
+ return { guid, meta, jsx: jsxBytes.toString("utf8"), baseline, assets };
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Package-side reads
246
+ // ---------------------------------------------------------------------------
247
+
248
+ // Read every tag reachable from a slide part's custDataLst refs (slide-level
249
+ // AND shape-level — keys never collide: shapes carry only SLIDEMIND_PROV).
250
+ export function readSlideTags(files, slidePath) {
251
+ const xml = readText(files, slidePath);
252
+ if (!xml) return {};
253
+ const tagRels = {};
254
+ const dir = slidePath.slice(0, slidePath.lastIndexOf("/"));
255
+ for (const rel of relsFor(files, slidePath)) {
256
+ if (rel.id && rel.type === TAGS_REL_TYPE) {
257
+ tagRels[rel.id] = resolvePartPath(dir, rel.target);
258
+ }
259
+ }
260
+ const out = {};
261
+ for (const cust of xml.matchAll(/<p:custDataLst\b[\s\S]*?<\/p:custDataLst>/g)) {
262
+ for (const ref of cust[0].matchAll(/<p:tags\b[^>]*\br:id="([^"]+)"/g)) {
263
+ const partPath = tagRels[ref[1]];
264
+ const tagXml = partPath ? readText(files, partPath) : null;
265
+ if (!tagXml) continue;
266
+ for (const t of tagXml.matchAll(/<p:tag\b([^>]*)\/>/g)) {
267
+ const name = t[1].match(/\bname="([^"]*)"/)?.[1];
268
+ const val = t[1].match(/\bval="([^"]*)"/)?.[1];
269
+ if (name) out[decodeXmlEntities(name)] = decodeXmlEntities(val ?? "");
270
+ }
271
+ }
272
+ }
273
+ return out;
274
+ }
275
+
276
+ // True when a slide part carries any tag key OUTSIDE the SLIDEMIND_ namespace
277
+ // (another add-in marked it — think-cell tags every shape it owns).
278
+ export function slideHasForeignTags(files, slidePath) {
279
+ for (const key of Object.keys(readSlideTags(files, slidePath))) {
280
+ if (!key.startsWith(SLIDEMIND_TAG_PREFIX)) return true;
281
+ }
282
+ return false;
283
+ }
284
+
285
+
286
+ // True when the slide references an embedded WORKBOOK (.xls*) — the
287
+ // think-cell "slide workbook" pattern. Deliberately NOT any embeddings/*.bin:
288
+ // generic hidden OLE junk (developer-machine leftovers) also lives under
289
+ // ppt/embeddings/ and is exactly what the hidden-OLE strip must keep removing.
290
+ export function slideReferencesEmbeddedWorkbook(files, slidePath) {
291
+ const dir = slidePath.slice(0, slidePath.lastIndexOf("/"));
292
+ for (const rel of relsFor(files, slidePath)) {
293
+ if (rel.targetMode === "External" || !rel.target) continue;
294
+ const target = resolvePartPath(dir, rel.target);
295
+ if (target.startsWith("ppt/embeddings/") && /\.xls[xmb]?$/i.test(target)) {
296
+ return true;
297
+ }
298
+ }
299
+ return false;
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Bake (spec §5.4) — ADDITIVE ONLY (coexistence rule C1)
304
+ // ---------------------------------------------------------------------------
305
+
306
+ function tagPartXml(tags) {
307
+ return (
308
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n<p:tagLst ${TAG_XML_NS}>` +
309
+ Object.entries(tags)
310
+ .map(([name, val]) => `<p:tag name="${xmlEsc(name)}" val="${xmlEsc(val)}"/>`)
311
+ .join("") +
312
+ `</p:tagLst>`
313
+ );
314
+ }
315
+
316
+ function nextTagIndex(files) {
317
+ let max = 0;
318
+ for (const name of Object.keys(files)) {
319
+ const m = name.match(/^ppt\/tags\/tag(\d+)\.xml$/);
320
+ if (m) max = Math.max(max, Number(m[1]));
321
+ }
322
+ return max + 1;
323
+ }
324
+
325
+ function nextRid(relsXml) {
326
+ let max = 0;
327
+ for (const m of relsXml.matchAll(/\bId="rId(\d+)"/g)) {
328
+ max = Math.max(max, Number(m[1]));
329
+ }
330
+ return max + 1;
331
+ }
332
+
333
+ const enc = (s) => new TextEncoder().encode(s);
334
+
335
+ // Bake slide-level SLIDEMIND tags into the entry map IN PLACE.
336
+ // entries = [{ partName, tags }] with `tags` from encodeSourceTags. Existing
337
+ // tag parts, rels, and custDataLst content are never modified or renumbered.
338
+ export function bakeSlideSourceTags(files, entries) {
339
+ if (!entries?.length) return false;
340
+ let contentTypes = readText(files, "[Content_Types].xml") ?? "";
341
+ let tagIndex = nextTagIndex(files);
342
+ let changed = false;
343
+
344
+ for (const entry of entries) {
345
+ const slideXml = readText(files, entry.partName);
346
+ if (!slideXml || !slideXml.includes("</p:cSld>")) continue;
347
+
348
+ const partName = `ppt/tags/tag${tagIndex}.xml`;
349
+ files[partName] = enc(tagPartXml(entry.tags));
350
+ if (!contentTypes.includes(`PartName="/${partName}"`)) {
351
+ contentTypes = contentTypes.replace(
352
+ "</Types>",
353
+ `<Override PartName="/${partName}" ContentType="${TAGS_CONTENT_TYPE}"/></Types>`,
354
+ );
355
+ }
356
+
357
+ const relsPath = relsPathFor(entry.partName);
358
+ const relsXml =
359
+ readText(files, relsPath) ??
360
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`;
361
+ const rId = `rId${nextRid(relsXml)}`;
362
+ files[relsPath] = enc(
363
+ relsXml.replace(
364
+ "</Relationships>",
365
+ `<Relationship Id="${rId}" Type="${TAGS_REL_TYPE}" Target="../tags/tag${tagIndex}.xml"/></Relationships>`,
366
+ ),
367
+ );
368
+
369
+ // Slide-level custDataLst sits in p:cSld directly after p:spTree
370
+ // (ECMA-376 CT_CommonSlideData order, maxOccurs 1). Append to an existing
371
+ // list, else insert a fresh one. The existing element is matched
372
+ // attribute- and self-closing-tolerantly: a second <p:custDataLst> in one
373
+ // p:cSld is exactly the malformed placement rule C5 forbids (PowerPoint
374
+ // repairs the file on open).
375
+ const existingCust = slideXml.match(
376
+ /<\/p:spTree>\s*<p:custDataLst\b[^>]*?(\/?)>/,
377
+ );
378
+ let nextXml;
379
+ if (existingCust && existingCust[1] === "/") {
380
+ // Empty <p:custDataLst/> — expand it into a list holding our ref.
381
+ nextXml = slideXml.replace(
382
+ existingCust[0],
383
+ `${existingCust[0].replace(/\/>$/, ">")}<p:tags r:id="${rId}"/></p:custDataLst>`,
384
+ );
385
+ } else if (existingCust) {
386
+ nextXml = slideXml.replace(
387
+ existingCust[0],
388
+ `${existingCust[0]}<p:tags r:id="${rId}"/>`,
389
+ );
390
+ } else {
391
+ nextXml = slideXml.replace(
392
+ "</p:spTree>",
393
+ `</p:spTree><p:custDataLst><p:tags r:id="${rId}"/></p:custDataLst>`,
394
+ );
395
+ }
396
+ files[entry.partName] = enc(nextXml);
397
+ tagIndex += 1;
398
+ changed = true;
399
+ }
400
+
401
+ // Never write back an EMPTY content-type map: a package without one is
402
+ // already broken, and clobbering it would turn a missing part into a
403
+ // truncated one.
404
+ if (changed && contentTypes) {
405
+ files["[Content_Types].xml"] = enc(contentTypes);
406
+ }
407
+ return changed;
408
+ }
409
+
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Structural projection (spec §2.5) — MIRROR of computeProjection in the
413
+ // add-in's src/ui/src/agents/services/slide-source-tags.ts
414
+ // ---------------------------------------------------------------------------
415
+ //
416
+ // Deterministic function over a single-slide OOXML package: the drift baseline
417
+ // P(slide). The two implementations are cross-checked by the shared fixtures in
418
+ // test/fixtures/slide-source-tags/ (spec §9) — same original.pptx in both
419
+ // repos, same committed expected-projection.json, one CI job on each side.
420
+ // Every regex, every default and every rounding rule below is copied from the
421
+ // TypeScript, deliberately verbatim; change them in lockstep or the parity test
422
+ // goes red.
423
+ //
424
+ // The CLI reads an already-unzipped `files` map, so this is synchronous where
425
+ // the TS is async over JSZip. That is the only intended difference.
426
+
427
+ // The five shape elements, plus the two non-shape children that also count as a
428
+ // top-level unit: mc:AlternateContent and p:contentPart. PowerPoint serializes
429
+ // an embedded OLE object as a single p:spTree child holding a FULL copy of the
430
+ // frame in each markup-compatibility branch (mc:Choice graphicFrame +
431
+ // mc:Fallback picture). Counting the branch shapes instead of the wrapper made
432
+ // one object read as two top-level shapes, and the add-in's foreign lift then
433
+ // appended BOTH branches — the object shipped twice, unwrapped. One block is one
434
+ // unit, at the wrapper's own position, in every raw-XML scanner on both sides of
435
+ // the mirror.
436
+ //
437
+ // LOCKSTEP: the add-in exports this same pattern as the factory
438
+ // `topLevelShapeRe()` in src/ui/src/agents/services/slide-source-tags.ts, where
439
+ // three scanners share it. Here it stays a plain const: this file is its ONLY
440
+ // consumer, so there is nothing to centralize — but the pattern itself must
441
+ // still change on both sides at once, or expected-projection.json goes red.
442
+ const TOP_SHAPE_RE =
443
+ /<(\/?)(?:p:(sp|grpSp|pic|graphicFrame|cxnSp|contentPart)|mc:(AlternateContent))(?=[\s/>])[^>]*?(\/?)>/g;
444
+
445
+ // Both non-shape units project as the carrier the wrapped object uses — a
446
+ // graphic frame. Canonical, so the two implementations of this projection cannot
447
+ // disagree about a unit's kind.
448
+ const UNIT_BLOCK_KIND = "graphicFrame";
449
+
450
+ // Split the spTree inner XML into top-level shape subtrees (depth-aware).
451
+ function topLevelShapes(spTreeInner) {
452
+ const shapes = [];
453
+ let depth = 0;
454
+ let start = -1;
455
+ let startKind = null;
456
+ // Tracked separately from `depth`: an MC block is a CONTAINER, not a shape.
457
+ let mcDepth = 0;
458
+ let mcStart = -1;
459
+ TOP_SHAPE_RE.lastIndex = 0;
460
+ for (const m of spTreeInner.matchAll(TOP_SHAPE_RE)) {
461
+ const closing = m[1] === "/";
462
+ const selfClosing = m[4] === "/";
463
+ if (m[3]) {
464
+ // Only a TOP-LEVEL wrapper is a unit: one nested inside a shape is that
465
+ // shape's own business, and a self-closing one carries no shape at all.
466
+ if (depth > 0 || selfClosing) continue;
467
+ if (!closing) {
468
+ if (mcDepth === 0) mcStart = m.index;
469
+ mcDepth++;
470
+ } else if (mcDepth > 0) {
471
+ mcDepth--;
472
+ if (mcDepth === 0 && mcStart >= 0) {
473
+ shapes.push({
474
+ kind: UNIT_BLOCK_KIND,
475
+ xml: spTreeInner.slice(mcStart, m.index + m[0].length),
476
+ });
477
+ mcStart = -1;
478
+ }
479
+ }
480
+ continue;
481
+ }
482
+ // Every shape inside a top-level MC block belongs to a branch of the ONE
483
+ // unit that block already is, so it never opens a unit of its own.
484
+ if (mcDepth > 0) continue;
485
+ // p:contentPart is a bare r:id reference, not a shape carrier: it projects
486
+ // as the same canonical unit kind an MC block does.
487
+ const kind = m[2] === "contentPart" ? UNIT_BLOCK_KIND : m[2];
488
+ if (!closing && !selfClosing) {
489
+ if (depth === 0) {
490
+ start = m.index;
491
+ startKind = kind;
492
+ }
493
+ depth++;
494
+ } else if (closing) {
495
+ depth--;
496
+ if (depth === 0 && start >= 0 && startKind) {
497
+ shapes.push({
498
+ kind: startKind,
499
+ xml: spTreeInner.slice(start, m.index + m[0].length),
500
+ });
501
+ start = -1;
502
+ startKind = null;
503
+ }
504
+ } else if (selfClosing && depth === 0) {
505
+ shapes.push({ kind, xml: m[0] });
506
+ }
507
+ }
508
+ return shapes;
509
+ }
510
+
511
+ const roundEmu = (n) => Math.round(n / 100) * 100;
512
+
513
+ function parseXfrm(xml) {
514
+ const xfrm = xml.match(
515
+ /<(?:a|p):xfrm\b([^>]*)>[\s\S]*?<a:off\b([^>]*)\/>\s*<a:ext\b([^>]*)\/>/,
516
+ );
517
+ if (!xfrm) return { x: 0, y: 0, w: 0, h: 0, rot: 0 };
518
+ const rot60k = Number(xfrm[1].match(/\brot="(-?\d+)"/)?.[1] ?? 0);
519
+ const x = Number(xfrm[2].match(/\bx="(-?\d+)"/)?.[1] ?? 0);
520
+ const y = Number(xfrm[2].match(/\by="(-?\d+)"/)?.[1] ?? 0);
521
+ const w = Number(xfrm[3].match(/\bcx="(\d+)"/)?.[1] ?? 0);
522
+ const h = Number(xfrm[3].match(/\bcy="(\d+)"/)?.[1] ?? 0);
523
+ return {
524
+ x: roundEmu(x),
525
+ y: roundEmu(y),
526
+ w: roundEmu(w),
527
+ h: roundEmu(h),
528
+ rot: Math.round(rot60k / 60000),
529
+ };
530
+ }
531
+
532
+ // The five predefined entities plus DECIMAL character references, in the TS's
533
+ // order (ampersand last, so a decoded `&` is never re-read as an entity head).
534
+ // NOT decodeXmlEntities: that one also decodes hex refs, which the TS reader
535
+ // does not — a projection must not decode more than its twin does.
536
+ function xmlUnescape(s) {
537
+ return s
538
+ .replace(/&lt;/g, "<")
539
+ .replace(/&gt;/g, ">")
540
+ .replace(/&quot;/g, '"')
541
+ .replace(/&apos;/g, "'")
542
+ .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
543
+ .replace(/&amp;/g, "&");
544
+ }
545
+
546
+ // Two text carriers, one walk. `p:txBody` covers sp/pic/cxnSp/grpSp children;
547
+ // a table graphicFrame has NO `p:txBody` at all — its text lives in
548
+ // `a:tbl/a:tr/a:tc/a:txBody`, which is why table cell edits used to be invisible
549
+ // to drift detection. Matching the whole `a:tbl` subtree leaves the run walk
550
+ // untouched: XML document order inside `a:tbl` (rows in order, cells in order
551
+ // within a row) IS row-major reading order, and the frame keeps its position in
552
+ // the shape sequence because this runs per top-level shape. Merge-continuation
553
+ // cells (`hMerge`/`vMerge`) carry no runs, so they add nothing — cell index is
554
+ // NOT recoverable from the run index, exactly as for a multi-paragraph frame.
555
+ function parseTextRuns(xml) {
556
+ const runs = [];
557
+ const carriers =
558
+ /<p:txBody>[\s\S]*?<\/p:txBody>|<a:tbl\b[^>]*>[\s\S]*?<\/a:tbl>/g;
559
+ for (const body of xml.matchAll(carriers)) {
560
+ for (const r of body[0].matchAll(/<a:r>([\s\S]*?)<\/a:r>/g)) {
561
+ const run = r[1];
562
+ const t = run.match(/<a:t>([\s\S]*?)<\/a:t>/)?.[1] ?? "";
563
+ const rPr = run.match(/<a:rPr\b([^>]*)(?:\/>|>([\s\S]*?)<\/a:rPr>)/);
564
+ const attrs = rPr?.[1] ?? "";
565
+ const inner = rPr?.[2] ?? "";
566
+ runs.push({
567
+ t: xmlUnescape(t),
568
+ b: /\bb="1"/.test(attrs),
569
+ i: /\bi="1"/.test(attrs),
570
+ sz: attrs.match(/\bsz="(\d+)"/)
571
+ ? Number(attrs.match(/\bsz="(\d+)"/)?.[1])
572
+ : null,
573
+ color:
574
+ inner.match(/<a:srgbClr val="([0-9A-Fa-f]{6})"/)?.[1]?.toUpperCase() ??
575
+ null,
576
+ });
577
+ }
578
+ }
579
+ return runs.length ? runs : null;
580
+ }
581
+
582
+ function firstSolidFill(section) {
583
+ if (!section) return null;
584
+ return (
585
+ section
586
+ .match(/<a:solidFill>\s*<a:srgbClr val="([0-9A-Fa-f]{6})"/)?.[1]
587
+ ?.toUpperCase() ?? null
588
+ );
589
+ }
590
+
591
+ function parseFillAndLine(xml) {
592
+ const spPr = xml.match(/<p:spPr\b[^>]*>([\s\S]*?)<\/p:spPr>/)?.[1] ?? null;
593
+ if (!spPr) return { fill: null, line: null };
594
+ const lnMatch = spPr.match(/<a:ln\b[\s\S]*?<\/a:ln>/);
595
+ const ln = lnMatch ? lnMatch[0] : null;
596
+ const spPrNoLn = ln ? spPr.replace(ln, "") : spPr;
597
+ return { fill: firstSolidFill(spPrNoLn), line: firstSolidFill(ln) };
598
+ }
599
+
600
+ // rId -> { type, target } for one part, mirroring the TS parseRels map.
601
+ function relsById(files, partPath) {
602
+ const map = new Map();
603
+ for (const rel of relsFor(files, partPath)) {
604
+ if (rel.id) map.set(rel.id, { type: rel.type ?? "", target: rel.target ?? "" });
605
+ }
606
+ return map;
607
+ }
608
+
609
+ function parseMediaSha(files, slidePath, shapeXml) {
610
+ const rId = shapeXml.match(/<a:blip\b[^>]*\br:embed="([^"]+)"/)?.[1];
611
+ if (!rId) return null;
612
+ const rel = relsById(files, slidePath).get(rId);
613
+ if (!rel) return null;
614
+ const dir = slidePath.slice(0, slidePath.lastIndexOf("/"));
615
+ const bytes = files[resolvePartPath(dir, rel.target)];
616
+ if (!bytes) return null;
617
+ return sha256Hex(bytes);
618
+ }
619
+
620
+ function parseProv(files, slidePath, shapeXml) {
621
+ const nv = shapeXml.match(/<p:nvPr>[\s\S]*?<\/p:nvPr>/)?.[0];
622
+ if (!nv) return null;
623
+ const cust = nv.match(/<p:custDataLst\b[\s\S]*?<\/p:custDataLst>/)?.[0];
624
+ if (!cust) return null;
625
+ const rels = relsById(files, slidePath);
626
+ const dir = slidePath.slice(0, slidePath.lastIndexOf("/"));
627
+ const tags = {};
628
+ for (const m of cust.matchAll(/<p:tags\b[^>]*\br:id="([^"]+)"/g)) {
629
+ const rel = rels.get(m[1]);
630
+ if (!rel?.type.endsWith("/tags")) continue;
631
+ const tagXml = readText(files, resolvePartPath(dir, rel.target));
632
+ if (!tagXml) continue;
633
+ for (const t of tagXml.matchAll(/<p:tag\b([^>]*)\/>/g)) {
634
+ const name = t[1].match(/\bname="([^"]+)"/)?.[1];
635
+ const val = t[1].match(/\bval="([^"]*)"/)?.[1];
636
+ if (name) tags[xmlUnescape(name)] = xmlUnescape(val ?? "");
637
+ }
638
+ }
639
+ const raw = tags[TAG_PROV];
640
+ if (!raw) return null;
641
+ try {
642
+ const parsed = JSON.parse(raw);
643
+ if (typeof parsed?.f === "string" && typeof parsed?.p === "string") {
644
+ return parsed;
645
+ }
646
+ } catch {
647
+ /* not a provenance ref */
648
+ }
649
+ return null;
650
+ }
651
+
652
+ // Structural projection of a slide in an unzipped package (the FIRST slide when
653
+ // `slidePathOverride` is omitted — the single-slide case). Deterministic;
654
+ // excludes every id/name/theme-resolved churn source. Groups are one entry each
655
+ // (molecule = reconcile unit); their child text runs are collected in document
656
+ // order.
657
+ export function computeProjection(files, slidePathOverride) {
658
+ const slidePath = slidePathOverride ?? firstSlidePath(files);
659
+ if (!slidePath) return { v: 1, shapes: [] };
660
+ const xml = readText(files, slidePath) ?? "";
661
+ const spTree = xml.match(/<p:spTree>([\s\S]*?)<\/p:spTree>/)?.[1] ?? "";
662
+ const shapes = [];
663
+ for (const raw of topLevelShapes(spTree)) {
664
+ const paint = parseFillAndLine(raw.xml);
665
+ shapes.push({
666
+ prov: parseProv(files, slidePath, raw.xml),
667
+ kind: raw.kind,
668
+ geom: parseXfrm(raw.xml),
669
+ text: parseTextRuns(raw.xml),
670
+ fill: paint.fill,
671
+ line: paint.line,
672
+ mediaSha256: parseMediaSha(files, slidePath, raw.xml),
673
+ });
674
+ }
675
+ return { v: 1, shapes };
676
+ }