@microlee666/dom-to-pptx 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -25,13 +25,68 @@ import {
25
25
  getUsedFontFamilies,
26
26
  getAutoDetectedFonts,
27
27
  extractTableData,
28
- computeTableCellFill
28
+ computeTableCellFill,
29
+ buildChartRegistry
29
30
  } from './utils.js';
30
31
  import { getProcessedImage } from './image-processor.js';
31
32
 
32
33
  const PPI = 96;
33
34
  const PX_TO_INCH = 1 / PPI;
34
35
 
36
+ /**
37
+ * Fix Chinese font attributes (pitchFamily and charset) in PPTX XML.
38
+ * PptxGenJS sometimes generates incorrect charset="0" for East Asian fonts.
39
+ * @param {Blob} blob - The PPTX blob
40
+ * @returns {Promise<Blob>} - Fixed PPTX blob
41
+ */
42
+ async function fixChineseFontAttributes(blob) {
43
+ const zip = await JSZip.loadAsync(blob);
44
+ const cjkFonts = ['Heiti TC', 'Heiti SC', 'PingFang SC', 'Microsoft YaHei', 'SimHei', 'SimSun'];
45
+
46
+ // Process all slide XML files
47
+ const slideFiles = Object.keys(zip.files).filter(name =>
48
+ name.startsWith('ppt/slides/slide') && name.endsWith('.xml')
49
+ );
50
+
51
+ for (const slidePath of slideFiles) {
52
+ let xml = await zip.file(slidePath).async('text');
53
+ let modified = false;
54
+
55
+ // Fix <a:ea> and <a:cs> tags for CJK fonts
56
+ // Replace tags that have charset="0" (incorrect for CJK) with proper values
57
+ for (const fontName of cjkFonts) {
58
+ // Fix <a:ea> - East Asian font
59
+ const eaPattern = new RegExp(
60
+ `<a:ea typeface="${fontName}"[^>]*>`,
61
+ 'g'
62
+ );
63
+ xml = xml.replace(eaPattern, `<a:ea typeface="${fontName}" pitchFamily="34" charset="-122"/>`);
64
+
65
+ // Fix <a:cs> - Complex Script font
66
+ const csPattern = new RegExp(
67
+ `<a:cs typeface="${fontName}"[^>]*>`,
68
+ 'g'
69
+ );
70
+ xml = xml.replace(csPattern, `<a:cs typeface="${fontName}" pitchFamily="34" charset="-120"/>`);
71
+
72
+ // Fix <a:latin> - Latin font (if it's a CJK font used as Latin)
73
+ const latinPattern = new RegExp(
74
+ `<a:latin typeface="${fontName}"[^>]*>`,
75
+ 'g'
76
+ );
77
+ xml = xml.replace(latinPattern, `<a:latin typeface="${fontName}" pitchFamily="34" charset="0"/>`);
78
+ }
79
+ modified = true;
80
+
81
+ if (modified) {
82
+ zip.file(slidePath, xml);
83
+ }
84
+ }
85
+
86
+ // Generate new blob
87
+ return await zip.generateAsync({ type: 'blob' });
88
+ }
89
+
35
90
  /**
36
91
  * Main export function.
37
92
  * @param {HTMLElement | string | Array<HTMLElement | string>} target
@@ -56,6 +111,7 @@ export async function exportToPptx(target, options = {}) {
56
111
  if (!PptxConstructor) throw new Error('PptxGenJS constructor not found.');
57
112
  const pptx = new PptxConstructor();
58
113
  pptx.layout = 'LAYOUT_16x9';
114
+ pptx.language = 'zh-CN';
59
115
 
60
116
  const elements = Array.isArray(target) ? target : [target];
61
117
 
@@ -130,6 +186,9 @@ export async function exportToPptx(target, options = {}) {
130
186
  finalBlob = await pptx.write({ outputType: 'blob' });
131
187
  }
132
188
 
189
+ // Fix Chinese font attributes (pitchFamily and charset) in all slide XMLs
190
+ finalBlob = await fixChineseFontAttributes(finalBlob);
191
+
133
192
  // 4. Output Handling
134
193
  // If skipDownload is NOT true, proceed with browser download
135
194
  if (!options.skipDownload) {
@@ -171,6 +230,9 @@ async function processSlide(root, slide, pptx, globalOptions = {}) {
171
230
  offY: (PPTX_HEIGHT_IN - contentHeightIn * scale) / 2,
172
231
  };
173
232
 
233
+ const chartRegistry = buildChartRegistry(root, globalOptions.chartConfigs);
234
+ const slideOptions = { ...globalOptions, chartRegistry };
235
+
174
236
  const renderQueue = [];
175
237
  const asyncTasks = []; // Queue for heavy operations (Images, Canvas)
176
238
  let domOrderCounter = 0;
@@ -206,7 +268,7 @@ async function processSlide(root, slide, pptx, globalOptions = {}) {
206
268
  pptx,
207
269
  currentZ,
208
270
  nodeStyle,
209
- globalOptions
271
+ slideOptions
210
272
  );
211
273
 
212
274
  if (result) {
@@ -252,6 +314,10 @@ async function processSlide(root, slide, pptx, globalOptions = {}) {
252
314
  if (item.type === 'shape') slide.addShape(item.shapeType, item.options);
253
315
  if (item.type === 'image') slide.addImage(item.options);
254
316
  if (item.type === 'text') slide.addText(item.textParts, item.options);
317
+ if (item.type === 'chart') {
318
+ const chartType = PptxGenJS.ChartType?.[item.chartType] ?? item.chartType;
319
+ slide.addChart(chartType, item.chartData, item.options);
320
+ }
255
321
  if (item.type === 'table') {
256
322
  slide.addTable(item.tableData.rows, {
257
323
  x: item.options.x,
@@ -452,13 +518,23 @@ function prepareRenderItem(
452
518
  range.detach();
453
519
 
454
520
  const style = window.getComputedStyle(parent);
455
- const widthPx = rect.width;
456
- const heightPx = rect.height;
521
+ // Use parent element's rect for better width calculation
522
+ // This is especially important for flex children where text node rect may be too narrow
523
+ const parentRect = parent.getBoundingClientRect();
524
+ const useParentRect = parentRect.width > rect.width;
525
+ let widthPx = useParentRect ? parentRect.width : rect.width;
526
+ const heightPx = useParentRect ? parentRect.height : rect.height;
527
+
528
+ // Add extra width buffer to prevent text wrapping in PPTX due to font rendering differences
529
+ // Chinese characters and certain fonts need more space in PPTX than in browser
530
+ widthPx = widthPx * 1.3; // Add 30% extra width
457
531
  const unrotatedW = widthPx * PX_TO_INCH * config.scale;
458
532
  const unrotatedH = heightPx * PX_TO_INCH * config.scale;
459
533
 
460
- const x = config.offX + (rect.left - config.rootX) * PX_TO_INCH * config.scale;
461
- const y = config.offY + (rect.top - config.rootY) * PX_TO_INCH * config.scale;
534
+ // Use parent rect's position if we're using parent dimensions
535
+ const sourceRect = useParentRect ? parentRect : rect;
536
+ const x = config.offX + (sourceRect.left - config.rootX) * PX_TO_INCH * config.scale;
537
+ const y = config.offY + (sourceRect.top - config.rootY) * PX_TO_INCH * config.scale;
462
538
 
463
539
  return {
464
540
  items: [
@@ -497,7 +573,7 @@ function prepareRenderItem(
497
573
  const elementOpacity = parseFloat(style.opacity);
498
574
  const safeOpacity = isNaN(elementOpacity) ? 1 : elementOpacity;
499
575
 
500
- const widthPx = node.offsetWidth || rect.width;
576
+ let widthPx = node.offsetWidth || rect.width;
501
577
  const heightPx = node.offsetHeight || rect.height;
502
578
  const unrotatedW = widthPx * PX_TO_INCH * config.scale;
503
579
  const unrotatedH = heightPx * PX_TO_INCH * config.scale;
@@ -511,8 +587,41 @@ function prepareRenderItem(
511
587
 
512
588
  const items = [];
513
589
 
514
- if (node.tagName === 'TABLE') {
515
- const tableData = extractTableData(node, config.scale, { ...globalOptions, root: config.root });
590
+ const chartConfig = globalOptions.chartRegistry?.get(node);
591
+ if (chartConfig) {
592
+ const chartOptions = {
593
+ ...chartConfig.chartOptions,
594
+ x,
595
+ y,
596
+ w: unrotatedW,
597
+ h: unrotatedH,
598
+ };
599
+
600
+ return {
601
+ items: [
602
+ {
603
+ type: 'chart',
604
+ zIndex: effectiveZIndex,
605
+ domOrder,
606
+ chartType: chartConfig.chartType,
607
+ chartData: chartConfig.chartData,
608
+ options: chartOptions,
609
+ },
610
+ ],
611
+ stopRecursion: true,
612
+ };
613
+ }
614
+
615
+ // --- Handle both native TABLE and div-based table structures ---
616
+ const isTableLike = node.tagName === 'TABLE' || detectTableLikeStructure(node);
617
+ if (isTableLike) {
618
+ let tableData;
619
+ if (node.tagName === 'TABLE') {
620
+ tableData = extractTableData(node, config.scale, { ...globalOptions, root: config.root });
621
+ } else {
622
+ // Extract data from div-based table structure
623
+ tableData = extractDivTableData(node, config.scale, { ...globalOptions, root: config.root });
624
+ }
516
625
  const cellBgItems = [];
517
626
  const renderCellBg = globalOptions.tableConfig?.renderCellBackgrounds !== false;
518
627
 
@@ -856,6 +965,36 @@ function prepareRenderItem(
856
965
  return { items: [item], job, stopRecursion: true };
857
966
  }
858
967
 
968
+ // --- Handle vertical stat cards (like .mini-stat with number + label) ---
969
+ if (isVerticalStatCard(node)) {
970
+ // Capture the entire stat card as an image to preserve vertical layout
971
+ const item = {
972
+ type: 'image',
973
+ zIndex,
974
+ domOrder,
975
+ options: { x, y, w, h, rotate: rotation, data: null },
976
+ };
977
+ const job = async () => {
978
+ const pngData = await elementToCanvasImage(node, widthPx, heightPx);
979
+ if (pngData) item.options.data = pngData;
980
+ else item.skip = true;
981
+ };
982
+ return { items: [item], job, stopRecursion: true };
983
+ }
984
+
985
+ // --- Handle absolutely positioned children that overflow parent ---
986
+ // Check if this element has absolutely positioned children that extend outside
987
+ const overflowingChildren = detectOverflowingChildren(node);
988
+ if (overflowingChildren.length > 0) {
989
+ // Process this node normally, but also capture overflowing children separately
990
+ const baseResult = processOverflowingContent(
991
+ node, overflowingChildren, config, domOrder, pptx, zIndex, style, globalOptions
992
+ );
993
+ if (baseResult) {
994
+ return baseResult;
995
+ }
996
+ }
997
+
859
998
  // Radii logic
860
999
  const borderRadiusValue = parseFloat(style.borderRadius) || 0;
861
1000
  const borderBottomLeftRadius = parseFloat(style.borderBottomLeftRadius) || 0;
@@ -958,6 +1097,27 @@ function prepareRenderItem(
958
1097
  let textPayload = null;
959
1098
  const isText = isTextContainer(node);
960
1099
 
1100
+ // Add extra width buffer for inline text elements to prevent wrapping in PPTX
1101
+ // This is especially needed for Chinese text and certain fonts
1102
+ // Only apply to elements that are direct children of flex containers with space distribution
1103
+ // and don't have a parent with visible background (to avoid overflowing card boundaries)
1104
+ if (isText && !bgColorObj.hex && !hasBorder && !hasShadow) {
1105
+ const parentEl = node.parentElement;
1106
+ if (parentEl) {
1107
+ const parentStyle = window.getComputedStyle(parentEl);
1108
+ const parentDisplay = parentStyle.display || '';
1109
+ const parentJustify = parentStyle.justifyContent || '';
1110
+ const isFlexWithSpaceDist = (parentDisplay.includes('flex') || parentDisplay.includes('grid')) &&
1111
+ (parentJustify.includes('space-between') || parentJustify.includes('space-around') || parentJustify.includes('space-evenly'));
1112
+
1113
+ if (isFlexWithSpaceDist) {
1114
+ widthPx = widthPx * 1.3; // Add 30% extra width
1115
+ // Recalculate w with the new width
1116
+ w = widthPx * PX_TO_INCH * config.scale;
1117
+ }
1118
+ }
1119
+ }
1120
+
961
1121
  if (isText) {
962
1122
  const textParts = [];
963
1123
  let trimNextLeading = false;
@@ -1014,8 +1174,91 @@ function prepareRenderItem(
1014
1174
  let align = style.textAlign || 'left';
1015
1175
  if (align === 'start') align = 'left';
1016
1176
  if (align === 'end') align = 'right';
1177
+
1178
+ // Fix: If this element is a flex/grid child and has no explicit text-align,
1179
+ // force left alignment to match HTML default behavior
1180
+ if (node.parentElement && (!style.textAlign || style.textAlign === 'start')) {
1181
+ const parentStyle = window.getComputedStyle(node.parentElement);
1182
+ if (parentStyle.display.includes('flex') || parentStyle.display.includes('grid')) {
1183
+ align = 'left';
1184
+ }
1185
+ }
1186
+
1187
+ // Detect badge/pill buttons (high border-radius + short text) and auto-center them
1188
+ const borderRadius = parseFloat(style.borderRadius) || 0;
1189
+ const height = parseFloat(style.height) || node.offsetHeight;
1190
+ const textContent = node.textContent.trim();
1191
+ const className = (node.className || '').toLowerCase();
1192
+
1193
+ // Real badges/pills typically have visible styling (background, border, or large border-radius)
1194
+ const hasVisibleBackground = (bgColorObj.hex && bgColorObj.opacity > 0.1) || hasGradient;
1195
+ const hasVisibleBorder = borderWidth > 0;
1196
+ const hasLargeBorderRadius = borderRadius >= height / 2;
1197
+ const hasBadgeClass = className.includes('badge') || className.includes('pill') || className.includes('tag');
1198
+
1199
+ // Check if it's a small tag/label with rounded corners and short text
1200
+ const paddingLeft = parseFloat(style.paddingLeft) || 0;
1201
+ const paddingRight = parseFloat(style.paddingRight) || 0;
1202
+ const paddingTop = parseFloat(style.paddingTop) || 0;
1203
+ const paddingBottom = parseFloat(style.paddingBottom) || 0;
1204
+ const hasSmallPadding = Math.max(paddingLeft, paddingRight, paddingTop, paddingBottom) <= 12;
1205
+ const hasEvenPadding = Math.abs(paddingTop - paddingBottom) <= 4 && Math.abs(paddingLeft - paddingRight) <= 8;
1206
+ const hasRoundedCorners = borderRadius >= 3;
1207
+ const isInlineBlock = style.display === 'inline-block' || style.display === 'inline-flex';
1208
+
1209
+ // Small tag detection: inline-block with background, rounded corners, small even padding, and short text
1210
+ const isSmallTag =
1211
+ isInlineBlock &&
1212
+ hasVisibleBackground &&
1213
+ hasRoundedCorners &&
1214
+ hasSmallPadding &&
1215
+ hasEvenPadding &&
1216
+ textContent.length <= 10;
1217
+
1218
+ // Only consider it a badge if it has visual styling AND short text
1219
+ const isLikelyBadge =
1220
+ ((hasLargeBorderRadius || hasBadgeClass) &&
1221
+ textContent.length <= 10 &&
1222
+ (hasVisibleBackground || hasVisibleBorder || hasLargeBorderRadius)) ||
1223
+ isSmallTag;
1224
+
1225
+ if (isLikelyBadge) {
1226
+ align = 'center';
1227
+ }
1228
+
1017
1229
  let valign = 'top';
1018
- if (style.alignItems === 'center') valign = 'middle';
1230
+
1231
+ // For flex items, valign should be determined by PARENT's align-items, not self
1232
+ // Self's align-items controls how children are aligned, not self's position in parent
1233
+ const parentEl = node.parentElement;
1234
+ if (parentEl) {
1235
+ const parentStyle = window.getComputedStyle(parentEl);
1236
+ if (parentStyle.display.includes('flex')) {
1237
+ // Parent's align-items controls this element's cross-axis alignment
1238
+ if (parentStyle.alignItems === 'center') {
1239
+ valign = 'middle';
1240
+ } else if (parentStyle.alignItems === 'flex-end' || parentStyle.alignItems === 'end') {
1241
+ valign = 'bottom';
1242
+ }
1243
+ // Default (stretch, flex-start) keeps valign = 'top'
1244
+ }
1245
+ }
1246
+
1247
+ // If element is not a flex item (no flex parent), then its own align-items
1248
+ // might indicate self-centering intent for single-element containers
1249
+ if (valign === 'top' && style.alignItems === 'center' && !parentEl) {
1250
+ valign = 'middle';
1251
+ }
1252
+
1253
+ // Auto-center vertically for likely badges
1254
+ if (isLikelyBadge && valign !== 'middle') {
1255
+ const paddingTop = parseFloat(style.paddingTop) || 0;
1256
+ const paddingBottom = parseFloat(style.paddingBottom) || 0;
1257
+ // If padding is relatively even, assume vertical centering is intended
1258
+ if (Math.abs(paddingTop - paddingBottom) <= 4) {
1259
+ valign = 'middle';
1260
+ }
1261
+ }
1019
1262
  if (style.justifyContent === 'center' && style.display.includes('flex')) align = 'center';
1020
1263
 
1021
1264
  const pt = parseFloat(style.paddingTop) || 0;
@@ -1374,3 +1617,280 @@ function createCompositeBorderItems(sides, x, y, w, h, scale, zIndex, domOrder)
1374
1617
 
1375
1618
  return items;
1376
1619
  }
1620
+
1621
+ /**
1622
+ * Detect absolutely positioned children that overflow their parent
1623
+ * This handles cases like chart value labels positioned above bars
1624
+ */
1625
+ function detectOverflowingChildren(node) {
1626
+ const overflowing = [];
1627
+ const parentRect = node.getBoundingClientRect();
1628
+
1629
+ // Only check for elements with specific class patterns that suggest chart values
1630
+ const children = node.querySelectorAll('.bar-value, [class*="value"], [class*="label"]');
1631
+
1632
+ children.forEach(child => {
1633
+ const childStyle = window.getComputedStyle(child);
1634
+ const childRect = child.getBoundingClientRect();
1635
+
1636
+ // Check if absolutely positioned and outside parent bounds
1637
+ if (childStyle.position === 'absolute') {
1638
+ const isOutside =
1639
+ childRect.bottom < parentRect.top ||
1640
+ childRect.top > parentRect.bottom ||
1641
+ childRect.right < parentRect.left ||
1642
+ childRect.left > parentRect.right;
1643
+
1644
+ if (isOutside || childRect.top < parentRect.top) {
1645
+ overflowing.push(child);
1646
+ }
1647
+ }
1648
+ });
1649
+
1650
+ return overflowing;
1651
+ }
1652
+
1653
+ /**
1654
+ * Process content with overflowing children
1655
+ * Captures the entire visual area including overflowing elements
1656
+ */
1657
+ function processOverflowingContent(node, overflowingChildren, config, domOrder, pptx, zIndex, style, globalOptions) {
1658
+ // Calculate bounding box that includes all overflowing children
1659
+ const rect = node.getBoundingClientRect();
1660
+ let minX = rect.left;
1661
+ let minY = rect.top;
1662
+ let maxX = rect.right;
1663
+ let maxY = rect.bottom;
1664
+
1665
+ overflowingChildren.forEach(child => {
1666
+ const childRect = child.getBoundingClientRect();
1667
+ minX = Math.min(minX, childRect.left);
1668
+ minY = Math.min(minY, childRect.top);
1669
+ maxX = Math.max(maxX, childRect.right);
1670
+ maxY = Math.max(maxY, childRect.bottom);
1671
+ });
1672
+
1673
+ const totalWidth = maxX - minX;
1674
+ const totalHeight = maxY - minY;
1675
+
1676
+ // Use html2canvas to capture the entire area
1677
+ const item = {
1678
+ type: 'image',
1679
+ zIndex,
1680
+ domOrder,
1681
+ options: {
1682
+ x: config.offX + (minX - config.rootX) * PX_TO_INCH * config.scale,
1683
+ y: config.offY + (minY - config.rootY) * PX_TO_INCH * config.scale,
1684
+ w: totalWidth * PX_TO_INCH * config.scale,
1685
+ h: totalHeight * PX_TO_INCH * config.scale,
1686
+ data: null
1687
+ }
1688
+ };
1689
+
1690
+ const job = async () => {
1691
+ try {
1692
+ // Temporarily adjust node position for capture
1693
+ const originalPosition = node.style.position;
1694
+ const originalTransform = node.style.transform;
1695
+
1696
+ node.style.position = 'relative';
1697
+ node.style.transform = 'none';
1698
+
1699
+ const canvas = await html2canvas(node, {
1700
+ backgroundColor: null,
1701
+ logging: false,
1702
+ scale: 2,
1703
+ useCORS: true,
1704
+ x: minX - rect.left,
1705
+ y: minY - rect.top,
1706
+ width: totalWidth,
1707
+ height: totalHeight
1708
+ });
1709
+
1710
+ // Restore original styles
1711
+ node.style.position = originalPosition;
1712
+ node.style.transform = originalTransform;
1713
+
1714
+ item.options.data = canvas.toDataURL('image/png');
1715
+ } catch (e) {
1716
+ console.warn('Failed to capture overflowing content:', e);
1717
+ item.skip = true;
1718
+ }
1719
+ };
1720
+
1721
+ return { items: [item], job, stopRecursion: true };
1722
+ }
1723
+
1724
+ /**
1725
+ * Detect vertical stat cards (like .mini-stat with number above label)
1726
+ * These have block-level children that should stack vertically
1727
+ */
1728
+ function isVerticalStatCard(node) {
1729
+ const className = (node.className || '').toLowerCase();
1730
+
1731
+ // Check for stat-like class names
1732
+ const statIndicators = ['stat', 'metric', 'kpi', 'data-item', 'stat-item'];
1733
+ const hasStatClass = statIndicators.some(indicator => className.includes(indicator));
1734
+
1735
+ if (!hasStatClass) return false;
1736
+
1737
+ const children = Array.from(node.children);
1738
+ if (children.length !== 2) return false;
1739
+
1740
+ // Check if children are likely number + label pair
1741
+ const child1 = children[0];
1742
+ const child2 = children[1];
1743
+
1744
+ const style1 = window.getComputedStyle(child1);
1745
+ const style2 = window.getComputedStyle(child2);
1746
+
1747
+ // Both should be block elements (or have block-like display)
1748
+ const isBlock1 = style1.display === 'block' || style1.display === 'flex';
1749
+ const isBlock2 = style2.display === 'block' || style2.display === 'flex';
1750
+
1751
+ if (!isBlock1 || !isBlock2) return false;
1752
+
1753
+ // First child should have larger font (the number)
1754
+ const fontSize1 = parseFloat(style1.fontSize) || 0;
1755
+ const fontSize2 = parseFloat(style2.fontSize) || 0;
1756
+
1757
+ // Number should be larger than label, or at least bold
1758
+ const isBold1 = parseInt(style1.fontWeight) >= 600;
1759
+
1760
+ return fontSize1 >= fontSize2 || isBold1;
1761
+ }
1762
+
1763
+ /**
1764
+ * Detect if a div structure looks like a table
1765
+ * Checks for table-like class names and structure patterns
1766
+ */
1767
+ function detectTableLikeStructure(node) {
1768
+ const className = (node.className || '').toLowerCase();
1769
+
1770
+ // Check for table-related class names
1771
+ const tableIndicators = ['table', 'data-table', 'grid', 'list'];
1772
+ const hasTableClass = tableIndicators.some(indicator => className.includes(indicator));
1773
+
1774
+ if (!hasTableClass) return false;
1775
+
1776
+ // Check structure: should have header row and data rows
1777
+ const children = Array.from(node.children);
1778
+
1779
+ // Look for header-like element
1780
+ const hasHeader = children.some(child =>
1781
+ (child.className || '').toLowerCase().includes('header') ||
1782
+ child.tagName === 'THEAD'
1783
+ );
1784
+
1785
+ // Look for row-like elements
1786
+ const hasRows = children.some(child =>
1787
+ (child.className || '').toLowerCase().includes('row') ||
1788
+ child.tagName === 'TR' ||
1789
+ child.tagName === 'TBODY'
1790
+ );
1791
+
1792
+ // Check for cell-like structure in children
1793
+ const hasCellStructure = children.some(child => {
1794
+ const childChildren = Array.from(child.children);
1795
+ return childChildren.some(grandchild =>
1796
+ (grandchild.className || '').toLowerCase().includes('cell') ||
1797
+ grandchild.className.toLowerCase().includes('col') ||
1798
+ grandchild.className.toLowerCase().includes('td')
1799
+ );
1800
+ });
1801
+
1802
+ return (hasHeader || hasRows) && hasCellStructure;
1803
+ }
1804
+
1805
+ /**
1806
+ * Extract table data from div-based table structure
1807
+ * Similar to extractTableData but for div grids
1808
+ */
1809
+ function extractDivTableData(node, scale, options = {}) {
1810
+ const rows = [];
1811
+ const colWidths = [];
1812
+ const root = options.root || null;
1813
+
1814
+ // Find header row
1815
+ const headerRow = node.querySelector('.table-header, [class*="header"]');
1816
+ if (headerRow) {
1817
+ const headerCells = Array.from(headerRow.children);
1818
+ headerCells.forEach((cell, index) => {
1819
+ const rect = cell.getBoundingClientRect();
1820
+ const wIn = rect.width * (1 / 96) * scale;
1821
+ colWidths[index] = wIn;
1822
+ });
1823
+
1824
+ // Process header as first row
1825
+ const headerData = headerCells.map(cell => {
1826
+ const style = window.getComputedStyle(cell);
1827
+ const cellText = cell.innerText.replace(/[\n\r\t]+/g, ' ').trim();
1828
+ const textStyle = getTextStyle(style, scale, cellText, options);
1829
+ const fill = computeTableCellFill(style, cell, root, options);
1830
+
1831
+ return {
1832
+ text: cellText,
1833
+ options: {
1834
+ ...textStyle,
1835
+ fill: fill || { color: '16A085', transparency: 80 },
1836
+ bold: true,
1837
+ align: style.textAlign === 'center' ? 'center' : 'left',
1838
+ border: { type: 'none' }
1839
+ }
1840
+ };
1841
+ });
1842
+ rows.push(headerData);
1843
+ }
1844
+
1845
+ // Find data rows
1846
+ const dataRows = node.querySelectorAll('.table-row, [class*="row"]');
1847
+ dataRows.forEach(row => {
1848
+ const cells = Array.from(row.children);
1849
+ const rowData = cells.map(cell => {
1850
+ const style = window.getComputedStyle(cell);
1851
+ const cellText = cell.innerText.replace(/[\n\r\t]+/g, ' ').trim();
1852
+ const textStyle = getTextStyle(style, scale, cellText, options);
1853
+ const fill = computeTableCellFill(style, cell, root, options);
1854
+
1855
+ // Detect trend colors
1856
+ const className = (cell.className || '').toLowerCase();
1857
+ let textColor = textStyle.color;
1858
+ if (className.includes('up') || className.includes('positive')) {
1859
+ textColor = '16A085';
1860
+ } else if (className.includes('down') || className.includes('negative')) {
1861
+ textColor = 'E74C3C';
1862
+ }
1863
+
1864
+ return {
1865
+ text: cellText,
1866
+ options: {
1867
+ ...textStyle,
1868
+ color: textColor,
1869
+ fill: fill,
1870
+ align: style.textAlign === 'center' ? 'center' : 'left',
1871
+ border: {
1872
+ type: 'none',
1873
+ bottom: { type: 'solid', pt: 0.5, color: 'FFFFFF', transparency: 95 }
1874
+ }
1875
+ }
1876
+ };
1877
+ });
1878
+
1879
+ if (rowData.length > 0) {
1880
+ rows.push(rowData);
1881
+ }
1882
+ });
1883
+
1884
+ // Ensure all rows have same column count
1885
+ const maxCols = Math.max(...rows.map(r => r.length), colWidths.length);
1886
+ rows.forEach(row => {
1887
+ while (row.length < maxCols) {
1888
+ row.push({ text: '', options: {} });
1889
+ }
1890
+ });
1891
+ while (colWidths.length < maxCols) {
1892
+ colWidths.push(colWidths[colWidths.length - 1] || 1);
1893
+ }
1894
+
1895
+ return { rows, colWidths };
1896
+ }