@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.
@@ -9,6 +9,7 @@ import type {
9
9
  TextRun,
10
10
  ShapeElement,
11
11
  ShapeKind,
12
+ ShapePath,
12
13
  ImageElement,
13
14
  LineElement,
14
15
  TableElement,
@@ -49,6 +50,13 @@ interface ThemeColors {
49
50
  accent6: string;
50
51
  hlink: string;
51
52
  folHlink: string;
53
+ // bg1/bg2/tx1/tx2 are *token* names rather than colour scheme entries —
54
+ // their resolved hexes come from the master's <p:clrMap>. Slidewise bakes
55
+ // those into the theme so resolveSchemeToken stays a flat lookup.
56
+ bg1: string;
57
+ bg2: string;
58
+ tx1: string;
59
+ tx2: string;
52
60
  }
53
61
 
54
62
  interface PlaceholderInfo {
@@ -62,14 +70,18 @@ interface PlaceholderInfo {
62
70
  rPr?: any;
63
71
  pPr?: any;
64
72
  bodyPr?: any;
73
+ /** Per-level paragraph defaults from <a:lstStyle><a:lvlNpPr>. */
74
+ lvlPPr?: (any | undefined)[];
65
75
  /** Fallback paragraphs (used when the slide placeholder has no text). */
66
76
  paragraphs?: any[];
77
+ /** Raw spPr (used to resolve the placeholder's own fill / stroke). */
78
+ spPr?: any;
67
79
  }
68
80
 
69
81
  interface MasterTextDefaults {
70
- title?: any; // a:lvl1pPr (and friends) we only use lvl1.
71
- body?: any;
72
- other?: any;
82
+ title?: (any | undefined)[]; // titleStyle lvl1..lvl9 pPr
83
+ body?: (any | undefined)[]; // bodyStyle lvl1..lvl9 pPr
84
+ other?: (any | undefined)[];
73
85
  }
74
86
 
75
87
  interface ParseContext {
@@ -79,11 +91,50 @@ interface ParseContext {
79
91
  slideRels: Rels;
80
92
  fit: Fit;
81
93
  theme: ThemeColors;
94
+ themeFills: ThemeFills;
95
+ themeFonts: ThemeFonts;
82
96
  layoutPh: Map<string, PlaceholderInfo>;
83
97
  masterPh: Map<string, PlaceholderInfo>;
84
98
  masterTextDefaults: MasterTextDefaults;
85
99
  }
86
100
 
101
+ /**
102
+ * Ordered theme fill lists from a:fmtScheme. PPTX <p:bgRef idx="1001+"> looks
103
+ * up the (idx - 1000)th entry of bgFillStyleLst (and analogously fillStyleLst
104
+ * for 1+). Order across mixed tag types matters — fast-xml-parser flattens by
105
+ * tag, so we reconstruct order from the raw XML.
106
+ */
107
+ interface ThemeFill {
108
+ kind: "solidFill" | "gradFill" | "blipFill" | "pattFill" | "noFill";
109
+ node: any;
110
+ }
111
+
112
+ interface ThemeFills {
113
+ bg: ThemeFill[];
114
+ fg: ThemeFill[];
115
+ }
116
+
117
+ interface ThemeFonts {
118
+ /** Major (heading) Latin typeface — referenced via `+mj-lt`. */
119
+ majorLatin?: string;
120
+ /** Minor (body) Latin typeface — referenced via `+mn-lt`. */
121
+ minorLatin?: string;
122
+ }
123
+
124
+ /**
125
+ * Maps generic colour tokens used by slides (`bg1`, `tx1`, `bg2`, `tx2`) onto
126
+ * actual theme entries (`lt1`, `dk1`, …). Defined on the slide master via
127
+ * `<p:clrMap>`; individual slides may override via `<p:clrMapOvr>`.
128
+ */
129
+ interface ClrMap {
130
+ bg1: keyof ThemeColors;
131
+ bg2: keyof ThemeColors;
132
+ tx1: keyof ThemeColors;
133
+ tx2: keyof ThemeColors;
134
+ }
135
+
136
+ const DEFAULT_CLR_MAP: ClrMap = { bg1: "lt1", bg2: "lt2", tx1: "dk1", tx2: "dk2" };
137
+
87
138
  const xmlParser = new XMLParser({
88
139
  ignoreAttributes: false,
89
140
  attributeNamePrefix: "@_",
@@ -110,6 +161,8 @@ const ARRAY_TAGS = new Set([
110
161
  "p:grpSp",
111
162
  "a:p",
112
163
  "a:r",
164
+ "a:br",
165
+ "a:fld",
113
166
  "a:tr",
114
167
  "a:tc",
115
168
  "Relationship",
@@ -128,6 +181,11 @@ const DEFAULT_THEME: ThemeColors = {
128
181
  accent6: "#F79646",
129
182
  hlink: "#0000FF",
130
183
  folHlink: "#800080",
184
+ // Default clrMap (bg1→lt1, tx1→dk1, …).
185
+ bg1: "#FFFFFF",
186
+ bg2: "#EEECE1",
187
+ tx1: "#000000",
188
+ tx2: "#1F497D",
131
189
  };
132
190
 
133
191
  /**
@@ -146,8 +204,14 @@ const DEFAULT_THEME: ThemeColors = {
146
204
  * UnknownElement carrying its raw OOXML so a save round-trip can re-emit
147
205
  * it without data loss.
148
206
  */
149
- export async function parsePptx(blob: Blob | ArrayBuffer): Promise<Deck> {
150
- 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);
151
215
  const diagnostics: ParseDiagnostics = {
152
216
  unknownElementCount: 0,
153
217
  droppedAnimations: 0,
@@ -172,7 +236,17 @@ export async function parsePptx(blob: Blob | ArrayBuffer): Promise<Deck> {
172
236
  const slides: Slide[] = [];
173
237
  for (const slidePath of slidePaths) {
174
238
  const slide = await parseSlide(zip, slidePath, diagnostics, fit);
175
- 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
+ }
176
250
  }
177
251
 
178
252
  if (!slides.length) {
@@ -181,12 +255,39 @@ export async function parsePptx(blob: Blob | ArrayBuffer): Promise<Deck> {
181
255
  }
182
256
 
183
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
+ });
184
263
  if (diagnostics.warnings.length) {
185
264
  console.info("[slidewise/pptx] parse diagnostics:", diagnostics);
186
265
  }
187
266
  return deck;
188
267
  }
189
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
+
190
291
  async function parseSlide(
191
292
  zip: JSZip,
192
293
  slidePath: string,
@@ -224,7 +325,29 @@ async function parseSlide(
224
325
  ? normalisePath(themeTarget, dirOf(masterPath))
225
326
  : null;
226
327
  const themeXml = themePath ? await readXml(zip, themePath) : null;
227
- const theme = themeXml ? extractTheme(themeXml) : DEFAULT_THEME;
328
+ const themeRaw = themePath ? await readXmlRaw(zip, themePath) : null;
329
+ const baseTheme = themeXml ? extractTheme(themeXml) : DEFAULT_THEME;
330
+ const themeFills =
331
+ themeXml && themeRaw
332
+ ? extractThemeFills(themeXml, themeRaw)
333
+ : { bg: [], fg: [] };
334
+ const themeFonts = themeXml ? extractThemeFonts(themeXml) : {};
335
+ // <p:clrMap> lives on the master; <p:clrMapOvr><a:overrideClrMapping/> on
336
+ // the slide can override individual mappings. Slides commonly only declare
337
+ // <a:masterClrMapping/> which means "inherit the master's map verbatim".
338
+ const masterClrMap = masterXml
339
+ ? extractClrMap(masterXml?.["p:sldMaster"]?.["p:clrMap"])
340
+ : DEFAULT_CLR_MAP;
341
+ const clrMapOvr = xml["p:sld"]?.["p:clrMapOvr"]?.["a:overrideClrMapping"];
342
+ const clrMap = clrMapOvr ? extractClrMap(clrMapOvr) : masterClrMap;
343
+ // Bake the clrMap into the theme so bg1/bg2/tx1/tx2 stay flat lookups.
344
+ const theme: ThemeColors = {
345
+ ...baseTheme,
346
+ bg1: baseTheme[clrMap.bg1],
347
+ bg2: baseTheme[clrMap.bg2],
348
+ tx1: baseTheme[clrMap.tx1],
349
+ tx2: baseTheme[clrMap.tx2],
350
+ };
228
351
 
229
352
  const layoutPh = layoutXml ? extractPlaceholders(layoutXml) : new Map();
230
353
  const masterPh = masterXml ? extractPlaceholders(masterXml) : new Map();
@@ -239,6 +362,8 @@ async function parseSlide(
239
362
  slideRels,
240
363
  fit,
241
364
  theme,
365
+ themeFills,
366
+ themeFonts,
242
367
  layoutPh,
243
368
  masterPh,
244
369
  masterTextDefaults,
@@ -246,27 +371,64 @@ async function parseSlide(
246
371
 
247
372
  const sld = xml["p:sld"];
248
373
  const cSld = sld?.["p:cSld"];
249
- const slideBg = extractBackgroundColor(cSld?.["p:bg"], theme);
374
+ const slideBg = await extractBackground(
375
+ cSld?.["p:bg"],
376
+ ctx,
377
+ slideRels,
378
+ slidePath
379
+ );
250
380
  const layoutBg = layoutXml
251
- ? extractBackgroundColor(
381
+ ? await extractBackground(
252
382
  layoutXml?.["p:sldLayout"]?.["p:cSld"]?.["p:bg"],
253
- theme
383
+ ctx,
384
+ layoutRels,
385
+ layoutPath!
254
386
  )
255
387
  : undefined;
256
388
  const masterBg = masterXml
257
- ? extractBackgroundColor(
389
+ ? await extractBackground(
258
390
  masterXml?.["p:sldMaster"]?.["p:cSld"]?.["p:bg"],
259
- theme
391
+ ctx,
392
+ masterRels,
393
+ masterPath!
260
394
  )
261
395
  : undefined;
262
396
  const background = slideBg ?? layoutBg ?? masterBg ?? "#FFFFFF";
263
397
 
264
398
  const spTree = cSld?.["p:spTree"];
399
+
400
+ // Layout & master visuals (non-placeholder shapes/pics, plus the fill of
401
+ // placeholder-bearing shapes) form an underlay so brand bars, side gradients,
402
+ // logo pics, and tinted placeholder boxes appear behind slide content.
403
+ // Placeholders the slide already overrides (e.g. picture placeholder filled
404
+ // by an in-slide <p:pic>) are skipped so their "Insert Picture" prompt
405
+ // background doesn't leak through.
406
+ const slidePhKeys = collectSlidePlaceholderKeys(spTree);
407
+ const masterUnderlay = masterXml
408
+ ? await parseUnderlay(
409
+ masterXml["p:sldMaster"]?.["p:cSld"]?.["p:spTree"],
410
+ ctx,
411
+ masterPath!,
412
+ masterRels,
413
+ slidePhKeys
414
+ )
415
+ : [];
416
+ const layoutUnderlay = layoutXml
417
+ ? await parseUnderlay(
418
+ layoutXml["p:sldLayout"]?.["p:cSld"]?.["p:spTree"],
419
+ ctx,
420
+ layoutPath!,
421
+ layoutRels,
422
+ slidePhKeys
423
+ )
424
+ : [];
265
425
  const elements: SlideElement[] = [];
266
426
 
427
+ let z = 1;
428
+ for (const el of masterUnderlay) elements.push({ ...el, z: z++ });
429
+ for (const el of layoutUnderlay) elements.push({ ...el, z: z++ });
267
430
  if (spTree) {
268
431
  const collected = await parseSpTree(spTree, ctx, identityTransform());
269
- let z = 1;
270
432
  for (const el of collected) {
271
433
  elements.push({ ...el, z: z++ });
272
434
  }
@@ -279,29 +441,64 @@ async function parseSlide(
279
441
  };
280
442
  }
281
443
 
282
- interface GroupTransform {
283
- /** Linear transform for child raw-px coordinates: x' = a*x + c, y' = b*y + d. */
284
- a: number;
285
- b: number;
286
- c: number;
287
- d: number;
288
- }
289
-
290
- function identityTransform(): GroupTransform {
291
- return { a: 1, b: 1, c: 0, d: 0 };
444
+ /**
445
+ * Walk a layout or master spTree and return elements to render behind the
446
+ * slide: non-placeholder shapes/pics (the brand bars, gradient bands, corner
447
+ * logos) and any explicit fill on placeholder-bearing shapes (the tinted
448
+ * boxes some templates host on layout placeholders). Hidden shapes are
449
+ * skipped. Placeholder text/positions remain handled by the existing
450
+ * inheritance path so we don't duplicate them.
451
+ */
452
+ async function parseUnderlay(
453
+ spTree: any,
454
+ ctx: ParseContext,
455
+ ownerPath: string,
456
+ ownerRels: Rels,
457
+ slidePhKeys: Set<string>
458
+ ): Promise<SlideElement[]> {
459
+ if (!spTree) return [];
460
+ const underlayCtx: ParseContext = {
461
+ ...ctx,
462
+ slidePath: ownerPath,
463
+ slideRels: ownerRels,
464
+ };
465
+ return walkUnderlay(spTree, underlayCtx, identityTransform(), slidePhKeys);
292
466
  }
293
467
 
294
- async function parseSpTree(
468
+ async function walkUnderlay(
295
469
  spTree: any,
296
470
  ctx: ParseContext,
297
- outer: GroupTransform
471
+ outer: GroupTransform,
472
+ slidePhKeys: Set<string>
298
473
  ): Promise<SlideElement[]> {
299
474
  const out: SlideElement[] = [];
300
475
  for (const sp of asArray(spTree["p:sp"])) {
301
- const el = await parseSpOrText(sp, ctx, outer);
476
+ if (isHiddenNode(sp, "p:nvSpPr")) continue;
477
+ const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
478
+ if (ph) {
479
+ const isPicPrompt = ph["@_type"] === "pic";
480
+ const isOverridden = slidePhKeys.has(placeholderKey(ph));
481
+ // Picture placeholders are "Insert Picture" prompts; when the slide
482
+ // supplies an actual image, the prompt panel must hide.
483
+ if (isPicPrompt && isOverridden) continue;
484
+ // When the slide hosts this placeholder, its fill rides on the
485
+ // slide's text element (TextElement.background) so it stays at the
486
+ // text's z-index — important when the slide also has a full-bleed
487
+ // image that would otherwise cover an underlay-emitted backing.
488
+ if (isOverridden) continue;
489
+ // Unreferenced placeholders: emit a fill-only backing so coloured
490
+ // boxes (numbered chips, decorative panels) appear.
491
+ const filler = await placeholderFillUnderlay(sp, ctx, outer);
492
+ if (filler) out.push(filler);
493
+ continue;
494
+ }
495
+ const el = await parseSpOrText(sp, ctx, outer, { underlay: true });
302
496
  if (el) out.push(el);
303
497
  }
304
498
  for (const pic of asArray(spTree["p:pic"])) {
499
+ if (isHiddenNode(pic, "p:nvPicPr")) continue;
500
+ const ph = pic?.["p:nvPicPr"]?.["p:nvPr"]?.["p:ph"];
501
+ if (ph && slidePhKeys.has(placeholderKey(ph))) continue;
305
502
  const el = await parsePic(pic, ctx, outer);
306
503
  if (el) out.push(el);
307
504
  }
@@ -309,14 +506,147 @@ async function parseSpTree(
309
506
  const el = parseCxn(cxn, ctx, outer);
310
507
  if (el) out.push(el);
311
508
  }
312
- for (const gf of asArray(spTree["p:graphicFrame"])) {
313
- const el = parseGraphicFrame(gf, ctx, outer);
314
- if (el) out.push(el);
315
- }
316
509
  for (const grp of asArray(spTree["p:grpSp"])) {
317
510
  const inner = composeGroupTransform(grp, outer);
318
- const children = await parseSpTree(grp, ctx, inner);
319
- out.push(...children);
511
+ out.push(...(await walkUnderlay(grp, ctx, inner, slidePhKeys)));
512
+ }
513
+ return out;
514
+ }
515
+
516
+ function collectSlidePlaceholderKeys(spTree: any): Set<string> {
517
+ const keys = new Set<string>();
518
+ if (!spTree) return keys;
519
+ const visit = (tree: any) => {
520
+ if (!tree) return;
521
+ for (const sp of asArray(tree["p:sp"])) {
522
+ const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
523
+ if (ph) keys.add(placeholderKey(ph));
524
+ }
525
+ for (const pic of asArray(tree["p:pic"])) {
526
+ const ph = pic?.["p:nvPicPr"]?.["p:nvPr"]?.["p:ph"];
527
+ if (ph) keys.add(placeholderKey(ph));
528
+ }
529
+ for (const grp of asArray(tree["p:grpSp"])) visit(grp);
530
+ };
531
+ visit(spTree);
532
+ return keys;
533
+ }
534
+
535
+ function isHiddenNode(node: any, nvKey: "p:nvSpPr" | "p:nvPicPr"): boolean {
536
+ const cNvPr = node?.[nvKey]?.["p:cNvPr"];
537
+ return cNvPr?.["@_hidden"] === "1" || cNvPr?.["@_hidden"] === 1;
538
+ }
539
+
540
+ /**
541
+ * Resolve a shape's <p:style><a:fillRef idx="N">...<a:schemeClr/></a:fillRef>
542
+ * against the theme's fillStyleLst. idx=0 = noFill; 1+ indexes the fillStyleLst
543
+ * children; 1001+ indexes bgFillStyleLst (rarely used here). The colour child
544
+ * inside fillRef plays the role of phClr in the theme fill template.
545
+ */
546
+ function resolveStyleFillRef(sp: any, ctx: ParseContext): string | undefined {
547
+ const fillRef = sp?.["p:style"]?.["a:fillRef"];
548
+ if (!fillRef) return undefined;
549
+ const idx = Number(fillRef["@_idx"]);
550
+ if (!Number.isFinite(idx) || idx === 0) return undefined;
551
+ const list = idx >= 1000 ? ctx.themeFills.bg : ctx.themeFills.fg;
552
+ const entry = list[(idx >= 1000 ? idx - 1001 : idx - 1)];
553
+ if (!entry) return undefined;
554
+ const phColor = readBaseHex(fillRef, ctx.theme);
555
+ if (entry.kind === "solidFill") {
556
+ return resolveColor(substitutePhClr(entry.node, phColor), ctx.theme);
557
+ }
558
+ if (entry.kind === "gradFill") {
559
+ return extractShapeFill(
560
+ { "a:gradFill": substitutePhClr(entry.node, phColor) },
561
+ ctx.theme
562
+ );
563
+ }
564
+ if (entry.kind === "noFill") return "transparent";
565
+ return undefined;
566
+ }
567
+
568
+ async function placeholderFillUnderlay(
569
+ sp: any,
570
+ ctx: ParseContext,
571
+ outer: GroupTransform = identityTransform()
572
+ ): Promise<ShapeElement | null> {
573
+ const spPr = sp?.["p:spPr"];
574
+ if (!spPr) return null;
575
+ const fill = extractShapeFill(spPr, ctx.theme);
576
+ if (!fill || fill === "transparent") return null;
577
+ const geom = readGeometry(spPr["a:xfrm"], ctx.fit, outer);
578
+ if (!geom) return null;
579
+ return {
580
+ id: nanoid(8),
581
+ type: "shape",
582
+ ...geom,
583
+ z: 0,
584
+ shape: "rect",
585
+ fill,
586
+ };
587
+ }
588
+
589
+ interface GroupTransform {
590
+ /** Linear transform for child raw-px coordinates: x' = a*x + c, y' = b*y + d. */
591
+ a: number;
592
+ b: number;
593
+ c: number;
594
+ d: number;
595
+ }
596
+
597
+ function identityTransform(): GroupTransform {
598
+ return { a: 1, b: 1, c: 0, d: 0 };
599
+ }
600
+
601
+ async function parseSpTree(
602
+ spTree: any,
603
+ ctx: ParseContext,
604
+ outer: GroupTransform
605
+ ): Promise<SlideElement[]> {
606
+ const out: SlideElement[] = [];
607
+ // Cursor per tag — we pop from each parsed array as we encounter its tag
608
+ // in the document-order list, so the same elements get visited in the
609
+ // order they appeared in the source XML (which determines z-index).
610
+ const cursors: Record<string, number> = {
611
+ "p:sp": 0,
612
+ "p:pic": 0,
613
+ "p:cxnSp": 0,
614
+ "p:graphicFrame": 0,
615
+ "p:grpSp": 0,
616
+ };
617
+ const order: string[] = (spTree as any)?._childOrder ?? [
618
+ // Fall back to legacy tag-grouped order when raw isn't attached
619
+ // (e.g. tests that build a parsed structure by hand).
620
+ ...asArray(spTree["p:sp"]).map(() => "p:sp"),
621
+ ...asArray(spTree["p:pic"]).map(() => "p:pic"),
622
+ ...asArray(spTree["p:cxnSp"]).map(() => "p:cxnSp"),
623
+ ...asArray(spTree["p:graphicFrame"]).map(() => "p:graphicFrame"),
624
+ ...asArray(spTree["p:grpSp"]).map(() => "p:grpSp"),
625
+ ];
626
+
627
+ for (const tag of order) {
628
+ if (!(tag in cursors)) continue;
629
+ const arr = asArray((spTree as any)[tag]);
630
+ const idx = cursors[tag]++;
631
+ const node = arr[idx];
632
+ if (!node) continue;
633
+ if (tag === "p:sp") {
634
+ const el = await parseSpOrText(node, ctx, outer);
635
+ if (el) out.push(el);
636
+ } else if (tag === "p:pic") {
637
+ const el = await parsePic(node, ctx, outer);
638
+ if (el) out.push(el);
639
+ } else if (tag === "p:cxnSp") {
640
+ const el = parseCxn(node, ctx, outer);
641
+ if (el) out.push(el);
642
+ } else if (tag === "p:graphicFrame") {
643
+ const el = parseGraphicFrame(node, ctx, outer);
644
+ if (el) out.push(el);
645
+ } else if (tag === "p:grpSp") {
646
+ const inner = composeGroupTransform(node, outer);
647
+ const children = await parseSpTree(node, ctx, inner);
648
+ out.push(...children);
649
+ }
320
650
  }
321
651
  return out;
322
652
  }
@@ -367,7 +697,8 @@ function composeGroupTransform(grp: any, outer: GroupTransform): GroupTransform
367
697
  async function parseSpOrText(
368
698
  sp: any,
369
699
  ctx: ParseContext,
370
- outer: GroupTransform
700
+ outer: GroupTransform,
701
+ opts: { underlay?: boolean } = {}
371
702
  ): Promise<SlideElement | null> {
372
703
  const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
373
704
  const phKey = ph ? placeholderKey(ph) : null;
@@ -386,6 +717,8 @@ async function parseSpOrText(
386
717
  const txBody = sp["p:txBody"];
387
718
  const prstGeom = sp?.["p:spPr"]?.["a:prstGeom"];
388
719
  const presetName = prstGeom?.["@_prst"];
720
+ const custGeom = sp?.["p:spPr"]?.["a:custGeom"];
721
+ const customPath = custGeom ? parseCustGeomPath(custGeom) : undefined;
389
722
 
390
723
  // Lines are sometimes authored as <p:sp prst="line">.
391
724
  if (presetName === "line" || presetName === "straightConnector1") {
@@ -401,10 +734,13 @@ async function parseSpOrText(
401
734
  const phType = ph?.["@_type"];
402
735
  const isPlaceholderTextHost = !!ph && phType !== "pic";
403
736
  const hasText = !!txBody && hasAnyText(txBody);
404
- const isText =
405
- hasText ||
406
- (isPlaceholderTextHost && !presetName) ||
407
- (!!txBody && (!presetName || presetName === "rect"));
737
+ // Treat as text when the element actually carries text OR when it's a
738
+ // placeholder text host with no preset geometry override. A
739
+ // non-placeholder shape with an empty <p:txBody> (commonly authored
740
+ // around <a:custGeom> graphics like brand icons) is a SHAPE — promoting
741
+ // it to a text element would drop the silhouette and fill.
742
+ const isText = hasText || (isPlaceholderTextHost && !presetName);
743
+ void opts;
408
744
 
409
745
  if (isText) {
410
746
  return makeTextElement(sp, txBody, geom, ctx, ph, layoutPh, masterPh);
@@ -412,7 +748,10 @@ async function parseSpOrText(
412
748
 
413
749
  // Fill / stroke. Use placeholder-inherited spPr if slide spPr is empty.
414
750
  const spPr = sp?.["p:spPr"];
415
- const fillColor = extractShapeFill(spPr, ctx.theme) ?? "transparent";
751
+ const fillColor =
752
+ extractShapeFill(spPr, ctx.theme)
753
+ ?? resolveStyleFillRef(sp, ctx)
754
+ ?? "transparent";
416
755
  const lineProps = spPr?.["a:ln"];
417
756
  const lineHasNoFill = lineProps?.["a:noFill"] !== undefined;
418
757
  const stroke = lineHasNoFill
@@ -427,7 +766,9 @@ async function parseSpOrText(
427
766
  if (!kind) {
428
767
  // Fall back to a rect with the shape's fill so it remains visible at the
429
768
  // correct position rather than dropping to an opaque "Imported content"
430
- // tile.
769
+ // tile. When the source carried a <a:custGeom> path we attach it so the
770
+ // renderer draws the actual silhouette (logos, brand marks) instead of
771
+ // the rectangle stand-in.
431
772
  const fallback: ShapeElement = {
432
773
  id: nanoid(8),
433
774
  type: "shape",
@@ -439,6 +780,7 @@ async function parseSpOrText(
439
780
  strokeWidth: strokeWidthEmu
440
781
  ? Math.max(1, Math.round(emuToPx(strokeWidthEmu) * ctx.fit.scale))
441
782
  : undefined,
783
+ ...(customPath ? { path: customPath } : {}),
442
784
  };
443
785
  return fallback;
444
786
  }
@@ -473,7 +815,7 @@ async function parseSpOrText(
473
815
  }
474
816
 
475
817
  function makeTextElement(
476
- _sp: any,
818
+ sp: any,
477
819
  txBody: any,
478
820
  geom: { x: number; y: number; w: number; h: number; rotation: number },
479
821
  ctx: ParseContext,
@@ -493,31 +835,74 @@ function makeTextElement(
493
835
  ? { "a:bodyPr": masterPh.bodyPr, "a:p": masterPh.paragraphs }
494
836
  : txBody;
495
837
 
496
- // Master defaults for the placeholder type (title vs body vs other).
838
+ // Master defaults for the placeholder type (title vs body vs other), as
839
+ // an array of lvl1..lvl9 paragraph properties.
497
840
  const phType = ph?.["@_type"];
498
- const masterDef =
841
+ const masterLevels: (any | undefined)[] =
499
842
  phType === "title" || phType === "ctrTitle"
500
- ? ctx.masterTextDefaults.title
843
+ ? (ctx.masterTextDefaults.title ?? [])
501
844
  : phType === "body" || phType === "subTitle"
502
- ? ctx.masterTextDefaults.body
503
- : ctx.masterTextDefaults.other;
504
-
505
- // Accumulate inheritance: slide < layout < master < masterDefaults.
506
- const fallbackRPr = mergeFirst(
845
+ ? (ctx.masterTextDefaults.body ?? [])
846
+ : (ctx.masterTextDefaults.other ?? []);
847
+ const masterLvl1 = masterLevels[0];
848
+
849
+ // Accumulate inheritance: slide < layout < master < masterDefaults. Each
850
+ // level can specify just a subset of fields (the layout might set the
851
+ // typeface while only the master defines the colour), so merge field by
852
+ // field with earlier candidates winning.
853
+ const fallbackRPr = mergeRPrChain(
507
854
  layoutPh?.rPr,
508
855
  masterPh?.rPr,
509
- masterDef?.["a:defRPr"]
856
+ masterLvl1?.["a:defRPr"]
510
857
  );
511
858
  const fallbackPPr = mergeFirst(
512
859
  layoutPh?.pPr,
513
860
  masterPh?.pPr,
514
- masterDef
861
+ masterLvl1
515
862
  );
516
863
  const fallbackBodyPr = mergeFirst(layoutPh?.bodyPr, masterPh?.bodyPr);
517
864
 
518
- const text = extractRuns(effectiveTxBody, ctx.theme, fallbackRPr, fallbackPPr);
865
+ // Resolve a per-level [layoutLvl, masterPhLvl, masterTxStyleLvl] chain so
866
+ // bullet/alignment/lineSpacing each fall through independently when an
867
+ // earlier layer is silent on that particular field.
868
+ const listStyle: (any | undefined)[][] = [];
869
+ for (let i = 0; i < 9; i++) {
870
+ const chain = [
871
+ layoutPh?.lvlPPr?.[i],
872
+ masterPh?.lvlPPr?.[i],
873
+ masterLevels[i],
874
+ ].filter(Boolean);
875
+ listStyle.push(chain);
876
+ }
877
+
878
+ // <a:bodyPr><a:normAutofit fontScale="..." lnSpcReduction="..."/> shrinks
879
+ // text that overflowed when authored — apply so wraps don't push runs off
880
+ // the slide.
881
+ const autoFit = readNormAutofit(
882
+ effectiveTxBody?.["a:bodyPr"] ?? fallbackBodyPr
883
+ );
884
+
885
+ const text = extractRuns(
886
+ effectiveTxBody,
887
+ ctx.theme,
888
+ fallbackRPr,
889
+ fallbackPPr,
890
+ ctx.themeFonts,
891
+ listStyle,
892
+ autoFit
893
+ );
519
894
  const first = text.runs[0];
520
- const align = text.align ?? readAlign(fallbackPPr) ?? "left";
895
+ // Each layer of the inheritance chain may set @algn independently — a
896
+ // layout placeholder can override geometry without touching alignment,
897
+ // expecting the master's algn="r" (slide-number, page-footer right-edge
898
+ // style) to still apply. mergeFirst would lock onto the layout's whole
899
+ // pPr and hide the master's algn, so check each layer in turn.
900
+ const align =
901
+ text.align ??
902
+ readAlign(layoutPh?.pPr) ??
903
+ readAlign(masterPh?.pPr) ??
904
+ readAlign(masterLvl1) ??
905
+ "left";
521
906
  const valign =
522
907
  readBodyVAlign(effectiveTxBody?.["a:bodyPr"]) ??
523
908
  readBodyVAlign(fallbackBodyPr) ??
@@ -529,7 +914,10 @@ function makeTextElement(
529
914
  : Math.round(defaultFontSizePx(phType, ctx) * scale);
530
915
  const fontFamily =
531
916
  first?.fontFamily ??
532
- fallbackRPr?.["a:latin"]?.["@_typeface"] ??
917
+ resolveFontFamily(
918
+ fallbackRPr?.["a:latin"]?.["@_typeface"],
919
+ ctx.themeFonts
920
+ ) ??
533
921
  "Inter";
534
922
  const fontWeight = first?.bold ? 700 : 400;
535
923
  const color =
@@ -585,6 +973,65 @@ function makeTextElement(
585
973
  : 0,
586
974
  ...(hasMixedFormatting ? { runs } : {}),
587
975
  };
976
+ // Inner padding from <a:bodyPr lIns/tIns/rIns/bIns>. PowerPoint applies
977
+ // these as text-box insets (the typographic equivalent of CSS padding).
978
+ // Default values in OOXML are 91440 / 45720 / 91440 / 45720 EMU. The
979
+ // slide often carries an empty <a:bodyPr/> that should silently inherit
980
+ // each attribute from the layout/master, so we read per-field rather
981
+ // than swap whole bodyPr objects.
982
+ const slideBp = effectiveTxBody?.["a:bodyPr"];
983
+ const layoutBp = layoutPh?.bodyPr;
984
+ const masterBp = masterPh?.bodyPr;
985
+ const readIns = (key: string, fallback: number): number => {
986
+ const v =
987
+ slideBp?.[`@_${key}`] ??
988
+ layoutBp?.[`@_${key}`] ??
989
+ masterBp?.[`@_${key}`];
990
+ return v !== undefined ? Number(v) : fallback;
991
+ };
992
+ const lIns = readIns("lIns", 91440);
993
+ const tIns = readIns("tIns", 45720);
994
+ const rIns = readIns("rIns", 91440);
995
+ const bIns = readIns("bIns", 45720);
996
+ const padding = {
997
+ l: Math.round(emuToPx(lIns) * ctx.fit.scale),
998
+ t: Math.round(emuToPx(tIns) * ctx.fit.scale),
999
+ r: Math.round(emuToPx(rIns) * ctx.fit.scale),
1000
+ b: Math.round(emuToPx(bIns) * ctx.fit.scale),
1001
+ };
1002
+ if (padding.l || padding.t || padding.r || padding.b) {
1003
+ el.padding = padding;
1004
+ }
1005
+
1006
+ // Layout placeholders often supply a fill (e.g. a tinted body box) or a
1007
+ // <a:custGeom> path (a white brand-logo plate) that should sit
1008
+ // *immediately* behind the slide's hosted text — at the same z, not in
1009
+ // the underlay. Otherwise a full-bleed image on the slide will cover the
1010
+ // backing. The slide's own <p:spPr> can also override the fill on a
1011
+ // per-element basis (e.g. one chip in a roadmap is the "active" red
1012
+ // tile) — read the slide's spPr first and fall back to the layout's.
1013
+ const slideSpPr = sp?.["p:spPr"];
1014
+ const layoutSpPr = layoutPh?.spPr;
1015
+ const slideFill = slideSpPr ? extractShapeFill(slideSpPr, ctx.theme) : undefined;
1016
+ const layoutFill = layoutSpPr ? extractShapeFill(layoutSpPr, ctx.theme) : undefined;
1017
+ const phFill = slideFill ?? layoutFill;
1018
+ const phSpPr = layoutSpPr;
1019
+ const phPath = phSpPr?.["a:custGeom"]
1020
+ ? parseCustGeomPath(phSpPr["a:custGeom"])
1021
+ : undefined;
1022
+ if (phPath && phFill && phFill !== "transparent") {
1023
+ // custGeom defines the actual rendered silhouette; the fill applies to
1024
+ // it. Skip the flat background and render the glyph as a backing path.
1025
+ el.backingPath = {
1026
+ d: phPath.d,
1027
+ viewW: phPath.viewW,
1028
+ viewH: phPath.viewH,
1029
+ fill: phFill,
1030
+ fillRule: phPath.fillRule,
1031
+ };
1032
+ } else if (phFill && phFill !== "transparent") {
1033
+ el.background = phFill;
1034
+ }
588
1035
  return el;
589
1036
  }
590
1037
 
@@ -601,10 +1048,25 @@ async function parsePic(
601
1048
  outer: GroupTransform
602
1049
  ): Promise<SlideElement | null> {
603
1050
  const xfrm = pic?.["p:spPr"]?.["a:xfrm"];
604
- const geom = readGeometry(xfrm, ctx.fit, outer);
1051
+ // Picture placeholders (<p:ph type="pic">) often omit xfrm inherit
1052
+ // geometry from the layout/master placeholder of the same key.
1053
+ const ph = pic?.["p:nvPicPr"]?.["p:nvPr"]?.["p:ph"];
1054
+ const layoutPh = ph ? lookupPlaceholder(ctx.layoutPh, ph) : undefined;
1055
+ const masterPh = ph ? lookupPlaceholder(ctx.masterPh, ph) : undefined;
1056
+ const geom =
1057
+ readGeometry(xfrm, ctx.fit, outer)
1058
+ ?? placeholderGeometry(layoutPh, ctx.fit, outer)
1059
+ ?? placeholderGeometry(masterPh, ctx.fit, outer);
605
1060
  if (!geom) return toUnknown(pic, "p:pic", ctx, outer);
606
1061
 
607
- const blipRef = pic?.["p:blipFill"]?.["a:blip"]?.["@_r:embed"];
1062
+ // Modern PPTX embeds SVGs via a dual-blip: <a:blip r:embed="rId_png">…
1063
+ // <a:extLst><a:ext uri="…"><asvg:svgBlip r:embed="rId_svg"/></a:ext></a:extLst>
1064
+ // </a:blip>. The outer embed is the raster fallback; prefer the SVG when
1065
+ // present so vector logos stay sharp.
1066
+ const blip = pic?.["p:blipFill"]?.["a:blip"];
1067
+ const svgRef = findSvgBlipRef(blip);
1068
+ const rasterRef = blip?.["@_r:embed"];
1069
+ const blipRef = svgRef ?? rasterRef;
608
1070
  if (!blipRef) return toUnknown(pic, "p:pic", ctx, outer);
609
1071
 
610
1072
  const mediaPath = ctx.slideRels.byId.get(blipRef)?.target;
@@ -614,8 +1076,20 @@ async function parsePic(
614
1076
  const file = ctx.zip.file(fullPath);
615
1077
  if (!file) return toUnknown(pic, "p:pic", ctx, outer);
616
1078
 
617
- const base64 = await file.async("base64");
618
1079
  const ext = (fullPath.split(".").pop() || "png").toLowerCase();
1080
+ // EMF / WMF are Microsoft vector formats that browsers can't render
1081
+ // natively. Skip them with a diagnostic — emitting them as
1082
+ // <img src="data:application/octet-stream…"> surfaces a broken-image
1083
+ // icon, and synthesising a fake placeholder only hides the gap.
1084
+ // Consumers needing fidelity should pre-rasterise the metafiles before
1085
+ // import; a true EMF→SVG path needs a dedicated JS decoder (separate PR).
1086
+ if (ext === "emf" || ext === "wmf") {
1087
+ ctx.diagnostics.warnings.push(
1088
+ `Skipped ${ext.toUpperCase()} image at ${fullPath} — vector metafiles aren't supported in the browser.`
1089
+ );
1090
+ return null;
1091
+ }
1092
+ const base64 = await file.async("base64");
619
1093
  const mime = mimeForExt(ext);
620
1094
 
621
1095
  const blipFill = pic?.["p:blipFill"];
@@ -646,6 +1120,21 @@ async function parsePic(
646
1120
  return image;
647
1121
  }
648
1122
 
1123
+ /**
1124
+ * Pull the SVG blip rId from a:blip/a:extLst/a:ext/asvg:svgBlip if present.
1125
+ * Returns undefined when the picture is raster-only.
1126
+ */
1127
+ function findSvgBlipRef(blip: any): string | undefined {
1128
+ if (!blip) return undefined;
1129
+ const exts = asArray(blip?.["a:extLst"]?.["a:ext"]);
1130
+ for (const ext of exts) {
1131
+ const svg = ext?.["asvg:svgBlip"];
1132
+ const ref = svg?.["@_r:embed"];
1133
+ if (ref) return ref;
1134
+ }
1135
+ return undefined;
1136
+ }
1137
+
649
1138
  function parseCxn(
650
1139
  cxn: any,
651
1140
  ctx: ParseContext,
@@ -746,7 +1235,7 @@ function parseTable(
746
1235
  }
747
1236
  const txBody = tc["a:txBody"];
748
1237
  const text = txBody
749
- ? extractRuns(txBody, ctx.theme)
1238
+ ? extractRuns(txBody, ctx.theme, undefined, undefined, ctx.themeFonts)
750
1239
  : { plain: "", runs: [] as RunInfo[] };
751
1240
  cells.push(text.plain);
752
1241
 
@@ -844,11 +1333,24 @@ function lookupPlaceholder(
844
1333
  map.get(`${type}|${idx}`) ??
845
1334
  map.get(`|${idx}`) ??
846
1335
  map.get(`${type}|`) ??
847
- (type === "ctrTitle" ? map.get("title|") : undefined) ??
848
- (type === "subTitle" ? map.get("body|") : undefined)
1336
+ findByType(map, type) ??
1337
+ (type === "ctrTitle" ? findByType(map, "title") : undefined) ??
1338
+ (type === "subTitle" ? findByType(map, "body") : undefined)
849
1339
  );
850
1340
  }
851
1341
 
1342
+ function findByType(
1343
+ map: Map<string, PlaceholderInfo>,
1344
+ type: string
1345
+ ): PlaceholderInfo | undefined {
1346
+ if (!type) return undefined;
1347
+ const prefix = `${type}|`;
1348
+ for (const [key, value] of map) {
1349
+ if (key.startsWith(prefix)) return value;
1350
+ }
1351
+ return undefined;
1352
+ }
1353
+
852
1354
  function placeholderGeometry(
853
1355
  ph: PlaceholderInfo | undefined,
854
1356
  fit: Fit,
@@ -883,36 +1385,148 @@ function extractPlaceholders(rootXml: any): Map<string, PlaceholderInfo> {
883
1385
  const paragraphs = asArray(txBody?.["a:p"]);
884
1386
  const firstP = paragraphs[0];
885
1387
  const firstR = asArray(firstP?.["a:r"])[0];
1388
+ // Default run/paragraph properties live on the placeholder's
1389
+ // <a:lstStyle><a:lvl1pPr> (font, size, colour, etc). A stub <a:r><a:rPr/>
1390
+ // with just `lang` carries no real style — prefer the lstStyle defaults
1391
+ // over an empty inline rPr so the layout's typography reaches the slide.
1392
+ const lstStyle = txBody?.["a:lstStyle"];
1393
+ const lvl1 = lstStyle?.["a:lvl1pPr"];
1394
+ const lvlPPr = collectLevelPPrs(lstStyle);
1395
+ const stubRPr = firstR?.["a:rPr"];
886
1396
  const info: PlaceholderInfo = {
887
1397
  rawX: off ? emuToPx(Number(off["@_x"] ?? 0)) : undefined,
888
1398
  rawY: off ? emuToPx(Number(off["@_y"] ?? 0)) : undefined,
889
1399
  rawW: ext ? emuToPx(Number(ext["@_cx"] ?? 0)) : undefined,
890
1400
  rawH: ext ? emuToPx(Number(ext["@_cy"] ?? 0)) : undefined,
891
1401
  rotation: xfrm?.["@_rot"] ? Number(xfrm["@_rot"]) / 60000 : 0,
892
- rPr: firstR?.["a:rPr"] ?? firstP?.["a:pPr"]?.["a:defRPr"],
893
- pPr: firstP?.["a:pPr"],
1402
+ rPr: pickMeaningful(
1403
+ stubRPr,
1404
+ lvl1?.["a:defRPr"],
1405
+ firstP?.["a:pPr"]?.["a:defRPr"]
1406
+ ),
1407
+ pPr: lvl1 ?? firstP?.["a:pPr"],
894
1408
  bodyPr: txBody?.["a:bodyPr"],
1409
+ lvlPPr: lvlPPr.some(Boolean) ? lvlPPr : undefined,
895
1410
  paragraphs: hasAnyText(txBody) ? paragraphs : undefined,
1411
+ spPr: sp?.["p:spPr"],
896
1412
  };
897
1413
  out.set(placeholderKey(ph), info);
898
1414
  }
899
1415
  return out;
900
1416
  }
901
1417
 
1418
+ /**
1419
+ * Return the first candidate that carries actual style fields (font, size,
1420
+ * colour, weight, italic, underline). An empty `<a:rPr lang="en-GB"/>` looks
1421
+ * truthy but contributes nothing, so it shouldn't shadow a meaningful
1422
+ * lstStyle/defRPr further down the chain.
1423
+ */
1424
+ function pickMeaningful(...candidates: any[]): any {
1425
+ for (const c of candidates) {
1426
+ if (!c) continue;
1427
+ if (rPrHasStyle(c)) return c;
1428
+ }
1429
+ // Fall back to the first defined value, even if otherwise empty, so we
1430
+ // never regress callers that depend on truthiness.
1431
+ return candidates.find((c) => c !== undefined && c !== null);
1432
+ }
1433
+
1434
+ function rPrHasStyle(rPr: any): boolean {
1435
+ if (!rPr || typeof rPr !== "object") return false;
1436
+ return (
1437
+ rPr["@_sz"] !== undefined ||
1438
+ rPr["@_b"] !== undefined ||
1439
+ rPr["@_i"] !== undefined ||
1440
+ rPr["@_u"] !== undefined ||
1441
+ rPr["@_spc"] !== undefined ||
1442
+ rPr["a:latin"] !== undefined ||
1443
+ rPr["a:solidFill"] !== undefined
1444
+ );
1445
+ }
1446
+
902
1447
  function extractMasterTextDefaults(masterXml: any): MasterTextDefaults {
903
1448
  const txStyles = masterXml?.["p:sldMaster"]?.["p:txStyles"];
904
1449
  if (!txStyles) return {};
905
1450
  return {
906
- title: txStyles?.["p:titleStyle"]?.["a:lvl1pPr"],
907
- body: txStyles?.["p:bodyStyle"]?.["a:lvl1pPr"],
908
- other: txStyles?.["p:otherStyle"]?.["a:lvl1pPr"],
1451
+ title: collectLevelPPrs(txStyles?.["p:titleStyle"]),
1452
+ body: collectLevelPPrs(txStyles?.["p:bodyStyle"]),
1453
+ other: collectLevelPPrs(txStyles?.["p:otherStyle"]),
909
1454
  };
910
1455
  }
911
1456
 
1457
+ function collectLevelPPrs(style: any): (any | undefined)[] {
1458
+ if (!style) return [];
1459
+ const out: (any | undefined)[] = [];
1460
+ for (let lvl = 1; lvl <= 9; lvl++) {
1461
+ out.push(style[`a:lvl${lvl}pPr`]);
1462
+ }
1463
+ return out;
1464
+ }
1465
+
912
1466
  // ---------------------------------------------------------------------------
913
1467
  // theme
914
1468
  // ---------------------------------------------------------------------------
915
1469
 
1470
+ function extractThemeFonts(themeXml: any): ThemeFonts {
1471
+ const fs = themeXml?.["a:theme"]?.["a:themeElements"]?.["a:fontScheme"];
1472
+ return {
1473
+ majorLatin: fs?.["a:majorFont"]?.["a:latin"]?.["@_typeface"] || undefined,
1474
+ minorLatin: fs?.["a:minorFont"]?.["a:latin"]?.["@_typeface"] || undefined,
1475
+ };
1476
+ }
1477
+
1478
+ function extractClrMap(node: any): ClrMap {
1479
+ if (!node) return DEFAULT_CLR_MAP;
1480
+ const pick = (attr: string, fallback: ClrMap[keyof ClrMap]) => {
1481
+ const v = node[`@_${attr}`];
1482
+ return isThemeKey(v) ? (v as ClrMap[keyof ClrMap]) : fallback;
1483
+ };
1484
+ return {
1485
+ bg1: pick("bg1", DEFAULT_CLR_MAP.bg1),
1486
+ bg2: pick("bg2", DEFAULT_CLR_MAP.bg2),
1487
+ tx1: pick("tx1", DEFAULT_CLR_MAP.tx1),
1488
+ tx2: pick("tx2", DEFAULT_CLR_MAP.tx2),
1489
+ };
1490
+ }
1491
+
1492
+ const THEME_KEYS = new Set([
1493
+ "dk1",
1494
+ "lt1",
1495
+ "dk2",
1496
+ "lt2",
1497
+ "accent1",
1498
+ "accent2",
1499
+ "accent3",
1500
+ "accent4",
1501
+ "accent5",
1502
+ "accent6",
1503
+ "hlink",
1504
+ "folHlink",
1505
+ ]);
1506
+
1507
+ function isThemeKey(v: unknown): v is keyof ThemeColors {
1508
+ return typeof v === "string" && THEME_KEYS.has(v);
1509
+ }
1510
+
1511
+ /**
1512
+ * Resolve OOXML major/minor font tokens (`+mj-lt`, `+mn-lt`, …) against the
1513
+ * theme's font scheme. Returns the original value when it isn't a token, and
1514
+ * `undefined` when the token can't be resolved.
1515
+ */
1516
+ function resolveFontFamily(
1517
+ raw: string | undefined,
1518
+ fonts: ThemeFonts
1519
+ ): string | undefined {
1520
+ if (!raw) return undefined;
1521
+ if (raw === "+mj-lt" || raw === "+mj-ea" || raw === "+mj-cs") {
1522
+ return fonts.majorLatin;
1523
+ }
1524
+ if (raw === "+mn-lt" || raw === "+mn-ea" || raw === "+mn-cs") {
1525
+ return fonts.minorLatin;
1526
+ }
1527
+ return raw;
1528
+ }
1529
+
916
1530
  function extractTheme(themeXml: any): ThemeColors {
917
1531
  const scheme =
918
1532
  themeXml?.["a:theme"]?.["a:themeElements"]?.["a:clrScheme"] ?? {};
@@ -1020,23 +1634,13 @@ function readBaseHex(node: any, theme: ThemeColors): string | undefined {
1020
1634
  }
1021
1635
 
1022
1636
  function resolveSchemeToken(token: string, theme: ThemeColors): string {
1023
- switch (token) {
1024
- case "bg1":
1025
- return theme.lt1;
1026
- case "bg2":
1027
- return theme.lt2;
1028
- case "tx1":
1029
- return theme.dk1;
1030
- case "tx2":
1031
- return theme.dk2;
1032
- case "phClr":
1033
- // Placeholder color sentinel — best-effort fallback.
1034
- return theme.dk1;
1035
- default: {
1036
- const v = (theme as unknown as Record<string, string>)[token];
1037
- return v ?? "#000000";
1038
- }
1637
+ if (token === "phClr") {
1638
+ // Placeholder colour sentinel — caller (substitutePhClr) should already
1639
+ // have replaced this; surface a sensible fallback if it didn't.
1640
+ return theme.dk1;
1039
1641
  }
1642
+ const v = (theme as unknown as Record<string, string>)[token];
1643
+ return v ?? "#000000";
1040
1644
  }
1041
1645
 
1042
1646
  function resolvePresetColor(name: string): string {
@@ -1150,6 +1754,33 @@ function extractShapeFill(spPr: any, theme: ThemeColors): string | undefined {
1150
1754
  (s) => s.color.length === 9 && s.color.endsWith("00")
1151
1755
  );
1152
1756
  if (allTransparent) return "transparent";
1757
+ // Radial / path gradient: <a:path path="circle|rect|shape"> with
1758
+ // <a:fillToRect> giving the focus rectangle. l/t/r/b are percentage
1759
+ // insets from each edge of the shape; the rect's centre is the focus
1760
+ // point. OOXML radial stops use the SAME convention as CSS — pos=0 at
1761
+ // the centre, pos=100% at the outer boundary — so we keep the stop
1762
+ // positions verbatim. The visual blob lands where fillToRect sits;
1763
+ // on a tall, narrow panel the same radial reads almost vertical, on a
1764
+ // 16:9 slide it reads as the expected red orb fading to purple.
1765
+ const pathNode = gf["a:path"];
1766
+ if (pathNode) {
1767
+ const pathType = pathNode["@_path"];
1768
+ const ftr = pathNode["a:fillToRect"];
1769
+ const lIn = Number(ftr?.["@_l"] ?? 0) / 1000;
1770
+ const tIn = Number(ftr?.["@_t"] ?? 0) / 1000;
1771
+ const rIn = Number(ftr?.["@_r"] ?? 0) / 1000;
1772
+ const bIn = Number(ftr?.["@_b"] ?? 0) / 1000;
1773
+ const focusX = clampPct((lIn + (100 - rIn)) / 2);
1774
+ const focusY = clampPct((tIn + (100 - bIn)) / 2);
1775
+ // path="circle" — a true geometric circle in CSS terms (so the blob
1776
+ // stays round on rectangular shapes); path="rect" — anisotropic
1777
+ // ellipse stretched with the shape's aspect ratio.
1778
+ const shape = pathType === "circle" ? "circle" : "ellipse";
1779
+ const stopsCss = stops
1780
+ .map((s) => `${s.color} ${s.pos.toFixed(2)}%`)
1781
+ .join(", ");
1782
+ return `radial-gradient(${shape} at ${focusX.toFixed(2)}% ${focusY.toFixed(2)}%, ${stopsCss})`;
1783
+ }
1153
1784
  const angDeg = gf["a:lin"]?.["@_ang"]
1154
1785
  ? (Number(gf["a:lin"]["@_ang"]) / 60000 + 90) % 360
1155
1786
  : 90;
@@ -1159,11 +1790,185 @@ function extractShapeFill(spPr: any, theme: ThemeColors): string | undefined {
1159
1790
  return undefined;
1160
1791
  }
1161
1792
 
1162
- function extractBackgroundColor(bg: any, theme: ThemeColors): string | undefined {
1163
- return (
1164
- resolveColor(bg?.["p:bgPr"]?.["a:solidFill"], theme) ??
1165
- (bg?.["p:bgPr"]?.["a:noFill"] !== undefined ? "transparent" : undefined)
1166
- );
1793
+ function clampPct(v: number): number {
1794
+ if (!Number.isFinite(v)) return 0;
1795
+ if (v < 0) return 0;
1796
+ if (v > 100) return 100;
1797
+ return v;
1798
+ }
1799
+
1800
+ async function extractBackground(
1801
+ bg: any,
1802
+ ctx: ParseContext,
1803
+ rels: Rels,
1804
+ basePath: string
1805
+ ): Promise<string | undefined> {
1806
+ if (!bg) return undefined;
1807
+ const bgPr = bg["p:bgPr"];
1808
+ if (bgPr) {
1809
+ if (bgPr["a:noFill"] !== undefined) return "transparent";
1810
+ const solid = resolveColor(bgPr["a:solidFill"], ctx.theme);
1811
+ if (solid) return solid;
1812
+ const grad = extractShapeFill({ "a:gradFill": bgPr["a:gradFill"] }, ctx.theme);
1813
+ if (grad) return grad;
1814
+ const blip = bgPr["a:blipFill"]?.["a:blip"];
1815
+ if (blip) {
1816
+ const url = await blipDataUrl(blip, ctx, rels, basePath);
1817
+ if (url) return `center / cover no-repeat url("${url}")`;
1818
+ }
1819
+ }
1820
+ const bgRef = bg["p:bgRef"];
1821
+ if (bgRef) return resolveBgRef(bgRef, ctx);
1822
+ return undefined;
1823
+ }
1824
+
1825
+ async function blipDataUrl(
1826
+ blip: any,
1827
+ ctx: ParseContext,
1828
+ rels: Rels,
1829
+ basePath: string
1830
+ ): Promise<string | undefined> {
1831
+ // Prefer the vector embed when present (dual-blip SVG pattern); fall
1832
+ // back to the raster.
1833
+ const svgRef = findSvgBlipRef(blip);
1834
+ const rid = svgRef ?? blip?.["@_r:embed"];
1835
+ if (!rid) return undefined;
1836
+ const target = rels.byId.get(rid)?.target;
1837
+ if (!target) return undefined;
1838
+ const full = normalisePath(target, dirOf(basePath));
1839
+ const file = ctx.zip.file(full);
1840
+ if (!file) return undefined;
1841
+ const base64 = await file.async("base64");
1842
+ const ext = (full.split(".").pop() || "png").toLowerCase();
1843
+ return `data:${mimeForExt(ext)};base64,${base64}`;
1844
+ }
1845
+
1846
+ /**
1847
+ * Resolve <p:bgRef idx="..."> against the theme fill lists. idx 1001..1003
1848
+ * indexes a:bgFillStyleLst; idx 1+ indexes a:fillStyleLst (rarely used for
1849
+ * backgrounds). The <p:bgRef> also carries a color child (e.g. schemeClr) that
1850
+ * fills in any <a:schemeClr val="phClr"> placeholders inside the theme fill.
1851
+ */
1852
+ function resolveBgRef(bgRef: any, ctx: ParseContext): string | undefined {
1853
+ const rawIdx = bgRef?.["@_idx"];
1854
+ if (rawIdx === undefined) return undefined;
1855
+ const idx = Number(rawIdx);
1856
+ if (!Number.isFinite(idx)) return undefined;
1857
+ const list = idx >= 1000 ? ctx.themeFills.bg : ctx.themeFills.fg;
1858
+ const entry = list[(idx >= 1000 ? idx - 1001 : idx - 1)];
1859
+ if (!entry) return undefined;
1860
+ const phColor = readBaseHex(bgRef, ctx.theme);
1861
+ if (entry.kind === "solidFill") {
1862
+ const node = substitutePhClr(entry.node, phColor);
1863
+ return resolveColor(node, ctx.theme);
1864
+ }
1865
+ if (entry.kind === "gradFill") {
1866
+ const node = substitutePhClr(entry.node, phColor);
1867
+ return extractShapeFill({ "a:gradFill": node }, ctx.theme);
1868
+ }
1869
+ // blipFill / pattFill / noFill — not modelled as a slide background yet.
1870
+ if (entry.kind === "noFill") return "transparent";
1871
+ return undefined;
1872
+ }
1873
+
1874
+ /**
1875
+ * Replace <a:schemeClr val="phClr"> placeholders inside a theme fill template
1876
+ * with the caller's actual color, so the modifier chain (lumMod, shade, etc.)
1877
+ * still applies. Returns a shallow-cloned tree.
1878
+ */
1879
+ function substitutePhClr(node: any, phHex: string | undefined): any {
1880
+ if (!phHex) return node;
1881
+ const replacement = phHex.startsWith("#") ? phHex.slice(1) : phHex;
1882
+ const walk = (n: any): any => {
1883
+ if (n == null || typeof n !== "object") return n;
1884
+ if (Array.isArray(n)) return n.map(walk);
1885
+ const out: any = {};
1886
+ for (const k of Object.keys(n)) {
1887
+ if (k === "a:schemeClr" && n[k]?.["@_val"] === "phClr") {
1888
+ const mods: any = { ...n[k] };
1889
+ delete mods["@_val"];
1890
+ out["a:srgbClr"] = { ...mods, "@_val": replacement };
1891
+ } else {
1892
+ out[k] = walk(n[k]);
1893
+ }
1894
+ }
1895
+ return out;
1896
+ };
1897
+ return walk(node);
1898
+ }
1899
+
1900
+ function extractThemeFills(themeXml: any, themeRaw: string): ThemeFills {
1901
+ const fmt = themeXml?.["a:theme"]?.["a:themeElements"]?.["a:fmtScheme"];
1902
+ if (!fmt) return { bg: [], fg: [] };
1903
+ const build = (blockTag: string, parsed: any): ThemeFill[] => {
1904
+ const kinds = extractDirectChildOrder(themeRaw, blockTag);
1905
+ if (!kinds.length || !parsed) return [];
1906
+ const buckets: Record<string, any[]> = {};
1907
+ for (const kind of new Set(kinds)) {
1908
+ buckets[kind] = asArray(parsed[`a:${kind}`]).slice();
1909
+ }
1910
+ const out: ThemeFill[] = [];
1911
+ for (const kind of kinds) {
1912
+ const node = buckets[kind]?.shift();
1913
+ if (node !== undefined) out.push({ kind: kind as ThemeFill["kind"], node });
1914
+ }
1915
+ return out;
1916
+ };
1917
+ return {
1918
+ bg: build("bgFillStyleLst", fmt["a:bgFillStyleLst"]),
1919
+ fg: build("fillStyleLst", fmt["a:fillStyleLst"]),
1920
+ };
1921
+ }
1922
+
1923
+ /**
1924
+ * Returns the local tag names of direct children of the first <a:{blockTag}>
1925
+ * in document order. Used to recover the cross-tag-type order that
1926
+ * fast-xml-parser drops when it groups by element name.
1927
+ */
1928
+ function extractDirectChildOrder(rawXml: string, blockTag: string): string[] {
1929
+ const openRe = new RegExp(`<a:${blockTag}\\b[^>]*>`);
1930
+ const close = `</a:${blockTag}>`;
1931
+ const m = openRe.exec(rawXml);
1932
+ if (!m) return [];
1933
+ const start = m.index + m[0].length;
1934
+ const end = rawXml.indexOf(close, start);
1935
+ if (end < 0) return [];
1936
+ const inner = rawXml.slice(start, end);
1937
+ const tags: string[] = [];
1938
+ let depth = 0;
1939
+ let i = 0;
1940
+ while (i < inner.length) {
1941
+ if (inner[i] !== "<") {
1942
+ i++;
1943
+ continue;
1944
+ }
1945
+ if (inner.startsWith("<!--", i)) {
1946
+ const j = inner.indexOf("-->", i);
1947
+ if (j < 0) break;
1948
+ i = j + 3;
1949
+ continue;
1950
+ }
1951
+ if (inner.startsWith("<?", i)) {
1952
+ const j = inner.indexOf("?>", i);
1953
+ if (j < 0) break;
1954
+ i = j + 2;
1955
+ continue;
1956
+ }
1957
+ const closeBracket = inner.indexOf(">", i);
1958
+ if (closeBracket < 0) break;
1959
+ const tag = inner.slice(i, closeBracket + 1);
1960
+ if (tag.startsWith("</")) {
1961
+ depth--;
1962
+ i = closeBracket + 1;
1963
+ continue;
1964
+ }
1965
+ const isSelfClose = tag.endsWith("/>");
1966
+ const nameMatch = /^<a:([\w]+)/.exec(tag);
1967
+ if (depth === 0 && nameMatch) tags.push(nameMatch[1]);
1968
+ if (!isSelfClose) depth++;
1969
+ i = closeBracket + 1;
1970
+ }
1971
+ return tags;
1167
1972
  }
1168
1973
 
1169
1974
  function readBodyVAlign(bodyPr: any): "top" | "middle" | "bottom" | undefined {
@@ -1198,7 +2003,10 @@ function extractRuns(
1198
2003
  txBody: any,
1199
2004
  theme: ThemeColors,
1200
2005
  fallbackRPr?: any,
1201
- fallbackPPr?: any
2006
+ fallbackPPr?: any,
2007
+ themeFonts: ThemeFonts = {},
2008
+ listStyle: (any | undefined)[][] = [],
2009
+ autoFit?: AutoFit
1202
2010
  ): {
1203
2011
  runs: RunInfo[];
1204
2012
  plain: string;
@@ -1210,57 +2018,92 @@ function extractRuns(
1210
2018
  let lineHeightPct: number | undefined;
1211
2019
  const paragraphs = asArray(txBody?.["a:p"]);
1212
2020
  const pieces: string[] = [];
2021
+ const autoNumCounters = new Map<number, number>();
2022
+ let prevAutoKey: string | undefined;
1213
2023
 
1214
2024
  for (let pi = 0; pi < paragraphs.length; pi++) {
1215
2025
  const p = paragraphs[pi];
1216
2026
  const pPr = p?.["a:pPr"];
2027
+ const lvl = clampLevel(Number(pPr?.["@_lvl"] ?? 0));
2028
+ const levelChain = listStyle[lvl] ?? [];
2029
+ const findLevel = (k: string): any =>
2030
+ [pPr, ...levelChain, fallbackPPr].find((s) => s?.[k] !== undefined);
1217
2031
  if (!align) {
1218
- align = readAlign(pPr) ?? readAlign(fallbackPPr);
2032
+ align =
2033
+ readAlign(pPr) ??
2034
+ readAlign(levelChain.find((s) => s?.["@_algn"])) ??
2035
+ readAlign(fallbackPPr);
1219
2036
  }
1220
2037
  if (lineHeightPct === undefined) {
1221
- const lnPct =
1222
- pPr?.["a:lnSpc"]?.["a:spcPct"]?.["@_val"] ??
1223
- fallbackPPr?.["a:lnSpc"]?.["a:spcPct"]?.["@_val"];
1224
- if (lnPct) lineHeightPct = Number(lnPct) / 100000;
2038
+ const src = findLevel("a:lnSpc");
2039
+ const lnPct = src?.["a:lnSpc"]?.["a:spcPct"]?.["@_val"];
2040
+ if (lnPct) {
2041
+ const base = Number(lnPct) / 100000;
2042
+ const reduction = autoFit?.lnSpcReduction ?? 0;
2043
+ lineHeightPct = base * (1 - reduction);
2044
+ }
1225
2045
  }
2046
+
2047
+ // Resolve bullet across the inheritance chain (slide pPr > layout > master
2048
+ // placeholder > master txStyles). Each layer can specify just the bullet
2049
+ // without overriding everything else.
2050
+ const bullet = resolveBullet([pPr, ...levelChain]);
2051
+ const prefix = computeBulletPrefix(
2052
+ bullet,
2053
+ lvl,
2054
+ autoNumCounters,
2055
+ prevAutoKey
2056
+ );
2057
+ prevAutoKey = prefix.autoKey;
2058
+
1226
2059
  const rs = asArray(p?.["a:r"]);
2060
+ const flds = asArray(p?.["a:fld"]);
2061
+ const paraStart = runs.length;
1227
2062
  const paragraphText: string[] = [];
1228
- for (const r of rs) {
1229
- const t = r?.["a:t"];
1230
- const rPr = r?.["a:rPr"] ?? {};
1231
- const text = typeof t === "string" ? t : t?.["#text"] ?? "";
1232
- paragraphText.push(text);
1233
- const spcRaw = rPr?.["@_spc"] ?? fallbackRPr?.["@_spc"];
1234
- const letterSpacing =
1235
- spcRaw !== undefined && spcRaw !== ""
1236
- ? pointsToPx(Number(spcRaw) / 100)
1237
- : undefined;
1238
- const fontSize =
1239
- rPr?.["@_sz"] ?? fallbackRPr?.["@_sz"]
1240
- ? pointsToPx(Number(rPr?.["@_sz"] ?? fallbackRPr?.["@_sz"]) / 100)
1241
- : undefined;
1242
- const fontFamily =
1243
- rPr?.["a:latin"]?.["@_typeface"] ??
1244
- fallbackRPr?.["a:latin"]?.["@_typeface"];
1245
- const color =
1246
- resolveColor(rPr?.["a:solidFill"], theme) ??
1247
- resolveColor(fallbackRPr?.["a:solidFill"], theme);
1248
- const boldVal = rPr?.["@_b"] ?? fallbackRPr?.["@_b"];
1249
- const italicVal = rPr?.["@_i"] ?? fallbackRPr?.["@_i"];
1250
- const underlineVal = rPr?.["@_u"] ?? fallbackRPr?.["@_u"];
1251
- const strikeVal = rPr?.["@_strike"] ?? fallbackRPr?.["@_strike"];
1252
- runs.push({
1253
- text,
1254
- fontFamily,
1255
- fontSize,
1256
- bold: boldVal === "1" || boldVal === 1,
1257
- italic: italicVal === "1" || italicVal === 1,
1258
- underline: underlineVal && underlineVal !== "none",
1259
- strike: strikeVal === "sngStrike",
1260
- color,
1261
- letterSpacing,
1262
- });
2063
+ if (prefix.text) paragraphText.push(prefix.text);
2064
+
2065
+ const onRun = (r: any) => {
2066
+ const built = buildRunInfo(r, theme, themeFonts, fallbackRPr);
2067
+ if (autoFit?.fontScale && built.run.fontSize) {
2068
+ built.run.fontSize *= autoFit.fontScale;
2069
+ }
2070
+ runs.push(built.run);
2071
+ paragraphText.push(built.text);
2072
+ };
2073
+
2074
+ // <a:br/> hard line breaks AND <a:fld> field placeholders (e.g.
2075
+ // datetime1, slidenum) are siblings of <a:r>; when any are present
2076
+ // walk the paragraph children in document order so they land in the
2077
+ // right place. Otherwise stay on the fast path.
2078
+ if (p?.["a:br"] || p?.["a:fld"]) {
2079
+ const order = paragraphChildOrder(p);
2080
+ for (const entry of order) {
2081
+ if (entry.kind === "br") {
2082
+ if (runs.length) runs[runs.length - 1].text += "\n";
2083
+ paragraphText.push("\n");
2084
+ continue;
2085
+ }
2086
+ const source = entry.kind === "r" ? rs : flds;
2087
+ const r = source[entry.index];
2088
+ if (r) onRun(r);
2089
+ }
2090
+ } else {
2091
+ for (const r of rs) onRun(r);
2092
+ }
2093
+
2094
+ // Prepend the bullet prefix to the first run of this paragraph so it
2095
+ // survives renderers that walk `runs` instead of the joined `plain` text.
2096
+ if (prefix.text && runs.length > paraStart) {
2097
+ runs[paraStart].text = prefix.text + runs[paraStart].text;
2098
+ }
2099
+
2100
+ // Carry the inter-paragraph break onto the last run we just emitted —
2101
+ // renderers that walk `runs` (mixed-formatting path) would otherwise
2102
+ // concatenate paragraphs into one long line.
2103
+ if (pi < paragraphs.length - 1 && runs.length > 0) {
2104
+ runs[runs.length - 1].text += "\n";
1263
2105
  }
2106
+
1264
2107
  pieces.push(paragraphText.join(""));
1265
2108
  }
1266
2109
  return {
@@ -1271,11 +2114,419 @@ function extractRuns(
1271
2114
  };
1272
2115
  }
1273
2116
 
2117
+ interface AutoFit {
2118
+ fontScale?: number; // 0..1
2119
+ lnSpcReduction?: number; // 0..1
2120
+ }
2121
+
2122
+ function readNormAutofit(bodyPr: any): AutoFit | undefined {
2123
+ const af = bodyPr?.["a:normAutofit"];
2124
+ if (!af) return undefined;
2125
+ const fontScale = af["@_fontScale"] ? Number(af["@_fontScale"]) / 100000 : undefined;
2126
+ const lnSpcReduction = af["@_lnSpcReduction"]
2127
+ ? Number(af["@_lnSpcReduction"]) / 100000
2128
+ : undefined;
2129
+ if (fontScale === undefined && lnSpcReduction === undefined) return undefined;
2130
+ return { fontScale, lnSpcReduction };
2131
+ }
2132
+
2133
+ interface ResolvedBullet {
2134
+ kind: "none" | "char" | "auto";
2135
+ char?: string;
2136
+ autoType?: string;
2137
+ autoStartAt?: number;
2138
+ }
2139
+
2140
+ function resolveBullet(sources: (any | undefined)[]): ResolvedBullet {
2141
+ // First source that defines any of buNone/buChar/buAutoNum wins.
2142
+ for (const src of sources) {
2143
+ if (!src) continue;
2144
+ if (src["a:buNone"] !== undefined) return { kind: "none" };
2145
+ if (src["a:buChar"]?.["@_char"])
2146
+ return { kind: "char", char: String(src["a:buChar"]["@_char"]) };
2147
+ if (src["a:buAutoNum"]) {
2148
+ return {
2149
+ kind: "auto",
2150
+ autoType: src["a:buAutoNum"]["@_type"] ?? "arabicPeriod",
2151
+ autoStartAt: src["a:buAutoNum"]["@_startAt"]
2152
+ ? Number(src["a:buAutoNum"]["@_startAt"])
2153
+ : 1,
2154
+ };
2155
+ }
2156
+ }
2157
+ return { kind: "none" };
2158
+ }
2159
+
2160
+ function computeBulletPrefix(
2161
+ bullet: ResolvedBullet,
2162
+ level: number,
2163
+ counters: Map<number, number>,
2164
+ prevAutoKey: string | undefined
2165
+ ): { text: string; autoKey: string | undefined } {
2166
+ const indent = " ".repeat(level);
2167
+ if (bullet.kind === "none") {
2168
+ // Drop the counter so a later run of <a:buAutoNum> restarts at 1 even at
2169
+ // the same level.
2170
+ counters.delete(level);
2171
+ return { text: "", autoKey: undefined };
2172
+ }
2173
+ if (bullet.kind === "char") {
2174
+ counters.delete(level);
2175
+ return {
2176
+ text: `${indent}${bullet.char ?? "•"} `,
2177
+ autoKey: undefined,
2178
+ };
2179
+ }
2180
+ const key = `auto|${level}|${bullet.autoType ?? "arabicPeriod"}`;
2181
+ const continuing = key === prevAutoKey;
2182
+ const next = continuing
2183
+ ? (counters.get(level) ?? bullet.autoStartAt ?? 1) + 1
2184
+ : bullet.autoStartAt ?? 1;
2185
+ counters.set(level, next);
2186
+ return {
2187
+ text: `${indent}${formatAutoNum(next, bullet.autoType ?? "arabicPeriod")} `,
2188
+ autoKey: key,
2189
+ };
2190
+ }
2191
+
2192
+ function formatAutoNum(n: number, type: string): string {
2193
+ switch (type) {
2194
+ case "arabicPlain":
2195
+ return `${n}`;
2196
+ case "arabicParenR":
2197
+ return `${n})`;
2198
+ case "arabicParenBoth":
2199
+ return `(${n})`;
2200
+ case "arabicPeriod":
2201
+ return `${n}.`;
2202
+ case "alphaUcPeriod":
2203
+ return `${toAlpha(n).toUpperCase()}.`;
2204
+ case "alphaLcPeriod":
2205
+ return `${toAlpha(n)}.`;
2206
+ case "romanUcPeriod":
2207
+ return `${toRoman(n).toUpperCase()}.`;
2208
+ case "romanLcPeriod":
2209
+ return `${toRoman(n)}.`;
2210
+ default:
2211
+ return `${n}.`;
2212
+ }
2213
+ }
2214
+
2215
+ function toAlpha(n: number): string {
2216
+ let s = "";
2217
+ let v = n;
2218
+ while (v > 0) {
2219
+ const r = (v - 1) % 26;
2220
+ s = String.fromCharCode(97 + r) + s;
2221
+ v = Math.floor((v - 1) / 26);
2222
+ }
2223
+ return s || "a";
2224
+ }
2225
+
2226
+ function toRoman(n: number): string {
2227
+ if (n <= 0) return "";
2228
+ const pairs: [number, string][] = [
2229
+ [1000, "m"],
2230
+ [900, "cm"],
2231
+ [500, "d"],
2232
+ [400, "cd"],
2233
+ [100, "c"],
2234
+ [90, "xc"],
2235
+ [50, "l"],
2236
+ [40, "xl"],
2237
+ [10, "x"],
2238
+ [9, "ix"],
2239
+ [5, "v"],
2240
+ [4, "iv"],
2241
+ [1, "i"],
2242
+ ];
2243
+ let out = "";
2244
+ let v = n;
2245
+ for (const [val, sym] of pairs) {
2246
+ while (v >= val) {
2247
+ out += sym;
2248
+ v -= val;
2249
+ }
2250
+ }
2251
+ return out;
2252
+ }
2253
+
2254
+ function clampLevel(n: number): number {
2255
+ if (!Number.isFinite(n) || n < 0) return 0;
2256
+ if (n > 8) return 8;
2257
+ return Math.floor(n);
2258
+ }
2259
+
2260
+ function buildRunInfo(
2261
+ r: any,
2262
+ theme: ThemeColors,
2263
+ themeFonts: ThemeFonts,
2264
+ fallbackRPr: any
2265
+ ): { run: RunInfo; text: string } {
2266
+ const t = r?.["a:t"];
2267
+ const rPr = r?.["a:rPr"] ?? {};
2268
+ const text = typeof t === "string" ? t : t?.["#text"] ?? "";
2269
+ const spcRaw = rPr?.["@_spc"] ?? fallbackRPr?.["@_spc"];
2270
+ const letterSpacing =
2271
+ spcRaw !== undefined && spcRaw !== ""
2272
+ ? pointsToPx(Number(spcRaw) / 100)
2273
+ : undefined;
2274
+ const fontSize =
2275
+ rPr?.["@_sz"] ?? fallbackRPr?.["@_sz"]
2276
+ ? pointsToPx(Number(rPr?.["@_sz"] ?? fallbackRPr?.["@_sz"]) / 100)
2277
+ : undefined;
2278
+ const rawFontFamily =
2279
+ rPr?.["a:latin"]?.["@_typeface"] ??
2280
+ fallbackRPr?.["a:latin"]?.["@_typeface"];
2281
+ const fontFamily = resolveFontFamily(rawFontFamily, themeFonts);
2282
+ const color =
2283
+ resolveColor(rPr?.["a:solidFill"], theme) ??
2284
+ resolveColor(fallbackRPr?.["a:solidFill"], theme);
2285
+ const boldVal = rPr?.["@_b"] ?? fallbackRPr?.["@_b"];
2286
+ const italicVal = rPr?.["@_i"] ?? fallbackRPr?.["@_i"];
2287
+ const underlineVal = rPr?.["@_u"] ?? fallbackRPr?.["@_u"];
2288
+ const strikeVal = rPr?.["@_strike"] ?? fallbackRPr?.["@_strike"];
2289
+ return {
2290
+ text,
2291
+ run: {
2292
+ text,
2293
+ fontFamily,
2294
+ fontSize,
2295
+ bold: boldVal === "1" || boldVal === 1,
2296
+ italic: italicVal === "1" || italicVal === 1,
2297
+ underline: !!(underlineVal && underlineVal !== "none"),
2298
+ strike: strikeVal === "sngStrike",
2299
+ color,
2300
+ letterSpacing,
2301
+ },
2302
+ };
2303
+ }
2304
+
2305
+ /**
2306
+ * Returns the ordered list of <a:r>/<a:br> direct children of a paragraph
2307
+ * with each entry's running index into the corresponding array on the parsed
2308
+ * paragraph. The order is recovered from the paragraph's raw XML (attached
2309
+ * during readXml) because fast-xml-parser groups children by tag name.
2310
+ */
2311
+ function paragraphChildOrder(
2312
+ p: any
2313
+ ): { kind: "r" | "br" | "fld"; index: number }[] {
2314
+ const raw = (p as any)?._rawSrc as string | undefined;
2315
+ if (raw) return paragraphChildOrderFromRaw(raw);
2316
+ // No raw available (paragraph has no <a:br/> and pre-PR-2 readXml didn't
2317
+ // attach it). Fall back to the order implied by the parsed arrays: all
2318
+ // runs, then all fields. Document order across these tag types is lost
2319
+ // by fast-xml-parser, but the typical PPTX paragraph has either runs or
2320
+ // a field, not both, so this matches reality in practice.
2321
+ const out: { kind: "r" | "br" | "fld"; index: number }[] = [];
2322
+ asArray(p?.["a:r"]).forEach((_, i) => out.push({ kind: "r", index: i }));
2323
+ asArray(p?.["a:fld"]).forEach((_, i) => out.push({ kind: "fld", index: i }));
2324
+ return out;
2325
+ }
2326
+
2327
+ /**
2328
+ * Convert a PPTX `<a:custGeom>` (custom geometry — used for logos, brand
2329
+ * marks, hand-drawn shapes) into an SVG path. Reads command order from the
2330
+ * raw XML attached during readXml, since fast-xml-parser groups children by
2331
+ * tag name and drops cross-tag document order. Supports moveTo, lnTo,
2332
+ * cubicBezTo, quadBezTo, and close; arcTo and formula-based guide
2333
+ * references aren't translated yet (they degrade to a flat-fill rect).
2334
+ */
2335
+ function parseCustGeomPath(custGeom: any): ShapePath | undefined {
2336
+ const raw = (custGeom as any)?._rawSrc as string | undefined;
2337
+ if (!raw) return undefined;
2338
+ // Each <a:path w="…" h="…"> defines its own coordinate system; the SVG
2339
+ // viewBox uses the FIRST path's dimensions and subsequent paths inherit
2340
+ // it. In practice almost every custGeom in real decks uses one viewbox
2341
+ // across all sub-paths.
2342
+ const paths = findAllElementRawBlocks(raw, "path");
2343
+ if (!paths.length) return undefined;
2344
+ let viewW = 0;
2345
+ let viewH = 0;
2346
+ let d = "";
2347
+ for (const block of paths) {
2348
+ const headerEnd = block.indexOf(">");
2349
+ if (headerEnd < 0) continue;
2350
+ const header = block.slice(0, headerEnd + 1);
2351
+ const w = Number(/\bw="(\d+)"/.exec(header)?.[1] ?? 0);
2352
+ const h = Number(/\bh="(\d+)"/.exec(header)?.[1] ?? 0);
2353
+ if (w > viewW) viewW = w;
2354
+ if (h > viewH) viewH = h;
2355
+ d += (d ? " " : "") + custGeomBodyToSvgD(block);
2356
+ }
2357
+ if (!d || viewW <= 0 || viewH <= 0) return undefined;
2358
+ // OOXML composite paths (multiple subpaths with internal holes — letters
2359
+ // like the "e" and "o" in the eon wordmark) render with even-odd winding
2360
+ // by default; the nonzero default of SVG would fill the holes.
2361
+ return { d, viewW, viewH, fillRule: "evenodd" };
2362
+ }
2363
+
2364
+ function custGeomBodyToSvgD(pathBlock: string): string {
2365
+ const headerEnd = pathBlock.indexOf(">");
2366
+ const closeIdx = pathBlock.lastIndexOf("</a:path>");
2367
+ const inner =
2368
+ closeIdx > headerEnd
2369
+ ? pathBlock.slice(headerEnd + 1, closeIdx)
2370
+ : pathBlock.slice(headerEnd + 1);
2371
+ let out = "";
2372
+ let i = 0;
2373
+ let depth = 0;
2374
+ // Track the current pen position so <a:arcTo> (which doesn't carry an
2375
+ // explicit end point) can compute its SVG `A` endpoint from start +
2376
+ // sweep angle, just like PowerPoint does at render time.
2377
+ let penX = 0;
2378
+ let penY = 0;
2379
+ while (i < inner.length) {
2380
+ if (inner[i] !== "<") {
2381
+ i++;
2382
+ continue;
2383
+ }
2384
+ if (inner.startsWith("</", i)) {
2385
+ depth--;
2386
+ const end = inner.indexOf(">", i);
2387
+ if (end < 0) break;
2388
+ i = end + 1;
2389
+ continue;
2390
+ }
2391
+ if (inner.startsWith("<!--", i)) {
2392
+ const end = inner.indexOf("-->", i);
2393
+ if (end < 0) break;
2394
+ i = end + 3;
2395
+ continue;
2396
+ }
2397
+ const close = inner.indexOf(">", i);
2398
+ if (close < 0) break;
2399
+ const tag = inner.slice(i, close + 1);
2400
+ const nameMatch = /^<a:([\w]+)/.exec(tag);
2401
+ const isSelfClose = tag.endsWith("/>");
2402
+ if (depth === 0 && nameMatch) {
2403
+ const name = nameMatch[1];
2404
+ if (name === "close") {
2405
+ out += " Z";
2406
+ } else if (
2407
+ name === "moveTo" ||
2408
+ name === "lnTo" ||
2409
+ name === "cubicBezTo" ||
2410
+ name === "quadBezTo"
2411
+ ) {
2412
+ const cmdClose = inner.indexOf(`</a:${name}>`, close + 1);
2413
+ if (cmdClose < 0) break;
2414
+ const body = inner.slice(close + 1, cmdClose);
2415
+ const pts: Array<[number, number]> = [];
2416
+ const ptRe = /<a:pt\s+x="(-?\d+)"\s+y="(-?\d+)"\s*\/>/g;
2417
+ let m: RegExpExecArray | null;
2418
+ while ((m = ptRe.exec(body))) {
2419
+ pts.push([Number(m[1]), Number(m[2])]);
2420
+ }
2421
+ if (pts.length) {
2422
+ const letter =
2423
+ name === "moveTo"
2424
+ ? "M"
2425
+ : name === "lnTo"
2426
+ ? "L"
2427
+ : name === "cubicBezTo"
2428
+ ? "C"
2429
+ : "Q";
2430
+ out += ` ${letter} ${pts.map(([x, y]) => `${x} ${y}`).join(" ")}`;
2431
+ const last = pts[pts.length - 1];
2432
+ penX = last[0];
2433
+ penY = last[1];
2434
+ }
2435
+ i = cmdClose + `</a:${name}>`.length;
2436
+ continue;
2437
+ } else if (name === "arcTo") {
2438
+ // <a:arcTo wR="" hR="" stAng="" swAng="" /> — elliptical arc
2439
+ // starting at the current pen position. wR/hR are the axis radii;
2440
+ // stAng/swAng are start/sweep angles measured in 60000ths of a
2441
+ // degree (OOXML convention). The start point on the ellipse is
2442
+ // (centre.x + wR·cos(stAng), centre.y + hR·sin(stAng)); the
2443
+ // centre is therefore (pen.x − wR·cos(stAng), pen.y − hR·sin(stAng)).
2444
+ // SVG `A` takes the END point instead of an angle, so we compute
2445
+ // the end from start + swAng.
2446
+ const wR = Number(/\bwR="(-?\d+)"/.exec(tag)?.[1] ?? 0);
2447
+ const hR = Number(/\bhR="(-?\d+)"/.exec(tag)?.[1] ?? 0);
2448
+ const stAng = Number(/\bstAng="(-?\d+)"/.exec(tag)?.[1] ?? 0) / 60000;
2449
+ const swAng = Number(/\bswAng="(-?\d+)"/.exec(tag)?.[1] ?? 0) / 60000;
2450
+ if (wR > 0 && hR > 0) {
2451
+ const rad = (deg: number) => (deg * Math.PI) / 180;
2452
+ const cx = penX - wR * Math.cos(rad(stAng));
2453
+ const cy = penY - hR * Math.sin(rad(stAng));
2454
+ const endAng = stAng + swAng;
2455
+ const ex = cx + wR * Math.cos(rad(endAng));
2456
+ const ey = cy + hR * Math.sin(rad(endAng));
2457
+ const largeArc = Math.abs(swAng) > 180 ? 1 : 0;
2458
+ const sweep = swAng > 0 ? 1 : 0;
2459
+ out += ` A ${wR} ${hR} 0 ${largeArc} ${sweep} ${ex.toFixed(2)} ${ey.toFixed(2)}`;
2460
+ penX = ex;
2461
+ penY = ey;
2462
+ }
2463
+ }
2464
+ // Unknown commands are skipped — the rest of the path stays valid.
2465
+ }
2466
+ if (!isSelfClose) depth++;
2467
+ i = close + 1;
2468
+ }
2469
+ return out.trim();
2470
+ }
2471
+
2472
+ function paragraphChildOrderFromRaw(
2473
+ raw: string
2474
+ ): { kind: "r" | "br" | "fld"; index: number }[] {
2475
+ const tagEnd = raw.indexOf(">");
2476
+ const closeIdx = raw.lastIndexOf("</a:p>");
2477
+ if (tagEnd < 0 || closeIdx < 0) return [];
2478
+ const inner = raw.slice(tagEnd + 1, closeIdx);
2479
+ const out: { kind: "r" | "br" | "fld"; index: number }[] = [];
2480
+ let depth = 0;
2481
+ let rIdx = 0;
2482
+ let brIdx = 0;
2483
+ let fldIdx = 0;
2484
+ let i = 0;
2485
+ while (i < inner.length) {
2486
+ if (inner[i] !== "<") {
2487
+ i++;
2488
+ continue;
2489
+ }
2490
+ if (inner.startsWith("</", i)) {
2491
+ depth--;
2492
+ const end = inner.indexOf(">", i);
2493
+ if (end < 0) break;
2494
+ i = end + 1;
2495
+ continue;
2496
+ }
2497
+ if (inner.startsWith("<!--", i)) {
2498
+ const end = inner.indexOf("-->", i);
2499
+ if (end < 0) break;
2500
+ i = end + 3;
2501
+ continue;
2502
+ }
2503
+ if (inner.startsWith("<?", i)) {
2504
+ const end = inner.indexOf("?>", i);
2505
+ if (end < 0) break;
2506
+ i = end + 2;
2507
+ continue;
2508
+ }
2509
+ const close = inner.indexOf(">", i);
2510
+ if (close < 0) break;
2511
+ const tag = inner.slice(i, close + 1);
2512
+ const isSelfClose = tag.endsWith("/>");
2513
+ const nameMatch = /^<(a:[\w]+)/.exec(tag);
2514
+ if (depth === 0 && nameMatch) {
2515
+ const name = nameMatch[1];
2516
+ if (name === "a:r") out.push({ kind: "r", index: rIdx++ });
2517
+ else if (name === "a:br") out.push({ kind: "br", index: brIdx++ });
2518
+ else if (name === "a:fld") out.push({ kind: "fld", index: fldIdx++ });
2519
+ }
2520
+ if (!isSelfClose) depth++;
2521
+ i = close + 1;
2522
+ }
2523
+ return out;
2524
+ }
2525
+
1274
2526
  function hasAnyText(txBody: any): boolean {
1275
2527
  const ps = asArray(txBody?.["a:p"]);
1276
2528
  for (const p of ps) {
1277
- const rs = asArray(p?.["a:r"]);
1278
- for (const r of rs) {
2529
+ for (const r of [...asArray(p?.["a:r"]), ...asArray(p?.["a:fld"])]) {
1279
2530
  const t = r?.["a:t"];
1280
2531
  const text = typeof t === "string" ? t : t?.["#text"] ?? "";
1281
2532
  if (text && String(text).length > 0) return true;
@@ -1291,6 +2542,27 @@ function mergeFirst<T>(...candidates: (T | undefined)[]): T | undefined {
1291
2542
  return undefined;
1292
2543
  }
1293
2544
 
2545
+ /**
2546
+ * Per-field rPr merge: earlier candidates win for each individual attribute
2547
+ * (font typeface, size, colour, weight, italic, …). Used to flatten the
2548
+ * layout→master→txStyles inheritance chain into a single fallback rPr for
2549
+ * the run extractor.
2550
+ */
2551
+ function mergeRPrChain(...candidates: (any | undefined)[]): any | undefined {
2552
+ const out: any = {};
2553
+ let touched = false;
2554
+ for (const c of candidates) {
2555
+ if (!c || typeof c !== "object") continue;
2556
+ for (const k of Object.keys(c)) {
2557
+ if (out[k] === undefined) {
2558
+ out[k] = c[k];
2559
+ touched = true;
2560
+ }
2561
+ }
2562
+ }
2563
+ return touched ? out : undefined;
2564
+ }
2565
+
1294
2566
  /**
1295
2567
  * Map an OOXML preset shape name to the closest Slidewise ShapeKind. Returns
1296
2568
  * null only for shapes we genuinely cannot represent at all (the caller then
@@ -1436,7 +2708,274 @@ async function readXml(zip: JSZip, path: string): Promise<any | null> {
1436
2708
  const file = zip.file(path);
1437
2709
  if (!file) return null;
1438
2710
  const text = await file.async("string");
1439
- return xmlParser.parse(text);
2711
+ const parsed = xmlParser.parse(text);
2712
+ // fast-xml-parser groups children by tag name and drops cross-tag
2713
+ // document order. Attach raw fragments for paragraphs (run/br/fld
2714
+ // interleaving), custGeom path commands, and spTree/grpSp children
2715
+ // (which carry z-index via source order).
2716
+ annotateRawOrder(parsed, text);
2717
+ return parsed;
2718
+ }
2719
+
2720
+ function annotateRawOrder(parsed: any, rawText: string): void {
2721
+ annotateParagraphRawSrc(parsed, rawText);
2722
+ annotateSpTreeChildOrder(parsed, rawText);
2723
+ }
2724
+
2725
+ /**
2726
+ * Attach `_childOrder: string[]` to every parsed `<p:spTree>` and
2727
+ * `<p:grpSp>` so callers can iterate children (sp, pic, cxnSp,
2728
+ * graphicFrame, grpSp) in document order. PPTX z-index follows source
2729
+ * order — a slide that lists `<p:pic>` before `<p:sp>` means the picture
2730
+ * sits behind the text — but fast-xml-parser groups children by tag name
2731
+ * and drops cross-tag ordering.
2732
+ */
2733
+ function annotateSpTreeChildOrder(parsed: any, rawText: string): void {
2734
+ if (!rawText.includes("<p:spTree") && !rawText.includes("<p:grpSp")) return;
2735
+ for (const tag of ["p:spTree", "p:grpSp"] as const) {
2736
+ if (!rawText.includes(`<${tag}`)) continue;
2737
+ const blocks = findAllRawBlocks(rawText, tag);
2738
+ if (!blocks.length) continue;
2739
+ const nodes: any[] = [];
2740
+ collectNamedDfs(parsed, tag, nodes);
2741
+ const n = Math.min(blocks.length, nodes.length);
2742
+ for (let i = 0; i < n; i++) {
2743
+ const order = extractTopLevelChildNames(blocks[i]);
2744
+ Object.defineProperty(nodes[i], "_childOrder", {
2745
+ value: order,
2746
+ enumerable: false,
2747
+ configurable: true,
2748
+ });
2749
+ }
2750
+ }
2751
+ }
2752
+
2753
+ function extractTopLevelChildNames(blockXml: string): string[] {
2754
+ const tagEnd = blockXml.indexOf(">");
2755
+ const close = blockXml.lastIndexOf("<");
2756
+ if (tagEnd < 0 || close <= tagEnd) return [];
2757
+ const inner = blockXml.slice(tagEnd + 1, close);
2758
+ const out: string[] = [];
2759
+ let depth = 0;
2760
+ let i = 0;
2761
+ while (i < inner.length) {
2762
+ if (inner[i] !== "<") {
2763
+ i++;
2764
+ continue;
2765
+ }
2766
+ if (inner.startsWith("</", i)) {
2767
+ depth--;
2768
+ const end = inner.indexOf(">", i);
2769
+ if (end < 0) break;
2770
+ i = end + 1;
2771
+ continue;
2772
+ }
2773
+ if (inner.startsWith("<!--", i)) {
2774
+ const end = inner.indexOf("-->", i);
2775
+ if (end < 0) break;
2776
+ i = end + 3;
2777
+ continue;
2778
+ }
2779
+ const end = inner.indexOf(">", i);
2780
+ if (end < 0) break;
2781
+ const tag = inner.slice(i, end + 1);
2782
+ const selfClose = tag.endsWith("/>");
2783
+ const nameMatch = /^<([\w:]+)/.exec(tag);
2784
+ if (depth === 0 && nameMatch) out.push(nameMatch[1]);
2785
+ if (!selfClose) depth++;
2786
+ i = end + 1;
2787
+ }
2788
+ return out;
2789
+ }
2790
+
2791
+ function findAllRawBlocks(raw: string, fullName: string): string[] {
2792
+ const blocks: string[] = [];
2793
+ // Depth-aware scan so self-nesting tags (e.g. `<p:grpSp>` inside another
2794
+ // `<p:grpSp>`) match their correct closing tag instead of the first inner
2795
+ // one we encounter.
2796
+ const openRe = new RegExp(`<${fullName}\\b[^>]*?(/?)>`, "g");
2797
+ const closeTag = `</${fullName}>`;
2798
+ let i = 0;
2799
+ while (i < raw.length) {
2800
+ openRe.lastIndex = i;
2801
+ const m = openRe.exec(raw);
2802
+ if (!m) break;
2803
+ const start = m.index;
2804
+ const tagEnd = openRe.lastIndex;
2805
+ if (m[1] === "/") {
2806
+ blocks.push(raw.slice(start, tagEnd));
2807
+ i = tagEnd;
2808
+ continue;
2809
+ }
2810
+ let depth = 1;
2811
+ let scan = tagEnd;
2812
+ const innerOpenRe = new RegExp(`<${fullName}\\b[^>]*?(/?)>`, "g");
2813
+ while (depth > 0 && scan < raw.length) {
2814
+ innerOpenRe.lastIndex = scan;
2815
+ const nextOpen = innerOpenRe.exec(raw);
2816
+ const nextClose = raw.indexOf(closeTag, scan);
2817
+ if (nextClose < 0) {
2818
+ depth = -1;
2819
+ break;
2820
+ }
2821
+ if (nextOpen && nextOpen.index < nextClose) {
2822
+ // Self-closing opens don't change depth.
2823
+ if (nextOpen[1] !== "/") depth++;
2824
+ scan = innerOpenRe.lastIndex;
2825
+ } else {
2826
+ depth--;
2827
+ scan = nextClose + closeTag.length;
2828
+ }
2829
+ }
2830
+ if (depth !== 0) break;
2831
+ blocks.push(raw.slice(start, scan));
2832
+ i = scan;
2833
+ }
2834
+ return blocks;
2835
+ }
2836
+
2837
+ function annotateParagraphRawSrc(parsed: any, rawText: string): void {
2838
+ // Cross-tag document order matters when a paragraph mixes <a:r>, <a:br>,
2839
+ // and <a:fld>. fast-xml-parser groups by tag name, so we keep the raw
2840
+ // XML for any paragraph that contains a break or a field.
2841
+ if (rawText.includes("<a:br") || rawText.includes("<a:fld")) {
2842
+ const blocks = findAllParagraphRawBlocks(rawText);
2843
+ if (blocks.length) {
2844
+ const parsedPs: any[] = [];
2845
+ collectParagraphsDfs(parsed, parsedPs);
2846
+ const n = Math.min(blocks.length, parsedPs.length);
2847
+ for (let i = 0; i < n; i++) {
2848
+ const block = blocks[i];
2849
+ if (block.includes("<a:br") || block.includes("<a:fld")) {
2850
+ Object.defineProperty(parsedPs[i], "_rawSrc", {
2851
+ value: block,
2852
+ enumerable: false,
2853
+ configurable: true,
2854
+ });
2855
+ }
2856
+ }
2857
+ }
2858
+ }
2859
+ // <a:custGeom> path commands (moveTo, lnTo, cubicBezTo, …) are siblings,
2860
+ // and their cross-tag order defines the silhouette of brand logos and
2861
+ // hand-drawn shapes. Same fast-xml-parser issue, same fix: attach raw.
2862
+ if (rawText.includes("<a:custGeom")) {
2863
+ const blocks = findAllElementRawBlocks(rawText, "custGeom");
2864
+ if (blocks.length) {
2865
+ const parsedCustGeoms: any[] = [];
2866
+ collectNamedDfs(parsed, "a:custGeom", parsedCustGeoms);
2867
+ const n = Math.min(blocks.length, parsedCustGeoms.length);
2868
+ for (let i = 0; i < n; i++) {
2869
+ Object.defineProperty(parsedCustGeoms[i], "_rawSrc", {
2870
+ value: blocks[i],
2871
+ enumerable: false,
2872
+ configurable: true,
2873
+ });
2874
+ }
2875
+ }
2876
+ }
2877
+ }
2878
+
2879
+ function collectNamedDfs(node: any, key: string, acc: any[]): void {
2880
+ if (!node || typeof node !== "object") return;
2881
+ if (Array.isArray(node)) {
2882
+ for (const n of node) collectNamedDfs(n, key, acc);
2883
+ return;
2884
+ }
2885
+ for (const k of Object.keys(node)) {
2886
+ if (k.startsWith("@_") || k === "#text") continue;
2887
+ if (k === key) {
2888
+ for (const v of asArray(node[k])) acc.push(v);
2889
+ } else {
2890
+ collectNamedDfs(node[k], key, acc);
2891
+ }
2892
+ }
2893
+ }
2894
+
2895
+ function findAllElementRawBlocks(raw: string, localName: string): string[] {
2896
+ const blocks: string[] = [];
2897
+ const openSelfClose = new RegExp(`<a:${localName}\\b[^>]*/>`);
2898
+ const openTag = new RegExp(`<a:${localName}\\b[^>]*>`);
2899
+ const close = `</a:${localName}>`;
2900
+ let cursor = 0;
2901
+ while (cursor < raw.length) {
2902
+ const slice = raw.slice(cursor);
2903
+ const sc = openSelfClose.exec(slice);
2904
+ const ot = openTag.exec(slice);
2905
+ // Pick whichever comes first (and only if it isn't a self-close that was
2906
+ // also matched by openTag).
2907
+ let start = -1;
2908
+ let selfClose = false;
2909
+ if (sc && (!ot || sc.index <= ot.index)) {
2910
+ start = cursor + sc.index;
2911
+ selfClose = true;
2912
+ } else if (ot) {
2913
+ start = cursor + ot.index;
2914
+ selfClose = ot[0].endsWith("/>");
2915
+ }
2916
+ if (start < 0) break;
2917
+ if (selfClose) {
2918
+ const end = raw.indexOf(">", start);
2919
+ blocks.push(raw.slice(start, end + 1));
2920
+ cursor = end + 1;
2921
+ continue;
2922
+ }
2923
+ const tagEnd = raw.indexOf(">", start);
2924
+ const closeIdx = raw.indexOf(close, tagEnd + 1);
2925
+ if (closeIdx < 0) break;
2926
+ blocks.push(raw.slice(start, closeIdx + close.length));
2927
+ cursor = closeIdx + close.length;
2928
+ }
2929
+ return blocks;
2930
+ }
2931
+
2932
+ function findAllParagraphRawBlocks(raw: string): string[] {
2933
+ const blocks: string[] = [];
2934
+ let i = 0;
2935
+ while (i < raw.length) {
2936
+ const a = raw.indexOf("<a:p>", i);
2937
+ const b = raw.indexOf("<a:p ", i);
2938
+ let start: number;
2939
+ if (a < 0 && b < 0) break;
2940
+ if (a < 0) start = b;
2941
+ else if (b < 0) start = a;
2942
+ else start = Math.min(a, b);
2943
+ const tagEnd = raw.indexOf(">", start);
2944
+ if (tagEnd < 0) break;
2945
+ if (raw[tagEnd - 1] === "/") {
2946
+ blocks.push(raw.slice(start, tagEnd + 1));
2947
+ i = tagEnd + 1;
2948
+ continue;
2949
+ }
2950
+ // <a:p> never nests inside another <a:p>, so the first </a:p> is ours.
2951
+ const close = raw.indexOf("</a:p>", tagEnd + 1);
2952
+ if (close < 0) break;
2953
+ blocks.push(raw.slice(start, close + "</a:p>".length));
2954
+ i = close + "</a:p>".length;
2955
+ }
2956
+ return blocks;
2957
+ }
2958
+
2959
+ function collectParagraphsDfs(node: any, acc: any[]): void {
2960
+ if (!node || typeof node !== "object") return;
2961
+ if (Array.isArray(node)) {
2962
+ for (const n of node) collectParagraphsDfs(n, acc);
2963
+ return;
2964
+ }
2965
+ for (const k of Object.keys(node)) {
2966
+ if (k.startsWith("@_") || k === "#text") continue;
2967
+ if (k === "a:p") {
2968
+ for (const p of asArray(node[k])) acc.push(p);
2969
+ } else {
2970
+ collectParagraphsDfs(node[k], acc);
2971
+ }
2972
+ }
2973
+ }
2974
+
2975
+ async function readXmlRaw(zip: JSZip, path: string): Promise<string | null> {
2976
+ const file = zip.file(path);
2977
+ if (!file) return null;
2978
+ return file.async("string");
1440
2979
  }
1441
2980
 
1442
2981
  async function readRels(zip: JSZip, path: string): Promise<Rels> {