@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.
@@ -12981,7 +12981,8 @@
12981
12981
  bodyProperties += ' rtlCol="0"';
12982
12982
  // D: Add anchorPoints
12983
12983
  if (slideObject.options._bodyProp.anchor)
12984
- bodyProperties += ' anchor="' + slideObject.options._bodyProp.anchor + '"'; // VALS: [t,ctr,b]
12984
+ bodyProperties += ' anchor="' + slideObject.options._bodyProp.anchor + '"'; // VALS: [t,ctr,b]
12985
+ if (slideObject.options._bodyProp.anchor === 'ctr') bodyProperties += ' anchorCtr=\"1\"';
12985
12986
  if (slideObject.options._bodyProp.vert)
12986
12987
  bodyProperties += ' vert="' + slideObject.options._bodyProp.vert + '"'; // VALS: [eaVert,horz,mongolianVert,vert,vert270,wordArtVert,wordArtVertRtl]
12987
12988
  // E: Close <a:bodyPr element
@@ -62520,6 +62521,7 @@
62520
62521
  bold: textStyle.bold,
62521
62522
  italic: textStyle.italic,
62522
62523
  underline: textStyle.underline,
62524
+ lang: 'zh-CN',
62523
62525
 
62524
62526
  fill: fill,
62525
62527
  align: align,
@@ -62594,11 +62596,11 @@
62594
62596
  // 3. Find first part that is a color (skip angle/direction)
62595
62597
  for (const part of parts) {
62596
62598
  // Ignore directions (to right) or angles (90deg, 0.5turn)
62597
- if (/^(to\s|[\d\.]+(deg|rad|turn|grad))/.test(part)) continue;
62599
+ if (/^(to\s|[\d.]+(deg|rad|turn|grad))/.test(part)) continue;
62598
62600
 
62599
62601
  // Extract color: Remove trailing position (e.g. "red 50%" -> "red")
62600
62602
  // Regex matches whitespace + number + unit at end of string
62601
- const colorPart = part.replace(/\s+(-?[\d\.]+(%|px|em|rem|ch|vh|vw)?)$/, '');
62603
+ const colorPart = part.replace(/\s+(-?[\d.]+(%|px|em|rem|ch|vh|vw)?)$/, '');
62602
62604
 
62603
62605
  // Check if it's not just a number (some gradients might have bare numbers? unlikely in standard syntax)
62604
62606
  if (colorPart) return colorPart;
@@ -62869,6 +62871,214 @@
62869
62871
  return { hex, opacity: a };
62870
62872
  }
62871
62873
 
62874
+ const SUPPORTED_CHART_TYPES = new Set(['area', 'bar', 'line', 'pie', 'doughnut', 'radar', 'scatter']);
62875
+ const CHART_TYPE_ALIASES = {
62876
+ column: 'bar',
62877
+ columnbar: 'bar',
62878
+ column3d: 'bar',
62879
+ bar3d: 'bar',
62880
+ donut: 'doughnut',
62881
+ ring: 'doughnut',
62882
+ };
62883
+
62884
+ function normalizeLegendPos(value) {
62885
+ if (!value) return null;
62886
+ const normalized = value.toString().toLowerCase().replace(/[^a-z]/g, '');
62887
+ if (normalized === 'tr' || normalized === 'topright') return 'tr';
62888
+ if (normalized === 'top') return 't';
62889
+ if (normalized === 'bottom') return 'b';
62890
+ if (normalized === 'left') return 'l';
62891
+ if (normalized === 'right') return 'r';
62892
+ return null;
62893
+ }
62894
+
62895
+ function normalizeChartType(value) {
62896
+ if (!value) return null;
62897
+ const raw = value.toString().trim().toLowerCase();
62898
+ const alias = CHART_TYPE_ALIASES[raw];
62899
+ const candidate = alias || raw;
62900
+ return SUPPORTED_CHART_TYPES.has(candidate) ? candidate : null;
62901
+ }
62902
+
62903
+ function colorToHex(value) {
62904
+ const parsed = parseColor(value);
62905
+ return parsed.hex || null;
62906
+ }
62907
+
62908
+ function resolveChartElement(root, config) {
62909
+ if (!config) return null;
62910
+ if (config.element instanceof HTMLElement) return config.element;
62911
+ if (typeof config.element === 'function') return config.element(root);
62912
+ if (typeof config.selector === 'string') {
62913
+ return root.querySelector(config.selector);
62914
+ }
62915
+ return null;
62916
+ }
62917
+
62918
+ function buildSeriesFromSource(source = {}) {
62919
+ const fallbackLabels = Array.isArray(source.labels) ? source.labels : [];
62920
+ const candidateDatasets = Array.isArray(source.datasets) ? source.datasets : [];
62921
+ const series = [];
62922
+
62923
+ candidateDatasets.forEach((dataset, index) => {
62924
+ if (!dataset) return;
62925
+ const values = Array.isArray(dataset.values)
62926
+ ? dataset.values
62927
+ : Array.isArray(dataset.data)
62928
+ ? dataset.data
62929
+ : [];
62930
+
62931
+ if (!values.length) return;
62932
+
62933
+ const record = {
62934
+ name: dataset.name || dataset.label || `Series ${index + 1}`,
62935
+ values,
62936
+ };
62937
+
62938
+ if (Array.isArray(dataset.labels) && dataset.labels.length === values.length) {
62939
+ record.labels = dataset.labels;
62940
+ } else if (fallbackLabels.length === values.length) {
62941
+ record.labels = fallbackLabels;
62942
+ }
62943
+
62944
+ if (Array.isArray(dataset.sizes) && dataset.sizes.length === values.length) {
62945
+ record.sizes = dataset.sizes;
62946
+ }
62947
+
62948
+ // Chart.js often provides strings/arrays in backgroundColor
62949
+ const candidateColor =
62950
+ dataset.color || dataset.backgroundColor || dataset.borderColor || dataset.fillColor;
62951
+ if (candidateColor) {
62952
+ if (Array.isArray(candidateColor)) {
62953
+ record.color = candidateColor[0];
62954
+ } else {
62955
+ record.color = candidateColor;
62956
+ }
62957
+ }
62958
+
62959
+ series.push(record);
62960
+ });
62961
+
62962
+ if (!series.length) {
62963
+ const fallbackValues = Array.isArray(source.values)
62964
+ ? source.values
62965
+ : Array.isArray(source.data)
62966
+ ? source.data
62967
+ : [];
62968
+
62969
+ if (fallbackValues.length) {
62970
+ const fallbackRecord = {
62971
+ name: source.name || source.label || 'Series 1',
62972
+ values: fallbackValues,
62973
+ };
62974
+ if (Array.isArray(source.labels) && source.labels.length === fallbackValues.length) {
62975
+ fallbackRecord.labels = source.labels;
62976
+ } else if (fallbackLabels.length === fallbackValues.length) {
62977
+ fallbackRecord.labels = fallbackLabels;
62978
+ }
62979
+ if (Array.isArray(source.sizes) && source.sizes.length === fallbackValues.length) {
62980
+ fallbackRecord.sizes = source.sizes;
62981
+ }
62982
+ if (source.color) {
62983
+ fallbackRecord.color = source.color;
62984
+ }
62985
+ series.push(fallbackRecord);
62986
+ }
62987
+ }
62988
+
62989
+ return series;
62990
+ }
62991
+
62992
+ function buildChartOptions(raw, derivedColors) {
62993
+ const opts = { ...((raw && raw.chartOptions) || {}) };
62994
+ if (raw && raw.showLegend !== undefined) opts.showLegend = raw.showLegend;
62995
+ if (raw) {
62996
+ const legendTarget = raw.legendPos || raw.legendPosition;
62997
+ const normalizedLegend = normalizeLegendPos(legendTarget);
62998
+ if (normalizedLegend) opts.legendPos = normalizedLegend;
62999
+ }
63000
+ if (raw && raw.title) opts.title = raw.title;
63001
+ if (raw && raw.altText) opts.altText = raw.altText;
63002
+ if (raw && raw.showDataTable !== undefined) opts.showDataTable = raw.showDataTable;
63003
+ if (raw && raw.chartColorsOpacity !== undefined) opts.chartColorsOpacity = raw.chartColorsOpacity;
63004
+ if (raw && raw.showLabel !== undefined) opts.showLabel = raw.showLabel;
63005
+
63006
+ const paletteFromConfig =
63007
+ raw && Array.isArray(raw.chartColors) ? raw.chartColors.map(colorToHex).filter(Boolean) : [];
63008
+ const palette = paletteFromConfig.length ? paletteFromConfig : derivedColors;
63009
+ if (palette.length && !opts.chartColors) {
63010
+ opts.chartColors = palette;
63011
+ }
63012
+
63013
+ return opts;
63014
+ }
63015
+
63016
+ function normalizeChartConfig(raw) {
63017
+ if (!raw) return null;
63018
+ const chartType = normalizeChartType(raw.chartType || raw.type || raw.chart);
63019
+ if (!chartType) return null;
63020
+
63021
+ let seriesWithColor = [];
63022
+ if (Array.isArray(raw.chartData) && raw.chartData.length) {
63023
+ seriesWithColor = raw.chartData
63024
+ .map((entry, index) => {
63025
+ if (!entry || !Array.isArray(entry.values)) return null;
63026
+ return {
63027
+ ...entry,
63028
+ name: entry.name || entry.label || `Series ${index + 1}`,
63029
+ };
63030
+ })
63031
+ .filter((entry) => entry && entry.values && entry.values.length);
63032
+ } else {
63033
+ const source = {
63034
+ labels: raw.data?.labels || raw.labels,
63035
+ datasets: Array.isArray(raw.data?.datasets)
63036
+ ? raw.data.datasets
63037
+ : Array.isArray(raw.datasets)
63038
+ ? raw.datasets
63039
+ : [],
63040
+ values: raw.data?.values || raw.values,
63041
+ data: raw.data?.data,
63042
+ name: raw.name,
63043
+ label: raw.label,
63044
+ color: raw.color,
63045
+ sizes: raw.data?.sizes || raw.sizes,
63046
+ };
63047
+ seriesWithColor = buildSeriesFromSource(source);
63048
+ }
63049
+
63050
+ if (!seriesWithColor.length) return null;
63051
+
63052
+ const derivedColors = seriesWithColor
63053
+ .map((dataset) => dataset.color)
63054
+ .map(colorToHex)
63055
+ .filter(Boolean);
63056
+
63057
+ const chartOptions = buildChartOptions(raw, derivedColors);
63058
+ const chartData = seriesWithColor.map(({ color, ...rest }) => rest);
63059
+
63060
+ return {
63061
+ chartType,
63062
+ chartData,
63063
+ chartOptions,
63064
+ };
63065
+ }
63066
+
63067
+ function buildChartRegistry(root, chartConfigs = []) {
63068
+ const registry = new Map();
63069
+ if (!root || !Array.isArray(chartConfigs)) return registry;
63070
+
63071
+ chartConfigs.forEach((config) => {
63072
+ const node = resolveChartElement(root, config);
63073
+ if (!node) return;
63074
+ const normalized = normalizeChartConfig(config);
63075
+ if (!normalized || !normalized.chartData || !normalized.chartData.length) return;
63076
+ registry.set(node, normalized);
63077
+ });
63078
+
63079
+ return registry;
63080
+ }
63081
+
62872
63082
  function getPadding(style, scale) {
62873
63083
  const pxToInch = 1 / 96;
62874
63084
  return [
@@ -62887,6 +63097,8 @@
62887
63097
  }
62888
63098
 
62889
63099
  const DEFAULT_CJK_FONTS = [
63100
+ 'Heiti TC',
63101
+ 'Heiti SC',
62890
63102
  'PingFang SC',
62891
63103
  'Hiragino Sans GB',
62892
63104
  'Microsoft YaHei',
@@ -62938,7 +63150,7 @@
62938
63150
  }
62939
63151
 
62940
63152
  const autoMatch = DEFAULT_CJK_FONTS.find((f) => lowered.includes(f.toLowerCase()));
62941
- return autoMatch || primary;
63153
+ return autoMatch || 'Heiti TC';
62942
63154
  }
62943
63155
 
62944
63156
  function getTextStyle(style, scale, text = '', options = {}) {
@@ -62969,6 +63181,9 @@
62969
63181
  // And apply the global layout scale.
62970
63182
  lineSpacing = lhPx * 0.75 * scale;
62971
63183
  }
63184
+ } else {
63185
+ // Default line height when 'normal' - use 1.2 multiplier to match browser default
63186
+ lineSpacing = fontSizePx * 1.2 * 0.75 * scale;
62972
63187
  }
62973
63188
 
62974
63189
  // --- Spacing (Margins) ---
@@ -62987,10 +63202,11 @@
62987
63202
  return {
62988
63203
  color: colorObj.hex || '000000',
62989
63204
  fontFace: fontFace,
62990
- fontSize: Math.floor(fontSizePx * 0.75 * scale),
63205
+ fontSize: Math.max(8, Math.floor(fontSizePx * 0.75 * scale)),
62991
63206
  bold: parseInt(style.fontWeight) >= 600,
62992
63207
  italic: style.fontStyle === 'italic',
62993
63208
  underline: style.textDecoration.includes('underline'),
63209
+ lang: 'zh-CN',
62994
63210
  // Only add if we have a valid value
62995
63211
  ...(lineSpacing && { lineSpacing }),
62996
63212
  ...(paraSpaceBefore > 0 && { paraSpaceBefore }),
@@ -63003,6 +63219,7 @@
63003
63219
  /**
63004
63220
  * Determines if a given DOM node is primarily a text container.
63005
63221
  * Updated to correctly reject Icon elements so they are rendered as images.
63222
+ * Also rejects flex/grid containers with distributed children (space-between/around/evenly).
63006
63223
  */
63007
63224
  function isTextContainer(node) {
63008
63225
  const hasText = node.textContent.trim().length > 0;
@@ -63011,6 +63228,26 @@
63011
63228
  const children = Array.from(node.children);
63012
63229
  if (children.length === 0) return true;
63013
63230
 
63231
+ // Check if parent is a flex/grid container with special layout
63232
+ // In such cases, children should be treated as separate elements
63233
+ const parentStyle = window.getComputedStyle(node);
63234
+ const display = parentStyle.display;
63235
+ const justifyContent = parentStyle.justifyContent || '';
63236
+ parentStyle.alignItems || '';
63237
+
63238
+ // If parent uses flex/grid with space distribution, don't treat as text container
63239
+ if ((display.includes('flex') || display.includes('grid')) &&
63240
+ (justifyContent.includes('space-between') ||
63241
+ justifyContent.includes('space-around') ||
63242
+ justifyContent.includes('space-evenly'))) {
63243
+ return false;
63244
+ }
63245
+
63246
+ // Note: We don't skip text container for align-items: center here.
63247
+ // The valign inheritance is handled in prepareRenderItem with single-child check.
63248
+ // This allows elements like "密钥状态流转" to properly inherit center alignment
63249
+ // while preventing STEP 1/2/3 from being incorrectly centered.
63250
+
63014
63251
  const isSafeInline = (el) => {
63015
63252
  // 1. Reject Web Components / Custom Elements
63016
63253
  if (el.tagName.includes('-')) return false;
@@ -63569,6 +63806,60 @@
63569
63806
  const PPI = 96;
63570
63807
  const PX_TO_INCH = 1 / PPI;
63571
63808
 
63809
+ /**
63810
+ * Fix Chinese font attributes (pitchFamily and charset) in PPTX XML.
63811
+ * PptxGenJS sometimes generates incorrect charset="0" for East Asian fonts.
63812
+ * @param {Blob} blob - The PPTX blob
63813
+ * @returns {Promise<Blob>} - Fixed PPTX blob
63814
+ */
63815
+ async function fixChineseFontAttributes(blob) {
63816
+ const zip = await JSZip.loadAsync(blob);
63817
+ const cjkFonts = ['Heiti TC', 'Heiti SC', 'PingFang SC', 'Microsoft YaHei', 'SimHei', 'SimSun'];
63818
+
63819
+ // Process all slide XML files
63820
+ const slideFiles = Object.keys(zip.files).filter(name =>
63821
+ name.startsWith('ppt/slides/slide') && name.endsWith('.xml')
63822
+ );
63823
+
63824
+ for (const slidePath of slideFiles) {
63825
+ let xml = await zip.file(slidePath).async('text');
63826
+ let modified = false;
63827
+
63828
+ // Fix <a:ea> and <a:cs> tags for CJK fonts
63829
+ // Replace tags that have charset="0" (incorrect for CJK) with proper values
63830
+ for (const fontName of cjkFonts) {
63831
+ // Fix <a:ea> - East Asian font
63832
+ const eaPattern = new RegExp(
63833
+ `<a:ea typeface="${fontName}"[^>]*>`,
63834
+ 'g'
63835
+ );
63836
+ xml = xml.replace(eaPattern, `<a:ea typeface="${fontName}" pitchFamily="34" charset="-122"/>`);
63837
+
63838
+ // Fix <a:cs> - Complex Script font
63839
+ const csPattern = new RegExp(
63840
+ `<a:cs typeface="${fontName}"[^>]*>`,
63841
+ 'g'
63842
+ );
63843
+ xml = xml.replace(csPattern, `<a:cs typeface="${fontName}" pitchFamily="34" charset="-120"/>`);
63844
+
63845
+ // Fix <a:latin> - Latin font (if it's a CJK font used as Latin)
63846
+ const latinPattern = new RegExp(
63847
+ `<a:latin typeface="${fontName}"[^>]*>`,
63848
+ 'g'
63849
+ );
63850
+ xml = xml.replace(latinPattern, `<a:latin typeface="${fontName}" pitchFamily="34" charset="0"/>`);
63851
+ }
63852
+ modified = true;
63853
+
63854
+ if (modified) {
63855
+ zip.file(slidePath, xml);
63856
+ }
63857
+ }
63858
+
63859
+ // Generate new blob
63860
+ return await zip.generateAsync({ type: 'blob' });
63861
+ }
63862
+
63572
63863
  /**
63573
63864
  * Main export function.
63574
63865
  * @param {HTMLElement | string | Array<HTMLElement | string>} target
@@ -63593,6 +63884,7 @@
63593
63884
  if (!PptxConstructor) throw new Error('PptxGenJS constructor not found.');
63594
63885
  const pptx = new PptxConstructor();
63595
63886
  pptx.layout = 'LAYOUT_16x9';
63887
+ pptx.language = 'zh-CN';
63596
63888
 
63597
63889
  const elements = Array.isArray(target) ? target : [target];
63598
63890
 
@@ -63667,6 +63959,9 @@
63667
63959
  finalBlob = await pptx.write({ outputType: 'blob' });
63668
63960
  }
63669
63961
 
63962
+ // Fix Chinese font attributes (pitchFamily and charset) in all slide XMLs
63963
+ finalBlob = await fixChineseFontAttributes(finalBlob);
63964
+
63670
63965
  // 4. Output Handling
63671
63966
  // If skipDownload is NOT true, proceed with browser download
63672
63967
  if (!options.skipDownload) {
@@ -63708,6 +64003,9 @@
63708
64003
  offY: (PPTX_HEIGHT_IN - contentHeightIn * scale) / 2,
63709
64004
  };
63710
64005
 
64006
+ const chartRegistry = buildChartRegistry(root, globalOptions.chartConfigs);
64007
+ const slideOptions = { ...globalOptions, chartRegistry };
64008
+
63711
64009
  const renderQueue = [];
63712
64010
  const asyncTasks = []; // Queue for heavy operations (Images, Canvas)
63713
64011
  let domOrderCounter = 0;
@@ -63743,7 +64041,7 @@
63743
64041
  pptx,
63744
64042
  currentZ,
63745
64043
  nodeStyle,
63746
- globalOptions
64044
+ slideOptions
63747
64045
  );
63748
64046
 
63749
64047
  if (result) {
@@ -63789,6 +64087,10 @@
63789
64087
  if (item.type === 'shape') slide.addShape(item.shapeType, item.options);
63790
64088
  if (item.type === 'image') slide.addImage(item.options);
63791
64089
  if (item.type === 'text') slide.addText(item.textParts, item.options);
64090
+ if (item.type === 'chart') {
64091
+ const chartType = PptxGenJS.ChartType?.[item.chartType] ?? item.chartType;
64092
+ slide.addChart(chartType, item.chartData, item.options);
64093
+ }
63792
64094
  if (item.type === 'table') {
63793
64095
  slide.addTable(item.tableData.rows, {
63794
64096
  x: item.options.x,
@@ -63989,13 +64291,23 @@
63989
64291
  range.detach();
63990
64292
 
63991
64293
  const style = window.getComputedStyle(parent);
63992
- const widthPx = rect.width;
63993
- const heightPx = rect.height;
64294
+ // Use parent element's rect for better width calculation
64295
+ // This is especially important for flex children where text node rect may be too narrow
64296
+ const parentRect = parent.getBoundingClientRect();
64297
+ const useParentRect = parentRect.width > rect.width;
64298
+ let widthPx = useParentRect ? parentRect.width : rect.width;
64299
+ const heightPx = useParentRect ? parentRect.height : rect.height;
64300
+
64301
+ // Add extra width buffer to prevent text wrapping in PPTX due to font rendering differences
64302
+ // Chinese characters and certain fonts need more space in PPTX than in browser
64303
+ widthPx = widthPx * 1.3; // Add 30% extra width
63994
64304
  const unrotatedW = widthPx * PX_TO_INCH * config.scale;
63995
64305
  const unrotatedH = heightPx * PX_TO_INCH * config.scale;
63996
64306
 
63997
- const x = config.offX + (rect.left - config.rootX) * PX_TO_INCH * config.scale;
63998
- const y = config.offY + (rect.top - config.rootY) * PX_TO_INCH * config.scale;
64307
+ // Use parent rect's position if we're using parent dimensions
64308
+ const sourceRect = useParentRect ? parentRect : rect;
64309
+ const x = config.offX + (sourceRect.left - config.rootX) * PX_TO_INCH * config.scale;
64310
+ const y = config.offY + (sourceRect.top - config.rootY) * PX_TO_INCH * config.scale;
63999
64311
 
64000
64312
  return {
64001
64313
  items: [
@@ -64034,7 +64346,7 @@
64034
64346
  const elementOpacity = parseFloat(style.opacity);
64035
64347
  const safeOpacity = isNaN(elementOpacity) ? 1 : elementOpacity;
64036
64348
 
64037
- const widthPx = node.offsetWidth || rect.width;
64349
+ let widthPx = node.offsetWidth || rect.width;
64038
64350
  const heightPx = node.offsetHeight || rect.height;
64039
64351
  const unrotatedW = widthPx * PX_TO_INCH * config.scale;
64040
64352
  const unrotatedH = heightPx * PX_TO_INCH * config.scale;
@@ -64048,8 +64360,41 @@
64048
64360
 
64049
64361
  const items = [];
64050
64362
 
64051
- if (node.tagName === 'TABLE') {
64052
- const tableData = extractTableData(node, config.scale, { ...globalOptions, root: config.root });
64363
+ const chartConfig = globalOptions.chartRegistry?.get(node);
64364
+ if (chartConfig) {
64365
+ const chartOptions = {
64366
+ ...chartConfig.chartOptions,
64367
+ x,
64368
+ y,
64369
+ w: unrotatedW,
64370
+ h: unrotatedH,
64371
+ };
64372
+
64373
+ return {
64374
+ items: [
64375
+ {
64376
+ type: 'chart',
64377
+ zIndex: effectiveZIndex,
64378
+ domOrder,
64379
+ chartType: chartConfig.chartType,
64380
+ chartData: chartConfig.chartData,
64381
+ options: chartOptions,
64382
+ },
64383
+ ],
64384
+ stopRecursion: true,
64385
+ };
64386
+ }
64387
+
64388
+ // --- Handle both native TABLE and div-based table structures ---
64389
+ const isTableLike = node.tagName === 'TABLE' || detectTableLikeStructure(node);
64390
+ if (isTableLike) {
64391
+ let tableData;
64392
+ if (node.tagName === 'TABLE') {
64393
+ tableData = extractTableData(node, config.scale, { ...globalOptions, root: config.root });
64394
+ } else {
64395
+ // Extract data from div-based table structure
64396
+ tableData = extractDivTableData(node, config.scale, { ...globalOptions, root: config.root });
64397
+ }
64053
64398
  const cellBgItems = [];
64054
64399
  const renderCellBg = globalOptions.tableConfig?.renderCellBackgrounds !== false;
64055
64400
 
@@ -64393,6 +64738,35 @@
64393
64738
  return { items: [item], job, stopRecursion: true };
64394
64739
  }
64395
64740
 
64741
+ // --- Handle vertical stat cards (like .mini-stat with number + label) ---
64742
+ if (isVerticalStatCard(node)) {
64743
+ // Capture the entire stat card as an image to preserve vertical layout
64744
+ const item = {
64745
+ type: 'image',
64746
+ zIndex,
64747
+ domOrder,
64748
+ options: { x, y, w, h, rotate: rotation, data: null },
64749
+ };
64750
+ const job = async () => {
64751
+ const pngData = await elementToCanvasImage(node, widthPx, heightPx);
64752
+ if (pngData) item.options.data = pngData;
64753
+ else item.skip = true;
64754
+ };
64755
+ return { items: [item], job, stopRecursion: true };
64756
+ }
64757
+
64758
+ // --- Handle absolutely positioned children that overflow parent ---
64759
+ // Check if this element has absolutely positioned children that extend outside
64760
+ const overflowingChildren = detectOverflowingChildren(node);
64761
+ if (overflowingChildren.length > 0) {
64762
+ // Process this node normally, but also capture overflowing children separately
64763
+ const baseResult = processOverflowingContent(
64764
+ node, overflowingChildren, config, domOrder, pptx, zIndex);
64765
+ if (baseResult) {
64766
+ return baseResult;
64767
+ }
64768
+ }
64769
+
64396
64770
  // Radii logic
64397
64771
  const borderRadiusValue = parseFloat(style.borderRadius) || 0;
64398
64772
  const borderBottomLeftRadius = parseFloat(style.borderBottomLeftRadius) || 0;
@@ -64495,6 +64869,27 @@
64495
64869
  let textPayload = null;
64496
64870
  const isText = isTextContainer(node);
64497
64871
 
64872
+ // Add extra width buffer for inline text elements to prevent wrapping in PPTX
64873
+ // This is especially needed for Chinese text and certain fonts
64874
+ // Only apply to elements that are direct children of flex containers with space distribution
64875
+ // and don't have a parent with visible background (to avoid overflowing card boundaries)
64876
+ if (isText && !bgColorObj.hex && !hasBorder && !hasShadow) {
64877
+ const parentEl = node.parentElement;
64878
+ if (parentEl) {
64879
+ const parentStyle = window.getComputedStyle(parentEl);
64880
+ const parentDisplay = parentStyle.display || '';
64881
+ const parentJustify = parentStyle.justifyContent || '';
64882
+ const isFlexWithSpaceDist = (parentDisplay.includes('flex') || parentDisplay.includes('grid')) &&
64883
+ (parentJustify.includes('space-between') || parentJustify.includes('space-around') || parentJustify.includes('space-evenly'));
64884
+
64885
+ if (isFlexWithSpaceDist) {
64886
+ widthPx = widthPx * 1.3; // Add 30% extra width
64887
+ // Recalculate w with the new width
64888
+ w = widthPx * PX_TO_INCH * config.scale;
64889
+ }
64890
+ }
64891
+ }
64892
+
64498
64893
  if (isText) {
64499
64894
  const textParts = [];
64500
64895
  let trimNextLeading = false;
@@ -64551,8 +64946,71 @@
64551
64946
  let align = style.textAlign || 'left';
64552
64947
  if (align === 'start') align = 'left';
64553
64948
  if (align === 'end') align = 'right';
64949
+
64950
+ // Fix: If this element is a flex/grid child and has no explicit text-align,
64951
+ // force left alignment to match HTML default behavior
64952
+ if (node.parentElement && (!style.textAlign || style.textAlign === 'start')) {
64953
+ const parentStyle = window.getComputedStyle(node.parentElement);
64954
+ if (parentStyle.display.includes('flex') || parentStyle.display.includes('grid')) {
64955
+ align = 'left';
64956
+ }
64957
+ }
64958
+
64959
+ // Detect badge/pill buttons (high border-radius + short text) and auto-center them
64960
+ const borderRadius = parseFloat(style.borderRadius) || 0;
64961
+ const height = parseFloat(style.height) || node.offsetHeight;
64962
+ const textContent = node.textContent.trim();
64963
+ const className = (node.className || '').toLowerCase();
64964
+
64965
+ // Real badges/pills typically have visible styling (background, border, or large border-radius)
64966
+ const hasVisibleBackground = bgColorObj.hex && bgColorObj.opacity > 0.1;
64967
+ const hasVisibleBorder = borderWidth > 0;
64968
+ const hasLargeBorderRadius = borderRadius >= height / 2;
64969
+ const hasBadgeClass = className.includes('badge') || className.includes('pill');
64970
+
64971
+ // Only consider it a badge if it has visual styling AND short text
64972
+ const isLikelyBadge =
64973
+ (hasLargeBorderRadius || hasBadgeClass) &&
64974
+ textContent.length <= 10 &&
64975
+ (hasVisibleBackground || hasVisibleBorder || hasLargeBorderRadius);
64976
+
64977
+ if (isLikelyBadge) {
64978
+ align = 'center';
64979
+ }
64980
+
64554
64981
  let valign = 'top';
64555
- if (style.alignItems === 'center') valign = 'middle';
64982
+
64983
+ // For flex items, valign should be determined by PARENT's align-items, not self
64984
+ // Self's align-items controls how children are aligned, not self's position in parent
64985
+ const parentEl = node.parentElement;
64986
+ if (parentEl) {
64987
+ const parentStyle = window.getComputedStyle(parentEl);
64988
+ if (parentStyle.display.includes('flex')) {
64989
+ // Parent's align-items controls this element's cross-axis alignment
64990
+ if (parentStyle.alignItems === 'center') {
64991
+ valign = 'middle';
64992
+ } else if (parentStyle.alignItems === 'flex-end' || parentStyle.alignItems === 'end') {
64993
+ valign = 'bottom';
64994
+ }
64995
+ // Default (stretch, flex-start) keeps valign = 'top'
64996
+ }
64997
+ }
64998
+
64999
+ // If element is not a flex item (no flex parent), then its own align-items
65000
+ // might indicate self-centering intent for single-element containers
65001
+ if (valign === 'top' && style.alignItems === 'center' && !parentEl) {
65002
+ valign = 'middle';
65003
+ }
65004
+
65005
+ // Auto-center vertically for likely badges
65006
+ if (isLikelyBadge && valign !== 'middle') {
65007
+ const paddingTop = parseFloat(style.paddingTop) || 0;
65008
+ const paddingBottom = parseFloat(style.paddingBottom) || 0;
65009
+ // If padding is relatively even, assume vertical centering is intended
65010
+ if (Math.abs(paddingTop - paddingBottom) <= 4) {
65011
+ valign = 'middle';
65012
+ }
65013
+ }
64556
65014
  if (style.justifyContent === 'center' && style.display.includes('flex')) align = 'center';
64557
65015
 
64558
65016
  const pt = parseFloat(style.paddingTop) || 0;
@@ -64912,6 +65370,283 @@
64912
65370
  return items;
64913
65371
  }
64914
65372
 
65373
+ /**
65374
+ * Detect absolutely positioned children that overflow their parent
65375
+ * This handles cases like chart value labels positioned above bars
65376
+ */
65377
+ function detectOverflowingChildren(node) {
65378
+ const overflowing = [];
65379
+ const parentRect = node.getBoundingClientRect();
65380
+
65381
+ // Only check for elements with specific class patterns that suggest chart values
65382
+ const children = node.querySelectorAll('.bar-value, [class*="value"], [class*="label"]');
65383
+
65384
+ children.forEach(child => {
65385
+ const childStyle = window.getComputedStyle(child);
65386
+ const childRect = child.getBoundingClientRect();
65387
+
65388
+ // Check if absolutely positioned and outside parent bounds
65389
+ if (childStyle.position === 'absolute') {
65390
+ const isOutside =
65391
+ childRect.bottom < parentRect.top ||
65392
+ childRect.top > parentRect.bottom ||
65393
+ childRect.right < parentRect.left ||
65394
+ childRect.left > parentRect.right;
65395
+
65396
+ if (isOutside || childRect.top < parentRect.top) {
65397
+ overflowing.push(child);
65398
+ }
65399
+ }
65400
+ });
65401
+
65402
+ return overflowing;
65403
+ }
65404
+
65405
+ /**
65406
+ * Process content with overflowing children
65407
+ * Captures the entire visual area including overflowing elements
65408
+ */
65409
+ function processOverflowingContent(node, overflowingChildren, config, domOrder, pptx, zIndex, style, globalOptions) {
65410
+ // Calculate bounding box that includes all overflowing children
65411
+ const rect = node.getBoundingClientRect();
65412
+ let minX = rect.left;
65413
+ let minY = rect.top;
65414
+ let maxX = rect.right;
65415
+ let maxY = rect.bottom;
65416
+
65417
+ overflowingChildren.forEach(child => {
65418
+ const childRect = child.getBoundingClientRect();
65419
+ minX = Math.min(minX, childRect.left);
65420
+ minY = Math.min(minY, childRect.top);
65421
+ maxX = Math.max(maxX, childRect.right);
65422
+ maxY = Math.max(maxY, childRect.bottom);
65423
+ });
65424
+
65425
+ const totalWidth = maxX - minX;
65426
+ const totalHeight = maxY - minY;
65427
+
65428
+ // Use html2canvas to capture the entire area
65429
+ const item = {
65430
+ type: 'image',
65431
+ zIndex,
65432
+ domOrder,
65433
+ options: {
65434
+ x: config.offX + (minX - config.rootX) * PX_TO_INCH * config.scale,
65435
+ y: config.offY + (minY - config.rootY) * PX_TO_INCH * config.scale,
65436
+ w: totalWidth * PX_TO_INCH * config.scale,
65437
+ h: totalHeight * PX_TO_INCH * config.scale,
65438
+ data: null
65439
+ }
65440
+ };
65441
+
65442
+ const job = async () => {
65443
+ try {
65444
+ // Temporarily adjust node position for capture
65445
+ const originalPosition = node.style.position;
65446
+ const originalTransform = node.style.transform;
65447
+
65448
+ node.style.position = 'relative';
65449
+ node.style.transform = 'none';
65450
+
65451
+ const canvas = await html2canvas(node, {
65452
+ backgroundColor: null,
65453
+ logging: false,
65454
+ scale: 2,
65455
+ useCORS: true,
65456
+ x: minX - rect.left,
65457
+ y: minY - rect.top,
65458
+ width: totalWidth,
65459
+ height: totalHeight
65460
+ });
65461
+
65462
+ // Restore original styles
65463
+ node.style.position = originalPosition;
65464
+ node.style.transform = originalTransform;
65465
+
65466
+ item.options.data = canvas.toDataURL('image/png');
65467
+ } catch (e) {
65468
+ console.warn('Failed to capture overflowing content:', e);
65469
+ item.skip = true;
65470
+ }
65471
+ };
65472
+
65473
+ return { items: [item], job, stopRecursion: true };
65474
+ }
65475
+
65476
+ /**
65477
+ * Detect vertical stat cards (like .mini-stat with number above label)
65478
+ * These have block-level children that should stack vertically
65479
+ */
65480
+ function isVerticalStatCard(node) {
65481
+ const className = (node.className || '').toLowerCase();
65482
+
65483
+ // Check for stat-like class names
65484
+ const statIndicators = ['stat', 'metric', 'kpi', 'data-item', 'stat-item'];
65485
+ const hasStatClass = statIndicators.some(indicator => className.includes(indicator));
65486
+
65487
+ if (!hasStatClass) return false;
65488
+
65489
+ const children = Array.from(node.children);
65490
+ if (children.length !== 2) return false;
65491
+
65492
+ // Check if children are likely number + label pair
65493
+ const child1 = children[0];
65494
+ const child2 = children[1];
65495
+
65496
+ const style1 = window.getComputedStyle(child1);
65497
+ const style2 = window.getComputedStyle(child2);
65498
+
65499
+ // Both should be block elements (or have block-like display)
65500
+ const isBlock1 = style1.display === 'block' || style1.display === 'flex';
65501
+ const isBlock2 = style2.display === 'block' || style2.display === 'flex';
65502
+
65503
+ if (!isBlock1 || !isBlock2) return false;
65504
+
65505
+ // First child should have larger font (the number)
65506
+ const fontSize1 = parseFloat(style1.fontSize) || 0;
65507
+ const fontSize2 = parseFloat(style2.fontSize) || 0;
65508
+
65509
+ // Number should be larger than label, or at least bold
65510
+ const isBold1 = parseInt(style1.fontWeight) >= 600;
65511
+
65512
+ return fontSize1 >= fontSize2 || isBold1;
65513
+ }
65514
+
65515
+ /**
65516
+ * Detect if a div structure looks like a table
65517
+ * Checks for table-like class names and structure patterns
65518
+ */
65519
+ function detectTableLikeStructure(node) {
65520
+ const className = (node.className || '').toLowerCase();
65521
+
65522
+ // Check for table-related class names
65523
+ const tableIndicators = ['table', 'data-table', 'grid', 'list'];
65524
+ const hasTableClass = tableIndicators.some(indicator => className.includes(indicator));
65525
+
65526
+ if (!hasTableClass) return false;
65527
+
65528
+ // Check structure: should have header row and data rows
65529
+ const children = Array.from(node.children);
65530
+
65531
+ // Look for header-like element
65532
+ const hasHeader = children.some(child =>
65533
+ (child.className || '').toLowerCase().includes('header') ||
65534
+ child.tagName === 'THEAD'
65535
+ );
65536
+
65537
+ // Look for row-like elements
65538
+ const hasRows = children.some(child =>
65539
+ (child.className || '').toLowerCase().includes('row') ||
65540
+ child.tagName === 'TR' ||
65541
+ child.tagName === 'TBODY'
65542
+ );
65543
+
65544
+ // Check for cell-like structure in children
65545
+ const hasCellStructure = children.some(child => {
65546
+ const childChildren = Array.from(child.children);
65547
+ return childChildren.some(grandchild =>
65548
+ (grandchild.className || '').toLowerCase().includes('cell') ||
65549
+ grandchild.className.toLowerCase().includes('col') ||
65550
+ grandchild.className.toLowerCase().includes('td')
65551
+ );
65552
+ });
65553
+
65554
+ return (hasHeader || hasRows) && hasCellStructure;
65555
+ }
65556
+
65557
+ /**
65558
+ * Extract table data from div-based table structure
65559
+ * Similar to extractTableData but for div grids
65560
+ */
65561
+ function extractDivTableData(node, scale, options = {}) {
65562
+ const rows = [];
65563
+ const colWidths = [];
65564
+ const root = options.root || null;
65565
+
65566
+ // Find header row
65567
+ const headerRow = node.querySelector('.table-header, [class*="header"]');
65568
+ if (headerRow) {
65569
+ const headerCells = Array.from(headerRow.children);
65570
+ headerCells.forEach((cell, index) => {
65571
+ const rect = cell.getBoundingClientRect();
65572
+ const wIn = rect.width * (1 / 96) * scale;
65573
+ colWidths[index] = wIn;
65574
+ });
65575
+
65576
+ // Process header as first row
65577
+ const headerData = headerCells.map(cell => {
65578
+ const style = window.getComputedStyle(cell);
65579
+ const cellText = cell.innerText.replace(/[\n\r\t]+/g, ' ').trim();
65580
+ const textStyle = getTextStyle(style, scale, cellText, options);
65581
+ const fill = computeTableCellFill(style, cell, root, options);
65582
+
65583
+ return {
65584
+ text: cellText,
65585
+ options: {
65586
+ ...textStyle,
65587
+ fill: fill || { color: '16A085', transparency: 80 },
65588
+ bold: true,
65589
+ align: style.textAlign === 'center' ? 'center' : 'left',
65590
+ border: { type: 'none' }
65591
+ }
65592
+ };
65593
+ });
65594
+ rows.push(headerData);
65595
+ }
65596
+
65597
+ // Find data rows
65598
+ const dataRows = node.querySelectorAll('.table-row, [class*="row"]');
65599
+ dataRows.forEach(row => {
65600
+ const cells = Array.from(row.children);
65601
+ const rowData = cells.map(cell => {
65602
+ const style = window.getComputedStyle(cell);
65603
+ const cellText = cell.innerText.replace(/[\n\r\t]+/g, ' ').trim();
65604
+ const textStyle = getTextStyle(style, scale, cellText, options);
65605
+ const fill = computeTableCellFill(style, cell, root, options);
65606
+
65607
+ // Detect trend colors
65608
+ const className = (cell.className || '').toLowerCase();
65609
+ let textColor = textStyle.color;
65610
+ if (className.includes('up') || className.includes('positive')) {
65611
+ textColor = '16A085';
65612
+ } else if (className.includes('down') || className.includes('negative')) {
65613
+ textColor = 'E74C3C';
65614
+ }
65615
+
65616
+ return {
65617
+ text: cellText,
65618
+ options: {
65619
+ ...textStyle,
65620
+ color: textColor,
65621
+ fill: fill,
65622
+ align: style.textAlign === 'center' ? 'center' : 'left',
65623
+ border: {
65624
+ type: 'none',
65625
+ bottom: { type: 'solid', pt: 0.5, color: 'FFFFFF', transparency: 95 }
65626
+ }
65627
+ }
65628
+ };
65629
+ });
65630
+
65631
+ if (rowData.length > 0) {
65632
+ rows.push(rowData);
65633
+ }
65634
+ });
65635
+
65636
+ // Ensure all rows have same column count
65637
+ const maxCols = Math.max(...rows.map(r => r.length), colWidths.length);
65638
+ rows.forEach(row => {
65639
+ while (row.length < maxCols) {
65640
+ row.push({ text: '', options: {} });
65641
+ }
65642
+ });
65643
+ while (colWidths.length < maxCols) {
65644
+ colWidths.push(colWidths[colWidths.length - 1] || 1);
65645
+ }
65646
+
65647
+ return { rows, colWidths };
65648
+ }
65649
+
64915
65650
  exports.exportToPptx = exportToPptx;
64916
65651
 
64917
65652
  }));