@textcortex/slidewise 1.8.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.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "Embeddable React PPTX editor.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -65,12 +65,17 @@ function TextView({
65
65
  : el.vAlign === "middle"
66
66
  ? "center"
67
67
  : "flex-end",
68
+ background: el.background,
69
+ padding: el.padding
70
+ ? `${el.padding.t}px ${el.padding.r}px ${el.padding.b}px ${el.padding.l}px`
71
+ : undefined,
72
+ boxSizing: el.padding ? "border-box" : undefined,
68
73
  cursor: editing ? "text" : "inherit",
69
74
  };
70
75
  const inner: React.CSSProperties = {
71
76
  width: "100%",
72
77
  color: el.color,
73
- fontFamily: el.fontFamily,
78
+ fontFamily: withGenericFallback(el.fontFamily),
74
79
  fontSize: el.fontSize,
75
80
  fontWeight: el.fontWeight,
76
81
  fontStyle: el.italic ? "italic" : "normal",
@@ -85,11 +90,40 @@ function TextView({
85
90
  outline: "none",
86
91
  };
87
92
 
93
+ const backingPath = el.backingPath;
94
+ const positionedOuter: React.CSSProperties = backingPath
95
+ ? { ...outer, position: "relative" }
96
+ : outer;
97
+ const innerStacked: React.CSSProperties = backingPath
98
+ ? { ...inner, position: "relative", zIndex: 1 }
99
+ : inner;
100
+ const backingSvg = backingPath ? (
101
+ <svg
102
+ viewBox={`0 0 ${backingPath.viewW} ${backingPath.viewH}`}
103
+ preserveAspectRatio="none"
104
+ style={{
105
+ position: "absolute",
106
+ inset: 0,
107
+ width: "100%",
108
+ height: "100%",
109
+ pointerEvents: "none",
110
+ zIndex: 0,
111
+ }}
112
+ >
113
+ <path
114
+ d={backingPath.d}
115
+ fill={backingPath.fill}
116
+ fillRule={backingPath.fillRule ?? "nonzero"}
117
+ />
118
+ </svg>
119
+ ) : null;
120
+
88
121
  if (editing) {
89
122
  return (
90
- <div style={outer}>
123
+ <div style={positionedOuter}>
124
+ {backingSvg}
91
125
  <EditableText
92
- style={inner}
126
+ style={innerStacked}
93
127
  initialText={el.text}
94
128
  initialRuns={el.runs}
95
129
  onCommit={(t, r) => onCommit?.(t, r)}
@@ -100,8 +134,9 @@ function TextView({
100
134
 
101
135
  if (el.runs && el.runs.length) {
102
136
  return (
103
- <div style={outer}>
104
- <div style={inner}>
137
+ <div style={positionedOuter}>
138
+ {backingSvg}
139
+ <div style={innerStacked}>
105
140
  {el.runs.map((r, i) => (
106
141
  <span key={i} style={runCssStyle(r)}>
107
142
  {r.text}
@@ -113,15 +148,40 @@ function TextView({
113
148
  }
114
149
 
115
150
  return (
116
- <div style={outer}>
117
- <div style={inner}>{el.text}</div>
151
+ <div style={positionedOuter}>
152
+ {backingSvg}
153
+ <div style={innerStacked}>{el.text}</div>
118
154
  </div>
119
155
  );
120
156
  }
121
157
 
158
+ /**
159
+ * Append a `sans-serif` generic so brand families imported from PPTX
160
+ * (e.g. "EON Office Head") degrade gracefully when the typeface isn't
161
+ * installed locally — without the generic the browser silently picks
162
+ * its default serif. Already-qualified stacks (containing a comma) and
163
+ * plain generics ("serif"/"monospace") pass through untouched.
164
+ */
165
+ function withGenericFallback(family: string | undefined): string | undefined {
166
+ if (!family) return family;
167
+ if (family.includes(",")) return family;
168
+ const lower = family.trim().toLowerCase();
169
+ if (
170
+ lower === "serif" ||
171
+ lower === "sans-serif" ||
172
+ lower === "monospace" ||
173
+ lower === "cursive" ||
174
+ lower === "fantasy" ||
175
+ lower === "system-ui"
176
+ ) {
177
+ return family;
178
+ }
179
+ return `${family}, sans-serif`;
180
+ }
181
+
122
182
  function runCssStyle(r: TextRun): React.CSSProperties {
123
183
  const s: React.CSSProperties = {};
124
- if (r.fontFamily) s.fontFamily = r.fontFamily;
184
+ if (r.fontFamily) s.fontFamily = withGenericFallback(r.fontFamily);
125
185
  if (r.fontSize) s.fontSize = r.fontSize;
126
186
  if (r.fontWeight) s.fontWeight = r.fontWeight;
127
187
  if (r.color) s.color = r.color;
@@ -315,6 +375,27 @@ function sameStyle(a: TextRun, b: TextRun): boolean {
315
375
  function ShapeView({ el }: { el: ShapeElement }) {
316
376
  const stroke = el.stroke ?? "transparent";
317
377
  const sw = el.strokeWidth ?? 0;
378
+ // Custom vector path (PPTX <a:custGeom>) takes precedence over the preset
379
+ // kind — the path coordinates already encode the actual silhouette.
380
+ if (el.path) {
381
+ return (
382
+ <svg
383
+ viewBox={`0 0 ${el.path.viewW} ${el.path.viewH}`}
384
+ preserveAspectRatio="none"
385
+ width="100%"
386
+ height="100%"
387
+ >
388
+ <path
389
+ d={el.path.d}
390
+ fill={el.fill}
391
+ fillRule={el.path.fillRule ?? "nonzero"}
392
+ stroke={stroke}
393
+ strokeWidth={sw || undefined}
394
+ vectorEffect="non-scaling-stroke"
395
+ />
396
+ </svg>
397
+ );
398
+ }
318
399
  if (el.shape === "rect" || el.shape === "rounded") {
319
400
  return (
320
401
  <div
@@ -474,7 +555,9 @@ function LineView({ el }: { el: LineElement }) {
474
555
  function TableView({ el }: { el: TableElement }) {
475
556
  const cols = el.rows[0]?.length ?? 1;
476
557
  // PPTX-faithful: contiguous cells, no inter-cell gap, no rounded corners.
477
- // Earlier "card grid" styling drifted too far from the source look.
558
+ // Cells share their dividers via inset box-shadows so we draw a single
559
+ // grid line between adjacent cells instead of doubling-up borders.
560
+ const stroke = el.borderColor ?? "rgba(0, 0, 0, 0.12)";
478
561
  return (
479
562
  <div
480
563
  style={{
@@ -485,6 +568,7 @@ function TableView({ el }: { el: TableElement }) {
485
568
  height: "100%",
486
569
  gap: 0,
487
570
  background: "transparent",
571
+ boxShadow: `inset 0 0 0 1px ${stroke}`,
488
572
  }}
489
573
  >
490
574
  {el.rows.flatMap((row, ri) =>
@@ -504,6 +588,10 @@ function TableView({ el }: { el: TableElement }) {
504
588
  minHeight: 0,
505
589
  overflow: "hidden",
506
590
  wordBreak: "break-word",
591
+ borderRight:
592
+ ci < cols - 1 ? `1px solid ${stroke}` : undefined,
593
+ borderBottom:
594
+ ri < el.rows.length - 1 ? `1px solid ${stroke}` : undefined,
507
595
  }}
508
596
  >
509
597
  {cell}
@@ -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 {