docgen-utils 1.0.11 → 1.0.12

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.
@@ -5,6 +5,7 @@
5
5
  * Usage: const slides = await importPptx(arrayBuffer);
6
6
  */
7
7
  import JSZip from "jszip";
8
+ import { cssFontFamily } from "./fonts";
8
9
  // ============================================================================
9
10
  // Constants
10
11
  // ============================================================================
@@ -13,121 +14,9 @@ const TARGET_WIDTH = 1280;
13
14
  const TARGET_HEIGHT = 720;
14
15
  // EMU (English Metric Units) to pixels: 1 inch = 914400 EMU, 96 DPI
15
16
  const EMU_PER_PX = 914400 / 96;
16
- // Font fallback mapping: maps fonts that may not be installed to the closest
17
- // available alternatives. This improves rendering fidelity when exact fonts
18
- // are not present on the system. Each value is a comma-separated CSS font stack.
19
- const FONT_FALLBACK_MAP = {
20
- // Microsoft Office fonts → metrically compatible open-source alternatives
21
- "Calibri": "'Calibri','Carlito','Helvetica Neue',Helvetica,Arial,sans-serif",
22
- "Calibri Light": "'Calibri Light','Carlito','Helvetica Neue Light','Helvetica Neue',Arial,sans-serif",
23
- "Cambria": "'Cambria','Caladea','Times New Roman',Georgia,serif",
24
- "Cambria Math": "'Cambria Math','Caladea','Times New Roman',serif",
25
- "Consolas": "'Consolas','Courier New',monospace",
26
- "Aptos": "'Aptos','Carlito','Helvetica Neue',Arial,sans-serif",
27
- "Times New Roman": "'Times New Roman','Liberation Serif',Georgia,serif",
28
- // Handwriting / decorative fonts
29
- "MV Boli": "'MV Boli','Comic Sans MS','Marker Felt',cursive",
30
- "Kristen ITC": "'Kristen ITC','Comic Sans MS','Marker Felt',cursive",
31
- "Stylus BT": "'Stylus BT','Brush Script MT','Snell Roundhand',cursive",
32
- // Japanese sans-serif fonts → Noto Sans CJK JP (commonly installed)
33
- "MS Gothic": "'MS Gothic','Noto Sans CJK JP','Hiragino Kaku Gothic ProN','Yu Gothic',sans-serif",
34
- "MS PGothic": "'MS PGothic','Noto Sans CJK JP','Hiragino Kaku Gothic ProN','Yu Gothic',sans-serif",
35
- "MS Pゴシック": "'MS Pゴシック','Noto Sans CJK JP','Hiragino Kaku Gothic ProN','Yu Gothic',sans-serif",
36
- "MS ゴシック": "'MS ゴシック','Noto Sans CJK JP','Hiragino Kaku Gothic ProN','Yu Gothic',sans-serif",
37
- "Kozuka Gothic Pro R": "'Kozuka Gothic Pro R','Noto Sans CJK JP','Hiragino Kaku Gothic ProN',sans-serif",
38
- "Kozuka Gothic Pro B": "'Kozuka Gothic Pro B','Noto Sans CJK JP Bold','Hiragino Kaku Gothic ProN',sans-serif",
39
- "Kozuka Gothic Pro M": "'Kozuka Gothic Pro M','Noto Sans CJK JP Medium','Hiragino Kaku Gothic ProN',sans-serif",
40
- "Kozuka Gothic Pro EL": "'Kozuka Gothic Pro EL','Noto Sans CJK JP Light','Hiragino Kaku Gothic ProN',sans-serif",
41
- "HGSKyokashotai": "'HGSKyokashotai','Noto Sans CJK JP','Hiragino Kaku Gothic ProN',sans-serif",
42
- // Japanese serif fonts → Noto Serif CJK JP
43
- "MS Mincho": "'MS Mincho','Noto Serif CJK JP','Hiragino Mincho ProN','Yu Mincho',serif",
44
- "MS PMincho": "'MS PMincho','Noto Serif CJK JP','Hiragino Mincho ProN','Yu Mincho',serif",
45
- // CJK fonts
46
- "宋体": "'宋体','Noto Serif CJK SC','Songti SC','STSong',serif",
47
- "新細明體": "'新細明體','Noto Serif CJK TC','Songti TC',serif",
48
- "黑体": "'黑体','Noto Sans CJK SC','Heiti SC','STHeiti',sans-serif",
49
- "微軟正黑體": "'微軟正黑體','Noto Sans CJK TC','Heiti TC',sans-serif",
50
- "맑은 고딕": "'맑은 고딕','Apple SD Gothic Neo',sans-serif",
51
- // UI / system fonts
52
- "Lucida Sans Unicode": "'Lucida Sans Unicode','Lucida Grande','Lucida Sans',sans-serif",
53
- "Segoe UI": "'Segoe UI','-apple-system','Helvetica Neue',sans-serif",
54
- "Segoe Sans Display": "'Segoe Sans Display','-apple-system','Helvetica Neue',Arial,sans-serif",
55
- "Segoe Sans Display Semibold": "'Segoe Sans Display Semibold','-apple-system','Helvetica Neue',Arial,sans-serif",
56
- // Google Fonts / web fonts commonly used in presentations
57
- "Inter": "'Inter','Helvetica Neue',Helvetica,Arial,sans-serif",
58
- "Inter Light": "'Inter Light','Inter','Helvetica Neue',Arial,sans-serif",
59
- "Inter ExtraBold": "'Inter ExtraBold','Inter','Helvetica Neue',Arial,sans-serif",
60
- "Space Grotesk": "'Space Grotesk','Helvetica Neue',Helvetica,Arial,sans-serif",
61
- "Montserrat": "'Montserrat','Helvetica Neue',Helvetica,Arial,sans-serif",
62
- "Open Sans": "'Open Sans','Helvetica Neue',Helvetica,Arial,sans-serif",
63
- "Lato": "'Lato','Helvetica Neue',Helvetica,Arial,sans-serif",
64
- "Oswald": "'Oswald','Helvetica Neue',Helvetica,Arial,sans-serif",
65
- "Archivo": "'Archivo','Helvetica Neue',Helvetica,Arial,sans-serif",
66
- "Economica": "'Economica','Helvetica Neue',Arial,sans-serif",
67
- "Libre Baskerville": "'Libre Baskerville','Georgia','Times New Roman',serif",
68
- // GitHub Copilot fonts
69
- "Ginto Copilot": "'Ginto Copilot','Helvetica Neue',Helvetica,Arial,sans-serif",
70
- "Ginto Copilot Light": "'Ginto Copilot Light','Helvetica Neue Light','Helvetica Neue',Arial,sans-serif",
71
- "Ginto Copilot 400": "'Ginto Copilot 400','Helvetica Neue',Helvetica,Arial,sans-serif",
72
- // Google Fonts / decorative (serif & display)
73
- "Playfair Display": "'Playfair Display','Georgia','Times New Roman',serif",
74
- "Playfair Display SemiBold": "'Playfair Display SemiBold','Playfair Display','Georgia',serif",
75
- "Caveat": "'Caveat','Comic Sans MS','Marker Felt',cursive",
76
- "Syncopate": "'Syncopate','Helvetica Neue',Helvetica,Arial,sans-serif",
77
- // Montserrat weight variants
78
- "Montserrat SemiBold": "'Montserrat SemiBold','Montserrat','Helvetica Neue',Arial,sans-serif",
79
- "Montserrat ExtraBold": "'Montserrat ExtraBold','Montserrat','Helvetica Neue',Arial,sans-serif",
80
- // GitHub Copilot font variants
81
- "Ginto Copilot Medium": "'Ginto Copilot Medium','Ginto Copilot','Helvetica Neue',Arial,sans-serif",
82
- "Ginto Copilot Black": "'Ginto Copilot Black','Ginto Copilot','Helvetica Neue',Arial,sans-serif",
83
- "Ginto Copilot Thin": "'Ginto Copilot Thin','Ginto Copilot Light','Helvetica Neue',Arial,sans-serif",
84
- // Microsoft UI fonts
85
- "Segoe Sans Small Regular": "'Segoe Sans Small Regular','Segoe UI','-apple-system','Helvetica Neue',sans-serif",
86
- "Segoe Sans Text Regular": "'Segoe Sans Text Regular','Segoe UI','-apple-system','Helvetica Neue',sans-serif",
87
- "Grandview": "'Grandview','Helvetica Neue',Arial,sans-serif",
88
- "Nirmala UI": "'Nirmala UI','Helvetica Neue',Arial,sans-serif",
89
- "Ebrima": "'Ebrima','Helvetica Neue',Arial,sans-serif",
90
- // Common system fonts with fallbacks
91
- "Arial": "Arial,'Helvetica Neue',Helvetica,sans-serif",
92
- "Arial Black": "'Arial Black','Helvetica Neue',Helvetica,Arial,sans-serif",
93
- "Georgia": "Georgia,'Times New Roman',serif",
94
- "Georgia Regular": "'Georgia Regular',Georgia,'Times New Roman',serif",
95
- "Courier New": "'Courier New',Courier,monospace",
96
- "Times": "Times,'Times New Roman',serif",
97
- // Symbol fonts (preserved as-is with system fallback)
98
- "Wingdings": "'Wingdings','Zapf Dingbats',sans-serif",
99
- "Wingdings 2": "'Wingdings 2','Zapf Dingbats',sans-serif",
100
- "Wingdings 3": "'Wingdings 3','Zapf Dingbats',sans-serif",
101
- // Common fallbacks
102
- "Tahoma": "'Tahoma','Verdana','Geneva',sans-serif",
103
- "Verdana": "'Verdana','Geneva',sans-serif",
104
- "Helvetica Neue Light": "'Helvetica Neue Light','Helvetica Neue',Helvetica,Arial,sans-serif",
105
- };
106
17
  // ============================================================================
107
18
  // Utility Functions
108
19
  // ============================================================================
109
- /**
110
- * Returns a CSS font-family value for the given font name.
111
- * Uses FONT_FALLBACK_MAP to provide platform-appropriate fallbacks
112
- * for fonts that may not be installed on the system.
113
- */
114
- function cssFontFamily(fontName) {
115
- const mapped = FONT_FALLBACK_MAP[fontName];
116
- if (mapped)
117
- return mapped;
118
- // Handle malformed typeface attributes that already contain commas (e.g. "Arial,Sans-Serif")
119
- if (fontName.includes(',')) {
120
- return fontName.split(',').map(f => {
121
- const trimmed = f.trim();
122
- const lower = trimmed.toLowerCase();
123
- if (lower === 'sans-serif' || lower === 'serif' || lower === 'monospace' || lower === 'cursive' || lower === 'fantasy') {
124
- return lower;
125
- }
126
- return `'${trimmed}'`;
127
- }).join(',');
128
- }
129
- return `'${fontName}',sans-serif`;
130
- }
131
20
  function emuToPx(emu) {
132
21
  return emu / EMU_PER_PX;
133
22
  }
@@ -238,7 +127,7 @@ function extractFill(spPr, themeColors) {
238
127
  return resolveColor(solidFill, themeColors);
239
128
  return extractGradientFill(spPr, themeColors);
240
129
  }
241
- function extractRunProps(rPr, scale, themeColors) {
130
+ function extractRunProps(rPr, scale, themeColors, themeFonts) {
242
131
  if (!rPr)
243
132
  return {};
244
133
  const result = {};
@@ -291,10 +180,32 @@ function extractRunProps(rPr, scale, themeColors) {
291
180
  }
292
181
  const latin = findChild(rPr, "latin");
293
182
  if (latin) {
294
- const typeface = latin.getAttribute("typeface");
295
- if (typeface)
183
+ let typeface = latin.getAttribute("typeface");
184
+ if (typeface && themeFonts) {
185
+ // Resolve theme font references
186
+ if (typeface === "+mn-lt")
187
+ typeface = themeFonts.minorLatin;
188
+ else if (typeface === "+mj-lt")
189
+ typeface = themeFonts.majorLatin;
190
+ }
191
+ if (typeface && !typeface.startsWith("+"))
296
192
  result.fontFamily = typeface;
297
193
  }
194
+ // Fallback: check East Asian font
195
+ if (!result.fontFamily) {
196
+ const ea = findChild(rPr, "ea");
197
+ if (ea) {
198
+ let typeface = ea.getAttribute("typeface");
199
+ if (typeface && themeFonts) {
200
+ if (typeface === "+mn-ea")
201
+ typeface = themeFonts.minorEastAsian ?? themeFonts.minorLatin;
202
+ else if (typeface === "+mj-ea")
203
+ typeface = themeFonts.majorEastAsian ?? themeFonts.majorLatin;
204
+ }
205
+ if (typeface && !typeface.startsWith("+"))
206
+ result.fontFamily = typeface;
207
+ }
208
+ }
298
209
  // Extract glow effect from effectLst
299
210
  const effectLst = findChild(rPr, "effectLst");
300
211
  if (effectLst) {
@@ -344,7 +255,7 @@ function extractRunProps(rPr, scale, themeColors) {
344
255
  // ============================================================================
345
256
  // Parsing Functions
346
257
  // ============================================================================
347
- function parseShape(sp, scale, themeColors) {
258
+ function parseShape(sp, scale, themeColors, themeFonts) {
348
259
  const spPr = findChild(sp, "spPr");
349
260
  if (!spPr)
350
261
  return null;
@@ -450,10 +361,23 @@ function parseShape(sp, scale, themeColors) {
450
361
  para.indentPx = Math.round(emuToPx(parseInt(indent, 10)) * scale);
451
362
  const defRPr = findChild(pPr, "defRPr");
452
363
  if (defRPr) {
453
- defaults = extractRunProps(defRPr, scale, themeColors);
364
+ defaults = extractRunProps(defRPr, scale, themeColors, themeFonts);
454
365
  }
455
366
  const lnSpc = findChild(pPr, "lnSpc");
456
367
  if (lnSpc) {
368
+ const spcPct = findChild(lnSpc, "spcPct");
369
+ if (spcPct) {
370
+ const pctVal = parseInt(spcPct.getAttribute("val") ?? "100000", 10);
371
+ // PPTX spcPct 100000 = 100% = "single" spacing.
372
+ // In CSS, line-height:100% is actually tighter than normal (~120%),
373
+ // so we only emit lineSpacingPercent when it differs from the default.
374
+ // This avoids both the tighter-than-intended 100% and the text-overflow
375
+ // issues caused by large values like 160% inside fixed-height boxes.
376
+ const pct = pctVal / 1000;
377
+ if (pct !== 100) {
378
+ para.lineSpacingPercent = pct;
379
+ }
380
+ }
457
381
  const spcPts = findChild(lnSpc, "spcPts");
458
382
  if (spcPts) {
459
383
  para.lineSpacingPt =
@@ -480,6 +404,11 @@ function parseShape(sp, scale, themeColors) {
480
404
  if (buChar) {
481
405
  para.bulletChar = buChar.getAttribute("char") ?? undefined;
482
406
  }
407
+ // Bullet color
408
+ const buClr = findChild(pPr, "buClr");
409
+ if (buClr) {
410
+ para.bulletColor = resolveColor(buClr, themeColors);
411
+ }
483
412
  }
484
413
  // Iterate over all children to handle both text runs (<a:r>) and line breaks (<a:br>)
485
414
  // This ensures proper spacing when text spans multiple lines
@@ -491,7 +420,7 @@ function parseShape(sp, scale, themeColors) {
491
420
  if (localName === 'r') {
492
421
  // Text run
493
422
  const rPr = findChild(el, "rPr");
494
- const props = extractRunProps(rPr, scale, themeColors);
423
+ const props = extractRunProps(rPr, scale, themeColors, themeFonts);
495
424
  const tEls = findChildren(el, "t");
496
425
  const text = tEls.map((t) => t.textContent ?? "").join("");
497
426
  if (text) {
@@ -619,6 +548,379 @@ function parsePicture(pic, scale, imageMap) {
619
548
  };
620
549
  }
621
550
  // ============================================================================
551
+ // Table Style Parsing
552
+ // ============================================================================
553
+ /**
554
+ * Parse ppt/tableStyles.xml and return a map from style GUID to TableStyleInfo.
555
+ * Table styles define default fills for wholeTbl, banded rows, header row, etc.
556
+ * Cells without explicit fills inherit colors from their table style.
557
+ */
558
+ async function parseTableStyles(zip, parser, themeColors) {
559
+ const map = new Map();
560
+ const xml = await zip.file("ppt/tableStyles.xml")?.async("text");
561
+ if (!xml)
562
+ return map;
563
+ const doc = parser.parseFromString(xml, "application/xml");
564
+ // Each <a:tblStyle styleId="{GUID}"> defines one table style
565
+ const styleEls = doc.getElementsByTagName("a:tblStyle");
566
+ for (let i = 0; i < styleEls.length; i++) {
567
+ const styleEl = styleEls[i];
568
+ const styleId = styleEl.getAttribute("styleId");
569
+ if (!styleId)
570
+ continue;
571
+ const info = {};
572
+ // Helper: extract solid fill color from a tcStyle element
573
+ const extractTcStyleFill = (sectionEl) => {
574
+ const tcStyle = findChild(sectionEl, "tcStyle");
575
+ if (!tcStyle)
576
+ return undefined;
577
+ const fill = findChild(tcStyle, "fill");
578
+ if (!fill)
579
+ return undefined;
580
+ const solidFill = findChild(fill, "solidFill");
581
+ if (!solidFill)
582
+ return undefined;
583
+ return resolveColor(solidFill, themeColors);
584
+ };
585
+ // wholeTbl — default fill for all cells
586
+ const wholeTbl = findChild(styleEl, "wholeTbl");
587
+ if (wholeTbl) {
588
+ info.wholeTblFill = extractTcStyleFill(wholeTbl);
589
+ }
590
+ // band1H — odd-row banding fill
591
+ const band1H = findChild(styleEl, "band1H");
592
+ if (band1H) {
593
+ info.band1Fill = extractTcStyleFill(band1H);
594
+ }
595
+ // band2H — even-row banding fill (if absent, inherits wholeTblFill)
596
+ const band2H = findChild(styleEl, "band2H");
597
+ if (band2H) {
598
+ info.band2Fill = extractTcStyleFill(band2H);
599
+ }
600
+ // firstRow — header-row fill
601
+ const firstRow = findChild(styleEl, "firstRow");
602
+ if (firstRow) {
603
+ info.firstRowFill = extractTcStyleFill(firstRow);
604
+ }
605
+ map.set(styleId, info);
606
+ }
607
+ return map;
608
+ }
609
+ // ============================================================================
610
+ // Table Parsing Functions
611
+ // ============================================================================
612
+ function parseCellBorder(tcPr, borderName, scale, themeColors) {
613
+ const borderEl = findChild(tcPr, borderName);
614
+ if (!borderEl)
615
+ return undefined;
616
+ // Check for noFill (no border)
617
+ const noFill = findChild(borderEl, "noFill");
618
+ if (noFill)
619
+ return undefined;
620
+ const wAttr = borderEl.getAttribute("w");
621
+ const width = wAttr
622
+ ? Math.max(1, Math.round(emuToPx(parseInt(wAttr, 10)) * scale))
623
+ : 1;
624
+ const solidFill = findChild(borderEl, "solidFill");
625
+ const color = solidFill ? resolveColor(solidFill, themeColors) ?? "#000000" : "#000000";
626
+ // Borders with fully-transparent color (alpha=0) are effectively invisible;
627
+ // treat them as no border to avoid layout artifacts in the HTML output.
628
+ if (color.startsWith("rgba(") && color.endsWith(",0)"))
629
+ return undefined;
630
+ // Parse dash type
631
+ let style = "solid";
632
+ const prstDash = findChild(borderEl, "prstDash");
633
+ if (prstDash) {
634
+ const dashVal = prstDash.getAttribute("val");
635
+ if (dashVal === "dash" || dashVal === "lgDash" || dashVal === "sysDash") {
636
+ style = "dashed";
637
+ }
638
+ else if (dashVal === "dot" || dashVal === "sysDot") {
639
+ style = "dotted";
640
+ }
641
+ }
642
+ return { width, color, style };
643
+ }
644
+ function parseParagraphsFromTxBody(txBody, scale, themeColors, themeFonts) {
645
+ const paragraphs = [];
646
+ const pEls = findChildren(txBody, "p");
647
+ for (const p of pEls) {
648
+ const para = { runs: [] };
649
+ const pPr = findChild(p, "pPr");
650
+ let defaults = undefined;
651
+ if (pPr) {
652
+ const algn = pPr.getAttribute("algn");
653
+ if (algn) {
654
+ para.align =
655
+ algn === "ctr"
656
+ ? "center"
657
+ : algn === "r"
658
+ ? "right"
659
+ : algn === "just"
660
+ ? "justify"
661
+ : "left";
662
+ }
663
+ const marL = pPr.getAttribute("marL");
664
+ if (marL)
665
+ para.marginLeftPx = Math.round(emuToPx(parseInt(marL, 10)) * scale);
666
+ const indent = pPr.getAttribute("indent");
667
+ if (indent)
668
+ para.indentPx = Math.round(emuToPx(parseInt(indent, 10)) * scale);
669
+ const defRPr = findChild(pPr, "defRPr");
670
+ if (defRPr) {
671
+ defaults = extractRunProps(defRPr, scale, themeColors, themeFonts);
672
+ }
673
+ const lnSpc = findChild(pPr, "lnSpc");
674
+ if (lnSpc) {
675
+ const spcPct = findChild(lnSpc, "spcPct");
676
+ if (spcPct) {
677
+ const pctVal = parseInt(spcPct.getAttribute("val") ?? "100000", 10);
678
+ // spcPct val is percentage * 1000, e.g. 150000 = 150%
679
+ // Skip 100% (default single spacing) — CSS line-height:100% is tighter than intended
680
+ const pct = pctVal / 1000;
681
+ if (pct !== 100) {
682
+ para.lineSpacingPercent = pct;
683
+ }
684
+ }
685
+ const spcPts = findChild(lnSpc, "spcPts");
686
+ if (spcPts) {
687
+ para.lineSpacingPt =
688
+ parseInt(spcPts.getAttribute("val") ?? "0", 10) / 100;
689
+ }
690
+ }
691
+ const spcAft = findChild(pPr, "spcAft");
692
+ if (spcAft) {
693
+ const spcPts = findChild(spcAft, "spcPts");
694
+ if (spcPts) {
695
+ para.spacingAfterPt =
696
+ parseInt(spcPts.getAttribute("val") ?? "0", 10) / 100;
697
+ }
698
+ }
699
+ const spcBef = findChild(pPr, "spcBef");
700
+ if (spcBef) {
701
+ const spcPts = findChild(spcBef, "spcPts");
702
+ if (spcPts) {
703
+ para.spacingBeforePt =
704
+ parseInt(spcPts.getAttribute("val") ?? "0", 10) / 100;
705
+ }
706
+ }
707
+ const buChar = findChild(pPr, "buChar");
708
+ if (buChar) {
709
+ para.bulletChar = buChar.getAttribute("char") ?? undefined;
710
+ }
711
+ // Bullet color
712
+ const buClr = findChild(pPr, "buClr");
713
+ if (buClr) {
714
+ para.bulletColor = resolveColor(buClr, themeColors);
715
+ }
716
+ }
717
+ for (const child of Array.from(p.childNodes)) {
718
+ if (child.nodeType !== 1)
719
+ continue;
720
+ const el = child;
721
+ const localName = el.localName || el.nodeName.split(':').pop();
722
+ if (localName === 'r') {
723
+ const rPr = findChild(el, "rPr");
724
+ const props = extractRunProps(rPr, scale, themeColors, themeFonts);
725
+ const tEls = findChildren(el, "t");
726
+ const text = tEls.map((t) => t.textContent ?? "").join("");
727
+ if (text) {
728
+ para.runs.push({
729
+ text,
730
+ bold: props.bold ?? defaults?.bold,
731
+ italic: props.italic ?? defaults?.italic,
732
+ fontSize: props.fontSize ?? defaults?.fontSize,
733
+ color: props.color ?? defaults?.color,
734
+ fontFamily: props.fontFamily ?? defaults?.fontFamily,
735
+ textShadow: props.textShadow ?? defaults?.textShadow,
736
+ gradientFill: props.gradientFill ?? defaults?.gradientFill,
737
+ });
738
+ }
739
+ }
740
+ else if (localName === 'br') {
741
+ if (para.runs.length > 0) {
742
+ para.runs[para.runs.length - 1].text += '\n';
743
+ }
744
+ else {
745
+ para.runs.push({ text: '\n' });
746
+ }
747
+ }
748
+ }
749
+ if (para.runs.length > 0) {
750
+ paragraphs.push(para);
751
+ }
752
+ }
753
+ return paragraphs;
754
+ }
755
+ function parseTable(graphicFrame, scale, themeColors, themeFonts, tableStyleMap) {
756
+ // Extract position from xfrm
757
+ const xfrm = findChild(graphicFrame, "xfrm");
758
+ if (!xfrm)
759
+ return null;
760
+ const off = findChild(xfrm, "off");
761
+ const ext = findChild(xfrm, "ext");
762
+ if (!off || !ext)
763
+ return null;
764
+ // Find the table element: graphicFrame > graphic > graphicData > tbl
765
+ const graphic = findChild(graphicFrame, "graphic");
766
+ if (!graphic)
767
+ return null;
768
+ const graphicData = findChild(graphic, "graphicData");
769
+ if (!graphicData)
770
+ return null;
771
+ // Check if it's a table (URI should contain "table")
772
+ const uri = graphicData.getAttribute("uri") ?? "";
773
+ if (!uri.includes("table"))
774
+ return null;
775
+ const tbl = findChild(graphicData, "tbl");
776
+ if (!tbl)
777
+ return null;
778
+ // Read table properties for style flags
779
+ const tblPr = findChild(tbl, "tblPr");
780
+ const hasFirstRow = tblPr?.getAttribute("firstRow") === "1";
781
+ const hasBandRow = tblPr?.getAttribute("bandRow") === "1";
782
+ // Look up table style for fill defaults
783
+ let styleInfo;
784
+ if (tblPr && tableStyleMap) {
785
+ const styleIdEl = findChild(tblPr, "tableStyleId");
786
+ if (styleIdEl) {
787
+ const styleId = styleIdEl.textContent?.trim();
788
+ if (styleId)
789
+ styleInfo = tableStyleMap.get(styleId);
790
+ }
791
+ }
792
+ const table = {
793
+ x: Math.round(emuToPx(parseInt(off.getAttribute("x") ?? "0", 10)) * scale),
794
+ y: Math.round(emuToPx(parseInt(off.getAttribute("y") ?? "0", 10)) * scale),
795
+ w: Math.round(emuToPx(parseInt(ext.getAttribute("cx") ?? "0", 10)) * scale),
796
+ h: Math.round(emuToPx(parseInt(ext.getAttribute("cy") ?? "0", 10)) * scale),
797
+ rows: [],
798
+ colWidths: [],
799
+ };
800
+ // Parse column widths from tblGrid
801
+ const tblGrid = findChild(tbl, "tblGrid");
802
+ if (tblGrid) {
803
+ const gridCols = findChildren(tblGrid, "gridCol");
804
+ for (const col of gridCols) {
805
+ const w = parseInt(col.getAttribute("w") ?? "0", 10);
806
+ table.colWidths.push(Math.round(emuToPx(w) * scale));
807
+ }
808
+ }
809
+ // Parse rows
810
+ const trEls = findChildren(tbl, "tr");
811
+ let rowIndex = 0;
812
+ for (const tr of trEls) {
813
+ const rowHeight = tr.getAttribute("h");
814
+ const row = {
815
+ cells: [],
816
+ height: rowHeight
817
+ ? Math.round(emuToPx(parseInt(rowHeight, 10)) * scale)
818
+ : undefined,
819
+ };
820
+ const tcEls = findChildren(tr, "tc");
821
+ for (const tc of tcEls) {
822
+ const cell = {
823
+ paragraphs: [],
824
+ };
825
+ // Parse cell text content
826
+ const txBody = findChild(tc, "txBody");
827
+ if (txBody) {
828
+ cell.paragraphs = parseParagraphsFromTxBody(txBody, scale, themeColors, themeFonts);
829
+ }
830
+ // Parse cell properties
831
+ const tcPr = findChild(tc, "tcPr");
832
+ if (tcPr) {
833
+ // Cell fill — use explicit fill from tcPr first, then fall back to
834
+ // the table style fill based on row position and style flags.
835
+ const explicitFill = extractFill(tcPr, themeColors);
836
+ let styleFill;
837
+ if (styleInfo) {
838
+ if (hasFirstRow && rowIndex === 0) {
839
+ // Header row: use firstRowFill from the table style
840
+ styleFill = styleInfo.firstRowFill;
841
+ }
842
+ else if (hasBandRow) {
843
+ // Body rows with banding enabled: alternate between band fills.
844
+ // The "data row index" starts after the header (if present).
845
+ const dataRowIdx = hasFirstRow ? rowIndex - 1 : rowIndex;
846
+ styleFill =
847
+ dataRowIdx % 2 === 0
848
+ ? styleInfo.band1Fill ?? styleInfo.wholeTblFill
849
+ : styleInfo.band2Fill ?? styleInfo.wholeTblFill;
850
+ }
851
+ else {
852
+ styleFill = styleInfo.wholeTblFill;
853
+ }
854
+ }
855
+ cell.fill = explicitFill ?? styleFill;
856
+ // Vertical alignment
857
+ const anchor = tcPr.getAttribute("anchor");
858
+ if (anchor === "ctr")
859
+ cell.verticalAlign = "center";
860
+ else if (anchor === "b")
861
+ cell.verticalAlign = "bottom";
862
+ else
863
+ cell.verticalAlign = "top";
864
+ // Cell margins (PPTX defaults: L/R=91440 EMU ~9.6px, T/B=45720 EMU ~4.8px)
865
+ const marL = tcPr.getAttribute("marL");
866
+ const marR = tcPr.getAttribute("marR");
867
+ const marT = tcPr.getAttribute("marT");
868
+ const marB = tcPr.getAttribute("marB");
869
+ cell.padding = {
870
+ left: Math.round(emuToPx(parseInt(marL ?? "91440", 10)) * scale),
871
+ right: Math.round(emuToPx(parseInt(marR ?? "91440", 10)) * scale),
872
+ top: Math.round(emuToPx(parseInt(marT ?? "45720", 10)) * scale),
873
+ bottom: Math.round(emuToPx(parseInt(marB ?? "45720", 10)) * scale),
874
+ };
875
+ // Cell borders
876
+ cell.borderLeft = parseCellBorder(tcPr, "lnL", scale, themeColors);
877
+ cell.borderRight = parseCellBorder(tcPr, "lnR", scale, themeColors);
878
+ cell.borderTop = parseCellBorder(tcPr, "lnT", scale, themeColors);
879
+ cell.borderBottom = parseCellBorder(tcPr, "lnB", scale, themeColors);
880
+ }
881
+ else {
882
+ // No tcPr: use PPTX default margins
883
+ cell.padding = {
884
+ left: Math.round(emuToPx(91440) * scale),
885
+ right: Math.round(emuToPx(91440) * scale),
886
+ top: Math.round(emuToPx(45720) * scale),
887
+ bottom: Math.round(emuToPx(45720) * scale),
888
+ };
889
+ }
890
+ // Column span
891
+ const gridSpan = tc.getAttribute("gridSpan");
892
+ if (gridSpan) {
893
+ const span = parseInt(gridSpan, 10);
894
+ if (span > 1)
895
+ cell.colSpan = span;
896
+ }
897
+ // Row span
898
+ const rowSpan = tc.getAttribute("rowSpan");
899
+ if (rowSpan) {
900
+ const span = parseInt(rowSpan, 10);
901
+ if (span > 1)
902
+ cell.rowSpan = span;
903
+ }
904
+ // Skip merged cells (vMerge = vertically merged continuation)
905
+ const vMerge = tc.getAttribute("vMerge");
906
+ if (vMerge === "1" || vMerge === "true") {
907
+ // This cell is a continuation of a vertical merge - still add it but mark it
908
+ // We'll skip rendering it in the HTML
909
+ continue;
910
+ }
911
+ // Skip horizontally merged continuation cells
912
+ const hMerge = tc.getAttribute("hMerge");
913
+ if (hMerge === "1" || hMerge === "true") {
914
+ continue;
915
+ }
916
+ row.cells.push(cell);
917
+ }
918
+ table.rows.push(row);
919
+ rowIndex++;
920
+ }
921
+ return table;
922
+ }
923
+ // ============================================================================
622
924
  // Rendering Functions
623
925
  // ============================================================================
624
926
  function renderSlideHtml(elements, bgColor) {
@@ -653,6 +955,121 @@ function renderSlideHtml(elements, bgColor) {
653
955
  inner += `<img id="${elId}" data-elementType="image" src="${img.dataUri}"${altAttr} style="${styles}" />\n`;
654
956
  continue;
655
957
  }
958
+ if (el.kind === "table") {
959
+ const table = el.data;
960
+ const tableStyles = [
961
+ "position:absolute",
962
+ `left:${table.x}px`,
963
+ `top:${table.y}px`,
964
+ `width:${table.w}px`,
965
+ `height:${table.h}px`,
966
+ ];
967
+ let tableHtml = `<table style="border-collapse:collapse;width:100%;height:100%;table-layout:fixed">`;
968
+ // Column widths via colgroup
969
+ if (table.colWidths.length > 0) {
970
+ tableHtml += `<colgroup>`;
971
+ for (const cw of table.colWidths) {
972
+ tableHtml += `<col style="width:${cw}px">`;
973
+ }
974
+ tableHtml += `</colgroup>`;
975
+ }
976
+ for (const row of table.rows) {
977
+ const rowStyle = row.height ? ` style="height:${row.height}px"` : "";
978
+ tableHtml += `<tr${rowStyle}>`;
979
+ for (const cell of row.cells) {
980
+ const cellPad = cell.padding ?? { top: 5, right: 10, bottom: 5, left: 10 };
981
+ const tdStyles = [
982
+ "box-sizing:border-box",
983
+ "overflow:hidden",
984
+ `padding:${cellPad.top}px ${cellPad.right}px ${cellPad.bottom}px ${cellPad.left}px`,
985
+ ];
986
+ if (cell.fill) {
987
+ tdStyles.push(`background:${cell.fill}`);
988
+ }
989
+ if (cell.verticalAlign === "center") {
990
+ tdStyles.push("vertical-align:middle");
991
+ }
992
+ else if (cell.verticalAlign === "bottom") {
993
+ tdStyles.push("vertical-align:bottom");
994
+ }
995
+ else {
996
+ tdStyles.push("vertical-align:top");
997
+ }
998
+ // Cell borders
999
+ if (cell.borderTop) {
1000
+ tdStyles.push(`border-top:${cell.borderTop.width}px ${cell.borderTop.style} ${cell.borderTop.color}`);
1001
+ }
1002
+ if (cell.borderBottom) {
1003
+ tdStyles.push(`border-bottom:${cell.borderBottom.width}px ${cell.borderBottom.style} ${cell.borderBottom.color}`);
1004
+ }
1005
+ if (cell.borderLeft) {
1006
+ tdStyles.push(`border-left:${cell.borderLeft.width}px ${cell.borderLeft.style} ${cell.borderLeft.color}`);
1007
+ }
1008
+ if (cell.borderRight) {
1009
+ tdStyles.push(`border-right:${cell.borderRight.width}px ${cell.borderRight.style} ${cell.borderRight.color}`);
1010
+ }
1011
+ const spanAttrs = [];
1012
+ if (cell.colSpan && cell.colSpan > 1)
1013
+ spanAttrs.push(` colspan="${cell.colSpan}"`);
1014
+ if (cell.rowSpan && cell.rowSpan > 1)
1015
+ spanAttrs.push(` rowspan="${cell.rowSpan}"`);
1016
+ tableHtml += `<td style="${tdStyles.join(";")}"${spanAttrs.join("")}>`;
1017
+ // Render cell paragraphs
1018
+ for (const para of cell.paragraphs) {
1019
+ const pStyles = ["margin:0"];
1020
+ if (para.align)
1021
+ pStyles.push(`text-align:${para.align}`);
1022
+ if (para.spacingAfterPt)
1023
+ pStyles.push(`margin-bottom:${para.spacingAfterPt}pt`);
1024
+ if (para.spacingBeforePt)
1025
+ pStyles.push(`margin-top:${para.spacingBeforePt}pt`);
1026
+ if (para.lineSpacingPt)
1027
+ pStyles.push(`line-height:${para.lineSpacingPt}pt`);
1028
+ else if (para.lineSpacingPercent)
1029
+ pStyles.push(`line-height:${para.lineSpacingPercent}%`);
1030
+ let runHtml = "";
1031
+ for (const run of para.runs) {
1032
+ const rStyles = [];
1033
+ if (run.fontSize)
1034
+ rStyles.push(`font-size:${run.fontSize}px`);
1035
+ if (run.gradientFill) {
1036
+ rStyles.push(`background:${run.gradientFill}`);
1037
+ rStyles.push("-webkit-background-clip:text");
1038
+ rStyles.push("-webkit-text-fill-color:transparent");
1039
+ rStyles.push("background-clip:text");
1040
+ }
1041
+ else if (run.color) {
1042
+ rStyles.push(`color:${run.color}`);
1043
+ }
1044
+ if (run.bold)
1045
+ rStyles.push("font-weight:bold");
1046
+ if (run.italic)
1047
+ rStyles.push("font-style:italic");
1048
+ if (run.fontFamily)
1049
+ rStyles.push(`font-family:${cssFontFamily(run.fontFamily)}`);
1050
+ if (run.textShadow)
1051
+ rStyles.push(`text-shadow:${run.textShadow}`);
1052
+ const escapedText = run.text
1053
+ .replace(/&/g, "&amp;")
1054
+ .replace(/</g, "&lt;")
1055
+ .replace(/>/g, "&gt;");
1056
+ if (rStyles.length > 0) {
1057
+ runHtml += `<span style="${rStyles.join(";")}">${escapedText}</span>`;
1058
+ }
1059
+ else {
1060
+ runHtml += escapedText;
1061
+ }
1062
+ }
1063
+ tableHtml += `<p style="${pStyles.join(";")}">${runHtml}</p>`;
1064
+ }
1065
+ tableHtml += `</td>`;
1066
+ }
1067
+ tableHtml += `</tr>`;
1068
+ }
1069
+ tableHtml += `</table>`;
1070
+ inner += `<div id="${elId}" data-elementType="table" style="${tableStyles.join(";")}">${tableHtml}</div>\n`;
1071
+ continue;
1072
+ }
656
1073
  const shape = el.data;
657
1074
  const styles = [
658
1075
  "position:absolute",
@@ -717,13 +1134,20 @@ function renderSlideHtml(elements, bgColor) {
717
1134
  pStyles.push(`margin-top:${para.spacingBeforePt}pt`);
718
1135
  if (para.lineSpacingPt)
719
1136
  pStyles.push(`line-height:${para.lineSpacingPt}pt`);
1137
+ // Note: lineSpacingPercent is intentionally NOT emitted for shapes.
1138
+ // Applying percentage-based line-height to fixed-height text boxes
1139
+ // often causes visual regressions (overlaps, clipping). It works
1140
+ // reliably only for tables where cell height auto-adjusts.
720
1141
  if (para.marginLeftPx)
721
1142
  pStyles.push(`padding-left:${para.marginLeftPx}px`);
722
1143
  if (para.indentPx)
723
1144
  pStyles.push(`text-indent:${para.indentPx}px`);
724
1145
  let runHtml = "";
725
1146
  if (para.bulletChar) {
726
- runHtml += `<span style="margin-right:4px">${para.bulletChar}</span>`;
1147
+ const bulletStyle = para.bulletColor
1148
+ ? `margin-right:4px;color:${para.bulletColor}`
1149
+ : "margin-right:4px";
1150
+ runHtml += `<span style="${bulletStyle}">${para.bulletChar}</span>`;
727
1151
  }
728
1152
  for (const run of para.runs) {
729
1153
  const rStyles = [];
@@ -880,7 +1304,41 @@ export default async function importPptx(arrayBuffer) {
880
1304
  }
881
1305
  }
882
1306
  }
1307
+ // Add OOXML aliases: tx1/tx2 map to dk1/dk2, bg1/bg2 map to lt1/lt2
1308
+ const dk1Color = themeColors.get("dk1");
1309
+ if (dk1Color)
1310
+ themeColors.set("tx1", dk1Color);
1311
+ const dk2Color = themeColors.get("dk2");
1312
+ if (dk2Color)
1313
+ themeColors.set("tx2", dk2Color);
1314
+ const lt1Color = themeColors.get("lt1");
1315
+ if (lt1Color)
1316
+ themeColors.set("bg1", lt1Color);
1317
+ const lt2Color = themeColors.get("lt2");
1318
+ if (lt2Color)
1319
+ themeColors.set("bg2", lt2Color);
883
1320
  }
1321
+ // Parse theme fonts
1322
+ let themeFonts;
1323
+ if (themeXml) {
1324
+ const themeDoc = parser.parseFromString(themeXml, "application/xml");
1325
+ const majorFont = themeDoc.getElementsByTagName("a:majorFont")[0];
1326
+ const minorFont = themeDoc.getElementsByTagName("a:minorFont")[0];
1327
+ if (majorFont || minorFont) {
1328
+ const majorLatin = majorFont ? findChild(majorFont, "latin")?.getAttribute("typeface") : null;
1329
+ const minorLatin = minorFont ? findChild(minorFont, "latin")?.getAttribute("typeface") : null;
1330
+ const majorEa = majorFont ? findChild(majorFont, "ea")?.getAttribute("typeface") : null;
1331
+ const minorEa = minorFont ? findChild(minorFont, "ea")?.getAttribute("typeface") : null;
1332
+ themeFonts = {
1333
+ majorLatin: majorLatin ?? "Calibri",
1334
+ minorLatin: minorLatin ?? "Calibri",
1335
+ majorEastAsian: majorEa ?? undefined,
1336
+ minorEastAsian: minorEa ?? undefined,
1337
+ };
1338
+ }
1339
+ }
1340
+ // Parse table styles from ppt/tableStyles.xml
1341
+ const tableStyleMap = await parseTableStyles(zip, parser, themeColors);
884
1342
  // Parse embedded fonts from presentation.xml
885
1343
  // PPTX files can embed TrueType fonts in ppt/fonts/ as .fntdata (EOT format)
886
1344
  const embeddedFontNames = new Set();
@@ -961,7 +1419,7 @@ export default async function importPptx(arrayBuffer) {
961
1419
  for (let i = 0; i < spTree.children.length; i++) {
962
1420
  const child = spTree.children[i];
963
1421
  if (child.localName === "sp") {
964
- const shape = parseShape(child, scale, themeColors);
1422
+ const shape = parseShape(child, scale, themeColors, themeFonts);
965
1423
  if (shape)
966
1424
  elements.push({ kind: "shape", data: shape });
967
1425
  }
@@ -970,6 +1428,11 @@ export default async function importPptx(arrayBuffer) {
970
1428
  if (img)
971
1429
  elements.push({ kind: "image", data: img });
972
1430
  }
1431
+ else if (child.localName === "graphicFrame") {
1432
+ const table = parseTable(child, scale, themeColors, themeFonts, tableStyleMap);
1433
+ if (table)
1434
+ elements.push({ kind: "table", data: table });
1435
+ }
973
1436
  }
974
1437
  slides.push(renderSlideHtml(elements, slideBg));
975
1438
  }