@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microlee666/dom-to-pptx",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "description": "A client-side library that converts any HTML element into a fully editable PowerPoint slide. **dom-to-pptx** transforms DOM structures into pixel-accurate `.pptx` content, preserving gradients, shadows, rounded images, and responsive layouts. It translates CSS Flexbox/Grid, linear-gradients, box-shadows, and typography into native PowerPoint shapes, enabling precise, design-faithful slide generation directly from the browser.",
5
5
  "main": "dist/dom-to-pptx.cjs",
6
6
  "module": "dist/dom-to-pptx.mjs",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "scripts": {
16
16
  "test": "echo \"Error: no test specified\" && exit 1",
17
- "build": "npx rollup -c",
17
+ "build": "npx rollup -c && node scripts/patch-bundle.js",
18
18
  "lint": "eslint .",
19
19
  "format": "prettier --write ."
20
20
  },
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Post-build patch script for dom-to-pptx bundle
4
+ * Adds anchorCtr="1" attribute when anchor="ctr" is set in bodyPr
5
+ * This ensures text is truly centered in PowerPoint
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const bundlePath = path.join(__dirname, '..', 'dist', 'dom-to-pptx.bundle.js');
12
+ const cliBundlePath = path.join(__dirname, '..', 'cli', 'dom-to-pptx.bundle.js');
13
+
14
+ function patchBundle(filePath) {
15
+ if (!fs.existsSync(filePath)) {
16
+ console.log('Bundle not found: ' + filePath);
17
+ return false;
18
+ }
19
+
20
+ let content = fs.readFileSync(filePath, 'utf8');
21
+
22
+ // Pattern to match the anchor setting line
23
+ const pattern = /bodyProperties\s*\+=\s*' anchor="'\s*\+\s*slideObject\.options\._bodyProp\.anchor\s*\+\s*'"';\s*\/\/\s*VALS:\s*\[t,ctr,b\]/;
24
+
25
+ // Check if already patched
26
+ if (content.includes("anchorCtr=\\\"1\\\"'")) {
27
+ console.log('Already patched: ' + path.basename(filePath));
28
+ return true;
29
+ }
30
+
31
+ // Apply patch
32
+ const patchedContent = content.replace(
33
+ pattern,
34
+ "bodyProperties += ' anchor=\"' + slideObject.options._bodyProp.anchor + '\"'; // VALS: [t,ctr,b]\n if (slideObject.options._bodyProp.anchor === 'ctr') bodyProperties += ' anchorCtr=\\\"1\\\"';"
35
+ );
36
+
37
+ if (patchedContent !== content) {
38
+ fs.writeFileSync(filePath, patchedContent, 'utf8');
39
+ console.log('Patched: ' + path.basename(filePath));
40
+ return true;
41
+ } else {
42
+ console.log('Pattern not found in: ' + path.basename(filePath));
43
+ return false;
44
+ }
45
+ }
46
+
47
+ // Patch both bundle files
48
+ const results = [];
49
+ results.push(patchBundle(bundlePath));
50
+
51
+ // Copy patched bundle to cli directory
52
+ if (fs.existsSync(bundlePath)) {
53
+ fs.copyFileSync(bundlePath, cliBundlePath);
54
+ console.log('Copied bundle to cli directory');
55
+ results.push(true);
56
+ } else {
57
+ results.push(patchBundle(cliBundlePath));
58
+ }
59
+
60
+ if (results.some(r => r)) {
61
+ console.log('Bundle patching completed successfully!');
62
+ process.exit(0);
63
+ } else {
64
+ console.log('No bundles were patched.');
65
+ process.exit(1);
66
+ }
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,71 @@ 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;
1195
+ const hasVisibleBorder = borderWidth > 0;
1196
+ const hasLargeBorderRadius = borderRadius >= height / 2;
1197
+ const hasBadgeClass = className.includes('badge') || className.includes('pill');
1198
+
1199
+ // Only consider it a badge if it has visual styling AND short text
1200
+ const isLikelyBadge =
1201
+ (hasLargeBorderRadius || hasBadgeClass) &&
1202
+ textContent.length <= 10 &&
1203
+ (hasVisibleBackground || hasVisibleBorder || hasLargeBorderRadius);
1204
+
1205
+ if (isLikelyBadge) {
1206
+ align = 'center';
1207
+ }
1208
+
1017
1209
  let valign = 'top';
1018
- if (style.alignItems === 'center') valign = 'middle';
1210
+
1211
+ // For flex items, valign should be determined by PARENT's align-items, not self
1212
+ // Self's align-items controls how children are aligned, not self's position in parent
1213
+ const parentEl = node.parentElement;
1214
+ if (parentEl) {
1215
+ const parentStyle = window.getComputedStyle(parentEl);
1216
+ if (parentStyle.display.includes('flex')) {
1217
+ // Parent's align-items controls this element's cross-axis alignment
1218
+ if (parentStyle.alignItems === 'center') {
1219
+ valign = 'middle';
1220
+ } else if (parentStyle.alignItems === 'flex-end' || parentStyle.alignItems === 'end') {
1221
+ valign = 'bottom';
1222
+ }
1223
+ // Default (stretch, flex-start) keeps valign = 'top'
1224
+ }
1225
+ }
1226
+
1227
+ // If element is not a flex item (no flex parent), then its own align-items
1228
+ // might indicate self-centering intent for single-element containers
1229
+ if (valign === 'top' && style.alignItems === 'center' && !parentEl) {
1230
+ valign = 'middle';
1231
+ }
1232
+
1233
+ // Auto-center vertically for likely badges
1234
+ if (isLikelyBadge && valign !== 'middle') {
1235
+ const paddingTop = parseFloat(style.paddingTop) || 0;
1236
+ const paddingBottom = parseFloat(style.paddingBottom) || 0;
1237
+ // If padding is relatively even, assume vertical centering is intended
1238
+ if (Math.abs(paddingTop - paddingBottom) <= 4) {
1239
+ valign = 'middle';
1240
+ }
1241
+ }
1019
1242
  if (style.justifyContent === 'center' && style.display.includes('flex')) align = 'center';
1020
1243
 
1021
1244
  const pt = parseFloat(style.paddingTop) || 0;
@@ -1374,3 +1597,280 @@ function createCompositeBorderItems(sides, x, y, w, h, scale, zIndex, domOrder)
1374
1597
 
1375
1598
  return items;
1376
1599
  }
1600
+
1601
+ /**
1602
+ * Detect absolutely positioned children that overflow their parent
1603
+ * This handles cases like chart value labels positioned above bars
1604
+ */
1605
+ function detectOverflowingChildren(node) {
1606
+ const overflowing = [];
1607
+ const parentRect = node.getBoundingClientRect();
1608
+
1609
+ // Only check for elements with specific class patterns that suggest chart values
1610
+ const children = node.querySelectorAll('.bar-value, [class*="value"], [class*="label"]');
1611
+
1612
+ children.forEach(child => {
1613
+ const childStyle = window.getComputedStyle(child);
1614
+ const childRect = child.getBoundingClientRect();
1615
+
1616
+ // Check if absolutely positioned and outside parent bounds
1617
+ if (childStyle.position === 'absolute') {
1618
+ const isOutside =
1619
+ childRect.bottom < parentRect.top ||
1620
+ childRect.top > parentRect.bottom ||
1621
+ childRect.right < parentRect.left ||
1622
+ childRect.left > parentRect.right;
1623
+
1624
+ if (isOutside || childRect.top < parentRect.top) {
1625
+ overflowing.push(child);
1626
+ }
1627
+ }
1628
+ });
1629
+
1630
+ return overflowing;
1631
+ }
1632
+
1633
+ /**
1634
+ * Process content with overflowing children
1635
+ * Captures the entire visual area including overflowing elements
1636
+ */
1637
+ function processOverflowingContent(node, overflowingChildren, config, domOrder, pptx, zIndex, style, globalOptions) {
1638
+ // Calculate bounding box that includes all overflowing children
1639
+ const rect = node.getBoundingClientRect();
1640
+ let minX = rect.left;
1641
+ let minY = rect.top;
1642
+ let maxX = rect.right;
1643
+ let maxY = rect.bottom;
1644
+
1645
+ overflowingChildren.forEach(child => {
1646
+ const childRect = child.getBoundingClientRect();
1647
+ minX = Math.min(minX, childRect.left);
1648
+ minY = Math.min(minY, childRect.top);
1649
+ maxX = Math.max(maxX, childRect.right);
1650
+ maxY = Math.max(maxY, childRect.bottom);
1651
+ });
1652
+
1653
+ const totalWidth = maxX - minX;
1654
+ const totalHeight = maxY - minY;
1655
+
1656
+ // Use html2canvas to capture the entire area
1657
+ const item = {
1658
+ type: 'image',
1659
+ zIndex,
1660
+ domOrder,
1661
+ options: {
1662
+ x: config.offX + (minX - config.rootX) * PX_TO_INCH * config.scale,
1663
+ y: config.offY + (minY - config.rootY) * PX_TO_INCH * config.scale,
1664
+ w: totalWidth * PX_TO_INCH * config.scale,
1665
+ h: totalHeight * PX_TO_INCH * config.scale,
1666
+ data: null
1667
+ }
1668
+ };
1669
+
1670
+ const job = async () => {
1671
+ try {
1672
+ // Temporarily adjust node position for capture
1673
+ const originalPosition = node.style.position;
1674
+ const originalTransform = node.style.transform;
1675
+
1676
+ node.style.position = 'relative';
1677
+ node.style.transform = 'none';
1678
+
1679
+ const canvas = await html2canvas(node, {
1680
+ backgroundColor: null,
1681
+ logging: false,
1682
+ scale: 2,
1683
+ useCORS: true,
1684
+ x: minX - rect.left,
1685
+ y: minY - rect.top,
1686
+ width: totalWidth,
1687
+ height: totalHeight
1688
+ });
1689
+
1690
+ // Restore original styles
1691
+ node.style.position = originalPosition;
1692
+ node.style.transform = originalTransform;
1693
+
1694
+ item.options.data = canvas.toDataURL('image/png');
1695
+ } catch (e) {
1696
+ console.warn('Failed to capture overflowing content:', e);
1697
+ item.skip = true;
1698
+ }
1699
+ };
1700
+
1701
+ return { items: [item], job, stopRecursion: true };
1702
+ }
1703
+
1704
+ /**
1705
+ * Detect vertical stat cards (like .mini-stat with number above label)
1706
+ * These have block-level children that should stack vertically
1707
+ */
1708
+ function isVerticalStatCard(node) {
1709
+ const className = (node.className || '').toLowerCase();
1710
+
1711
+ // Check for stat-like class names
1712
+ const statIndicators = ['stat', 'metric', 'kpi', 'data-item', 'stat-item'];
1713
+ const hasStatClass = statIndicators.some(indicator => className.includes(indicator));
1714
+
1715
+ if (!hasStatClass) return false;
1716
+
1717
+ const children = Array.from(node.children);
1718
+ if (children.length !== 2) return false;
1719
+
1720
+ // Check if children are likely number + label pair
1721
+ const child1 = children[0];
1722
+ const child2 = children[1];
1723
+
1724
+ const style1 = window.getComputedStyle(child1);
1725
+ const style2 = window.getComputedStyle(child2);
1726
+
1727
+ // Both should be block elements (or have block-like display)
1728
+ const isBlock1 = style1.display === 'block' || style1.display === 'flex';
1729
+ const isBlock2 = style2.display === 'block' || style2.display === 'flex';
1730
+
1731
+ if (!isBlock1 || !isBlock2) return false;
1732
+
1733
+ // First child should have larger font (the number)
1734
+ const fontSize1 = parseFloat(style1.fontSize) || 0;
1735
+ const fontSize2 = parseFloat(style2.fontSize) || 0;
1736
+
1737
+ // Number should be larger than label, or at least bold
1738
+ const isBold1 = parseInt(style1.fontWeight) >= 600;
1739
+
1740
+ return fontSize1 >= fontSize2 || isBold1;
1741
+ }
1742
+
1743
+ /**
1744
+ * Detect if a div structure looks like a table
1745
+ * Checks for table-like class names and structure patterns
1746
+ */
1747
+ function detectTableLikeStructure(node) {
1748
+ const className = (node.className || '').toLowerCase();
1749
+
1750
+ // Check for table-related class names
1751
+ const tableIndicators = ['table', 'data-table', 'grid', 'list'];
1752
+ const hasTableClass = tableIndicators.some(indicator => className.includes(indicator));
1753
+
1754
+ if (!hasTableClass) return false;
1755
+
1756
+ // Check structure: should have header row and data rows
1757
+ const children = Array.from(node.children);
1758
+
1759
+ // Look for header-like element
1760
+ const hasHeader = children.some(child =>
1761
+ (child.className || '').toLowerCase().includes('header') ||
1762
+ child.tagName === 'THEAD'
1763
+ );
1764
+
1765
+ // Look for row-like elements
1766
+ const hasRows = children.some(child =>
1767
+ (child.className || '').toLowerCase().includes('row') ||
1768
+ child.tagName === 'TR' ||
1769
+ child.tagName === 'TBODY'
1770
+ );
1771
+
1772
+ // Check for cell-like structure in children
1773
+ const hasCellStructure = children.some(child => {
1774
+ const childChildren = Array.from(child.children);
1775
+ return childChildren.some(grandchild =>
1776
+ (grandchild.className || '').toLowerCase().includes('cell') ||
1777
+ grandchild.className.toLowerCase().includes('col') ||
1778
+ grandchild.className.toLowerCase().includes('td')
1779
+ );
1780
+ });
1781
+
1782
+ return (hasHeader || hasRows) && hasCellStructure;
1783
+ }
1784
+
1785
+ /**
1786
+ * Extract table data from div-based table structure
1787
+ * Similar to extractTableData but for div grids
1788
+ */
1789
+ function extractDivTableData(node, scale, options = {}) {
1790
+ const rows = [];
1791
+ const colWidths = [];
1792
+ const root = options.root || null;
1793
+
1794
+ // Find header row
1795
+ const headerRow = node.querySelector('.table-header, [class*="header"]');
1796
+ if (headerRow) {
1797
+ const headerCells = Array.from(headerRow.children);
1798
+ headerCells.forEach((cell, index) => {
1799
+ const rect = cell.getBoundingClientRect();
1800
+ const wIn = rect.width * (1 / 96) * scale;
1801
+ colWidths[index] = wIn;
1802
+ });
1803
+
1804
+ // Process header as first row
1805
+ const headerData = headerCells.map(cell => {
1806
+ const style = window.getComputedStyle(cell);
1807
+ const cellText = cell.innerText.replace(/[\n\r\t]+/g, ' ').trim();
1808
+ const textStyle = getTextStyle(style, scale, cellText, options);
1809
+ const fill = computeTableCellFill(style, cell, root, options);
1810
+
1811
+ return {
1812
+ text: cellText,
1813
+ options: {
1814
+ ...textStyle,
1815
+ fill: fill || { color: '16A085', transparency: 80 },
1816
+ bold: true,
1817
+ align: style.textAlign === 'center' ? 'center' : 'left',
1818
+ border: { type: 'none' }
1819
+ }
1820
+ };
1821
+ });
1822
+ rows.push(headerData);
1823
+ }
1824
+
1825
+ // Find data rows
1826
+ const dataRows = node.querySelectorAll('.table-row, [class*="row"]');
1827
+ dataRows.forEach(row => {
1828
+ const cells = Array.from(row.children);
1829
+ const rowData = cells.map(cell => {
1830
+ const style = window.getComputedStyle(cell);
1831
+ const cellText = cell.innerText.replace(/[\n\r\t]+/g, ' ').trim();
1832
+ const textStyle = getTextStyle(style, scale, cellText, options);
1833
+ const fill = computeTableCellFill(style, cell, root, options);
1834
+
1835
+ // Detect trend colors
1836
+ const className = (cell.className || '').toLowerCase();
1837
+ let textColor = textStyle.color;
1838
+ if (className.includes('up') || className.includes('positive')) {
1839
+ textColor = '16A085';
1840
+ } else if (className.includes('down') || className.includes('negative')) {
1841
+ textColor = 'E74C3C';
1842
+ }
1843
+
1844
+ return {
1845
+ text: cellText,
1846
+ options: {
1847
+ ...textStyle,
1848
+ color: textColor,
1849
+ fill: fill,
1850
+ align: style.textAlign === 'center' ? 'center' : 'left',
1851
+ border: {
1852
+ type: 'none',
1853
+ bottom: { type: 'solid', pt: 0.5, color: 'FFFFFF', transparency: 95 }
1854
+ }
1855
+ }
1856
+ };
1857
+ });
1858
+
1859
+ if (rowData.length > 0) {
1860
+ rows.push(rowData);
1861
+ }
1862
+ });
1863
+
1864
+ // Ensure all rows have same column count
1865
+ const maxCols = Math.max(...rows.map(r => r.length), colWidths.length);
1866
+ rows.forEach(row => {
1867
+ while (row.length < maxCols) {
1868
+ row.push({ text: '', options: {} });
1869
+ }
1870
+ });
1871
+ while (colWidths.length < maxCols) {
1872
+ colWidths.push(colWidths[colWidths.length - 1] || 1);
1873
+ }
1874
+
1875
+ return { rows, colWidths };
1876
+ }