@textcortex/slidewise 1.9.0 → 1.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textcortex/slidewise",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Embeddable React PPTX editor.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -237,4 +237,87 @@ describe("pptx round-trip", () => {
237
237
  expect(colors).toContain("#FFFFFF");
238
238
  expect(colors).toContain("#0F1B3D");
239
239
  });
240
+
241
+ it("preserves UnknownElement OOXML and its rels across a round-trip", async () => {
242
+ // Build a deck with a single hand-crafted UnknownElement carrying a raw
243
+ // OOXML fragment that references rId7. parsePptx then attaches a fake
244
+ // source archive providing that rId; serializeDeck has to renumber the
245
+ // rId, write the matching <Relationship>, and copy the referenced
246
+ // media into the output zip so the fragment resolves on re-parse.
247
+ const JSZip = (await import("jszip")).default;
248
+ const sourceZip = new JSZip();
249
+ sourceZip.file(
250
+ "[Content_Types].xml",
251
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
252
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
253
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
254
+ <Default Extension="xml" ContentType="application/xml"/>
255
+ <Default Extension="png" ContentType="image/png"/>
256
+ <Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
257
+ <Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
258
+ </Types>`
259
+ );
260
+ sourceZip.file(
261
+ "_rels/.rels",
262
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>`
263
+ );
264
+ sourceZip.file(
265
+ "ppt/_rels/presentation.xml.rels",
266
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>`
267
+ );
268
+ sourceZip.file(
269
+ "ppt/presentation.xml",
270
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst><p:sldSz cx="12192000" cy="6858000"/></p:presentation>`
271
+ );
272
+ sourceZip.file(
273
+ "ppt/slides/slide1.xml",
274
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:cSld><p:spTree><p:graphicFrame><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/diagram"><dgm:relIds xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" r:dm="rId7"/></a:graphicData></a:graphic></p:graphicFrame></p:spTree></p:cSld></p:sld>`
275
+ );
276
+ sourceZip.file(
277
+ "ppt/slides/_rels/slide1.xml.rels",
278
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/preserved.png"/></Relationships>`
279
+ );
280
+ // Smallest valid PNG (1×1 transparent) so JSZip + serializer have real
281
+ // bytes to copy.
282
+ const onePxPng = Uint8Array.from([
283
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
284
+ 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
285
+ 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
286
+ 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
287
+ 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
288
+ 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
289
+ ]);
290
+ sourceZip.file("ppt/media/preserved.png", onePxPng);
291
+
292
+ const sourceBuffer = await sourceZip.generateAsync({ type: "arraybuffer" });
293
+ const parsed = await parsePptx(sourceBuffer);
294
+ const unknowns = parsed.slides[0].elements.filter(
295
+ (e) => e.type === "unknown"
296
+ );
297
+ expect(unknowns.length).toBe(1);
298
+ expect((unknowns[0] as { ooxmlXml: string }).ooxmlXml).toContain(
299
+ "diagram"
300
+ );
301
+ expect((unknowns[0] as { ooxmlXml: string }).ooxmlXml).toMatch(
302
+ /r:dm="rId7"/
303
+ );
304
+
305
+ const blob = await serializeDeck(parsed);
306
+ const out = await blob.arrayBuffer();
307
+ const reZip = await JSZip.loadAsync(out);
308
+ // The preserved diagram fragment landed in the generated slide1 xml.
309
+ const slide1 = await reZip.file("ppt/slides/slide1.xml")?.async("string");
310
+ expect(slide1).toContain("dgm:relIds");
311
+ // The original rId7 was renumbered; the slide rels now expose the new
312
+ // rId pointing at a preserved-prefixed media path.
313
+ const slide1Rels = await reZip
314
+ .file("ppt/slides/_rels/slide1.xml.rels")
315
+ ?.async("string");
316
+ expect(slide1Rels).toMatch(/slidewise_preserved_\d+_preserved\.png/);
317
+ // The actual PNG bytes were copied into the output archive.
318
+ const preservedFiles = Object.keys(reZip.files).filter((p) =>
319
+ /slidewise_preserved_\d+_preserved\.png$/.test(p)
320
+ );
321
+ expect(preservedFiles.length).toBe(1);
322
+ });
240
323
  });
@@ -1,4 +1,5 @@
1
1
  import pptxgen from "pptxgenjs";
2
+ import JSZip from "jszip";
2
3
  import type {
3
4
  Deck,
4
5
  Slide,
@@ -11,15 +12,25 @@ import type {
11
12
  TableElement,
12
13
  IconElement,
13
14
  EmbedElement,
15
+ UnknownElement,
14
16
  } from "@/lib/types";
15
17
  import { pxToInches, pxToPoints } from "./units";
18
+ import { SOURCE_PPTX, SOURCE_SLIDE_PATH } from "./pptxToDeck";
16
19
 
17
20
  /**
18
- * Serialize a Slidewise Deck to a real PPTX blob. Round-trips well for the
19
- * element types Slidewise natively supports (text, shape, image, line,
20
- * table, icon, embed). UnknownElement and entrance animations are dropped
21
- * with a warning proper preservation requires bypassing pptxgenjs and
22
- * is out of scope for v1.
21
+ * Serialize a Slidewise Deck to a real PPTX blob.
22
+ *
23
+ * Native element types (text, shape, image, line, table, icon, embed)
24
+ * are written through pptxgenjs. UnknownElement (charts, SmartArt,
25
+ * group shapes, OLE, math, anything else the importer couldn't model)
26
+ * is preserved verbatim: we keep the original PPTX bytes on the Deck
27
+ * during parse, and after pptxgenjs finishes, we post-process the
28
+ * generated zip to inject the preserved OOXML — plus any media those
29
+ * fragments referenced — into the matching slides. The fragments
30
+ * inside an UnknownElement keep their original rIds; we copy the
31
+ * corresponding rels entries (and media payloads) from the source
32
+ * zip, renumbering rIds as needed to avoid clashes with what
33
+ * pptxgenjs already wrote.
23
34
  */
24
35
  export async function serializeDeck(deck: Deck): Promise<Blob> {
25
36
  const pptx = new pptxgen();
@@ -30,9 +41,12 @@ export async function serializeDeck(deck: Deck): Promise<Blob> {
30
41
  addSlide(pptx, slide);
31
42
  }
32
43
 
33
- // pptxgenjs returns the requested type; outputType: "blob" Blob.
34
- const result = (await pptx.write({ outputType: "blob" })) as Blob;
35
- return result;
44
+ // Use arraybuffer (universal: works in Node + browser, accepted by JSZip
45
+ // directly) and wrap to Blob only when we're done post-processing.
46
+ const generated = (await pptx.write({
47
+ outputType: "arraybuffer",
48
+ })) as ArrayBuffer;
49
+ return preserveUnknowns(generated, deck);
36
50
  }
37
51
 
38
52
  function addSlide(pptx: pptxgen, slide: Slide): void {
@@ -76,9 +90,9 @@ function addElement(s: pptxgen.Slide, el: SlideElement): void {
76
90
  addEmbed(s, el);
77
91
  return;
78
92
  case "unknown":
79
- // Lossy: pptxgenjs has no public API for raw OOXML injection.
80
- // Future work: post-process the generated zip to re-inject UnknownElement
81
- // XML into the appropriate slide files for true round-trip.
93
+ // Preserved by preserveUnknowns() after pptxgenjs writes the zip.
94
+ // The post-process step injects el.ooxmlXml into the matching
95
+ // slide's <p:spTree> and copies any media the fragment referenced.
82
96
  return;
83
97
  }
84
98
  }
@@ -270,6 +284,261 @@ function addEmbed(s: pptxgen.Slide, el: EmbedElement): void {
270
284
  );
271
285
  }
272
286
 
287
+ // -- UnknownElement preservation -------------------------------------------
288
+
289
+ /**
290
+ * Post-process the zip pptxgenjs produced: for every slide that carries
291
+ * UnknownElement payloads, inject the preserved OOXML back into the
292
+ * generated `<p:spTree>` and pull along the rels + media those fragments
293
+ * referenced from the original archive.
294
+ *
295
+ * No-ops cleanly when the deck has no UnknownElements, when no source
296
+ * zip is attached (deck wasn't created via parsePptx), or when a slide
297
+ * the editor added doesn't have a source path.
298
+ */
299
+ async function preserveUnknowns(
300
+ generated: ArrayBuffer,
301
+ deck: Deck
302
+ ): Promise<Blob> {
303
+ const wrapBlob = () => new Blob([generated], { type: PPTX_MIME });
304
+ const unknownsBySlide = collectUnknowns(deck);
305
+ if (!unknownsBySlide.size) return wrapBlob();
306
+ const sourceBuffer = (deck as unknown as Record<string, unknown>)[SOURCE_PPTX];
307
+ if (!(sourceBuffer instanceof ArrayBuffer)) return wrapBlob();
308
+
309
+ const [outZip, srcZip] = await Promise.all([
310
+ JSZip.loadAsync(generated),
311
+ JSZip.loadAsync(sourceBuffer),
312
+ ]);
313
+
314
+ for (const [slideIndex, group] of unknownsBySlide) {
315
+ const generatedSlidePath = `ppt/slides/slide${slideIndex + 1}.xml`;
316
+ const generatedRelsPath = `ppt/slides/_rels/slide${slideIndex + 1}.xml.rels`;
317
+ if (!outZip.file(generatedSlidePath)) continue;
318
+ if (!group.sourcePath) continue;
319
+ const sourceRelsPath = relsPathFor(group.sourcePath);
320
+
321
+ await injectUnknownsIntoSlide(
322
+ outZip,
323
+ srcZip,
324
+ generatedSlidePath,
325
+ generatedRelsPath,
326
+ sourceRelsPath,
327
+ group.unknowns
328
+ );
329
+ }
330
+
331
+ // JSZip's blob output preserves the OOXML mime type set by pptxgenjs.
332
+ return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME });
333
+ }
334
+
335
+ interface UnknownGroup {
336
+ unknowns: UnknownElement[];
337
+ sourcePath: string | undefined;
338
+ }
339
+
340
+ function collectUnknowns(deck: Deck): Map<number, UnknownGroup> {
341
+ const out = new Map<number, UnknownGroup>();
342
+ for (let i = 0; i < deck.slides.length; i++) {
343
+ const slide = deck.slides[i];
344
+ const unknowns = slide.elements.filter(
345
+ (e): e is UnknownElement => e.type === "unknown" && !!e.ooxmlXml
346
+ );
347
+ if (!unknowns.length) continue;
348
+ const sourcePath = (slide as unknown as Record<string, unknown>)[
349
+ SOURCE_SLIDE_PATH
350
+ ];
351
+ out.set(i, {
352
+ unknowns,
353
+ sourcePath: typeof sourcePath === "string" ? sourcePath : undefined,
354
+ });
355
+ }
356
+ return out;
357
+ }
358
+
359
+ /**
360
+ * For one slide: rewrite the preserved fragments so their rIds don't
361
+ * collide with whatever pptxgenjs already allocated, copy the
362
+ * referenced rels + media from the source zip, and splice the
363
+ * fragments in before the closing `</p:spTree>`.
364
+ */
365
+ async function injectUnknownsIntoSlide(
366
+ outZip: JSZip,
367
+ srcZip: JSZip,
368
+ generatedSlidePath: string,
369
+ generatedRelsPath: string,
370
+ sourceRelsPath: string,
371
+ unknowns: UnknownElement[]
372
+ ): Promise<void> {
373
+ const slideXml = await outZip.file(generatedSlidePath)!.async("string");
374
+ const closeIdx = slideXml.lastIndexOf("</p:spTree>");
375
+ if (closeIdx < 0) return;
376
+
377
+ const srcRelsXml = (await srcZip.file(sourceRelsPath)?.async("string")) ?? null;
378
+ const outRelsXml =
379
+ (await outZip.file(generatedRelsPath)?.async("string")) ??
380
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`;
381
+
382
+ const srcRels = parseRels(srcRelsXml);
383
+ const outRels = parseRels(outRelsXml);
384
+ let nextRid = highestRid(outRels) + 1;
385
+ const newRelLines: string[] = [];
386
+ const ridMap = new Map<string, string>();
387
+ // Source slide's directory (used to resolve relative rel targets like
388
+ // "../media/imageN.png" against the source archive).
389
+ const sourceSlidePath = sourceRelsPath.replace(/_rels\/([^/]+)\.rels$/, "$1");
390
+ const sourceDir = dirOf(sourceSlidePath);
391
+ const outDir = dirOf(generatedSlidePath);
392
+ const rewritten: string[] = [];
393
+
394
+ for (const u of unknowns) {
395
+ // Every r:id / r:embed / r:link inside the preserved fragment refers
396
+ // to a relationship in the SOURCE slide's rels. Renumber to fresh
397
+ // rIds, copy the matching source rel into the generated rels, and
398
+ // copy the media payload into the generated zip (at a fresh path so
399
+ // pptxgenjs-allocated media doesn't clash with the preserved media).
400
+ // Match every `r:*="rIdN"` attribute. The relationship-namespaced
401
+ // attribute names depend on the schema: `r:id` / `r:embed` / `r:link`
402
+ // for slides + drawings, but charts use `r:id`, SmartArt uses `r:dm`
403
+ // (data model) / `r:cs` (colors) / `r:qs` (quick styles) / `r:lo`
404
+ // (layout), and embedded objects use `r:id`/`r:image`. Restricting to
405
+ // the value pattern `rId\d+` keeps unrelated `r:*` attributes
406
+ // untouched.
407
+ const xml = u.ooxmlXml.replace(
408
+ /\b(r:[a-zA-Z]+)="(rId\d+)"/g,
409
+ (_match, attr, srcRid) => {
410
+ const cached = ridMap.get(srcRid);
411
+ if (cached) return `${attr}="${cached}"`;
412
+ const srcRel = srcRels.get(srcRid);
413
+ if (!srcRel) return `${attr}="${srcRid}"`;
414
+
415
+ const newRid = `rId${nextRid++}`;
416
+ ridMap.set(srcRid, newRid);
417
+
418
+ let target = srcRel.target;
419
+ const isExternal = /^https?:\/\//i.test(target);
420
+ const isInternalPart = !isExternal && !target.startsWith("/");
421
+ if (isInternalPart) {
422
+ const srcFullTarget = normalisePath(target, sourceDir);
423
+ const srcFile = srcZip.file(srcFullTarget);
424
+ if (srcFile) {
425
+ // Always copy to a uniquely-prefixed path so we never collide
426
+ // with media pptxgenjs already wrote.
427
+ const newTarget = uniqueTarget(target, outZip, outDir);
428
+ const newFullTarget = normalisePath(newTarget, outDir);
429
+ outZip.file(newFullTarget, srcFile.async("uint8array"), {
430
+ binary: true,
431
+ });
432
+ target = newTarget;
433
+ }
434
+ }
435
+
436
+ newRelLines.push(buildRelXml(newRid, srcRel.type, target));
437
+ return `${attr}="${newRid}"`;
438
+ }
439
+ );
440
+ rewritten.push(xml);
441
+ }
442
+
443
+ if (rewritten.length) {
444
+ const inject = rewritten.join("");
445
+ const updatedSlide = slideXml.slice(0, closeIdx) + inject + slideXml.slice(closeIdx);
446
+ outZip.file(generatedSlidePath, updatedSlide);
447
+ }
448
+
449
+ if (newRelLines.length) {
450
+ const insertAt = outRelsXml.lastIndexOf("</Relationships>");
451
+ const updatedRels =
452
+ insertAt >= 0
453
+ ? outRelsXml.slice(0, insertAt) +
454
+ newRelLines.join("") +
455
+ outRelsXml.slice(insertAt)
456
+ : outRelsXml.replace(
457
+ /<Relationships[^>]*>/,
458
+ (m) => `${m}${newRelLines.join("")}`
459
+ );
460
+ outZip.file(generatedRelsPath, updatedRels);
461
+ }
462
+ }
463
+
464
+ function parseRels(xml: string | null): Map<string, { type: string; target: string }> {
465
+ const map = new Map<string, { type: string; target: string }>();
466
+ if (!xml) return map;
467
+ // Match each <Relationship .../> tag. Use a non-greedy scan up to the
468
+ // self-closing `/>` rather than a `[^/]` class — relationship targets
469
+ // routinely contain `/` (e.g. `Target="../charts/chart1.xml"`).
470
+ const re = /<Relationship\b([\s\S]*?)\/>/g;
471
+ let m: RegExpExecArray | null;
472
+ while ((m = re.exec(xml))) {
473
+ const attrs = m[1];
474
+ const id = /\bId="([^"]+)"/.exec(attrs)?.[1];
475
+ const type = /\bType="([^"]+)"/.exec(attrs)?.[1];
476
+ const target = /\bTarget="([^"]+)"/.exec(attrs)?.[1];
477
+ if (id && type && target) map.set(id, { type, target });
478
+ }
479
+ return map;
480
+ }
481
+
482
+ function highestRid(rels: Map<string, unknown>): number {
483
+ let max = 0;
484
+ for (const id of rels.keys()) {
485
+ const m = /^rId(\d+)$/.exec(id);
486
+ if (m) {
487
+ const n = Number(m[1]);
488
+ if (n > max) max = n;
489
+ }
490
+ }
491
+ return max;
492
+ }
493
+
494
+ function buildRelXml(id: string, type: string, target: string): string {
495
+ return `<Relationship Id="${id}" Type="${type}" Target="${target}"/>`;
496
+ }
497
+
498
+ function relsPathFor(xmlPath: string): string {
499
+ return xmlPath.replace(/([^/]+)\.xml$/, "_rels/$1.xml.rels");
500
+ }
501
+
502
+ /**
503
+ * Pick a target path that doesn't collide with anything pptxgenjs already
504
+ * wrote into the zip. We keep the original target's directory and
505
+ * extension so the file stays in `ppt/media/`, `ppt/charts/`, etc., but
506
+ * prefix the basename with `slidewise_preserved_N_` until the resolved
507
+ * full path is unique.
508
+ */
509
+ function uniqueTarget(originalTarget: string, outZip: JSZip, baseDir: string): string {
510
+ const slash = originalTarget.lastIndexOf("/");
511
+ const dir = slash >= 0 ? originalTarget.slice(0, slash + 1) : "";
512
+ const file = slash >= 0 ? originalTarget.slice(slash + 1) : originalTarget;
513
+ let i = 0;
514
+ let candidate = `${dir}slidewise_preserved_${i}_${file}`;
515
+ while (outZip.file(normalisePath(candidate, baseDir))) {
516
+ i++;
517
+ candidate = `${dir}slidewise_preserved_${i}_${file}`;
518
+ }
519
+ return candidate;
520
+ }
521
+
522
+ function dirOf(path: string): string {
523
+ const i = path.lastIndexOf("/");
524
+ return i >= 0 ? path.slice(0, i) : "";
525
+ }
526
+
527
+ function normalisePath(target: string, base: string): string {
528
+ if (/^https?:\/\//i.test(target)) return target;
529
+ if (target.startsWith("/")) return target.slice(1);
530
+ let t = target;
531
+ const segments = base.split("/").filter(Boolean);
532
+ while (t.startsWith("../")) {
533
+ segments.pop();
534
+ t = t.slice(3);
535
+ }
536
+ return [...segments, t].filter(Boolean).join("/");
537
+ }
538
+
539
+ const PPTX_MIME =
540
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation";
541
+
273
542
  // -- helpers ----------------------------------------------------------------
274
543
 
275
544
  function hexNoHash(color: string): string {
@@ -204,8 +204,14 @@ const DEFAULT_THEME: ThemeColors = {
204
204
  * UnknownElement carrying its raw OOXML so a save round-trip can re-emit
205
205
  * it without data loss.
206
206
  */
207
- export async function parsePptx(blob: Blob | ArrayBuffer): Promise<Deck> {
208
- const zip = await JSZip.loadAsync(blob);
207
+ export async function parsePptx(
208
+ blob: Blob | ArrayBuffer | Uint8Array
209
+ ): Promise<Deck> {
210
+ // Keep the original archive bytes so serializeDeck can re-inject any
211
+ // OOXML we couldn't model (UnknownElement) back into the saved file
212
+ // along with the media it referenced. See SOURCE_PPTX / SOURCE_SLIDE_PATH.
213
+ const sourceBuffer = await toArrayBuffer(blob);
214
+ const zip = await JSZip.loadAsync(sourceBuffer);
209
215
  const diagnostics: ParseDiagnostics = {
210
216
  unknownElementCount: 0,
211
217
  droppedAnimations: 0,
@@ -230,7 +236,17 @@ export async function parsePptx(blob: Blob | ArrayBuffer): Promise<Deck> {
230
236
  const slides: Slide[] = [];
231
237
  for (const slidePath of slidePaths) {
232
238
  const slide = await parseSlide(zip, slidePath, diagnostics, fit);
233
- if (slide) slides.push(slide);
239
+ if (slide) {
240
+ // Tag the slide with the source xml path so the serializer can pick
241
+ // the right `ppt/slides/slideN.xml.rels` to copy media refs from
242
+ // when the user adds / reorders / deletes slides in the editor.
243
+ Object.defineProperty(slide, SOURCE_SLIDE_PATH, {
244
+ value: slidePath,
245
+ enumerable: false,
246
+ configurable: true,
247
+ });
248
+ slides.push(slide);
249
+ }
234
250
  }
235
251
 
236
252
  if (!slides.length) {
@@ -239,12 +255,39 @@ export async function parsePptx(blob: Blob | ArrayBuffer): Promise<Deck> {
239
255
  }
240
256
 
241
257
  const deck: Deck = { version: CURRENT_DECK_VERSION, title, slides };
258
+ Object.defineProperty(deck, SOURCE_PPTX, {
259
+ value: sourceBuffer,
260
+ enumerable: false,
261
+ configurable: true,
262
+ });
242
263
  if (diagnostics.warnings.length) {
243
264
  console.info("[slidewise/pptx] parse diagnostics:", diagnostics);
244
265
  }
245
266
  return deck;
246
267
  }
247
268
 
269
+ /** Non-enumerable property keys used to ferry the original archive
270
+ * bytes from parse to serialize so we can round-trip the OOXML we
271
+ * couldn't model. Internal — do not depend on these from outside the
272
+ * package; the contract is enforced at the parse/serialize boundary. */
273
+ export const SOURCE_PPTX = "__slidewiseSourcePptx";
274
+ export const SOURCE_SLIDE_PATH = "__slidewiseSourceSlidePath";
275
+
276
+ async function toArrayBuffer(
277
+ input: Blob | ArrayBuffer | Uint8Array
278
+ ): Promise<ArrayBuffer> {
279
+ if (input instanceof ArrayBuffer) return input;
280
+ // Node Buffer is a Uint8Array subclass; honour it explicitly so the
281
+ // server-side `serializeDeck → arrayBuffer → parsePptx` round-trip
282
+ // works without the caller having to allocate a Blob.
283
+ if (input instanceof Uint8Array) {
284
+ const copy = new ArrayBuffer(input.byteLength);
285
+ new Uint8Array(copy).set(input);
286
+ return copy;
287
+ }
288
+ return input.arrayBuffer();
289
+ }
290
+
248
291
  async function parseSlide(
249
292
  zip: JSZip,
250
293
  slidePath: string,