@microlee666/dom-to-pptx 1.1.4 → 1.1.5

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.
@@ -309,6 +309,7 @@ function extractTableData(node, scale, options = {}) {
309
309
  bold: textStyle.bold,
310
310
  italic: textStyle.italic,
311
311
  underline: textStyle.underline,
312
+ lang: 'zh-CN',
312
313
 
313
314
  fill: fill,
314
315
  align: align,
@@ -383,11 +384,11 @@ function getGradientFallbackColor(bgImage) {
383
384
  // 3. Find first part that is a color (skip angle/direction)
384
385
  for (const part of parts) {
385
386
  // Ignore directions (to right) or angles (90deg, 0.5turn)
386
- if (/^(to\s|[\d\.]+(deg|rad|turn|grad))/.test(part)) continue;
387
+ if (/^(to\s|[\d.]+(deg|rad|turn|grad))/.test(part)) continue;
387
388
 
388
389
  // Extract color: Remove trailing position (e.g. "red 50%" -> "red")
389
390
  // Regex matches whitespace + number + unit at end of string
390
- const colorPart = part.replace(/\s+(-?[\d\.]+(%|px|em|rem|ch|vh|vw)?)$/, '');
391
+ const colorPart = part.replace(/\s+(-?[\d.]+(%|px|em|rem|ch|vh|vw)?)$/, '');
391
392
 
392
393
  // Check if it's not just a number (some gradients might have bare numbers? unlikely in standard syntax)
393
394
  if (colorPart) return colorPart;
@@ -658,6 +659,214 @@ function parseColor(str) {
658
659
  return { hex, opacity: a };
659
660
  }
660
661
 
662
+ const SUPPORTED_CHART_TYPES = new Set(['area', 'bar', 'line', 'pie', 'doughnut', 'radar', 'scatter']);
663
+ const CHART_TYPE_ALIASES = {
664
+ column: 'bar',
665
+ columnbar: 'bar',
666
+ column3d: 'bar',
667
+ bar3d: 'bar',
668
+ donut: 'doughnut',
669
+ ring: 'doughnut',
670
+ };
671
+
672
+ function normalizeLegendPos(value) {
673
+ if (!value) return null;
674
+ const normalized = value.toString().toLowerCase().replace(/[^a-z]/g, '');
675
+ if (normalized === 'tr' || normalized === 'topright') return 'tr';
676
+ if (normalized === 'top') return 't';
677
+ if (normalized === 'bottom') return 'b';
678
+ if (normalized === 'left') return 'l';
679
+ if (normalized === 'right') return 'r';
680
+ return null;
681
+ }
682
+
683
+ function normalizeChartType(value) {
684
+ if (!value) return null;
685
+ const raw = value.toString().trim().toLowerCase();
686
+ const alias = CHART_TYPE_ALIASES[raw];
687
+ const candidate = alias || raw;
688
+ return SUPPORTED_CHART_TYPES.has(candidate) ? candidate : null;
689
+ }
690
+
691
+ function colorToHex(value) {
692
+ const parsed = parseColor(value);
693
+ return parsed.hex || null;
694
+ }
695
+
696
+ function resolveChartElement(root, config) {
697
+ if (!config) return null;
698
+ if (config.element instanceof HTMLElement) return config.element;
699
+ if (typeof config.element === 'function') return config.element(root);
700
+ if (typeof config.selector === 'string') {
701
+ return root.querySelector(config.selector);
702
+ }
703
+ return null;
704
+ }
705
+
706
+ function buildSeriesFromSource(source = {}) {
707
+ const fallbackLabels = Array.isArray(source.labels) ? source.labels : [];
708
+ const candidateDatasets = Array.isArray(source.datasets) ? source.datasets : [];
709
+ const series = [];
710
+
711
+ candidateDatasets.forEach((dataset, index) => {
712
+ if (!dataset) return;
713
+ const values = Array.isArray(dataset.values)
714
+ ? dataset.values
715
+ : Array.isArray(dataset.data)
716
+ ? dataset.data
717
+ : [];
718
+
719
+ if (!values.length) return;
720
+
721
+ const record = {
722
+ name: dataset.name || dataset.label || `Series ${index + 1}`,
723
+ values,
724
+ };
725
+
726
+ if (Array.isArray(dataset.labels) && dataset.labels.length === values.length) {
727
+ record.labels = dataset.labels;
728
+ } else if (fallbackLabels.length === values.length) {
729
+ record.labels = fallbackLabels;
730
+ }
731
+
732
+ if (Array.isArray(dataset.sizes) && dataset.sizes.length === values.length) {
733
+ record.sizes = dataset.sizes;
734
+ }
735
+
736
+ // Chart.js often provides strings/arrays in backgroundColor
737
+ const candidateColor =
738
+ dataset.color || dataset.backgroundColor || dataset.borderColor || dataset.fillColor;
739
+ if (candidateColor) {
740
+ if (Array.isArray(candidateColor)) {
741
+ record.color = candidateColor[0];
742
+ } else {
743
+ record.color = candidateColor;
744
+ }
745
+ }
746
+
747
+ series.push(record);
748
+ });
749
+
750
+ if (!series.length) {
751
+ const fallbackValues = Array.isArray(source.values)
752
+ ? source.values
753
+ : Array.isArray(source.data)
754
+ ? source.data
755
+ : [];
756
+
757
+ if (fallbackValues.length) {
758
+ const fallbackRecord = {
759
+ name: source.name || source.label || 'Series 1',
760
+ values: fallbackValues,
761
+ };
762
+ if (Array.isArray(source.labels) && source.labels.length === fallbackValues.length) {
763
+ fallbackRecord.labels = source.labels;
764
+ } else if (fallbackLabels.length === fallbackValues.length) {
765
+ fallbackRecord.labels = fallbackLabels;
766
+ }
767
+ if (Array.isArray(source.sizes) && source.sizes.length === fallbackValues.length) {
768
+ fallbackRecord.sizes = source.sizes;
769
+ }
770
+ if (source.color) {
771
+ fallbackRecord.color = source.color;
772
+ }
773
+ series.push(fallbackRecord);
774
+ }
775
+ }
776
+
777
+ return series;
778
+ }
779
+
780
+ function buildChartOptions(raw, derivedColors) {
781
+ const opts = { ...((raw && raw.chartOptions) || {}) };
782
+ if (raw && raw.showLegend !== undefined) opts.showLegend = raw.showLegend;
783
+ if (raw) {
784
+ const legendTarget = raw.legendPos || raw.legendPosition;
785
+ const normalizedLegend = normalizeLegendPos(legendTarget);
786
+ if (normalizedLegend) opts.legendPos = normalizedLegend;
787
+ }
788
+ if (raw && raw.title) opts.title = raw.title;
789
+ if (raw && raw.altText) opts.altText = raw.altText;
790
+ if (raw && raw.showDataTable !== undefined) opts.showDataTable = raw.showDataTable;
791
+ if (raw && raw.chartColorsOpacity !== undefined) opts.chartColorsOpacity = raw.chartColorsOpacity;
792
+ if (raw && raw.showLabel !== undefined) opts.showLabel = raw.showLabel;
793
+
794
+ const paletteFromConfig =
795
+ raw && Array.isArray(raw.chartColors) ? raw.chartColors.map(colorToHex).filter(Boolean) : [];
796
+ const palette = paletteFromConfig.length ? paletteFromConfig : derivedColors;
797
+ if (palette.length && !opts.chartColors) {
798
+ opts.chartColors = palette;
799
+ }
800
+
801
+ return opts;
802
+ }
803
+
804
+ function normalizeChartConfig(raw) {
805
+ if (!raw) return null;
806
+ const chartType = normalizeChartType(raw.chartType || raw.type || raw.chart);
807
+ if (!chartType) return null;
808
+
809
+ let seriesWithColor = [];
810
+ if (Array.isArray(raw.chartData) && raw.chartData.length) {
811
+ seriesWithColor = raw.chartData
812
+ .map((entry, index) => {
813
+ if (!entry || !Array.isArray(entry.values)) return null;
814
+ return {
815
+ ...entry,
816
+ name: entry.name || entry.label || `Series ${index + 1}`,
817
+ };
818
+ })
819
+ .filter((entry) => entry && entry.values && entry.values.length);
820
+ } else {
821
+ const source = {
822
+ labels: raw.data?.labels || raw.labels,
823
+ datasets: Array.isArray(raw.data?.datasets)
824
+ ? raw.data.datasets
825
+ : Array.isArray(raw.datasets)
826
+ ? raw.datasets
827
+ : [],
828
+ values: raw.data?.values || raw.values,
829
+ data: raw.data?.data,
830
+ name: raw.name,
831
+ label: raw.label,
832
+ color: raw.color,
833
+ sizes: raw.data?.sizes || raw.sizes,
834
+ };
835
+ seriesWithColor = buildSeriesFromSource(source);
836
+ }
837
+
838
+ if (!seriesWithColor.length) return null;
839
+
840
+ const derivedColors = seriesWithColor
841
+ .map((dataset) => dataset.color)
842
+ .map(colorToHex)
843
+ .filter(Boolean);
844
+
845
+ const chartOptions = buildChartOptions(raw, derivedColors);
846
+ const chartData = seriesWithColor.map(({ color, ...rest }) => rest);
847
+
848
+ return {
849
+ chartType,
850
+ chartData,
851
+ chartOptions,
852
+ };
853
+ }
854
+
855
+ function buildChartRegistry(root, chartConfigs = []) {
856
+ const registry = new Map();
857
+ if (!root || !Array.isArray(chartConfigs)) return registry;
858
+
859
+ chartConfigs.forEach((config) => {
860
+ const node = resolveChartElement(root, config);
861
+ if (!node) return;
862
+ const normalized = normalizeChartConfig(config);
863
+ if (!normalized || !normalized.chartData || !normalized.chartData.length) return;
864
+ registry.set(node, normalized);
865
+ });
866
+
867
+ return registry;
868
+ }
869
+
661
870
  function getPadding(style, scale) {
662
871
  const pxToInch = 1 / 96;
663
872
  return [
@@ -676,6 +885,8 @@ function getSoftEdges(filterStr, scale) {
676
885
  }
677
886
 
678
887
  const DEFAULT_CJK_FONTS = [
888
+ 'Heiti TC',
889
+ 'Heiti SC',
679
890
  'PingFang SC',
680
891
  'Hiragino Sans GB',
681
892
  'Microsoft YaHei',
@@ -727,7 +938,7 @@ function pickFontFace(fontFamily, text, options) {
727
938
  }
728
939
 
729
940
  const autoMatch = DEFAULT_CJK_FONTS.find((f) => lowered.includes(f.toLowerCase()));
730
- return autoMatch || primary;
941
+ return autoMatch || 'Heiti TC';
731
942
  }
732
943
 
733
944
  function getTextStyle(style, scale, text = '', options = {}) {
@@ -758,6 +969,9 @@ function getTextStyle(style, scale, text = '', options = {}) {
758
969
  // And apply the global layout scale.
759
970
  lineSpacing = lhPx * 0.75 * scale;
760
971
  }
972
+ } else {
973
+ // Default line height when 'normal' - use 1.2 multiplier to match browser default
974
+ lineSpacing = fontSizePx * 1.2 * 0.75 * scale;
761
975
  }
762
976
 
763
977
  // --- Spacing (Margins) ---
@@ -776,10 +990,11 @@ function getTextStyle(style, scale, text = '', options = {}) {
776
990
  return {
777
991
  color: colorObj.hex || '000000',
778
992
  fontFace: fontFace,
779
- fontSize: Math.floor(fontSizePx * 0.75 * scale),
993
+ fontSize: Math.max(8, Math.floor(fontSizePx * 0.75 * scale)),
780
994
  bold: parseInt(style.fontWeight) >= 600,
781
995
  italic: style.fontStyle === 'italic',
782
996
  underline: style.textDecoration.includes('underline'),
997
+ lang: 'zh-CN',
783
998
  // Only add if we have a valid value
784
999
  ...(lineSpacing && { lineSpacing }),
785
1000
  ...(paraSpaceBefore > 0 && { paraSpaceBefore }),
@@ -792,6 +1007,7 @@ function getTextStyle(style, scale, text = '', options = {}) {
792
1007
  /**
793
1008
  * Determines if a given DOM node is primarily a text container.
794
1009
  * Updated to correctly reject Icon elements so they are rendered as images.
1010
+ * Also rejects flex/grid containers with distributed children (space-between/around/evenly).
795
1011
  */
796
1012
  function isTextContainer(node) {
797
1013
  const hasText = node.textContent.trim().length > 0;
@@ -800,6 +1016,26 @@ function isTextContainer(node) {
800
1016
  const children = Array.from(node.children);
801
1017
  if (children.length === 0) return true;
802
1018
 
1019
+ // Check if parent is a flex/grid container with special layout
1020
+ // In such cases, children should be treated as separate elements
1021
+ const parentStyle = window.getComputedStyle(node);
1022
+ const display = parentStyle.display;
1023
+ const justifyContent = parentStyle.justifyContent || '';
1024
+ parentStyle.alignItems || '';
1025
+
1026
+ // If parent uses flex/grid with space distribution, don't treat as text container
1027
+ if ((display.includes('flex') || display.includes('grid')) &&
1028
+ (justifyContent.includes('space-between') ||
1029
+ justifyContent.includes('space-around') ||
1030
+ justifyContent.includes('space-evenly'))) {
1031
+ return false;
1032
+ }
1033
+
1034
+ // Note: We don't skip text container for align-items: center here.
1035
+ // The valign inheritance is handled in prepareRenderItem with single-child check.
1036
+ // This allows elements like "密钥状态流转" to properly inherit center alignment
1037
+ // while preventing STEP 1/2/3 from being incorrectly centered.
1038
+
803
1039
  const isSafeInline = (el) => {
804
1040
  // 1. Reject Web Components / Custom Elements
805
1041
  if (el.tagName.includes('-')) return false;
@@ -1358,6 +1594,60 @@ const PptxGenJS = PptxGenJSImport?.default ?? PptxGenJSImport;
1358
1594
  const PPI = 96;
1359
1595
  const PX_TO_INCH = 1 / PPI;
1360
1596
 
1597
+ /**
1598
+ * Fix Chinese font attributes (pitchFamily and charset) in PPTX XML.
1599
+ * PptxGenJS sometimes generates incorrect charset="0" for East Asian fonts.
1600
+ * @param {Blob} blob - The PPTX blob
1601
+ * @returns {Promise<Blob>} - Fixed PPTX blob
1602
+ */
1603
+ async function fixChineseFontAttributes(blob) {
1604
+ const zip = await JSZip.loadAsync(blob);
1605
+ const cjkFonts = ['Heiti TC', 'Heiti SC', 'PingFang SC', 'Microsoft YaHei', 'SimHei', 'SimSun'];
1606
+
1607
+ // Process all slide XML files
1608
+ const slideFiles = Object.keys(zip.files).filter(name =>
1609
+ name.startsWith('ppt/slides/slide') && name.endsWith('.xml')
1610
+ );
1611
+
1612
+ for (const slidePath of slideFiles) {
1613
+ let xml = await zip.file(slidePath).async('text');
1614
+ let modified = false;
1615
+
1616
+ // Fix <a:ea> and <a:cs> tags for CJK fonts
1617
+ // Replace tags that have charset="0" (incorrect for CJK) with proper values
1618
+ for (const fontName of cjkFonts) {
1619
+ // Fix <a:ea> - East Asian font
1620
+ const eaPattern = new RegExp(
1621
+ `<a:ea typeface="${fontName}"[^>]*>`,
1622
+ 'g'
1623
+ );
1624
+ xml = xml.replace(eaPattern, `<a:ea typeface="${fontName}" pitchFamily="34" charset="-122"/>`);
1625
+
1626
+ // Fix <a:cs> - Complex Script font
1627
+ const csPattern = new RegExp(
1628
+ `<a:cs typeface="${fontName}"[^>]*>`,
1629
+ 'g'
1630
+ );
1631
+ xml = xml.replace(csPattern, `<a:cs typeface="${fontName}" pitchFamily="34" charset="-120"/>`);
1632
+
1633
+ // Fix <a:latin> - Latin font (if it's a CJK font used as Latin)
1634
+ const latinPattern = new RegExp(
1635
+ `<a:latin typeface="${fontName}"[^>]*>`,
1636
+ 'g'
1637
+ );
1638
+ xml = xml.replace(latinPattern, `<a:latin typeface="${fontName}" pitchFamily="34" charset="0"/>`);
1639
+ }
1640
+ modified = true;
1641
+
1642
+ if (modified) {
1643
+ zip.file(slidePath, xml);
1644
+ }
1645
+ }
1646
+
1647
+ // Generate new blob
1648
+ return await zip.generateAsync({ type: 'blob' });
1649
+ }
1650
+
1361
1651
  /**
1362
1652
  * Main export function.
1363
1653
  * @param {HTMLElement | string | Array<HTMLElement | string>} target
@@ -1382,6 +1672,7 @@ async function exportToPptx(target, options = {}) {
1382
1672
  if (!PptxConstructor) throw new Error('PptxGenJS constructor not found.');
1383
1673
  const pptx = new PptxConstructor();
1384
1674
  pptx.layout = 'LAYOUT_16x9';
1675
+ pptx.language = 'zh-CN';
1385
1676
 
1386
1677
  const elements = Array.isArray(target) ? target : [target];
1387
1678
 
@@ -1456,6 +1747,9 @@ async function exportToPptx(target, options = {}) {
1456
1747
  finalBlob = await pptx.write({ outputType: 'blob' });
1457
1748
  }
1458
1749
 
1750
+ // Fix Chinese font attributes (pitchFamily and charset) in all slide XMLs
1751
+ finalBlob = await fixChineseFontAttributes(finalBlob);
1752
+
1459
1753
  // 4. Output Handling
1460
1754
  // If skipDownload is NOT true, proceed with browser download
1461
1755
  if (!options.skipDownload) {
@@ -1497,6 +1791,9 @@ async function processSlide(root, slide, pptx, globalOptions = {}) {
1497
1791
  offY: (PPTX_HEIGHT_IN - contentHeightIn * scale) / 2,
1498
1792
  };
1499
1793
 
1794
+ const chartRegistry = buildChartRegistry(root, globalOptions.chartConfigs);
1795
+ const slideOptions = { ...globalOptions, chartRegistry };
1796
+
1500
1797
  const renderQueue = [];
1501
1798
  const asyncTasks = []; // Queue for heavy operations (Images, Canvas)
1502
1799
  let domOrderCounter = 0;
@@ -1532,7 +1829,7 @@ async function processSlide(root, slide, pptx, globalOptions = {}) {
1532
1829
  pptx,
1533
1830
  currentZ,
1534
1831
  nodeStyle,
1535
- globalOptions
1832
+ slideOptions
1536
1833
  );
1537
1834
 
1538
1835
  if (result) {
@@ -1578,6 +1875,10 @@ async function processSlide(root, slide, pptx, globalOptions = {}) {
1578
1875
  if (item.type === 'shape') slide.addShape(item.shapeType, item.options);
1579
1876
  if (item.type === 'image') slide.addImage(item.options);
1580
1877
  if (item.type === 'text') slide.addText(item.textParts, item.options);
1878
+ if (item.type === 'chart') {
1879
+ const chartType = PptxGenJS.ChartType?.[item.chartType] ?? item.chartType;
1880
+ slide.addChart(chartType, item.chartData, item.options);
1881
+ }
1581
1882
  if (item.type === 'table') {
1582
1883
  slide.addTable(item.tableData.rows, {
1583
1884
  x: item.options.x,
@@ -1778,13 +2079,23 @@ function prepareRenderItem(
1778
2079
  range.detach();
1779
2080
 
1780
2081
  const style = window.getComputedStyle(parent);
1781
- const widthPx = rect.width;
1782
- const heightPx = rect.height;
2082
+ // Use parent element's rect for better width calculation
2083
+ // This is especially important for flex children where text node rect may be too narrow
2084
+ const parentRect = parent.getBoundingClientRect();
2085
+ const useParentRect = parentRect.width > rect.width;
2086
+ let widthPx = useParentRect ? parentRect.width : rect.width;
2087
+ const heightPx = useParentRect ? parentRect.height : rect.height;
2088
+
2089
+ // Add extra width buffer to prevent text wrapping in PPTX due to font rendering differences
2090
+ // Chinese characters and certain fonts need more space in PPTX than in browser
2091
+ widthPx = widthPx * 1.3; // Add 30% extra width
1783
2092
  const unrotatedW = widthPx * PX_TO_INCH * config.scale;
1784
2093
  const unrotatedH = heightPx * PX_TO_INCH * config.scale;
1785
2094
 
1786
- const x = config.offX + (rect.left - config.rootX) * PX_TO_INCH * config.scale;
1787
- const y = config.offY + (rect.top - config.rootY) * PX_TO_INCH * config.scale;
2095
+ // Use parent rect's position if we're using parent dimensions
2096
+ const sourceRect = useParentRect ? parentRect : rect;
2097
+ const x = config.offX + (sourceRect.left - config.rootX) * PX_TO_INCH * config.scale;
2098
+ const y = config.offY + (sourceRect.top - config.rootY) * PX_TO_INCH * config.scale;
1788
2099
 
1789
2100
  return {
1790
2101
  items: [
@@ -1823,7 +2134,7 @@ function prepareRenderItem(
1823
2134
  const elementOpacity = parseFloat(style.opacity);
1824
2135
  const safeOpacity = isNaN(elementOpacity) ? 1 : elementOpacity;
1825
2136
 
1826
- const widthPx = node.offsetWidth || rect.width;
2137
+ let widthPx = node.offsetWidth || rect.width;
1827
2138
  const heightPx = node.offsetHeight || rect.height;
1828
2139
  const unrotatedW = widthPx * PX_TO_INCH * config.scale;
1829
2140
  const unrotatedH = heightPx * PX_TO_INCH * config.scale;
@@ -1837,8 +2148,41 @@ function prepareRenderItem(
1837
2148
 
1838
2149
  const items = [];
1839
2150
 
1840
- if (node.tagName === 'TABLE') {
1841
- const tableData = extractTableData(node, config.scale, { ...globalOptions, root: config.root });
2151
+ const chartConfig = globalOptions.chartRegistry?.get(node);
2152
+ if (chartConfig) {
2153
+ const chartOptions = {
2154
+ ...chartConfig.chartOptions,
2155
+ x,
2156
+ y,
2157
+ w: unrotatedW,
2158
+ h: unrotatedH,
2159
+ };
2160
+
2161
+ return {
2162
+ items: [
2163
+ {
2164
+ type: 'chart',
2165
+ zIndex: effectiveZIndex,
2166
+ domOrder,
2167
+ chartType: chartConfig.chartType,
2168
+ chartData: chartConfig.chartData,
2169
+ options: chartOptions,
2170
+ },
2171
+ ],
2172
+ stopRecursion: true,
2173
+ };
2174
+ }
2175
+
2176
+ // --- Handle both native TABLE and div-based table structures ---
2177
+ const isTableLike = node.tagName === 'TABLE' || detectTableLikeStructure(node);
2178
+ if (isTableLike) {
2179
+ let tableData;
2180
+ if (node.tagName === 'TABLE') {
2181
+ tableData = extractTableData(node, config.scale, { ...globalOptions, root: config.root });
2182
+ } else {
2183
+ // Extract data from div-based table structure
2184
+ tableData = extractDivTableData(node, config.scale, { ...globalOptions, root: config.root });
2185
+ }
1842
2186
  const cellBgItems = [];
1843
2187
  const renderCellBg = globalOptions.tableConfig?.renderCellBackgrounds !== false;
1844
2188
 
@@ -2182,6 +2526,35 @@ function prepareRenderItem(
2182
2526
  return { items: [item], job, stopRecursion: true };
2183
2527
  }
2184
2528
 
2529
+ // --- Handle vertical stat cards (like .mini-stat with number + label) ---
2530
+ if (isVerticalStatCard(node)) {
2531
+ // Capture the entire stat card as an image to preserve vertical layout
2532
+ const item = {
2533
+ type: 'image',
2534
+ zIndex,
2535
+ domOrder,
2536
+ options: { x, y, w, h, rotate: rotation, data: null },
2537
+ };
2538
+ const job = async () => {
2539
+ const pngData = await elementToCanvasImage(node, widthPx, heightPx);
2540
+ if (pngData) item.options.data = pngData;
2541
+ else item.skip = true;
2542
+ };
2543
+ return { items: [item], job, stopRecursion: true };
2544
+ }
2545
+
2546
+ // --- Handle absolutely positioned children that overflow parent ---
2547
+ // Check if this element has absolutely positioned children that extend outside
2548
+ const overflowingChildren = detectOverflowingChildren(node);
2549
+ if (overflowingChildren.length > 0) {
2550
+ // Process this node normally, but also capture overflowing children separately
2551
+ const baseResult = processOverflowingContent(
2552
+ node, overflowingChildren, config, domOrder, pptx, zIndex);
2553
+ if (baseResult) {
2554
+ return baseResult;
2555
+ }
2556
+ }
2557
+
2185
2558
  // Radii logic
2186
2559
  const borderRadiusValue = parseFloat(style.borderRadius) || 0;
2187
2560
  const borderBottomLeftRadius = parseFloat(style.borderBottomLeftRadius) || 0;
@@ -2284,6 +2657,27 @@ function prepareRenderItem(
2284
2657
  let textPayload = null;
2285
2658
  const isText = isTextContainer(node);
2286
2659
 
2660
+ // Add extra width buffer for inline text elements to prevent wrapping in PPTX
2661
+ // This is especially needed for Chinese text and certain fonts
2662
+ // Only apply to elements that are direct children of flex containers with space distribution
2663
+ // and don't have a parent with visible background (to avoid overflowing card boundaries)
2664
+ if (isText && !bgColorObj.hex && !hasBorder && !hasShadow) {
2665
+ const parentEl = node.parentElement;
2666
+ if (parentEl) {
2667
+ const parentStyle = window.getComputedStyle(parentEl);
2668
+ const parentDisplay = parentStyle.display || '';
2669
+ const parentJustify = parentStyle.justifyContent || '';
2670
+ const isFlexWithSpaceDist = (parentDisplay.includes('flex') || parentDisplay.includes('grid')) &&
2671
+ (parentJustify.includes('space-between') || parentJustify.includes('space-around') || parentJustify.includes('space-evenly'));
2672
+
2673
+ if (isFlexWithSpaceDist) {
2674
+ widthPx = widthPx * 1.3; // Add 30% extra width
2675
+ // Recalculate w with the new width
2676
+ w = widthPx * PX_TO_INCH * config.scale;
2677
+ }
2678
+ }
2679
+ }
2680
+
2287
2681
  if (isText) {
2288
2682
  const textParts = [];
2289
2683
  let trimNextLeading = false;
@@ -2340,8 +2734,71 @@ function prepareRenderItem(
2340
2734
  let align = style.textAlign || 'left';
2341
2735
  if (align === 'start') align = 'left';
2342
2736
  if (align === 'end') align = 'right';
2737
+
2738
+ // Fix: If this element is a flex/grid child and has no explicit text-align,
2739
+ // force left alignment to match HTML default behavior
2740
+ if (node.parentElement && (!style.textAlign || style.textAlign === 'start')) {
2741
+ const parentStyle = window.getComputedStyle(node.parentElement);
2742
+ if (parentStyle.display.includes('flex') || parentStyle.display.includes('grid')) {
2743
+ align = 'left';
2744
+ }
2745
+ }
2746
+
2747
+ // Detect badge/pill buttons (high border-radius + short text) and auto-center them
2748
+ const borderRadius = parseFloat(style.borderRadius) || 0;
2749
+ const height = parseFloat(style.height) || node.offsetHeight;
2750
+ const textContent = node.textContent.trim();
2751
+ const className = (node.className || '').toLowerCase();
2752
+
2753
+ // Real badges/pills typically have visible styling (background, border, or large border-radius)
2754
+ const hasVisibleBackground = bgColorObj.hex && bgColorObj.opacity > 0.1;
2755
+ const hasVisibleBorder = borderWidth > 0;
2756
+ const hasLargeBorderRadius = borderRadius >= height / 2;
2757
+ const hasBadgeClass = className.includes('badge') || className.includes('pill');
2758
+
2759
+ // Only consider it a badge if it has visual styling AND short text
2760
+ const isLikelyBadge =
2761
+ (hasLargeBorderRadius || hasBadgeClass) &&
2762
+ textContent.length <= 10 &&
2763
+ (hasVisibleBackground || hasVisibleBorder || hasLargeBorderRadius);
2764
+
2765
+ if (isLikelyBadge) {
2766
+ align = 'center';
2767
+ }
2768
+
2343
2769
  let valign = 'top';
2344
- if (style.alignItems === 'center') valign = 'middle';
2770
+
2771
+ // For flex items, valign should be determined by PARENT's align-items, not self
2772
+ // Self's align-items controls how children are aligned, not self's position in parent
2773
+ const parentEl = node.parentElement;
2774
+ if (parentEl) {
2775
+ const parentStyle = window.getComputedStyle(parentEl);
2776
+ if (parentStyle.display.includes('flex')) {
2777
+ // Parent's align-items controls this element's cross-axis alignment
2778
+ if (parentStyle.alignItems === 'center') {
2779
+ valign = 'middle';
2780
+ } else if (parentStyle.alignItems === 'flex-end' || parentStyle.alignItems === 'end') {
2781
+ valign = 'bottom';
2782
+ }
2783
+ // Default (stretch, flex-start) keeps valign = 'top'
2784
+ }
2785
+ }
2786
+
2787
+ // If element is not a flex item (no flex parent), then its own align-items
2788
+ // might indicate self-centering intent for single-element containers
2789
+ if (valign === 'top' && style.alignItems === 'center' && !parentEl) {
2790
+ valign = 'middle';
2791
+ }
2792
+
2793
+ // Auto-center vertically for likely badges
2794
+ if (isLikelyBadge && valign !== 'middle') {
2795
+ const paddingTop = parseFloat(style.paddingTop) || 0;
2796
+ const paddingBottom = parseFloat(style.paddingBottom) || 0;
2797
+ // If padding is relatively even, assume vertical centering is intended
2798
+ if (Math.abs(paddingTop - paddingBottom) <= 4) {
2799
+ valign = 'middle';
2800
+ }
2801
+ }
2345
2802
  if (style.justifyContent === 'center' && style.display.includes('flex')) align = 'center';
2346
2803
 
2347
2804
  const pt = parseFloat(style.paddingTop) || 0;
@@ -2701,5 +3158,282 @@ function createCompositeBorderItems(sides, x, y, w, h, scale, zIndex, domOrder)
2701
3158
  return items;
2702
3159
  }
2703
3160
 
3161
+ /**
3162
+ * Detect absolutely positioned children that overflow their parent
3163
+ * This handles cases like chart value labels positioned above bars
3164
+ */
3165
+ function detectOverflowingChildren(node) {
3166
+ const overflowing = [];
3167
+ const parentRect = node.getBoundingClientRect();
3168
+
3169
+ // Only check for elements with specific class patterns that suggest chart values
3170
+ const children = node.querySelectorAll('.bar-value, [class*="value"], [class*="label"]');
3171
+
3172
+ children.forEach(child => {
3173
+ const childStyle = window.getComputedStyle(child);
3174
+ const childRect = child.getBoundingClientRect();
3175
+
3176
+ // Check if absolutely positioned and outside parent bounds
3177
+ if (childStyle.position === 'absolute') {
3178
+ const isOutside =
3179
+ childRect.bottom < parentRect.top ||
3180
+ childRect.top > parentRect.bottom ||
3181
+ childRect.right < parentRect.left ||
3182
+ childRect.left > parentRect.right;
3183
+
3184
+ if (isOutside || childRect.top < parentRect.top) {
3185
+ overflowing.push(child);
3186
+ }
3187
+ }
3188
+ });
3189
+
3190
+ return overflowing;
3191
+ }
3192
+
3193
+ /**
3194
+ * Process content with overflowing children
3195
+ * Captures the entire visual area including overflowing elements
3196
+ */
3197
+ function processOverflowingContent(node, overflowingChildren, config, domOrder, pptx, zIndex, style, globalOptions) {
3198
+ // Calculate bounding box that includes all overflowing children
3199
+ const rect = node.getBoundingClientRect();
3200
+ let minX = rect.left;
3201
+ let minY = rect.top;
3202
+ let maxX = rect.right;
3203
+ let maxY = rect.bottom;
3204
+
3205
+ overflowingChildren.forEach(child => {
3206
+ const childRect = child.getBoundingClientRect();
3207
+ minX = Math.min(minX, childRect.left);
3208
+ minY = Math.min(minY, childRect.top);
3209
+ maxX = Math.max(maxX, childRect.right);
3210
+ maxY = Math.max(maxY, childRect.bottom);
3211
+ });
3212
+
3213
+ const totalWidth = maxX - minX;
3214
+ const totalHeight = maxY - minY;
3215
+
3216
+ // Use html2canvas to capture the entire area
3217
+ const item = {
3218
+ type: 'image',
3219
+ zIndex,
3220
+ domOrder,
3221
+ options: {
3222
+ x: config.offX + (minX - config.rootX) * PX_TO_INCH * config.scale,
3223
+ y: config.offY + (minY - config.rootY) * PX_TO_INCH * config.scale,
3224
+ w: totalWidth * PX_TO_INCH * config.scale,
3225
+ h: totalHeight * PX_TO_INCH * config.scale,
3226
+ data: null
3227
+ }
3228
+ };
3229
+
3230
+ const job = async () => {
3231
+ try {
3232
+ // Temporarily adjust node position for capture
3233
+ const originalPosition = node.style.position;
3234
+ const originalTransform = node.style.transform;
3235
+
3236
+ node.style.position = 'relative';
3237
+ node.style.transform = 'none';
3238
+
3239
+ const canvas = await html2canvas(node, {
3240
+ backgroundColor: null,
3241
+ logging: false,
3242
+ scale: 2,
3243
+ useCORS: true,
3244
+ x: minX - rect.left,
3245
+ y: minY - rect.top,
3246
+ width: totalWidth,
3247
+ height: totalHeight
3248
+ });
3249
+
3250
+ // Restore original styles
3251
+ node.style.position = originalPosition;
3252
+ node.style.transform = originalTransform;
3253
+
3254
+ item.options.data = canvas.toDataURL('image/png');
3255
+ } catch (e) {
3256
+ console.warn('Failed to capture overflowing content:', e);
3257
+ item.skip = true;
3258
+ }
3259
+ };
3260
+
3261
+ return { items: [item], job, stopRecursion: true };
3262
+ }
3263
+
3264
+ /**
3265
+ * Detect vertical stat cards (like .mini-stat with number above label)
3266
+ * These have block-level children that should stack vertically
3267
+ */
3268
+ function isVerticalStatCard(node) {
3269
+ const className = (node.className || '').toLowerCase();
3270
+
3271
+ // Check for stat-like class names
3272
+ const statIndicators = ['stat', 'metric', 'kpi', 'data-item', 'stat-item'];
3273
+ const hasStatClass = statIndicators.some(indicator => className.includes(indicator));
3274
+
3275
+ if (!hasStatClass) return false;
3276
+
3277
+ const children = Array.from(node.children);
3278
+ if (children.length !== 2) return false;
3279
+
3280
+ // Check if children are likely number + label pair
3281
+ const child1 = children[0];
3282
+ const child2 = children[1];
3283
+
3284
+ const style1 = window.getComputedStyle(child1);
3285
+ const style2 = window.getComputedStyle(child2);
3286
+
3287
+ // Both should be block elements (or have block-like display)
3288
+ const isBlock1 = style1.display === 'block' || style1.display === 'flex';
3289
+ const isBlock2 = style2.display === 'block' || style2.display === 'flex';
3290
+
3291
+ if (!isBlock1 || !isBlock2) return false;
3292
+
3293
+ // First child should have larger font (the number)
3294
+ const fontSize1 = parseFloat(style1.fontSize) || 0;
3295
+ const fontSize2 = parseFloat(style2.fontSize) || 0;
3296
+
3297
+ // Number should be larger than label, or at least bold
3298
+ const isBold1 = parseInt(style1.fontWeight) >= 600;
3299
+
3300
+ return fontSize1 >= fontSize2 || isBold1;
3301
+ }
3302
+
3303
+ /**
3304
+ * Detect if a div structure looks like a table
3305
+ * Checks for table-like class names and structure patterns
3306
+ */
3307
+ function detectTableLikeStructure(node) {
3308
+ const className = (node.className || '').toLowerCase();
3309
+
3310
+ // Check for table-related class names
3311
+ const tableIndicators = ['table', 'data-table', 'grid', 'list'];
3312
+ const hasTableClass = tableIndicators.some(indicator => className.includes(indicator));
3313
+
3314
+ if (!hasTableClass) return false;
3315
+
3316
+ // Check structure: should have header row and data rows
3317
+ const children = Array.from(node.children);
3318
+
3319
+ // Look for header-like element
3320
+ const hasHeader = children.some(child =>
3321
+ (child.className || '').toLowerCase().includes('header') ||
3322
+ child.tagName === 'THEAD'
3323
+ );
3324
+
3325
+ // Look for row-like elements
3326
+ const hasRows = children.some(child =>
3327
+ (child.className || '').toLowerCase().includes('row') ||
3328
+ child.tagName === 'TR' ||
3329
+ child.tagName === 'TBODY'
3330
+ );
3331
+
3332
+ // Check for cell-like structure in children
3333
+ const hasCellStructure = children.some(child => {
3334
+ const childChildren = Array.from(child.children);
3335
+ return childChildren.some(grandchild =>
3336
+ (grandchild.className || '').toLowerCase().includes('cell') ||
3337
+ grandchild.className.toLowerCase().includes('col') ||
3338
+ grandchild.className.toLowerCase().includes('td')
3339
+ );
3340
+ });
3341
+
3342
+ return (hasHeader || hasRows) && hasCellStructure;
3343
+ }
3344
+
3345
+ /**
3346
+ * Extract table data from div-based table structure
3347
+ * Similar to extractTableData but for div grids
3348
+ */
3349
+ function extractDivTableData(node, scale, options = {}) {
3350
+ const rows = [];
3351
+ const colWidths = [];
3352
+ const root = options.root || null;
3353
+
3354
+ // Find header row
3355
+ const headerRow = node.querySelector('.table-header, [class*="header"]');
3356
+ if (headerRow) {
3357
+ const headerCells = Array.from(headerRow.children);
3358
+ headerCells.forEach((cell, index) => {
3359
+ const rect = cell.getBoundingClientRect();
3360
+ const wIn = rect.width * (1 / 96) * scale;
3361
+ colWidths[index] = wIn;
3362
+ });
3363
+
3364
+ // Process header as first row
3365
+ const headerData = headerCells.map(cell => {
3366
+ const style = window.getComputedStyle(cell);
3367
+ const cellText = cell.innerText.replace(/[\n\r\t]+/g, ' ').trim();
3368
+ const textStyle = getTextStyle(style, scale, cellText, options);
3369
+ const fill = computeTableCellFill(style, cell, root, options);
3370
+
3371
+ return {
3372
+ text: cellText,
3373
+ options: {
3374
+ ...textStyle,
3375
+ fill: fill || { color: '16A085', transparency: 80 },
3376
+ bold: true,
3377
+ align: style.textAlign === 'center' ? 'center' : 'left',
3378
+ border: { type: 'none' }
3379
+ }
3380
+ };
3381
+ });
3382
+ rows.push(headerData);
3383
+ }
3384
+
3385
+ // Find data rows
3386
+ const dataRows = node.querySelectorAll('.table-row, [class*="row"]');
3387
+ dataRows.forEach(row => {
3388
+ const cells = Array.from(row.children);
3389
+ const rowData = cells.map(cell => {
3390
+ const style = window.getComputedStyle(cell);
3391
+ const cellText = cell.innerText.replace(/[\n\r\t]+/g, ' ').trim();
3392
+ const textStyle = getTextStyle(style, scale, cellText, options);
3393
+ const fill = computeTableCellFill(style, cell, root, options);
3394
+
3395
+ // Detect trend colors
3396
+ const className = (cell.className || '').toLowerCase();
3397
+ let textColor = textStyle.color;
3398
+ if (className.includes('up') || className.includes('positive')) {
3399
+ textColor = '16A085';
3400
+ } else if (className.includes('down') || className.includes('negative')) {
3401
+ textColor = 'E74C3C';
3402
+ }
3403
+
3404
+ return {
3405
+ text: cellText,
3406
+ options: {
3407
+ ...textStyle,
3408
+ color: textColor,
3409
+ fill: fill,
3410
+ align: style.textAlign === 'center' ? 'center' : 'left',
3411
+ border: {
3412
+ type: 'none',
3413
+ bottom: { type: 'solid', pt: 0.5, color: 'FFFFFF', transparency: 95 }
3414
+ }
3415
+ }
3416
+ };
3417
+ });
3418
+
3419
+ if (rowData.length > 0) {
3420
+ rows.push(rowData);
3421
+ }
3422
+ });
3423
+
3424
+ // Ensure all rows have same column count
3425
+ const maxCols = Math.max(...rows.map(r => r.length), colWidths.length);
3426
+ rows.forEach(row => {
3427
+ while (row.length < maxCols) {
3428
+ row.push({ text: '', options: {} });
3429
+ }
3430
+ });
3431
+ while (colWidths.length < maxCols) {
3432
+ colWidths.push(colWidths[colWidths.length - 1] || 1);
3433
+ }
3434
+
3435
+ return { rows, colWidths };
3436
+ }
3437
+
2704
3438
  export { exportToPptx };
2705
3439
  //# sourceMappingURL=dom-to-pptx.mjs.map