@textcortex/slidewise 1.7.0 → 1.9.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
  /**
@@ -224,7 +282,29 @@ async function parseSlide(
224
282
  ? normalisePath(themeTarget, dirOf(masterPath))
225
283
  : null;
226
284
  const themeXml = themePath ? await readXml(zip, themePath) : null;
227
- const theme = themeXml ? extractTheme(themeXml) : DEFAULT_THEME;
285
+ const themeRaw = themePath ? await readXmlRaw(zip, themePath) : null;
286
+ const baseTheme = themeXml ? extractTheme(themeXml) : DEFAULT_THEME;
287
+ const themeFills =
288
+ themeXml && themeRaw
289
+ ? extractThemeFills(themeXml, themeRaw)
290
+ : { bg: [], fg: [] };
291
+ const themeFonts = themeXml ? extractThemeFonts(themeXml) : {};
292
+ // <p:clrMap> lives on the master; <p:clrMapOvr><a:overrideClrMapping/> on
293
+ // the slide can override individual mappings. Slides commonly only declare
294
+ // <a:masterClrMapping/> which means "inherit the master's map verbatim".
295
+ const masterClrMap = masterXml
296
+ ? extractClrMap(masterXml?.["p:sldMaster"]?.["p:clrMap"])
297
+ : DEFAULT_CLR_MAP;
298
+ const clrMapOvr = xml["p:sld"]?.["p:clrMapOvr"]?.["a:overrideClrMapping"];
299
+ const clrMap = clrMapOvr ? extractClrMap(clrMapOvr) : masterClrMap;
300
+ // Bake the clrMap into the theme so bg1/bg2/tx1/tx2 stay flat lookups.
301
+ const theme: ThemeColors = {
302
+ ...baseTheme,
303
+ bg1: baseTheme[clrMap.bg1],
304
+ bg2: baseTheme[clrMap.bg2],
305
+ tx1: baseTheme[clrMap.tx1],
306
+ tx2: baseTheme[clrMap.tx2],
307
+ };
228
308
 
229
309
  const layoutPh = layoutXml ? extractPlaceholders(layoutXml) : new Map();
230
310
  const masterPh = masterXml ? extractPlaceholders(masterXml) : new Map();
@@ -239,6 +319,8 @@ async function parseSlide(
239
319
  slideRels,
240
320
  fit,
241
321
  theme,
322
+ themeFills,
323
+ themeFonts,
242
324
  layoutPh,
243
325
  masterPh,
244
326
  masterTextDefaults,
@@ -246,27 +328,64 @@ async function parseSlide(
246
328
 
247
329
  const sld = xml["p:sld"];
248
330
  const cSld = sld?.["p:cSld"];
249
- const slideBg = extractBackgroundColor(cSld?.["p:bg"], theme);
331
+ const slideBg = await extractBackground(
332
+ cSld?.["p:bg"],
333
+ ctx,
334
+ slideRels,
335
+ slidePath
336
+ );
250
337
  const layoutBg = layoutXml
251
- ? extractBackgroundColor(
338
+ ? await extractBackground(
252
339
  layoutXml?.["p:sldLayout"]?.["p:cSld"]?.["p:bg"],
253
- theme
340
+ ctx,
341
+ layoutRels,
342
+ layoutPath!
254
343
  )
255
344
  : undefined;
256
345
  const masterBg = masterXml
257
- ? extractBackgroundColor(
346
+ ? await extractBackground(
258
347
  masterXml?.["p:sldMaster"]?.["p:cSld"]?.["p:bg"],
259
- theme
348
+ ctx,
349
+ masterRels,
350
+ masterPath!
260
351
  )
261
352
  : undefined;
262
353
  const background = slideBg ?? layoutBg ?? masterBg ?? "#FFFFFF";
263
354
 
264
355
  const spTree = cSld?.["p:spTree"];
356
+
357
+ // Layout & master visuals (non-placeholder shapes/pics, plus the fill of
358
+ // placeholder-bearing shapes) form an underlay so brand bars, side gradients,
359
+ // logo pics, and tinted placeholder boxes appear behind slide content.
360
+ // Placeholders the slide already overrides (e.g. picture placeholder filled
361
+ // by an in-slide <p:pic>) are skipped so their "Insert Picture" prompt
362
+ // background doesn't leak through.
363
+ const slidePhKeys = collectSlidePlaceholderKeys(spTree);
364
+ const masterUnderlay = masterXml
365
+ ? await parseUnderlay(
366
+ masterXml["p:sldMaster"]?.["p:cSld"]?.["p:spTree"],
367
+ ctx,
368
+ masterPath!,
369
+ masterRels,
370
+ slidePhKeys
371
+ )
372
+ : [];
373
+ const layoutUnderlay = layoutXml
374
+ ? await parseUnderlay(
375
+ layoutXml["p:sldLayout"]?.["p:cSld"]?.["p:spTree"],
376
+ ctx,
377
+ layoutPath!,
378
+ layoutRels,
379
+ slidePhKeys
380
+ )
381
+ : [];
265
382
  const elements: SlideElement[] = [];
266
383
 
384
+ let z = 1;
385
+ for (const el of masterUnderlay) elements.push({ ...el, z: z++ });
386
+ for (const el of layoutUnderlay) elements.push({ ...el, z: z++ });
267
387
  if (spTree) {
268
388
  const collected = await parseSpTree(spTree, ctx, identityTransform());
269
- let z = 1;
270
389
  for (const el of collected) {
271
390
  elements.push({ ...el, z: z++ });
272
391
  }
@@ -279,29 +398,64 @@ async function parseSlide(
279
398
  };
280
399
  }
281
400
 
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 };
401
+ /**
402
+ * Walk a layout or master spTree and return elements to render behind the
403
+ * slide: non-placeholder shapes/pics (the brand bars, gradient bands, corner
404
+ * logos) and any explicit fill on placeholder-bearing shapes (the tinted
405
+ * boxes some templates host on layout placeholders). Hidden shapes are
406
+ * skipped. Placeholder text/positions remain handled by the existing
407
+ * inheritance path so we don't duplicate them.
408
+ */
409
+ async function parseUnderlay(
410
+ spTree: any,
411
+ ctx: ParseContext,
412
+ ownerPath: string,
413
+ ownerRels: Rels,
414
+ slidePhKeys: Set<string>
415
+ ): Promise<SlideElement[]> {
416
+ if (!spTree) return [];
417
+ const underlayCtx: ParseContext = {
418
+ ...ctx,
419
+ slidePath: ownerPath,
420
+ slideRels: ownerRels,
421
+ };
422
+ return walkUnderlay(spTree, underlayCtx, identityTransform(), slidePhKeys);
292
423
  }
293
424
 
294
- async function parseSpTree(
425
+ async function walkUnderlay(
295
426
  spTree: any,
296
427
  ctx: ParseContext,
297
- outer: GroupTransform
428
+ outer: GroupTransform,
429
+ slidePhKeys: Set<string>
298
430
  ): Promise<SlideElement[]> {
299
431
  const out: SlideElement[] = [];
300
432
  for (const sp of asArray(spTree["p:sp"])) {
301
- const el = await parseSpOrText(sp, ctx, outer);
433
+ if (isHiddenNode(sp, "p:nvSpPr")) continue;
434
+ const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
435
+ if (ph) {
436
+ const isPicPrompt = ph["@_type"] === "pic";
437
+ const isOverridden = slidePhKeys.has(placeholderKey(ph));
438
+ // Picture placeholders are "Insert Picture" prompts; when the slide
439
+ // supplies an actual image, the prompt panel must hide.
440
+ if (isPicPrompt && isOverridden) continue;
441
+ // When the slide hosts this placeholder, its fill rides on the
442
+ // slide's text element (TextElement.background) so it stays at the
443
+ // text's z-index — important when the slide also has a full-bleed
444
+ // image that would otherwise cover an underlay-emitted backing.
445
+ if (isOverridden) continue;
446
+ // Unreferenced placeholders: emit a fill-only backing so coloured
447
+ // boxes (numbered chips, decorative panels) appear.
448
+ const filler = await placeholderFillUnderlay(sp, ctx, outer);
449
+ if (filler) out.push(filler);
450
+ continue;
451
+ }
452
+ const el = await parseSpOrText(sp, ctx, outer, { underlay: true });
302
453
  if (el) out.push(el);
303
454
  }
304
455
  for (const pic of asArray(spTree["p:pic"])) {
456
+ if (isHiddenNode(pic, "p:nvPicPr")) continue;
457
+ const ph = pic?.["p:nvPicPr"]?.["p:nvPr"]?.["p:ph"];
458
+ if (ph && slidePhKeys.has(placeholderKey(ph))) continue;
305
459
  const el = await parsePic(pic, ctx, outer);
306
460
  if (el) out.push(el);
307
461
  }
@@ -309,14 +463,147 @@ async function parseSpTree(
309
463
  const el = parseCxn(cxn, ctx, outer);
310
464
  if (el) out.push(el);
311
465
  }
312
- for (const gf of asArray(spTree["p:graphicFrame"])) {
313
- const el = parseGraphicFrame(gf, ctx, outer);
314
- if (el) out.push(el);
315
- }
316
466
  for (const grp of asArray(spTree["p:grpSp"])) {
317
467
  const inner = composeGroupTransform(grp, outer);
318
- const children = await parseSpTree(grp, ctx, inner);
319
- out.push(...children);
468
+ out.push(...(await walkUnderlay(grp, ctx, inner, slidePhKeys)));
469
+ }
470
+ return out;
471
+ }
472
+
473
+ function collectSlidePlaceholderKeys(spTree: any): Set<string> {
474
+ const keys = new Set<string>();
475
+ if (!spTree) return keys;
476
+ const visit = (tree: any) => {
477
+ if (!tree) return;
478
+ for (const sp of asArray(tree["p:sp"])) {
479
+ const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
480
+ if (ph) keys.add(placeholderKey(ph));
481
+ }
482
+ for (const pic of asArray(tree["p:pic"])) {
483
+ const ph = pic?.["p:nvPicPr"]?.["p:nvPr"]?.["p:ph"];
484
+ if (ph) keys.add(placeholderKey(ph));
485
+ }
486
+ for (const grp of asArray(tree["p:grpSp"])) visit(grp);
487
+ };
488
+ visit(spTree);
489
+ return keys;
490
+ }
491
+
492
+ function isHiddenNode(node: any, nvKey: "p:nvSpPr" | "p:nvPicPr"): boolean {
493
+ const cNvPr = node?.[nvKey]?.["p:cNvPr"];
494
+ return cNvPr?.["@_hidden"] === "1" || cNvPr?.["@_hidden"] === 1;
495
+ }
496
+
497
+ /**
498
+ * Resolve a shape's <p:style><a:fillRef idx="N">...<a:schemeClr/></a:fillRef>
499
+ * against the theme's fillStyleLst. idx=0 = noFill; 1+ indexes the fillStyleLst
500
+ * children; 1001+ indexes bgFillStyleLst (rarely used here). The colour child
501
+ * inside fillRef plays the role of phClr in the theme fill template.
502
+ */
503
+ function resolveStyleFillRef(sp: any, ctx: ParseContext): string | undefined {
504
+ const fillRef = sp?.["p:style"]?.["a:fillRef"];
505
+ if (!fillRef) return undefined;
506
+ const idx = Number(fillRef["@_idx"]);
507
+ if (!Number.isFinite(idx) || idx === 0) return undefined;
508
+ const list = idx >= 1000 ? ctx.themeFills.bg : ctx.themeFills.fg;
509
+ const entry = list[(idx >= 1000 ? idx - 1001 : idx - 1)];
510
+ if (!entry) return undefined;
511
+ const phColor = readBaseHex(fillRef, ctx.theme);
512
+ if (entry.kind === "solidFill") {
513
+ return resolveColor(substitutePhClr(entry.node, phColor), ctx.theme);
514
+ }
515
+ if (entry.kind === "gradFill") {
516
+ return extractShapeFill(
517
+ { "a:gradFill": substitutePhClr(entry.node, phColor) },
518
+ ctx.theme
519
+ );
520
+ }
521
+ if (entry.kind === "noFill") return "transparent";
522
+ return undefined;
523
+ }
524
+
525
+ async function placeholderFillUnderlay(
526
+ sp: any,
527
+ ctx: ParseContext,
528
+ outer: GroupTransform = identityTransform()
529
+ ): Promise<ShapeElement | null> {
530
+ const spPr = sp?.["p:spPr"];
531
+ if (!spPr) return null;
532
+ const fill = extractShapeFill(spPr, ctx.theme);
533
+ if (!fill || fill === "transparent") return null;
534
+ const geom = readGeometry(spPr["a:xfrm"], ctx.fit, outer);
535
+ if (!geom) return null;
536
+ return {
537
+ id: nanoid(8),
538
+ type: "shape",
539
+ ...geom,
540
+ z: 0,
541
+ shape: "rect",
542
+ fill,
543
+ };
544
+ }
545
+
546
+ interface GroupTransform {
547
+ /** Linear transform for child raw-px coordinates: x' = a*x + c, y' = b*y + d. */
548
+ a: number;
549
+ b: number;
550
+ c: number;
551
+ d: number;
552
+ }
553
+
554
+ function identityTransform(): GroupTransform {
555
+ return { a: 1, b: 1, c: 0, d: 0 };
556
+ }
557
+
558
+ async function parseSpTree(
559
+ spTree: any,
560
+ ctx: ParseContext,
561
+ outer: GroupTransform
562
+ ): Promise<SlideElement[]> {
563
+ const out: SlideElement[] = [];
564
+ // Cursor per tag — we pop from each parsed array as we encounter its tag
565
+ // in the document-order list, so the same elements get visited in the
566
+ // order they appeared in the source XML (which determines z-index).
567
+ const cursors: Record<string, number> = {
568
+ "p:sp": 0,
569
+ "p:pic": 0,
570
+ "p:cxnSp": 0,
571
+ "p:graphicFrame": 0,
572
+ "p:grpSp": 0,
573
+ };
574
+ const order: string[] = (spTree as any)?._childOrder ?? [
575
+ // Fall back to legacy tag-grouped order when raw isn't attached
576
+ // (e.g. tests that build a parsed structure by hand).
577
+ ...asArray(spTree["p:sp"]).map(() => "p:sp"),
578
+ ...asArray(spTree["p:pic"]).map(() => "p:pic"),
579
+ ...asArray(spTree["p:cxnSp"]).map(() => "p:cxnSp"),
580
+ ...asArray(spTree["p:graphicFrame"]).map(() => "p:graphicFrame"),
581
+ ...asArray(spTree["p:grpSp"]).map(() => "p:grpSp"),
582
+ ];
583
+
584
+ for (const tag of order) {
585
+ if (!(tag in cursors)) continue;
586
+ const arr = asArray((spTree as any)[tag]);
587
+ const idx = cursors[tag]++;
588
+ const node = arr[idx];
589
+ if (!node) continue;
590
+ if (tag === "p:sp") {
591
+ const el = await parseSpOrText(node, ctx, outer);
592
+ if (el) out.push(el);
593
+ } else if (tag === "p:pic") {
594
+ const el = await parsePic(node, ctx, outer);
595
+ if (el) out.push(el);
596
+ } else if (tag === "p:cxnSp") {
597
+ const el = parseCxn(node, ctx, outer);
598
+ if (el) out.push(el);
599
+ } else if (tag === "p:graphicFrame") {
600
+ const el = parseGraphicFrame(node, ctx, outer);
601
+ if (el) out.push(el);
602
+ } else if (tag === "p:grpSp") {
603
+ const inner = composeGroupTransform(node, outer);
604
+ const children = await parseSpTree(node, ctx, inner);
605
+ out.push(...children);
606
+ }
320
607
  }
321
608
  return out;
322
609
  }
@@ -367,7 +654,8 @@ function composeGroupTransform(grp: any, outer: GroupTransform): GroupTransform
367
654
  async function parseSpOrText(
368
655
  sp: any,
369
656
  ctx: ParseContext,
370
- outer: GroupTransform
657
+ outer: GroupTransform,
658
+ opts: { underlay?: boolean } = {}
371
659
  ): Promise<SlideElement | null> {
372
660
  const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
373
661
  const phKey = ph ? placeholderKey(ph) : null;
@@ -386,6 +674,8 @@ async function parseSpOrText(
386
674
  const txBody = sp["p:txBody"];
387
675
  const prstGeom = sp?.["p:spPr"]?.["a:prstGeom"];
388
676
  const presetName = prstGeom?.["@_prst"];
677
+ const custGeom = sp?.["p:spPr"]?.["a:custGeom"];
678
+ const customPath = custGeom ? parseCustGeomPath(custGeom) : undefined;
389
679
 
390
680
  // Lines are sometimes authored as <p:sp prst="line">.
391
681
  if (presetName === "line" || presetName === "straightConnector1") {
@@ -401,10 +691,13 @@ async function parseSpOrText(
401
691
  const phType = ph?.["@_type"];
402
692
  const isPlaceholderTextHost = !!ph && phType !== "pic";
403
693
  const hasText = !!txBody && hasAnyText(txBody);
404
- const isText =
405
- hasText ||
406
- (isPlaceholderTextHost && !presetName) ||
407
- (!!txBody && (!presetName || presetName === "rect"));
694
+ // Treat as text when the element actually carries text OR when it's a
695
+ // placeholder text host with no preset geometry override. A
696
+ // non-placeholder shape with an empty <p:txBody> (commonly authored
697
+ // around <a:custGeom> graphics like brand icons) is a SHAPE — promoting
698
+ // it to a text element would drop the silhouette and fill.
699
+ const isText = hasText || (isPlaceholderTextHost && !presetName);
700
+ void opts;
408
701
 
409
702
  if (isText) {
410
703
  return makeTextElement(sp, txBody, geom, ctx, ph, layoutPh, masterPh);
@@ -412,7 +705,10 @@ async function parseSpOrText(
412
705
 
413
706
  // Fill / stroke. Use placeholder-inherited spPr if slide spPr is empty.
414
707
  const spPr = sp?.["p:spPr"];
415
- const fillColor = extractShapeFill(spPr, ctx.theme) ?? "transparent";
708
+ const fillColor =
709
+ extractShapeFill(spPr, ctx.theme)
710
+ ?? resolveStyleFillRef(sp, ctx)
711
+ ?? "transparent";
416
712
  const lineProps = spPr?.["a:ln"];
417
713
  const lineHasNoFill = lineProps?.["a:noFill"] !== undefined;
418
714
  const stroke = lineHasNoFill
@@ -427,7 +723,9 @@ async function parseSpOrText(
427
723
  if (!kind) {
428
724
  // Fall back to a rect with the shape's fill so it remains visible at the
429
725
  // correct position rather than dropping to an opaque "Imported content"
430
- // tile.
726
+ // tile. When the source carried a <a:custGeom> path we attach it so the
727
+ // renderer draws the actual silhouette (logos, brand marks) instead of
728
+ // the rectangle stand-in.
431
729
  const fallback: ShapeElement = {
432
730
  id: nanoid(8),
433
731
  type: "shape",
@@ -439,6 +737,7 @@ async function parseSpOrText(
439
737
  strokeWidth: strokeWidthEmu
440
738
  ? Math.max(1, Math.round(emuToPx(strokeWidthEmu) * ctx.fit.scale))
441
739
  : undefined,
740
+ ...(customPath ? { path: customPath } : {}),
442
741
  };
443
742
  return fallback;
444
743
  }
@@ -473,7 +772,7 @@ async function parseSpOrText(
473
772
  }
474
773
 
475
774
  function makeTextElement(
476
- _sp: any,
775
+ sp: any,
477
776
  txBody: any,
478
777
  geom: { x: number; y: number; w: number; h: number; rotation: number },
479
778
  ctx: ParseContext,
@@ -493,31 +792,74 @@ function makeTextElement(
493
792
  ? { "a:bodyPr": masterPh.bodyPr, "a:p": masterPh.paragraphs }
494
793
  : txBody;
495
794
 
496
- // Master defaults for the placeholder type (title vs body vs other).
795
+ // Master defaults for the placeholder type (title vs body vs other), as
796
+ // an array of lvl1..lvl9 paragraph properties.
497
797
  const phType = ph?.["@_type"];
498
- const masterDef =
798
+ const masterLevels: (any | undefined)[] =
499
799
  phType === "title" || phType === "ctrTitle"
500
- ? ctx.masterTextDefaults.title
800
+ ? (ctx.masterTextDefaults.title ?? [])
501
801
  : phType === "body" || phType === "subTitle"
502
- ? ctx.masterTextDefaults.body
503
- : ctx.masterTextDefaults.other;
504
-
505
- // Accumulate inheritance: slide < layout < master < masterDefaults.
506
- const fallbackRPr = mergeFirst(
802
+ ? (ctx.masterTextDefaults.body ?? [])
803
+ : (ctx.masterTextDefaults.other ?? []);
804
+ const masterLvl1 = masterLevels[0];
805
+
806
+ // Accumulate inheritance: slide < layout < master < masterDefaults. Each
807
+ // level can specify just a subset of fields (the layout might set the
808
+ // typeface while only the master defines the colour), so merge field by
809
+ // field with earlier candidates winning.
810
+ const fallbackRPr = mergeRPrChain(
507
811
  layoutPh?.rPr,
508
812
  masterPh?.rPr,
509
- masterDef?.["a:defRPr"]
813
+ masterLvl1?.["a:defRPr"]
510
814
  );
511
815
  const fallbackPPr = mergeFirst(
512
816
  layoutPh?.pPr,
513
817
  masterPh?.pPr,
514
- masterDef
818
+ masterLvl1
515
819
  );
516
820
  const fallbackBodyPr = mergeFirst(layoutPh?.bodyPr, masterPh?.bodyPr);
517
821
 
518
- const text = extractRuns(effectiveTxBody, ctx.theme, fallbackRPr, fallbackPPr);
822
+ // Resolve a per-level [layoutLvl, masterPhLvl, masterTxStyleLvl] chain so
823
+ // bullet/alignment/lineSpacing each fall through independently when an
824
+ // earlier layer is silent on that particular field.
825
+ const listStyle: (any | undefined)[][] = [];
826
+ for (let i = 0; i < 9; i++) {
827
+ const chain = [
828
+ layoutPh?.lvlPPr?.[i],
829
+ masterPh?.lvlPPr?.[i],
830
+ masterLevels[i],
831
+ ].filter(Boolean);
832
+ listStyle.push(chain);
833
+ }
834
+
835
+ // <a:bodyPr><a:normAutofit fontScale="..." lnSpcReduction="..."/> shrinks
836
+ // text that overflowed when authored — apply so wraps don't push runs off
837
+ // the slide.
838
+ const autoFit = readNormAutofit(
839
+ effectiveTxBody?.["a:bodyPr"] ?? fallbackBodyPr
840
+ );
841
+
842
+ const text = extractRuns(
843
+ effectiveTxBody,
844
+ ctx.theme,
845
+ fallbackRPr,
846
+ fallbackPPr,
847
+ ctx.themeFonts,
848
+ listStyle,
849
+ autoFit
850
+ );
519
851
  const first = text.runs[0];
520
- const align = text.align ?? readAlign(fallbackPPr) ?? "left";
852
+ // Each layer of the inheritance chain may set @algn independently — a
853
+ // layout placeholder can override geometry without touching alignment,
854
+ // expecting the master's algn="r" (slide-number, page-footer right-edge
855
+ // style) to still apply. mergeFirst would lock onto the layout's whole
856
+ // pPr and hide the master's algn, so check each layer in turn.
857
+ const align =
858
+ text.align ??
859
+ readAlign(layoutPh?.pPr) ??
860
+ readAlign(masterPh?.pPr) ??
861
+ readAlign(masterLvl1) ??
862
+ "left";
521
863
  const valign =
522
864
  readBodyVAlign(effectiveTxBody?.["a:bodyPr"]) ??
523
865
  readBodyVAlign(fallbackBodyPr) ??
@@ -529,7 +871,10 @@ function makeTextElement(
529
871
  : Math.round(defaultFontSizePx(phType, ctx) * scale);
530
872
  const fontFamily =
531
873
  first?.fontFamily ??
532
- fallbackRPr?.["a:latin"]?.["@_typeface"] ??
874
+ resolveFontFamily(
875
+ fallbackRPr?.["a:latin"]?.["@_typeface"],
876
+ ctx.themeFonts
877
+ ) ??
533
878
  "Inter";
534
879
  const fontWeight = first?.bold ? 700 : 400;
535
880
  const color =
@@ -585,6 +930,65 @@ function makeTextElement(
585
930
  : 0,
586
931
  ...(hasMixedFormatting ? { runs } : {}),
587
932
  };
933
+ // Inner padding from <a:bodyPr lIns/tIns/rIns/bIns>. PowerPoint applies
934
+ // these as text-box insets (the typographic equivalent of CSS padding).
935
+ // Default values in OOXML are 91440 / 45720 / 91440 / 45720 EMU. The
936
+ // slide often carries an empty <a:bodyPr/> that should silently inherit
937
+ // each attribute from the layout/master, so we read per-field rather
938
+ // than swap whole bodyPr objects.
939
+ const slideBp = effectiveTxBody?.["a:bodyPr"];
940
+ const layoutBp = layoutPh?.bodyPr;
941
+ const masterBp = masterPh?.bodyPr;
942
+ const readIns = (key: string, fallback: number): number => {
943
+ const v =
944
+ slideBp?.[`@_${key}`] ??
945
+ layoutBp?.[`@_${key}`] ??
946
+ masterBp?.[`@_${key}`];
947
+ return v !== undefined ? Number(v) : fallback;
948
+ };
949
+ const lIns = readIns("lIns", 91440);
950
+ const tIns = readIns("tIns", 45720);
951
+ const rIns = readIns("rIns", 91440);
952
+ const bIns = readIns("bIns", 45720);
953
+ const padding = {
954
+ l: Math.round(emuToPx(lIns) * ctx.fit.scale),
955
+ t: Math.round(emuToPx(tIns) * ctx.fit.scale),
956
+ r: Math.round(emuToPx(rIns) * ctx.fit.scale),
957
+ b: Math.round(emuToPx(bIns) * ctx.fit.scale),
958
+ };
959
+ if (padding.l || padding.t || padding.r || padding.b) {
960
+ el.padding = padding;
961
+ }
962
+
963
+ // Layout placeholders often supply a fill (e.g. a tinted body box) or a
964
+ // <a:custGeom> path (a white brand-logo plate) that should sit
965
+ // *immediately* behind the slide's hosted text — at the same z, not in
966
+ // the underlay. Otherwise a full-bleed image on the slide will cover the
967
+ // backing. The slide's own <p:spPr> can also override the fill on a
968
+ // per-element basis (e.g. one chip in a roadmap is the "active" red
969
+ // tile) — read the slide's spPr first and fall back to the layout's.
970
+ const slideSpPr = sp?.["p:spPr"];
971
+ const layoutSpPr = layoutPh?.spPr;
972
+ const slideFill = slideSpPr ? extractShapeFill(slideSpPr, ctx.theme) : undefined;
973
+ const layoutFill = layoutSpPr ? extractShapeFill(layoutSpPr, ctx.theme) : undefined;
974
+ const phFill = slideFill ?? layoutFill;
975
+ const phSpPr = layoutSpPr;
976
+ const phPath = phSpPr?.["a:custGeom"]
977
+ ? parseCustGeomPath(phSpPr["a:custGeom"])
978
+ : undefined;
979
+ if (phPath && phFill && phFill !== "transparent") {
980
+ // custGeom defines the actual rendered silhouette; the fill applies to
981
+ // it. Skip the flat background and render the glyph as a backing path.
982
+ el.backingPath = {
983
+ d: phPath.d,
984
+ viewW: phPath.viewW,
985
+ viewH: phPath.viewH,
986
+ fill: phFill,
987
+ fillRule: phPath.fillRule,
988
+ };
989
+ } else if (phFill && phFill !== "transparent") {
990
+ el.background = phFill;
991
+ }
588
992
  return el;
589
993
  }
590
994
 
@@ -601,10 +1005,25 @@ async function parsePic(
601
1005
  outer: GroupTransform
602
1006
  ): Promise<SlideElement | null> {
603
1007
  const xfrm = pic?.["p:spPr"]?.["a:xfrm"];
604
- const geom = readGeometry(xfrm, ctx.fit, outer);
1008
+ // Picture placeholders (<p:ph type="pic">) often omit xfrm inherit
1009
+ // geometry from the layout/master placeholder of the same key.
1010
+ const ph = pic?.["p:nvPicPr"]?.["p:nvPr"]?.["p:ph"];
1011
+ const layoutPh = ph ? lookupPlaceholder(ctx.layoutPh, ph) : undefined;
1012
+ const masterPh = ph ? lookupPlaceholder(ctx.masterPh, ph) : undefined;
1013
+ const geom =
1014
+ readGeometry(xfrm, ctx.fit, outer)
1015
+ ?? placeholderGeometry(layoutPh, ctx.fit, outer)
1016
+ ?? placeholderGeometry(masterPh, ctx.fit, outer);
605
1017
  if (!geom) return toUnknown(pic, "p:pic", ctx, outer);
606
1018
 
607
- const blipRef = pic?.["p:blipFill"]?.["a:blip"]?.["@_r:embed"];
1019
+ // Modern PPTX embeds SVGs via a dual-blip: <a:blip r:embed="rId_png">…
1020
+ // <a:extLst><a:ext uri="…"><asvg:svgBlip r:embed="rId_svg"/></a:ext></a:extLst>
1021
+ // </a:blip>. The outer embed is the raster fallback; prefer the SVG when
1022
+ // present so vector logos stay sharp.
1023
+ const blip = pic?.["p:blipFill"]?.["a:blip"];
1024
+ const svgRef = findSvgBlipRef(blip);
1025
+ const rasterRef = blip?.["@_r:embed"];
1026
+ const blipRef = svgRef ?? rasterRef;
608
1027
  if (!blipRef) return toUnknown(pic, "p:pic", ctx, outer);
609
1028
 
610
1029
  const mediaPath = ctx.slideRels.byId.get(blipRef)?.target;
@@ -614,8 +1033,20 @@ async function parsePic(
614
1033
  const file = ctx.zip.file(fullPath);
615
1034
  if (!file) return toUnknown(pic, "p:pic", ctx, outer);
616
1035
 
617
- const base64 = await file.async("base64");
618
1036
  const ext = (fullPath.split(".").pop() || "png").toLowerCase();
1037
+ // EMF / WMF are Microsoft vector formats that browsers can't render
1038
+ // natively. Skip them with a diagnostic — emitting them as
1039
+ // <img src="data:application/octet-stream…"> surfaces a broken-image
1040
+ // icon, and synthesising a fake placeholder only hides the gap.
1041
+ // Consumers needing fidelity should pre-rasterise the metafiles before
1042
+ // import; a true EMF→SVG path needs a dedicated JS decoder (separate PR).
1043
+ if (ext === "emf" || ext === "wmf") {
1044
+ ctx.diagnostics.warnings.push(
1045
+ `Skipped ${ext.toUpperCase()} image at ${fullPath} — vector metafiles aren't supported in the browser.`
1046
+ );
1047
+ return null;
1048
+ }
1049
+ const base64 = await file.async("base64");
619
1050
  const mime = mimeForExt(ext);
620
1051
 
621
1052
  const blipFill = pic?.["p:blipFill"];
@@ -646,6 +1077,21 @@ async function parsePic(
646
1077
  return image;
647
1078
  }
648
1079
 
1080
+ /**
1081
+ * Pull the SVG blip rId from a:blip/a:extLst/a:ext/asvg:svgBlip if present.
1082
+ * Returns undefined when the picture is raster-only.
1083
+ */
1084
+ function findSvgBlipRef(blip: any): string | undefined {
1085
+ if (!blip) return undefined;
1086
+ const exts = asArray(blip?.["a:extLst"]?.["a:ext"]);
1087
+ for (const ext of exts) {
1088
+ const svg = ext?.["asvg:svgBlip"];
1089
+ const ref = svg?.["@_r:embed"];
1090
+ if (ref) return ref;
1091
+ }
1092
+ return undefined;
1093
+ }
1094
+
649
1095
  function parseCxn(
650
1096
  cxn: any,
651
1097
  ctx: ParseContext,
@@ -746,7 +1192,7 @@ function parseTable(
746
1192
  }
747
1193
  const txBody = tc["a:txBody"];
748
1194
  const text = txBody
749
- ? extractRuns(txBody, ctx.theme)
1195
+ ? extractRuns(txBody, ctx.theme, undefined, undefined, ctx.themeFonts)
750
1196
  : { plain: "", runs: [] as RunInfo[] };
751
1197
  cells.push(text.plain);
752
1198
 
@@ -844,11 +1290,24 @@ function lookupPlaceholder(
844
1290
  map.get(`${type}|${idx}`) ??
845
1291
  map.get(`|${idx}`) ??
846
1292
  map.get(`${type}|`) ??
847
- (type === "ctrTitle" ? map.get("title|") : undefined) ??
848
- (type === "subTitle" ? map.get("body|") : undefined)
1293
+ findByType(map, type) ??
1294
+ (type === "ctrTitle" ? findByType(map, "title") : undefined) ??
1295
+ (type === "subTitle" ? findByType(map, "body") : undefined)
849
1296
  );
850
1297
  }
851
1298
 
1299
+ function findByType(
1300
+ map: Map<string, PlaceholderInfo>,
1301
+ type: string
1302
+ ): PlaceholderInfo | undefined {
1303
+ if (!type) return undefined;
1304
+ const prefix = `${type}|`;
1305
+ for (const [key, value] of map) {
1306
+ if (key.startsWith(prefix)) return value;
1307
+ }
1308
+ return undefined;
1309
+ }
1310
+
852
1311
  function placeholderGeometry(
853
1312
  ph: PlaceholderInfo | undefined,
854
1313
  fit: Fit,
@@ -883,36 +1342,148 @@ function extractPlaceholders(rootXml: any): Map<string, PlaceholderInfo> {
883
1342
  const paragraphs = asArray(txBody?.["a:p"]);
884
1343
  const firstP = paragraphs[0];
885
1344
  const firstR = asArray(firstP?.["a:r"])[0];
1345
+ // Default run/paragraph properties live on the placeholder's
1346
+ // <a:lstStyle><a:lvl1pPr> (font, size, colour, etc). A stub <a:r><a:rPr/>
1347
+ // with just `lang` carries no real style — prefer the lstStyle defaults
1348
+ // over an empty inline rPr so the layout's typography reaches the slide.
1349
+ const lstStyle = txBody?.["a:lstStyle"];
1350
+ const lvl1 = lstStyle?.["a:lvl1pPr"];
1351
+ const lvlPPr = collectLevelPPrs(lstStyle);
1352
+ const stubRPr = firstR?.["a:rPr"];
886
1353
  const info: PlaceholderInfo = {
887
1354
  rawX: off ? emuToPx(Number(off["@_x"] ?? 0)) : undefined,
888
1355
  rawY: off ? emuToPx(Number(off["@_y"] ?? 0)) : undefined,
889
1356
  rawW: ext ? emuToPx(Number(ext["@_cx"] ?? 0)) : undefined,
890
1357
  rawH: ext ? emuToPx(Number(ext["@_cy"] ?? 0)) : undefined,
891
1358
  rotation: xfrm?.["@_rot"] ? Number(xfrm["@_rot"]) / 60000 : 0,
892
- rPr: firstR?.["a:rPr"] ?? firstP?.["a:pPr"]?.["a:defRPr"],
893
- pPr: firstP?.["a:pPr"],
1359
+ rPr: pickMeaningful(
1360
+ stubRPr,
1361
+ lvl1?.["a:defRPr"],
1362
+ firstP?.["a:pPr"]?.["a:defRPr"]
1363
+ ),
1364
+ pPr: lvl1 ?? firstP?.["a:pPr"],
894
1365
  bodyPr: txBody?.["a:bodyPr"],
1366
+ lvlPPr: lvlPPr.some(Boolean) ? lvlPPr : undefined,
895
1367
  paragraphs: hasAnyText(txBody) ? paragraphs : undefined,
1368
+ spPr: sp?.["p:spPr"],
896
1369
  };
897
1370
  out.set(placeholderKey(ph), info);
898
1371
  }
899
1372
  return out;
900
1373
  }
901
1374
 
1375
+ /**
1376
+ * Return the first candidate that carries actual style fields (font, size,
1377
+ * colour, weight, italic, underline). An empty `<a:rPr lang="en-GB"/>` looks
1378
+ * truthy but contributes nothing, so it shouldn't shadow a meaningful
1379
+ * lstStyle/defRPr further down the chain.
1380
+ */
1381
+ function pickMeaningful(...candidates: any[]): any {
1382
+ for (const c of candidates) {
1383
+ if (!c) continue;
1384
+ if (rPrHasStyle(c)) return c;
1385
+ }
1386
+ // Fall back to the first defined value, even if otherwise empty, so we
1387
+ // never regress callers that depend on truthiness.
1388
+ return candidates.find((c) => c !== undefined && c !== null);
1389
+ }
1390
+
1391
+ function rPrHasStyle(rPr: any): boolean {
1392
+ if (!rPr || typeof rPr !== "object") return false;
1393
+ return (
1394
+ rPr["@_sz"] !== undefined ||
1395
+ rPr["@_b"] !== undefined ||
1396
+ rPr["@_i"] !== undefined ||
1397
+ rPr["@_u"] !== undefined ||
1398
+ rPr["@_spc"] !== undefined ||
1399
+ rPr["a:latin"] !== undefined ||
1400
+ rPr["a:solidFill"] !== undefined
1401
+ );
1402
+ }
1403
+
902
1404
  function extractMasterTextDefaults(masterXml: any): MasterTextDefaults {
903
1405
  const txStyles = masterXml?.["p:sldMaster"]?.["p:txStyles"];
904
1406
  if (!txStyles) return {};
905
1407
  return {
906
- title: txStyles?.["p:titleStyle"]?.["a:lvl1pPr"],
907
- body: txStyles?.["p:bodyStyle"]?.["a:lvl1pPr"],
908
- other: txStyles?.["p:otherStyle"]?.["a:lvl1pPr"],
1408
+ title: collectLevelPPrs(txStyles?.["p:titleStyle"]),
1409
+ body: collectLevelPPrs(txStyles?.["p:bodyStyle"]),
1410
+ other: collectLevelPPrs(txStyles?.["p:otherStyle"]),
909
1411
  };
910
1412
  }
911
1413
 
1414
+ function collectLevelPPrs(style: any): (any | undefined)[] {
1415
+ if (!style) return [];
1416
+ const out: (any | undefined)[] = [];
1417
+ for (let lvl = 1; lvl <= 9; lvl++) {
1418
+ out.push(style[`a:lvl${lvl}pPr`]);
1419
+ }
1420
+ return out;
1421
+ }
1422
+
912
1423
  // ---------------------------------------------------------------------------
913
1424
  // theme
914
1425
  // ---------------------------------------------------------------------------
915
1426
 
1427
+ function extractThemeFonts(themeXml: any): ThemeFonts {
1428
+ const fs = themeXml?.["a:theme"]?.["a:themeElements"]?.["a:fontScheme"];
1429
+ return {
1430
+ majorLatin: fs?.["a:majorFont"]?.["a:latin"]?.["@_typeface"] || undefined,
1431
+ minorLatin: fs?.["a:minorFont"]?.["a:latin"]?.["@_typeface"] || undefined,
1432
+ };
1433
+ }
1434
+
1435
+ function extractClrMap(node: any): ClrMap {
1436
+ if (!node) return DEFAULT_CLR_MAP;
1437
+ const pick = (attr: string, fallback: ClrMap[keyof ClrMap]) => {
1438
+ const v = node[`@_${attr}`];
1439
+ return isThemeKey(v) ? (v as ClrMap[keyof ClrMap]) : fallback;
1440
+ };
1441
+ return {
1442
+ bg1: pick("bg1", DEFAULT_CLR_MAP.bg1),
1443
+ bg2: pick("bg2", DEFAULT_CLR_MAP.bg2),
1444
+ tx1: pick("tx1", DEFAULT_CLR_MAP.tx1),
1445
+ tx2: pick("tx2", DEFAULT_CLR_MAP.tx2),
1446
+ };
1447
+ }
1448
+
1449
+ const THEME_KEYS = new Set([
1450
+ "dk1",
1451
+ "lt1",
1452
+ "dk2",
1453
+ "lt2",
1454
+ "accent1",
1455
+ "accent2",
1456
+ "accent3",
1457
+ "accent4",
1458
+ "accent5",
1459
+ "accent6",
1460
+ "hlink",
1461
+ "folHlink",
1462
+ ]);
1463
+
1464
+ function isThemeKey(v: unknown): v is keyof ThemeColors {
1465
+ return typeof v === "string" && THEME_KEYS.has(v);
1466
+ }
1467
+
1468
+ /**
1469
+ * Resolve OOXML major/minor font tokens (`+mj-lt`, `+mn-lt`, …) against the
1470
+ * theme's font scheme. Returns the original value when it isn't a token, and
1471
+ * `undefined` when the token can't be resolved.
1472
+ */
1473
+ function resolveFontFamily(
1474
+ raw: string | undefined,
1475
+ fonts: ThemeFonts
1476
+ ): string | undefined {
1477
+ if (!raw) return undefined;
1478
+ if (raw === "+mj-lt" || raw === "+mj-ea" || raw === "+mj-cs") {
1479
+ return fonts.majorLatin;
1480
+ }
1481
+ if (raw === "+mn-lt" || raw === "+mn-ea" || raw === "+mn-cs") {
1482
+ return fonts.minorLatin;
1483
+ }
1484
+ return raw;
1485
+ }
1486
+
916
1487
  function extractTheme(themeXml: any): ThemeColors {
917
1488
  const scheme =
918
1489
  themeXml?.["a:theme"]?.["a:themeElements"]?.["a:clrScheme"] ?? {};
@@ -1020,23 +1591,13 @@ function readBaseHex(node: any, theme: ThemeColors): string | undefined {
1020
1591
  }
1021
1592
 
1022
1593
  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
- }
1594
+ if (token === "phClr") {
1595
+ // Placeholder colour sentinel — caller (substitutePhClr) should already
1596
+ // have replaced this; surface a sensible fallback if it didn't.
1597
+ return theme.dk1;
1039
1598
  }
1599
+ const v = (theme as unknown as Record<string, string>)[token];
1600
+ return v ?? "#000000";
1040
1601
  }
1041
1602
 
1042
1603
  function resolvePresetColor(name: string): string {
@@ -1150,6 +1711,33 @@ function extractShapeFill(spPr: any, theme: ThemeColors): string | undefined {
1150
1711
  (s) => s.color.length === 9 && s.color.endsWith("00")
1151
1712
  );
1152
1713
  if (allTransparent) return "transparent";
1714
+ // Radial / path gradient: <a:path path="circle|rect|shape"> with
1715
+ // <a:fillToRect> giving the focus rectangle. l/t/r/b are percentage
1716
+ // insets from each edge of the shape; the rect's centre is the focus
1717
+ // point. OOXML radial stops use the SAME convention as CSS — pos=0 at
1718
+ // the centre, pos=100% at the outer boundary — so we keep the stop
1719
+ // positions verbatim. The visual blob lands where fillToRect sits;
1720
+ // on a tall, narrow panel the same radial reads almost vertical, on a
1721
+ // 16:9 slide it reads as the expected red orb fading to purple.
1722
+ const pathNode = gf["a:path"];
1723
+ if (pathNode) {
1724
+ const pathType = pathNode["@_path"];
1725
+ const ftr = pathNode["a:fillToRect"];
1726
+ const lIn = Number(ftr?.["@_l"] ?? 0) / 1000;
1727
+ const tIn = Number(ftr?.["@_t"] ?? 0) / 1000;
1728
+ const rIn = Number(ftr?.["@_r"] ?? 0) / 1000;
1729
+ const bIn = Number(ftr?.["@_b"] ?? 0) / 1000;
1730
+ const focusX = clampPct((lIn + (100 - rIn)) / 2);
1731
+ const focusY = clampPct((tIn + (100 - bIn)) / 2);
1732
+ // path="circle" — a true geometric circle in CSS terms (so the blob
1733
+ // stays round on rectangular shapes); path="rect" — anisotropic
1734
+ // ellipse stretched with the shape's aspect ratio.
1735
+ const shape = pathType === "circle" ? "circle" : "ellipse";
1736
+ const stopsCss = stops
1737
+ .map((s) => `${s.color} ${s.pos.toFixed(2)}%`)
1738
+ .join(", ");
1739
+ return `radial-gradient(${shape} at ${focusX.toFixed(2)}% ${focusY.toFixed(2)}%, ${stopsCss})`;
1740
+ }
1153
1741
  const angDeg = gf["a:lin"]?.["@_ang"]
1154
1742
  ? (Number(gf["a:lin"]["@_ang"]) / 60000 + 90) % 360
1155
1743
  : 90;
@@ -1159,11 +1747,185 @@ function extractShapeFill(spPr: any, theme: ThemeColors): string | undefined {
1159
1747
  return undefined;
1160
1748
  }
1161
1749
 
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
- );
1750
+ function clampPct(v: number): number {
1751
+ if (!Number.isFinite(v)) return 0;
1752
+ if (v < 0) return 0;
1753
+ if (v > 100) return 100;
1754
+ return v;
1755
+ }
1756
+
1757
+ async function extractBackground(
1758
+ bg: any,
1759
+ ctx: ParseContext,
1760
+ rels: Rels,
1761
+ basePath: string
1762
+ ): Promise<string | undefined> {
1763
+ if (!bg) return undefined;
1764
+ const bgPr = bg["p:bgPr"];
1765
+ if (bgPr) {
1766
+ if (bgPr["a:noFill"] !== undefined) return "transparent";
1767
+ const solid = resolveColor(bgPr["a:solidFill"], ctx.theme);
1768
+ if (solid) return solid;
1769
+ const grad = extractShapeFill({ "a:gradFill": bgPr["a:gradFill"] }, ctx.theme);
1770
+ if (grad) return grad;
1771
+ const blip = bgPr["a:blipFill"]?.["a:blip"];
1772
+ if (blip) {
1773
+ const url = await blipDataUrl(blip, ctx, rels, basePath);
1774
+ if (url) return `center / cover no-repeat url("${url}")`;
1775
+ }
1776
+ }
1777
+ const bgRef = bg["p:bgRef"];
1778
+ if (bgRef) return resolveBgRef(bgRef, ctx);
1779
+ return undefined;
1780
+ }
1781
+
1782
+ async function blipDataUrl(
1783
+ blip: any,
1784
+ ctx: ParseContext,
1785
+ rels: Rels,
1786
+ basePath: string
1787
+ ): Promise<string | undefined> {
1788
+ // Prefer the vector embed when present (dual-blip SVG pattern); fall
1789
+ // back to the raster.
1790
+ const svgRef = findSvgBlipRef(blip);
1791
+ const rid = svgRef ?? blip?.["@_r:embed"];
1792
+ if (!rid) return undefined;
1793
+ const target = rels.byId.get(rid)?.target;
1794
+ if (!target) return undefined;
1795
+ const full = normalisePath(target, dirOf(basePath));
1796
+ const file = ctx.zip.file(full);
1797
+ if (!file) return undefined;
1798
+ const base64 = await file.async("base64");
1799
+ const ext = (full.split(".").pop() || "png").toLowerCase();
1800
+ return `data:${mimeForExt(ext)};base64,${base64}`;
1801
+ }
1802
+
1803
+ /**
1804
+ * Resolve <p:bgRef idx="..."> against the theme fill lists. idx 1001..1003
1805
+ * indexes a:bgFillStyleLst; idx 1+ indexes a:fillStyleLst (rarely used for
1806
+ * backgrounds). The <p:bgRef> also carries a color child (e.g. schemeClr) that
1807
+ * fills in any <a:schemeClr val="phClr"> placeholders inside the theme fill.
1808
+ */
1809
+ function resolveBgRef(bgRef: any, ctx: ParseContext): string | undefined {
1810
+ const rawIdx = bgRef?.["@_idx"];
1811
+ if (rawIdx === undefined) return undefined;
1812
+ const idx = Number(rawIdx);
1813
+ if (!Number.isFinite(idx)) return undefined;
1814
+ const list = idx >= 1000 ? ctx.themeFills.bg : ctx.themeFills.fg;
1815
+ const entry = list[(idx >= 1000 ? idx - 1001 : idx - 1)];
1816
+ if (!entry) return undefined;
1817
+ const phColor = readBaseHex(bgRef, ctx.theme);
1818
+ if (entry.kind === "solidFill") {
1819
+ const node = substitutePhClr(entry.node, phColor);
1820
+ return resolveColor(node, ctx.theme);
1821
+ }
1822
+ if (entry.kind === "gradFill") {
1823
+ const node = substitutePhClr(entry.node, phColor);
1824
+ return extractShapeFill({ "a:gradFill": node }, ctx.theme);
1825
+ }
1826
+ // blipFill / pattFill / noFill — not modelled as a slide background yet.
1827
+ if (entry.kind === "noFill") return "transparent";
1828
+ return undefined;
1829
+ }
1830
+
1831
+ /**
1832
+ * Replace <a:schemeClr val="phClr"> placeholders inside a theme fill template
1833
+ * with the caller's actual color, so the modifier chain (lumMod, shade, etc.)
1834
+ * still applies. Returns a shallow-cloned tree.
1835
+ */
1836
+ function substitutePhClr(node: any, phHex: string | undefined): any {
1837
+ if (!phHex) return node;
1838
+ const replacement = phHex.startsWith("#") ? phHex.slice(1) : phHex;
1839
+ const walk = (n: any): any => {
1840
+ if (n == null || typeof n !== "object") return n;
1841
+ if (Array.isArray(n)) return n.map(walk);
1842
+ const out: any = {};
1843
+ for (const k of Object.keys(n)) {
1844
+ if (k === "a:schemeClr" && n[k]?.["@_val"] === "phClr") {
1845
+ const mods: any = { ...n[k] };
1846
+ delete mods["@_val"];
1847
+ out["a:srgbClr"] = { ...mods, "@_val": replacement };
1848
+ } else {
1849
+ out[k] = walk(n[k]);
1850
+ }
1851
+ }
1852
+ return out;
1853
+ };
1854
+ return walk(node);
1855
+ }
1856
+
1857
+ function extractThemeFills(themeXml: any, themeRaw: string): ThemeFills {
1858
+ const fmt = themeXml?.["a:theme"]?.["a:themeElements"]?.["a:fmtScheme"];
1859
+ if (!fmt) return { bg: [], fg: [] };
1860
+ const build = (blockTag: string, parsed: any): ThemeFill[] => {
1861
+ const kinds = extractDirectChildOrder(themeRaw, blockTag);
1862
+ if (!kinds.length || !parsed) return [];
1863
+ const buckets: Record<string, any[]> = {};
1864
+ for (const kind of new Set(kinds)) {
1865
+ buckets[kind] = asArray(parsed[`a:${kind}`]).slice();
1866
+ }
1867
+ const out: ThemeFill[] = [];
1868
+ for (const kind of kinds) {
1869
+ const node = buckets[kind]?.shift();
1870
+ if (node !== undefined) out.push({ kind: kind as ThemeFill["kind"], node });
1871
+ }
1872
+ return out;
1873
+ };
1874
+ return {
1875
+ bg: build("bgFillStyleLst", fmt["a:bgFillStyleLst"]),
1876
+ fg: build("fillStyleLst", fmt["a:fillStyleLst"]),
1877
+ };
1878
+ }
1879
+
1880
+ /**
1881
+ * Returns the local tag names of direct children of the first <a:{blockTag}>
1882
+ * in document order. Used to recover the cross-tag-type order that
1883
+ * fast-xml-parser drops when it groups by element name.
1884
+ */
1885
+ function extractDirectChildOrder(rawXml: string, blockTag: string): string[] {
1886
+ const openRe = new RegExp(`<a:${blockTag}\\b[^>]*>`);
1887
+ const close = `</a:${blockTag}>`;
1888
+ const m = openRe.exec(rawXml);
1889
+ if (!m) return [];
1890
+ const start = m.index + m[0].length;
1891
+ const end = rawXml.indexOf(close, start);
1892
+ if (end < 0) return [];
1893
+ const inner = rawXml.slice(start, end);
1894
+ const tags: string[] = [];
1895
+ let depth = 0;
1896
+ let i = 0;
1897
+ while (i < inner.length) {
1898
+ if (inner[i] !== "<") {
1899
+ i++;
1900
+ continue;
1901
+ }
1902
+ if (inner.startsWith("<!--", i)) {
1903
+ const j = inner.indexOf("-->", i);
1904
+ if (j < 0) break;
1905
+ i = j + 3;
1906
+ continue;
1907
+ }
1908
+ if (inner.startsWith("<?", i)) {
1909
+ const j = inner.indexOf("?>", i);
1910
+ if (j < 0) break;
1911
+ i = j + 2;
1912
+ continue;
1913
+ }
1914
+ const closeBracket = inner.indexOf(">", i);
1915
+ if (closeBracket < 0) break;
1916
+ const tag = inner.slice(i, closeBracket + 1);
1917
+ if (tag.startsWith("</")) {
1918
+ depth--;
1919
+ i = closeBracket + 1;
1920
+ continue;
1921
+ }
1922
+ const isSelfClose = tag.endsWith("/>");
1923
+ const nameMatch = /^<a:([\w]+)/.exec(tag);
1924
+ if (depth === 0 && nameMatch) tags.push(nameMatch[1]);
1925
+ if (!isSelfClose) depth++;
1926
+ i = closeBracket + 1;
1927
+ }
1928
+ return tags;
1167
1929
  }
1168
1930
 
1169
1931
  function readBodyVAlign(bodyPr: any): "top" | "middle" | "bottom" | undefined {
@@ -1198,7 +1960,10 @@ function extractRuns(
1198
1960
  txBody: any,
1199
1961
  theme: ThemeColors,
1200
1962
  fallbackRPr?: any,
1201
- fallbackPPr?: any
1963
+ fallbackPPr?: any,
1964
+ themeFonts: ThemeFonts = {},
1965
+ listStyle: (any | undefined)[][] = [],
1966
+ autoFit?: AutoFit
1202
1967
  ): {
1203
1968
  runs: RunInfo[];
1204
1969
  plain: string;
@@ -1210,57 +1975,92 @@ function extractRuns(
1210
1975
  let lineHeightPct: number | undefined;
1211
1976
  const paragraphs = asArray(txBody?.["a:p"]);
1212
1977
  const pieces: string[] = [];
1978
+ const autoNumCounters = new Map<number, number>();
1979
+ let prevAutoKey: string | undefined;
1213
1980
 
1214
1981
  for (let pi = 0; pi < paragraphs.length; pi++) {
1215
1982
  const p = paragraphs[pi];
1216
1983
  const pPr = p?.["a:pPr"];
1984
+ const lvl = clampLevel(Number(pPr?.["@_lvl"] ?? 0));
1985
+ const levelChain = listStyle[lvl] ?? [];
1986
+ const findLevel = (k: string): any =>
1987
+ [pPr, ...levelChain, fallbackPPr].find((s) => s?.[k] !== undefined);
1217
1988
  if (!align) {
1218
- align = readAlign(pPr) ?? readAlign(fallbackPPr);
1989
+ align =
1990
+ readAlign(pPr) ??
1991
+ readAlign(levelChain.find((s) => s?.["@_algn"])) ??
1992
+ readAlign(fallbackPPr);
1219
1993
  }
1220
1994
  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;
1995
+ const src = findLevel("a:lnSpc");
1996
+ const lnPct = src?.["a:lnSpc"]?.["a:spcPct"]?.["@_val"];
1997
+ if (lnPct) {
1998
+ const base = Number(lnPct) / 100000;
1999
+ const reduction = autoFit?.lnSpcReduction ?? 0;
2000
+ lineHeightPct = base * (1 - reduction);
2001
+ }
1225
2002
  }
2003
+
2004
+ // Resolve bullet across the inheritance chain (slide pPr > layout > master
2005
+ // placeholder > master txStyles). Each layer can specify just the bullet
2006
+ // without overriding everything else.
2007
+ const bullet = resolveBullet([pPr, ...levelChain]);
2008
+ const prefix = computeBulletPrefix(
2009
+ bullet,
2010
+ lvl,
2011
+ autoNumCounters,
2012
+ prevAutoKey
2013
+ );
2014
+ prevAutoKey = prefix.autoKey;
2015
+
1226
2016
  const rs = asArray(p?.["a:r"]);
2017
+ const flds = asArray(p?.["a:fld"]);
2018
+ const paraStart = runs.length;
1227
2019
  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
- });
2020
+ if (prefix.text) paragraphText.push(prefix.text);
2021
+
2022
+ const onRun = (r: any) => {
2023
+ const built = buildRunInfo(r, theme, themeFonts, fallbackRPr);
2024
+ if (autoFit?.fontScale && built.run.fontSize) {
2025
+ built.run.fontSize *= autoFit.fontScale;
2026
+ }
2027
+ runs.push(built.run);
2028
+ paragraphText.push(built.text);
2029
+ };
2030
+
2031
+ // <a:br/> hard line breaks AND <a:fld> field placeholders (e.g.
2032
+ // datetime1, slidenum) are siblings of <a:r>; when any are present
2033
+ // walk the paragraph children in document order so they land in the
2034
+ // right place. Otherwise stay on the fast path.
2035
+ if (p?.["a:br"] || p?.["a:fld"]) {
2036
+ const order = paragraphChildOrder(p);
2037
+ for (const entry of order) {
2038
+ if (entry.kind === "br") {
2039
+ if (runs.length) runs[runs.length - 1].text += "\n";
2040
+ paragraphText.push("\n");
2041
+ continue;
2042
+ }
2043
+ const source = entry.kind === "r" ? rs : flds;
2044
+ const r = source[entry.index];
2045
+ if (r) onRun(r);
2046
+ }
2047
+ } else {
2048
+ for (const r of rs) onRun(r);
1263
2049
  }
2050
+
2051
+ // Prepend the bullet prefix to the first run of this paragraph so it
2052
+ // survives renderers that walk `runs` instead of the joined `plain` text.
2053
+ if (prefix.text && runs.length > paraStart) {
2054
+ runs[paraStart].text = prefix.text + runs[paraStart].text;
2055
+ }
2056
+
2057
+ // Carry the inter-paragraph break onto the last run we just emitted —
2058
+ // renderers that walk `runs` (mixed-formatting path) would otherwise
2059
+ // concatenate paragraphs into one long line.
2060
+ if (pi < paragraphs.length - 1 && runs.length > 0) {
2061
+ runs[runs.length - 1].text += "\n";
2062
+ }
2063
+
1264
2064
  pieces.push(paragraphText.join(""));
1265
2065
  }
1266
2066
  return {
@@ -1271,11 +2071,419 @@ function extractRuns(
1271
2071
  };
1272
2072
  }
1273
2073
 
2074
+ interface AutoFit {
2075
+ fontScale?: number; // 0..1
2076
+ lnSpcReduction?: number; // 0..1
2077
+ }
2078
+
2079
+ function readNormAutofit(bodyPr: any): AutoFit | undefined {
2080
+ const af = bodyPr?.["a:normAutofit"];
2081
+ if (!af) return undefined;
2082
+ const fontScale = af["@_fontScale"] ? Number(af["@_fontScale"]) / 100000 : undefined;
2083
+ const lnSpcReduction = af["@_lnSpcReduction"]
2084
+ ? Number(af["@_lnSpcReduction"]) / 100000
2085
+ : undefined;
2086
+ if (fontScale === undefined && lnSpcReduction === undefined) return undefined;
2087
+ return { fontScale, lnSpcReduction };
2088
+ }
2089
+
2090
+ interface ResolvedBullet {
2091
+ kind: "none" | "char" | "auto";
2092
+ char?: string;
2093
+ autoType?: string;
2094
+ autoStartAt?: number;
2095
+ }
2096
+
2097
+ function resolveBullet(sources: (any | undefined)[]): ResolvedBullet {
2098
+ // First source that defines any of buNone/buChar/buAutoNum wins.
2099
+ for (const src of sources) {
2100
+ if (!src) continue;
2101
+ if (src["a:buNone"] !== undefined) return { kind: "none" };
2102
+ if (src["a:buChar"]?.["@_char"])
2103
+ return { kind: "char", char: String(src["a:buChar"]["@_char"]) };
2104
+ if (src["a:buAutoNum"]) {
2105
+ return {
2106
+ kind: "auto",
2107
+ autoType: src["a:buAutoNum"]["@_type"] ?? "arabicPeriod",
2108
+ autoStartAt: src["a:buAutoNum"]["@_startAt"]
2109
+ ? Number(src["a:buAutoNum"]["@_startAt"])
2110
+ : 1,
2111
+ };
2112
+ }
2113
+ }
2114
+ return { kind: "none" };
2115
+ }
2116
+
2117
+ function computeBulletPrefix(
2118
+ bullet: ResolvedBullet,
2119
+ level: number,
2120
+ counters: Map<number, number>,
2121
+ prevAutoKey: string | undefined
2122
+ ): { text: string; autoKey: string | undefined } {
2123
+ const indent = " ".repeat(level);
2124
+ if (bullet.kind === "none") {
2125
+ // Drop the counter so a later run of <a:buAutoNum> restarts at 1 even at
2126
+ // the same level.
2127
+ counters.delete(level);
2128
+ return { text: "", autoKey: undefined };
2129
+ }
2130
+ if (bullet.kind === "char") {
2131
+ counters.delete(level);
2132
+ return {
2133
+ text: `${indent}${bullet.char ?? "•"} `,
2134
+ autoKey: undefined,
2135
+ };
2136
+ }
2137
+ const key = `auto|${level}|${bullet.autoType ?? "arabicPeriod"}`;
2138
+ const continuing = key === prevAutoKey;
2139
+ const next = continuing
2140
+ ? (counters.get(level) ?? bullet.autoStartAt ?? 1) + 1
2141
+ : bullet.autoStartAt ?? 1;
2142
+ counters.set(level, next);
2143
+ return {
2144
+ text: `${indent}${formatAutoNum(next, bullet.autoType ?? "arabicPeriod")} `,
2145
+ autoKey: key,
2146
+ };
2147
+ }
2148
+
2149
+ function formatAutoNum(n: number, type: string): string {
2150
+ switch (type) {
2151
+ case "arabicPlain":
2152
+ return `${n}`;
2153
+ case "arabicParenR":
2154
+ return `${n})`;
2155
+ case "arabicParenBoth":
2156
+ return `(${n})`;
2157
+ case "arabicPeriod":
2158
+ return `${n}.`;
2159
+ case "alphaUcPeriod":
2160
+ return `${toAlpha(n).toUpperCase()}.`;
2161
+ case "alphaLcPeriod":
2162
+ return `${toAlpha(n)}.`;
2163
+ case "romanUcPeriod":
2164
+ return `${toRoman(n).toUpperCase()}.`;
2165
+ case "romanLcPeriod":
2166
+ return `${toRoman(n)}.`;
2167
+ default:
2168
+ return `${n}.`;
2169
+ }
2170
+ }
2171
+
2172
+ function toAlpha(n: number): string {
2173
+ let s = "";
2174
+ let v = n;
2175
+ while (v > 0) {
2176
+ const r = (v - 1) % 26;
2177
+ s = String.fromCharCode(97 + r) + s;
2178
+ v = Math.floor((v - 1) / 26);
2179
+ }
2180
+ return s || "a";
2181
+ }
2182
+
2183
+ function toRoman(n: number): string {
2184
+ if (n <= 0) return "";
2185
+ const pairs: [number, string][] = [
2186
+ [1000, "m"],
2187
+ [900, "cm"],
2188
+ [500, "d"],
2189
+ [400, "cd"],
2190
+ [100, "c"],
2191
+ [90, "xc"],
2192
+ [50, "l"],
2193
+ [40, "xl"],
2194
+ [10, "x"],
2195
+ [9, "ix"],
2196
+ [5, "v"],
2197
+ [4, "iv"],
2198
+ [1, "i"],
2199
+ ];
2200
+ let out = "";
2201
+ let v = n;
2202
+ for (const [val, sym] of pairs) {
2203
+ while (v >= val) {
2204
+ out += sym;
2205
+ v -= val;
2206
+ }
2207
+ }
2208
+ return out;
2209
+ }
2210
+
2211
+ function clampLevel(n: number): number {
2212
+ if (!Number.isFinite(n) || n < 0) return 0;
2213
+ if (n > 8) return 8;
2214
+ return Math.floor(n);
2215
+ }
2216
+
2217
+ function buildRunInfo(
2218
+ r: any,
2219
+ theme: ThemeColors,
2220
+ themeFonts: ThemeFonts,
2221
+ fallbackRPr: any
2222
+ ): { run: RunInfo; text: string } {
2223
+ const t = r?.["a:t"];
2224
+ const rPr = r?.["a:rPr"] ?? {};
2225
+ const text = typeof t === "string" ? t : t?.["#text"] ?? "";
2226
+ const spcRaw = rPr?.["@_spc"] ?? fallbackRPr?.["@_spc"];
2227
+ const letterSpacing =
2228
+ spcRaw !== undefined && spcRaw !== ""
2229
+ ? pointsToPx(Number(spcRaw) / 100)
2230
+ : undefined;
2231
+ const fontSize =
2232
+ rPr?.["@_sz"] ?? fallbackRPr?.["@_sz"]
2233
+ ? pointsToPx(Number(rPr?.["@_sz"] ?? fallbackRPr?.["@_sz"]) / 100)
2234
+ : undefined;
2235
+ const rawFontFamily =
2236
+ rPr?.["a:latin"]?.["@_typeface"] ??
2237
+ fallbackRPr?.["a:latin"]?.["@_typeface"];
2238
+ const fontFamily = resolveFontFamily(rawFontFamily, themeFonts);
2239
+ const color =
2240
+ resolveColor(rPr?.["a:solidFill"], theme) ??
2241
+ resolveColor(fallbackRPr?.["a:solidFill"], theme);
2242
+ const boldVal = rPr?.["@_b"] ?? fallbackRPr?.["@_b"];
2243
+ const italicVal = rPr?.["@_i"] ?? fallbackRPr?.["@_i"];
2244
+ const underlineVal = rPr?.["@_u"] ?? fallbackRPr?.["@_u"];
2245
+ const strikeVal = rPr?.["@_strike"] ?? fallbackRPr?.["@_strike"];
2246
+ return {
2247
+ text,
2248
+ run: {
2249
+ text,
2250
+ fontFamily,
2251
+ fontSize,
2252
+ bold: boldVal === "1" || boldVal === 1,
2253
+ italic: italicVal === "1" || italicVal === 1,
2254
+ underline: !!(underlineVal && underlineVal !== "none"),
2255
+ strike: strikeVal === "sngStrike",
2256
+ color,
2257
+ letterSpacing,
2258
+ },
2259
+ };
2260
+ }
2261
+
2262
+ /**
2263
+ * Returns the ordered list of <a:r>/<a:br> direct children of a paragraph
2264
+ * with each entry's running index into the corresponding array on the parsed
2265
+ * paragraph. The order is recovered from the paragraph's raw XML (attached
2266
+ * during readXml) because fast-xml-parser groups children by tag name.
2267
+ */
2268
+ function paragraphChildOrder(
2269
+ p: any
2270
+ ): { kind: "r" | "br" | "fld"; index: number }[] {
2271
+ const raw = (p as any)?._rawSrc as string | undefined;
2272
+ if (raw) return paragraphChildOrderFromRaw(raw);
2273
+ // No raw available (paragraph has no <a:br/> and pre-PR-2 readXml didn't
2274
+ // attach it). Fall back to the order implied by the parsed arrays: all
2275
+ // runs, then all fields. Document order across these tag types is lost
2276
+ // by fast-xml-parser, but the typical PPTX paragraph has either runs or
2277
+ // a field, not both, so this matches reality in practice.
2278
+ const out: { kind: "r" | "br" | "fld"; index: number }[] = [];
2279
+ asArray(p?.["a:r"]).forEach((_, i) => out.push({ kind: "r", index: i }));
2280
+ asArray(p?.["a:fld"]).forEach((_, i) => out.push({ kind: "fld", index: i }));
2281
+ return out;
2282
+ }
2283
+
2284
+ /**
2285
+ * Convert a PPTX `<a:custGeom>` (custom geometry — used for logos, brand
2286
+ * marks, hand-drawn shapes) into an SVG path. Reads command order from the
2287
+ * raw XML attached during readXml, since fast-xml-parser groups children by
2288
+ * tag name and drops cross-tag document order. Supports moveTo, lnTo,
2289
+ * cubicBezTo, quadBezTo, and close; arcTo and formula-based guide
2290
+ * references aren't translated yet (they degrade to a flat-fill rect).
2291
+ */
2292
+ function parseCustGeomPath(custGeom: any): ShapePath | undefined {
2293
+ const raw = (custGeom as any)?._rawSrc as string | undefined;
2294
+ if (!raw) return undefined;
2295
+ // Each <a:path w="…" h="…"> defines its own coordinate system; the SVG
2296
+ // viewBox uses the FIRST path's dimensions and subsequent paths inherit
2297
+ // it. In practice almost every custGeom in real decks uses one viewbox
2298
+ // across all sub-paths.
2299
+ const paths = findAllElementRawBlocks(raw, "path");
2300
+ if (!paths.length) return undefined;
2301
+ let viewW = 0;
2302
+ let viewH = 0;
2303
+ let d = "";
2304
+ for (const block of paths) {
2305
+ const headerEnd = block.indexOf(">");
2306
+ if (headerEnd < 0) continue;
2307
+ const header = block.slice(0, headerEnd + 1);
2308
+ const w = Number(/\bw="(\d+)"/.exec(header)?.[1] ?? 0);
2309
+ const h = Number(/\bh="(\d+)"/.exec(header)?.[1] ?? 0);
2310
+ if (w > viewW) viewW = w;
2311
+ if (h > viewH) viewH = h;
2312
+ d += (d ? " " : "") + custGeomBodyToSvgD(block);
2313
+ }
2314
+ if (!d || viewW <= 0 || viewH <= 0) return undefined;
2315
+ // OOXML composite paths (multiple subpaths with internal holes — letters
2316
+ // like the "e" and "o" in the eon wordmark) render with even-odd winding
2317
+ // by default; the nonzero default of SVG would fill the holes.
2318
+ return { d, viewW, viewH, fillRule: "evenodd" };
2319
+ }
2320
+
2321
+ function custGeomBodyToSvgD(pathBlock: string): string {
2322
+ const headerEnd = pathBlock.indexOf(">");
2323
+ const closeIdx = pathBlock.lastIndexOf("</a:path>");
2324
+ const inner =
2325
+ closeIdx > headerEnd
2326
+ ? pathBlock.slice(headerEnd + 1, closeIdx)
2327
+ : pathBlock.slice(headerEnd + 1);
2328
+ let out = "";
2329
+ let i = 0;
2330
+ let depth = 0;
2331
+ // Track the current pen position so <a:arcTo> (which doesn't carry an
2332
+ // explicit end point) can compute its SVG `A` endpoint from start +
2333
+ // sweep angle, just like PowerPoint does at render time.
2334
+ let penX = 0;
2335
+ let penY = 0;
2336
+ while (i < inner.length) {
2337
+ if (inner[i] !== "<") {
2338
+ i++;
2339
+ continue;
2340
+ }
2341
+ if (inner.startsWith("</", i)) {
2342
+ depth--;
2343
+ const end = inner.indexOf(">", i);
2344
+ if (end < 0) break;
2345
+ i = end + 1;
2346
+ continue;
2347
+ }
2348
+ if (inner.startsWith("<!--", i)) {
2349
+ const end = inner.indexOf("-->", i);
2350
+ if (end < 0) break;
2351
+ i = end + 3;
2352
+ continue;
2353
+ }
2354
+ const close = inner.indexOf(">", i);
2355
+ if (close < 0) break;
2356
+ const tag = inner.slice(i, close + 1);
2357
+ const nameMatch = /^<a:([\w]+)/.exec(tag);
2358
+ const isSelfClose = tag.endsWith("/>");
2359
+ if (depth === 0 && nameMatch) {
2360
+ const name = nameMatch[1];
2361
+ if (name === "close") {
2362
+ out += " Z";
2363
+ } else if (
2364
+ name === "moveTo" ||
2365
+ name === "lnTo" ||
2366
+ name === "cubicBezTo" ||
2367
+ name === "quadBezTo"
2368
+ ) {
2369
+ const cmdClose = inner.indexOf(`</a:${name}>`, close + 1);
2370
+ if (cmdClose < 0) break;
2371
+ const body = inner.slice(close + 1, cmdClose);
2372
+ const pts: Array<[number, number]> = [];
2373
+ const ptRe = /<a:pt\s+x="(-?\d+)"\s+y="(-?\d+)"\s*\/>/g;
2374
+ let m: RegExpExecArray | null;
2375
+ while ((m = ptRe.exec(body))) {
2376
+ pts.push([Number(m[1]), Number(m[2])]);
2377
+ }
2378
+ if (pts.length) {
2379
+ const letter =
2380
+ name === "moveTo"
2381
+ ? "M"
2382
+ : name === "lnTo"
2383
+ ? "L"
2384
+ : name === "cubicBezTo"
2385
+ ? "C"
2386
+ : "Q";
2387
+ out += ` ${letter} ${pts.map(([x, y]) => `${x} ${y}`).join(" ")}`;
2388
+ const last = pts[pts.length - 1];
2389
+ penX = last[0];
2390
+ penY = last[1];
2391
+ }
2392
+ i = cmdClose + `</a:${name}>`.length;
2393
+ continue;
2394
+ } else if (name === "arcTo") {
2395
+ // <a:arcTo wR="" hR="" stAng="" swAng="" /> — elliptical arc
2396
+ // starting at the current pen position. wR/hR are the axis radii;
2397
+ // stAng/swAng are start/sweep angles measured in 60000ths of a
2398
+ // degree (OOXML convention). The start point on the ellipse is
2399
+ // (centre.x + wR·cos(stAng), centre.y + hR·sin(stAng)); the
2400
+ // centre is therefore (pen.x − wR·cos(stAng), pen.y − hR·sin(stAng)).
2401
+ // SVG `A` takes the END point instead of an angle, so we compute
2402
+ // the end from start + swAng.
2403
+ const wR = Number(/\bwR="(-?\d+)"/.exec(tag)?.[1] ?? 0);
2404
+ const hR = Number(/\bhR="(-?\d+)"/.exec(tag)?.[1] ?? 0);
2405
+ const stAng = Number(/\bstAng="(-?\d+)"/.exec(tag)?.[1] ?? 0) / 60000;
2406
+ const swAng = Number(/\bswAng="(-?\d+)"/.exec(tag)?.[1] ?? 0) / 60000;
2407
+ if (wR > 0 && hR > 0) {
2408
+ const rad = (deg: number) => (deg * Math.PI) / 180;
2409
+ const cx = penX - wR * Math.cos(rad(stAng));
2410
+ const cy = penY - hR * Math.sin(rad(stAng));
2411
+ const endAng = stAng + swAng;
2412
+ const ex = cx + wR * Math.cos(rad(endAng));
2413
+ const ey = cy + hR * Math.sin(rad(endAng));
2414
+ const largeArc = Math.abs(swAng) > 180 ? 1 : 0;
2415
+ const sweep = swAng > 0 ? 1 : 0;
2416
+ out += ` A ${wR} ${hR} 0 ${largeArc} ${sweep} ${ex.toFixed(2)} ${ey.toFixed(2)}`;
2417
+ penX = ex;
2418
+ penY = ey;
2419
+ }
2420
+ }
2421
+ // Unknown commands are skipped — the rest of the path stays valid.
2422
+ }
2423
+ if (!isSelfClose) depth++;
2424
+ i = close + 1;
2425
+ }
2426
+ return out.trim();
2427
+ }
2428
+
2429
+ function paragraphChildOrderFromRaw(
2430
+ raw: string
2431
+ ): { kind: "r" | "br" | "fld"; index: number }[] {
2432
+ const tagEnd = raw.indexOf(">");
2433
+ const closeIdx = raw.lastIndexOf("</a:p>");
2434
+ if (tagEnd < 0 || closeIdx < 0) return [];
2435
+ const inner = raw.slice(tagEnd + 1, closeIdx);
2436
+ const out: { kind: "r" | "br" | "fld"; index: number }[] = [];
2437
+ let depth = 0;
2438
+ let rIdx = 0;
2439
+ let brIdx = 0;
2440
+ let fldIdx = 0;
2441
+ let i = 0;
2442
+ while (i < inner.length) {
2443
+ if (inner[i] !== "<") {
2444
+ i++;
2445
+ continue;
2446
+ }
2447
+ if (inner.startsWith("</", i)) {
2448
+ depth--;
2449
+ const end = inner.indexOf(">", i);
2450
+ if (end < 0) break;
2451
+ i = end + 1;
2452
+ continue;
2453
+ }
2454
+ if (inner.startsWith("<!--", i)) {
2455
+ const end = inner.indexOf("-->", i);
2456
+ if (end < 0) break;
2457
+ i = end + 3;
2458
+ continue;
2459
+ }
2460
+ if (inner.startsWith("<?", i)) {
2461
+ const end = inner.indexOf("?>", i);
2462
+ if (end < 0) break;
2463
+ i = end + 2;
2464
+ continue;
2465
+ }
2466
+ const close = inner.indexOf(">", i);
2467
+ if (close < 0) break;
2468
+ const tag = inner.slice(i, close + 1);
2469
+ const isSelfClose = tag.endsWith("/>");
2470
+ const nameMatch = /^<(a:[\w]+)/.exec(tag);
2471
+ if (depth === 0 && nameMatch) {
2472
+ const name = nameMatch[1];
2473
+ if (name === "a:r") out.push({ kind: "r", index: rIdx++ });
2474
+ else if (name === "a:br") out.push({ kind: "br", index: brIdx++ });
2475
+ else if (name === "a:fld") out.push({ kind: "fld", index: fldIdx++ });
2476
+ }
2477
+ if (!isSelfClose) depth++;
2478
+ i = close + 1;
2479
+ }
2480
+ return out;
2481
+ }
2482
+
1274
2483
  function hasAnyText(txBody: any): boolean {
1275
2484
  const ps = asArray(txBody?.["a:p"]);
1276
2485
  for (const p of ps) {
1277
- const rs = asArray(p?.["a:r"]);
1278
- for (const r of rs) {
2486
+ for (const r of [...asArray(p?.["a:r"]), ...asArray(p?.["a:fld"])]) {
1279
2487
  const t = r?.["a:t"];
1280
2488
  const text = typeof t === "string" ? t : t?.["#text"] ?? "";
1281
2489
  if (text && String(text).length > 0) return true;
@@ -1291,6 +2499,27 @@ function mergeFirst<T>(...candidates: (T | undefined)[]): T | undefined {
1291
2499
  return undefined;
1292
2500
  }
1293
2501
 
2502
+ /**
2503
+ * Per-field rPr merge: earlier candidates win for each individual attribute
2504
+ * (font typeface, size, colour, weight, italic, …). Used to flatten the
2505
+ * layout→master→txStyles inheritance chain into a single fallback rPr for
2506
+ * the run extractor.
2507
+ */
2508
+ function mergeRPrChain(...candidates: (any | undefined)[]): any | undefined {
2509
+ const out: any = {};
2510
+ let touched = false;
2511
+ for (const c of candidates) {
2512
+ if (!c || typeof c !== "object") continue;
2513
+ for (const k of Object.keys(c)) {
2514
+ if (out[k] === undefined) {
2515
+ out[k] = c[k];
2516
+ touched = true;
2517
+ }
2518
+ }
2519
+ }
2520
+ return touched ? out : undefined;
2521
+ }
2522
+
1294
2523
  /**
1295
2524
  * Map an OOXML preset shape name to the closest Slidewise ShapeKind. Returns
1296
2525
  * null only for shapes we genuinely cannot represent at all (the caller then
@@ -1436,7 +2665,274 @@ async function readXml(zip: JSZip, path: string): Promise<any | null> {
1436
2665
  const file = zip.file(path);
1437
2666
  if (!file) return null;
1438
2667
  const text = await file.async("string");
1439
- return xmlParser.parse(text);
2668
+ const parsed = xmlParser.parse(text);
2669
+ // fast-xml-parser groups children by tag name and drops cross-tag
2670
+ // document order. Attach raw fragments for paragraphs (run/br/fld
2671
+ // interleaving), custGeom path commands, and spTree/grpSp children
2672
+ // (which carry z-index via source order).
2673
+ annotateRawOrder(parsed, text);
2674
+ return parsed;
2675
+ }
2676
+
2677
+ function annotateRawOrder(parsed: any, rawText: string): void {
2678
+ annotateParagraphRawSrc(parsed, rawText);
2679
+ annotateSpTreeChildOrder(parsed, rawText);
2680
+ }
2681
+
2682
+ /**
2683
+ * Attach `_childOrder: string[]` to every parsed `<p:spTree>` and
2684
+ * `<p:grpSp>` so callers can iterate children (sp, pic, cxnSp,
2685
+ * graphicFrame, grpSp) in document order. PPTX z-index follows source
2686
+ * order — a slide that lists `<p:pic>` before `<p:sp>` means the picture
2687
+ * sits behind the text — but fast-xml-parser groups children by tag name
2688
+ * and drops cross-tag ordering.
2689
+ */
2690
+ function annotateSpTreeChildOrder(parsed: any, rawText: string): void {
2691
+ if (!rawText.includes("<p:spTree") && !rawText.includes("<p:grpSp")) return;
2692
+ for (const tag of ["p:spTree", "p:grpSp"] as const) {
2693
+ if (!rawText.includes(`<${tag}`)) continue;
2694
+ const blocks = findAllRawBlocks(rawText, tag);
2695
+ if (!blocks.length) continue;
2696
+ const nodes: any[] = [];
2697
+ collectNamedDfs(parsed, tag, nodes);
2698
+ const n = Math.min(blocks.length, nodes.length);
2699
+ for (let i = 0; i < n; i++) {
2700
+ const order = extractTopLevelChildNames(blocks[i]);
2701
+ Object.defineProperty(nodes[i], "_childOrder", {
2702
+ value: order,
2703
+ enumerable: false,
2704
+ configurable: true,
2705
+ });
2706
+ }
2707
+ }
2708
+ }
2709
+
2710
+ function extractTopLevelChildNames(blockXml: string): string[] {
2711
+ const tagEnd = blockXml.indexOf(">");
2712
+ const close = blockXml.lastIndexOf("<");
2713
+ if (tagEnd < 0 || close <= tagEnd) return [];
2714
+ const inner = blockXml.slice(tagEnd + 1, close);
2715
+ const out: string[] = [];
2716
+ let depth = 0;
2717
+ let i = 0;
2718
+ while (i < inner.length) {
2719
+ if (inner[i] !== "<") {
2720
+ i++;
2721
+ continue;
2722
+ }
2723
+ if (inner.startsWith("</", i)) {
2724
+ depth--;
2725
+ const end = inner.indexOf(">", i);
2726
+ if (end < 0) break;
2727
+ i = end + 1;
2728
+ continue;
2729
+ }
2730
+ if (inner.startsWith("<!--", i)) {
2731
+ const end = inner.indexOf("-->", i);
2732
+ if (end < 0) break;
2733
+ i = end + 3;
2734
+ continue;
2735
+ }
2736
+ const end = inner.indexOf(">", i);
2737
+ if (end < 0) break;
2738
+ const tag = inner.slice(i, end + 1);
2739
+ const selfClose = tag.endsWith("/>");
2740
+ const nameMatch = /^<([\w:]+)/.exec(tag);
2741
+ if (depth === 0 && nameMatch) out.push(nameMatch[1]);
2742
+ if (!selfClose) depth++;
2743
+ i = end + 1;
2744
+ }
2745
+ return out;
2746
+ }
2747
+
2748
+ function findAllRawBlocks(raw: string, fullName: string): string[] {
2749
+ const blocks: string[] = [];
2750
+ // Depth-aware scan so self-nesting tags (e.g. `<p:grpSp>` inside another
2751
+ // `<p:grpSp>`) match their correct closing tag instead of the first inner
2752
+ // one we encounter.
2753
+ const openRe = new RegExp(`<${fullName}\\b[^>]*?(/?)>`, "g");
2754
+ const closeTag = `</${fullName}>`;
2755
+ let i = 0;
2756
+ while (i < raw.length) {
2757
+ openRe.lastIndex = i;
2758
+ const m = openRe.exec(raw);
2759
+ if (!m) break;
2760
+ const start = m.index;
2761
+ const tagEnd = openRe.lastIndex;
2762
+ if (m[1] === "/") {
2763
+ blocks.push(raw.slice(start, tagEnd));
2764
+ i = tagEnd;
2765
+ continue;
2766
+ }
2767
+ let depth = 1;
2768
+ let scan = tagEnd;
2769
+ const innerOpenRe = new RegExp(`<${fullName}\\b[^>]*?(/?)>`, "g");
2770
+ while (depth > 0 && scan < raw.length) {
2771
+ innerOpenRe.lastIndex = scan;
2772
+ const nextOpen = innerOpenRe.exec(raw);
2773
+ const nextClose = raw.indexOf(closeTag, scan);
2774
+ if (nextClose < 0) {
2775
+ depth = -1;
2776
+ break;
2777
+ }
2778
+ if (nextOpen && nextOpen.index < nextClose) {
2779
+ // Self-closing opens don't change depth.
2780
+ if (nextOpen[1] !== "/") depth++;
2781
+ scan = innerOpenRe.lastIndex;
2782
+ } else {
2783
+ depth--;
2784
+ scan = nextClose + closeTag.length;
2785
+ }
2786
+ }
2787
+ if (depth !== 0) break;
2788
+ blocks.push(raw.slice(start, scan));
2789
+ i = scan;
2790
+ }
2791
+ return blocks;
2792
+ }
2793
+
2794
+ function annotateParagraphRawSrc(parsed: any, rawText: string): void {
2795
+ // Cross-tag document order matters when a paragraph mixes <a:r>, <a:br>,
2796
+ // and <a:fld>. fast-xml-parser groups by tag name, so we keep the raw
2797
+ // XML for any paragraph that contains a break or a field.
2798
+ if (rawText.includes("<a:br") || rawText.includes("<a:fld")) {
2799
+ const blocks = findAllParagraphRawBlocks(rawText);
2800
+ if (blocks.length) {
2801
+ const parsedPs: any[] = [];
2802
+ collectParagraphsDfs(parsed, parsedPs);
2803
+ const n = Math.min(blocks.length, parsedPs.length);
2804
+ for (let i = 0; i < n; i++) {
2805
+ const block = blocks[i];
2806
+ if (block.includes("<a:br") || block.includes("<a:fld")) {
2807
+ Object.defineProperty(parsedPs[i], "_rawSrc", {
2808
+ value: block,
2809
+ enumerable: false,
2810
+ configurable: true,
2811
+ });
2812
+ }
2813
+ }
2814
+ }
2815
+ }
2816
+ // <a:custGeom> path commands (moveTo, lnTo, cubicBezTo, …) are siblings,
2817
+ // and their cross-tag order defines the silhouette of brand logos and
2818
+ // hand-drawn shapes. Same fast-xml-parser issue, same fix: attach raw.
2819
+ if (rawText.includes("<a:custGeom")) {
2820
+ const blocks = findAllElementRawBlocks(rawText, "custGeom");
2821
+ if (blocks.length) {
2822
+ const parsedCustGeoms: any[] = [];
2823
+ collectNamedDfs(parsed, "a:custGeom", parsedCustGeoms);
2824
+ const n = Math.min(blocks.length, parsedCustGeoms.length);
2825
+ for (let i = 0; i < n; i++) {
2826
+ Object.defineProperty(parsedCustGeoms[i], "_rawSrc", {
2827
+ value: blocks[i],
2828
+ enumerable: false,
2829
+ configurable: true,
2830
+ });
2831
+ }
2832
+ }
2833
+ }
2834
+ }
2835
+
2836
+ function collectNamedDfs(node: any, key: string, acc: any[]): void {
2837
+ if (!node || typeof node !== "object") return;
2838
+ if (Array.isArray(node)) {
2839
+ for (const n of node) collectNamedDfs(n, key, acc);
2840
+ return;
2841
+ }
2842
+ for (const k of Object.keys(node)) {
2843
+ if (k.startsWith("@_") || k === "#text") continue;
2844
+ if (k === key) {
2845
+ for (const v of asArray(node[k])) acc.push(v);
2846
+ } else {
2847
+ collectNamedDfs(node[k], key, acc);
2848
+ }
2849
+ }
2850
+ }
2851
+
2852
+ function findAllElementRawBlocks(raw: string, localName: string): string[] {
2853
+ const blocks: string[] = [];
2854
+ const openSelfClose = new RegExp(`<a:${localName}\\b[^>]*/>`);
2855
+ const openTag = new RegExp(`<a:${localName}\\b[^>]*>`);
2856
+ const close = `</a:${localName}>`;
2857
+ let cursor = 0;
2858
+ while (cursor < raw.length) {
2859
+ const slice = raw.slice(cursor);
2860
+ const sc = openSelfClose.exec(slice);
2861
+ const ot = openTag.exec(slice);
2862
+ // Pick whichever comes first (and only if it isn't a self-close that was
2863
+ // also matched by openTag).
2864
+ let start = -1;
2865
+ let selfClose = false;
2866
+ if (sc && (!ot || sc.index <= ot.index)) {
2867
+ start = cursor + sc.index;
2868
+ selfClose = true;
2869
+ } else if (ot) {
2870
+ start = cursor + ot.index;
2871
+ selfClose = ot[0].endsWith("/>");
2872
+ }
2873
+ if (start < 0) break;
2874
+ if (selfClose) {
2875
+ const end = raw.indexOf(">", start);
2876
+ blocks.push(raw.slice(start, end + 1));
2877
+ cursor = end + 1;
2878
+ continue;
2879
+ }
2880
+ const tagEnd = raw.indexOf(">", start);
2881
+ const closeIdx = raw.indexOf(close, tagEnd + 1);
2882
+ if (closeIdx < 0) break;
2883
+ blocks.push(raw.slice(start, closeIdx + close.length));
2884
+ cursor = closeIdx + close.length;
2885
+ }
2886
+ return blocks;
2887
+ }
2888
+
2889
+ function findAllParagraphRawBlocks(raw: string): string[] {
2890
+ const blocks: string[] = [];
2891
+ let i = 0;
2892
+ while (i < raw.length) {
2893
+ const a = raw.indexOf("<a:p>", i);
2894
+ const b = raw.indexOf("<a:p ", i);
2895
+ let start: number;
2896
+ if (a < 0 && b < 0) break;
2897
+ if (a < 0) start = b;
2898
+ else if (b < 0) start = a;
2899
+ else start = Math.min(a, b);
2900
+ const tagEnd = raw.indexOf(">", start);
2901
+ if (tagEnd < 0) break;
2902
+ if (raw[tagEnd - 1] === "/") {
2903
+ blocks.push(raw.slice(start, tagEnd + 1));
2904
+ i = tagEnd + 1;
2905
+ continue;
2906
+ }
2907
+ // <a:p> never nests inside another <a:p>, so the first </a:p> is ours.
2908
+ const close = raw.indexOf("</a:p>", tagEnd + 1);
2909
+ if (close < 0) break;
2910
+ blocks.push(raw.slice(start, close + "</a:p>".length));
2911
+ i = close + "</a:p>".length;
2912
+ }
2913
+ return blocks;
2914
+ }
2915
+
2916
+ function collectParagraphsDfs(node: any, acc: any[]): void {
2917
+ if (!node || typeof node !== "object") return;
2918
+ if (Array.isArray(node)) {
2919
+ for (const n of node) collectParagraphsDfs(n, acc);
2920
+ return;
2921
+ }
2922
+ for (const k of Object.keys(node)) {
2923
+ if (k.startsWith("@_") || k === "#text") continue;
2924
+ if (k === "a:p") {
2925
+ for (const p of asArray(node[k])) acc.push(p);
2926
+ } else {
2927
+ collectParagraphsDfs(node[k], acc);
2928
+ }
2929
+ }
2930
+ }
2931
+
2932
+ async function readXmlRaw(zip: JSZip, path: string): Promise<string | null> {
2933
+ const file = zip.file(path);
2934
+ if (!file) return null;
2935
+ return file.async("string");
1440
2936
  }
1441
2937
 
1442
2938
  async function readRels(zip: JSZip, path: string): Promise<Rels> {