import { createLogger } from '@sim/logger' const logger = createLogger('PptxChartRenderer') /** * Chart renderer — converts OOXML chart XML into ECharts visualizations. */ import * as echarts from 'echarts' import type { ChartNodeData } from '../model/nodes/chart-node' import type { SafeXmlNode } from '../parser/xml-parser' import type { RenderContext } from './render-context' import { resolveColor } from './style-resolver' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface SeriesData { name: string order: number // c:order val — used to sort series into correct sequence categories: string[] values: number[] xValues?: number[] // scatter chart x values from c:xVal bubbleSizes?: number[] // bubble chart sizes from c:bubbleSize colorHex?: string | object // optional explicit series color (hex string or ECharts gradient) dataPointColors?: (string | undefined)[] // per-point colors (for pie charts) formatCode?: string // numCache formatCode (e.g. "0%", "0.0%", "General") markerSymbol?: string // OOXML c:marker > c:symbol val markerSize?: number // OOXML c:marker > c:size val (points) smooth?: boolean // OOXML c:smooth val for scatter/line-like charts lineWidth?: number // c:spPr > a:ln@w converted to renderer px scale } /** Parsed axis information from plotArea valAx / catAx / dateAx. */ interface AxisInfo { deleted: boolean tickLblPos: string // 'nextTo' | 'none' | 'high' | 'low' numFmt?: string // formatCode from axis numFmt min?: number max?: number hasMajorGridlines: boolean orientation: string // 'minMax' | 'maxMin' labelColor?: string // hex color from txPr for axis labels labelFontSize?: number // px from txPr defRPr@sz lineColor?: string // hex color from spPr > ln for axis line } interface DataLabelConfig { showVal: boolean showCatName: boolean showSerName: boolean showPercent: boolean position?: string // 'outEnd', 'inEnd', 'ctr', 'bestFit' color?: string // text color from dLbls > txPr fontSize?: number // font size from dLbls > txPr > defRPr@sz bold?: boolean // font bold from dLbls > txPr > defRPr@b } type OoxmlChartType = | 'barChart' | 'bar3DChart' | 'lineChart' | 'line3DChart' | 'areaChart' | 'area3DChart' | 'pieChart' | 'pie3DChart' | 'doughnutChart' | 'radarChart' | 'scatterChart' | 'bubbleChart' | 'stockChart' | 'surface3DChart' // --------------------------------------------------------------------------- // Chart Type Mapping // --------------------------------------------------------------------------- const CHART_TYPE_ELEMENTS: OoxmlChartType[] = [ 'barChart', 'bar3DChart', 'lineChart', 'line3DChart', 'areaChart', 'area3DChart', 'pieChart', 'pie3DChart', 'doughnutChart', 'radarChart', 'scatterChart', 'bubbleChart', 'stockChart', 'surface3DChart', ] // --------------------------------------------------------------------------- // Data Extraction Helpers // --------------------------------------------------------------------------- /** * Extract text values from a strRef or strCache structure. * Path: strRef > strCache > pt (with idx attr) > v */ function extractStringValues(refNode: SafeXmlNode): string[] { const cache = refNode.child('strRef').exists() ? refNode.child('strRef').child('strCache') : refNode.child('strCache') if (!cache.exists()) { // Try numRef > numCache as fallback (categories can be numeric) const numCache = refNode.child('numRef').exists() ? refNode.child('numRef').child('numCache') : refNode.child('numCache') if (numCache.exists()) { return extractNumericValuesAsStrings(numCache) } return [] } const ptCount = cache.child('ptCount').numAttr('val') ?? 0 const values: string[] = new Array(ptCount).fill('') for (const pt of cache.children('pt')) { const idx = pt.numAttr('idx') if (idx !== undefined) { const v = pt.child('v').text() values[idx] = v } } return values } /** * Extract the formatCode from a numRef > numCache > formatCode structure. */ function extractFormatCode(refNode: SafeXmlNode): string | undefined { const cache = refNode.child('numRef').exists() ? refNode.child('numRef').child('numCache') : refNode.child('numCache') if (!cache.exists()) return undefined const fc = cache.child('formatCode') if (!fc.exists()) return undefined const text = fc.text() return text || undefined } /** * Format a numeric value according to its numCache formatCode. * Handles percentage formats (containing '%') and general numbers. */ function formatValue(value: number, formatCode: string | undefined): string { if (!formatCode || formatCode === 'General') { // No format or "General": show a sensible number of decimal places. // Avoid ugly long floats (e.g. 0.91509433962264153 → "0.92"). if (Number.isInteger(value)) return String(value) // Up to 2 decimal places, strip trailing zeros return Number.parseFloat(value.toFixed(2)).toString() } // Percentage format: the raw value is a fraction (e.g., 0.213 means 21.3%) if (formatCode.includes('%')) { // Determine decimal places from the format code const match = formatCode.match(/0\.(0+)%/) const decimals = match ? match[1].length : 0 const pctValue = value * 100 return `${pctValue.toFixed(decimals)}%` } // Numeric format with decimal places: e.g. "0.00", "#,##0.0" const decMatch = formatCode.match(/\.(0+|#+)/) if (decMatch) { const decimals = decMatch[1].length return Number.parseFloat(value.toFixed(decimals)).toString() } // Integer format: "0", "#,##0" if (/^[#0,]+$/.test(formatCode.replace(/[[\]"\\]/g, ''))) { return Math.round(value).toString() } // Fallback: reasonable precision if (Number.isInteger(value)) return String(value) return Number.parseFloat(value.toFixed(2)).toString() } /** * Extract numeric values from a numRef > numCache structure. */ function extractNumericValues(refNode: SafeXmlNode): number[] { const cache = refNode.child('numRef').exists() ? refNode.child('numRef').child('numCache') : refNode.child('numCache') if (!cache.exists()) return [] const ptCount = cache.child('ptCount').numAttr('val') ?? 0 const values: number[] = new Array(ptCount).fill(0) for (const pt of cache.children('pt')) { const idx = pt.numAttr('idx') if (idx !== undefined) { const v = Number.parseFloat(pt.child('v').text()) values[idx] = Number.isNaN(v) ? 0 : v } } return values } /** * Extract numeric cache values as strings (for category axis that uses numbers). */ function extractNumericValuesAsStrings(cache: SafeXmlNode): string[] { const ptCount = cache.child('ptCount').numAttr('val') ?? 0 const values: string[] = new Array(ptCount).fill('') // Check if this is a date format — format date serial numbers to human-readable strings const fc = cache.child('formatCode').text() const isDateFmt = fc && /[yYmMdD]/.test(fc) && !/[#0]/.test(fc) for (const pt of cache.children('pt')) { const idx = pt.numAttr('idx') if (idx !== undefined) { const raw = pt.child('v').text() if (isDateFmt && raw) { values[idx] = excelSerialToDateString(Number.parseFloat(raw)) } else { values[idx] = raw } } } return values } /** * Convert Excel date serial number to a locale-formatted date string. * Excel epoch: 1899-12-30 (accounting for the Lotus 1-2-3 leap year bug). */ function excelSerialToDateString(serial: number): string { if (!Number.isFinite(serial) || serial < 1) return String(serial) // Excel serial date: 1 = 1900-01-01. // Excel has a Lotus 1-2-3 bug where serial 60 = Feb 29, 1900 (which doesn't exist). // For serials > 59, subtract 1 to correct for this phantom leap day. const adjusted = serial > 59 ? serial - 1 : serial // Use UTC to avoid locale timezone drift shifting the rendered calendar date. const epochUtc = Date.UTC(1899, 11, 31) const date = new Date(epochUtc + adjusted * 86400000) // Format as YYYY/M/D (matches CJK locale conventions used in the test data) return `${date.getUTCFullYear()}/${date.getUTCMonth() + 1}/${date.getUTCDate()}` } /** * Extract series name from c:tx element. */ function extractSeriesName(txNode: SafeXmlNode): string { // Try strRef > strCache > pt > v const strRef = txNode.child('strRef') if (strRef.exists()) { const strCache = strRef.child('strCache') const pts = strCache.children('pt') if (pts.length > 0) { return pts[0].child('v').text() } } // Try direct v element const v = txNode.child('v') if (v.exists()) return v.text() return '' } /** * Resolve a color from a fill node (solidFill) to a hex string. */ function resolveColorToHex(fillNode: SafeXmlNode, ctx: RenderContext): string | undefined { try { const { color } = resolveColor(fillNode, ctx) return color.startsWith('#') ? color : `#${color}` } catch { return undefined } } /** * Resolve a color from a single gradient stop node (a:gs > color child) to hex + alpha. */ function resolveGradientStop( gsNode: SafeXmlNode, ctx: RenderContext ): { color: string; alpha: number; pos: number } | undefined { const pos = gsNode.numAttr('pos') if (pos === undefined) return undefined // Try each color type: srgbClr, schemeClr, sysClr for (const child of gsNode.allChildren()) { const ln = child.localName if (ln === 'srgbClr' || ln === 'schemeClr' || ln === 'sysClr' || ln === 'prstClr') { try { const result = resolveColor(gsNode, ctx) const hex = result.color.startsWith('#') ? result.color : `#${result.color}` return { color: hex, alpha: result.alpha, pos: pos / 100000 } } catch { // For sysClr with lastClr, fall back to lastClr if (ln === 'sysClr') { const lastClr = child.attr('lastClr') if (lastClr) { const alphaNode = child.child('alpha') const alphaVal = alphaNode.exists() ? (alphaNode.numAttr('val') ?? 100000) / 100000 : 1 return { color: `#${lastClr}`, alpha: alphaVal, pos: pos / 100000 } } } return undefined } } } return undefined } /** * Extract a series color from c:ser > c:spPr fill. * Supports solidFill and gradFill (converted to ECharts LinearGradient). * Also checks c:spPr > a:ln > a:solidFill as fallback (used by line/area charts). */ function extractSeriesColor(ser: SafeXmlNode, ctx: RenderContext): string | object | undefined { const spPr = ser.child('spPr') if (!spPr.exists()) return undefined // Primary: solid fill const solidFill = spPr.child('solidFill') if (solidFill.exists()) { const hex = resolveColorToHex(solidFill, ctx) if (hex) return hex } // Gradient fill → ECharts LinearGradient const gradFill = spPr.child('gradFill') if (gradFill.exists()) { const grad = buildEChartsGradient(gradFill, ctx) if (grad) return grad } // Fallback: line color (used by lineChart, areaChart series) const ln = spPr.child('ln') if (ln.exists()) { const lnFill = ln.child('solidFill') if (lnFill.exists()) { const hex = resolveColorToHex(lnFill, ctx) if (hex) return hex } } return undefined } function extractSeriesLineWidth(ser: SafeXmlNode): number | undefined { const lnWidthEmu = ser.child('spPr').child('ln').numAttr('w') if (lnWidthEmu === undefined || lnWidthEmu <= 0) return undefined // OOXML line width uses EMU; 12700 EMU = 1pt. Renderer text sizing already // treats point-sized values as CSS px-like numbers, so keep the same scale here. return Math.max(1, Number((lnWidthEmu / 12700).toFixed(3))) } /** * Build an ECharts LinearGradient from an OOXML a:gradFill node. */ function buildEChartsGradient(gradFill: SafeXmlNode, ctx: RenderContext): object | undefined { const gsLst = gradFill.child('gsLst') if (!gsLst.exists()) return undefined const stops: { offset: number; color: string }[] = [] for (const gs of gsLst.children('gs')) { const stop = resolveGradientStop(gs, ctx) if (stop) { // Convert color + alpha to rgba string const hex = stop.color.replace('#', '') const r = Number.parseInt(hex.substring(0, 2), 16) const g = Number.parseInt(hex.substring(2, 4), 16) const b = Number.parseInt(hex.substring(4, 6), 16) stops.push({ offset: stop.pos, color: `rgba(${r},${g},${b},${stop.alpha})`, }) } } if (stops.length < 2) return undefined // Sort stops by offset stops.sort((a, b) => a.offset - b.offset) // Determine gradient direction from a:lin angle. Default: top-to-bottom (ang=5400000 = 90°) const lin = gradFill.child('lin') const angVal = lin.exists() ? (lin.numAttr('ang') ?? 5400000) : 5400000 const angleDeg = angVal / 60000 // Convert from 60000ths of a degree // Map angle to x0,y0,x1,y1 (ECharts LinearGradient coordinates) // OOXML angle: 0°=right(→), 90°=down(↓), 180°=left(←), 270°=up(↑) // Direction vector: dx=cos(θ), dy=sin(θ) (clockwise from east in screen coords) const rad = (angleDeg * Math.PI) / 180 const x0 = 0.5 - 0.5 * Math.cos(rad) const y0 = 0.5 - 0.5 * Math.sin(rad) const x1 = 0.5 + 0.5 * Math.cos(rad) const y1 = 0.5 + 0.5 * Math.sin(rad) return new echarts.graphic.LinearGradient(x0, y0, x1, y1, stops) } /** * Extract per-data-point colors from c:ser > c:dPt elements. * Each c:dPt has c:idx and c:spPr > a:solidFill. */ function extractDataPointColors( ser: SafeXmlNode, ctx: RenderContext ): (string | undefined)[] | undefined { const dPts = ser.children('dPt') if (dPts.length === 0) return undefined const colors: (string | undefined)[] = [] for (const dPt of dPts) { const idx = dPt.child('idx').numAttr('val') if (idx === undefined) continue const spPr = dPt.child('spPr') if (!spPr.exists()) continue const solidFill = spPr.child('solidFill') if (solidFill.exists()) { const hex = resolveColorToHex(solidFill, ctx) if (hex) { while (colors.length <= idx) colors.push(undefined) colors[idx] = hex } } } return colors.length > 0 ? colors : undefined } /** In OOXML, boolean elements are true when present unless val="0" or val="false". */ function parseDlblBool(dLbls: SafeXmlNode, childName: string): boolean { const el = dLbls.child(childName) if (!el.exists()) return false const val = el.attr('val') return val !== '0' && val !== 'false' } /** * Extract text color from a txPr element: txPr > p > pPr > defRPr > solidFill. */ function extractTxPrColor(parentNode: SafeXmlNode, ctx: RenderContext): string | undefined { const txPr = parentNode.child('txPr') if (!txPr.exists()) return undefined for (const p of txPr.children('p')) { const pPr = p.child('pPr') if (!pPr.exists()) continue const defRPr = pPr.child('defRPr') if (!defRPr.exists()) continue const fill = defRPr.child('solidFill') if (fill.exists()) { return resolveColorToHex(fill, ctx) } } return undefined } /** * Parse c:dLbls (data labels) configuration from a chart type node or series. */ function parseDataLabels(node: SafeXmlNode, ctx: RenderContext): DataLabelConfig | undefined { const dLbls = node.child('dLbls') if (!dLbls.exists()) return undefined const showVal = parseDlblBool(dLbls, 'showVal') const showCatName = parseDlblBool(dLbls, 'showCatName') const showSerName = parseDlblBool(dLbls, 'showSerName') const showPercent = parseDlblBool(dLbls, 'showPercent') const posNode = dLbls.child('dLblPos') const position = posNode.exists() ? posNode.attr('val') || undefined : undefined const txStyle = extractTxPrStyle(dLbls, ctx) const color = txStyle?.color ?? extractTxPrColor(dLbls, ctx) const fontSize = txStyle?.fontSize const bold = txStyle?.bold // If nothing is shown, return undefined if (!showVal && !showCatName && !showSerName && !showPercent) return undefined return { showVal, showCatName, showSerName, showPercent, position, color, fontSize, bold } } function parseDlblBoolOptional(dLbl: SafeXmlNode, childName: string): boolean | undefined { const el = dLbl.child(childName) if (!el.exists()) return undefined const val = el.attr('val') return val !== '0' && val !== 'false' } /** * Parse per-point data label overrides from c:dLbls > c:dLbl(idx=...). */ function parsePointDataLabelOverrides( dLbls: SafeXmlNode, ctx: RenderContext ): Map> { const out = new Map>() if (!dLbls.exists()) return out for (const dLbl of dLbls.children('dLbl')) { const idx = dLbl.child('idx').numAttr('val') if (idx === undefined) continue const txStyle = extractTxPrStyle(dLbl, ctx) const posNode = dLbl.child('dLblPos') const cfg: Partial = {} const showVal = parseDlblBoolOptional(dLbl, 'showVal') const showCatName = parseDlblBoolOptional(dLbl, 'showCatName') const showSerName = parseDlblBoolOptional(dLbl, 'showSerName') const showPercent = parseDlblBoolOptional(dLbl, 'showPercent') if (showVal !== undefined) cfg.showVal = showVal if (showCatName !== undefined) cfg.showCatName = showCatName if (showSerName !== undefined) cfg.showSerName = showSerName if (showPercent !== undefined) cfg.showPercent = showPercent if (posNode.exists()) cfg.position = posNode.attr('val') || undefined if (txStyle?.color) cfg.color = txStyle.color else { const c = extractTxPrColor(dLbl, ctx) if (c) cfg.color = c } if (txStyle?.fontSize !== undefined) cfg.fontSize = txStyle.fontSize if (txStyle?.bold !== undefined) cfg.bold = txStyle.bold if (Object.keys(cfg).length > 0) out.set(idx, cfg) } return out } /** * Parse pie slice explosion values from c:ser and c:dPt elements. */ function parseExplosion(ser: SafeXmlNode, pointCount: number): number[] | undefined { const explosions: number[] = new Array(pointCount).fill(0) let hasAny = false // Series-level explosion const serExplosion = ser.child('explosion').numAttr('val') ?? 0 if (serExplosion > 0) { explosions.fill(serExplosion) hasAny = true } // Per-point explosion overrides const dPts = ser.children('dPt') for (const dPt of dPts) { const idx = dPt.child('idx').numAttr('val') if (idx === undefined) continue const exp = dPt.child('explosion').numAttr('val') if (exp !== undefined && exp > 0) { explosions[idx] = exp hasAny = true } } return hasAny ? explosions : undefined } /** * Parse all c:ser elements from a chart type node into SeriesData[]. * Results are sorted by c:order to match the intended series sequence. */ function parseSeries(chartTypeNode: SafeXmlNode, ctx: RenderContext): SeriesData[] { const seriesArr: SeriesData[] = [] for (const ser of chartTypeNode.children('ser')) { const tx = ser.child('tx') const name = extractSeriesName(tx) const order = ser.child('order').numAttr('val') ?? seriesArr.length const cat = ser.child('cat') const categories = extractStringValues(cat) const val = ser.child('val') const values = extractNumericValues(val) const formatCode = extractFormatCode(val) // Scatter charts use xVal / yVal instead of cat / val const xValNode = ser.child('xVal') const yValNode = ser.child('yVal') let xValues: number[] | undefined if (yValNode.exists()) { // yVal overrides val for scatter const yVals = extractNumericValues(yValNode) if (yVals.length > 0) { values.length = 0 values.push(...yVals) } } if (xValNode.exists()) { xValues = extractNumericValues(xValNode) // If categories are empty but xVal exists as strings, try that if (categories.length === 0) { const xCats = extractStringValues(xValNode) if (xCats.length > 0) categories.push(...xCats) } } // Bubble chart sizes from c:bubbleSize const bubbleSizeNode = ser.child('bubbleSize') const bubbleSizes = bubbleSizeNode.exists() ? extractNumericValues(bubbleSizeNode) : undefined const colorHex = extractSeriesColor(ser, ctx) const lineWidth = extractSeriesLineWidth(ser) const dataPointColors = extractDataPointColors(ser, ctx) // Extract marker info (c:marker > c:symbol, c:size) const marker = ser.child('marker') const markerSymbol = marker.child('symbol').attr('val') const markerSize = marker.child('size').numAttr('val') const smooth = ser.child('smooth').attr('val') === '1' seriesArr.push({ name, order, categories, values, xValues, bubbleSizes, colorHex, dataPointColors, formatCode, markerSymbol, markerSize, smooth, lineWidth, }) } // Sort by c:order so legend/stacking matches PPT seriesArr.sort((a, b) => a.order - b.order) return seriesArr } // --------------------------------------------------------------------------- // Chart Title // --------------------------------------------------------------------------- /** * Extract chart title from chartSpace > chart > title. * Returns undefined when autoTitleDeleted val="1" (title was intentionally removed). */ function extractChartTitle(chartNode: SafeXmlNode, seriesArr?: SeriesData[]): string | undefined { // Respect autoTitleDeleted: if set, the title should not be shown const autoTitleDeleted = chartNode.child('autoTitleDeleted') if (autoTitleDeleted.exists() && autoTitleDeleted.attr('val') === '1') { return undefined } const title = chartNode.child('title') if (!title.exists()) { // OOXML spec: when autoTitleDeleted is NOT "1" and there is no explicit // , the chart auto-generates a title from the first series name. // This applies mainly to single-series charts like pie/doughnut. if (seriesArr && seriesArr.length === 1 && seriesArr[0].name) { return seriesArr[0].name } return undefined } const tx = title.child('tx') if (!tx.exists()) return undefined // Try rich text: tx > rich > p > r > t const rich = tx.child('rich') if (rich.exists()) { const parts: string[] = [] for (const p of rich.children('p')) { for (const r of p.children('r')) { const t = r.child('t').text() if (t) parts.push(t) } } if (parts.length > 0) return parts.join('') } // Try strRef const strRef = tx.child('strRef') if (strRef.exists()) { const strCache = strRef.child('strCache') const pts = strCache.children('pt') if (pts.length > 0) return pts[0].child('v').text() } return undefined } /** * Extract chart title manual layout (title > layout > manualLayout) to ECharts title position. */ function extractTitleManualLayout(chartNode: SafeXmlNode): Partial> { const manual = chartNode.child('title').child('layout').child('manualLayout') if (!manual.exists()) return {} const out: Partial> = {} const x = manual.child('x').numAttr('val') const y = manual.child('y').numAttr('val') if (x !== undefined) out.left = numToPct(x) if (y !== undefined) out.top = numToPct(y) return out } /** * Extract text color/font size from a txPr node: * txPr > p > pPr > defRPr (solidFill + sz). */ function extractTxPrStyle( parentNode: SafeXmlNode, ctx: RenderContext ): { color?: string; fontSize?: number; bold?: boolean; fontFamily?: string } | undefined { const txPr = parentNode.child('txPr') if (!txPr.exists()) return undefined for (const p of txPr.children('p')) { const pPr = p.child('pPr') if (!pPr.exists()) continue const defRPr = pPr.child('defRPr') if (!defRPr.exists()) continue const style: { color?: string; fontSize?: number; bold?: boolean; fontFamily?: string } = {} const fill = defRPr.child('solidFill') if (fill.exists()) { const c = resolveColorToHex(fill, ctx) if (c) style.color = c } const sz = defRPr.numAttr('sz') if (sz !== undefined && sz > 0) { // OOXML sz is 1/100 pt. Keep renderer's existing px-scale convention. style.fontSize = Math.round(sz / 100) } const b = defRPr.attr('b') if (b === '1' || b === 'true') style.bold = true else if (b === '0' || b === 'false') style.bold = false const latinTypeface = defRPr.child('latin').attr('typeface') const eaTypeface = defRPr.child('ea').attr('typeface') const csTypeface = defRPr.child('cs').attr('typeface') if (latinTypeface || eaTypeface || csTypeface) { style.fontFamily = latinTypeface || eaTypeface || csTypeface } if ( style.color || style.fontSize !== undefined || style.bold !== undefined || style.fontFamily !== undefined ) return style } return undefined } function getChartThemeFontFamily(ctx: RenderContext): string | undefined { return ( ctx.theme.minorFont.latin || ctx.theme.minorFont.ea || ctx.theme.majorFont.latin || ctx.theme.majorFont.ea || undefined ) } // --------------------------------------------------------------------------- // Legend // --------------------------------------------------------------------------- /** Parsed legend info including overlay flag. */ interface LegendInfo { option: echarts.EChartsOption['legend'] overlay: boolean // true = legend overlaps plot area (don't reserve grid space) textStyle?: { color?: string fontSize?: number fontWeight?: 'normal' | 'bold' | 'bolder' | 'lighter' | number fontFamily?: string } manualLayout?: Partial> } /** * Extract legend position from chartSpace > chart > legend > legendPos. */ function extractLegendInfo(chartNode: SafeXmlNode, ctx: RenderContext): LegendInfo | undefined { const legend = chartNode.child('legend') if (!legend.exists()) return undefined const legendPos = legend.child('legendPos') // OOXML default legend position is 'r' (right) per the spec, not 'b' const posVal = legendPos.exists() ? legendPos.attr('val') || 'r' : 'r' const overlay = legend.child('overlay').attr('val') === '1' // Map OOXML positions to ECharts; keep legend inside and below chart title (avoid overlap on slide 4, 6, etc.) const base = { confine: true as const } const topBelowTitle = '14%' // leave room for chart title so legend does not overlap let option: echarts.EChartsOption['legend'] switch (posVal) { case 'b': option = { ...base, bottom: '5%', orient: 'horizontal' as const } break case 't': option = { ...base, top: topBelowTitle, orient: 'horizontal' as const } break case 'l': option = { ...base, left: '2%', top: '44%', orient: 'vertical' as const } break case 'r': option = { ...base, right: '2%', top: '44%', orient: 'vertical' as const } break case 'tr': option = { ...base, top: topBelowTitle, right: '2%', orient: 'vertical' as const } break default: option = { ...base, right: '2%', top: '44%', orient: 'vertical' as const } break } return { option, overlay, textStyle: (() => { const s = extractTxPrStyle(legend, ctx) if (!s) return undefined return { ...(s.color ? { color: s.color } : {}), ...(s.fontSize !== undefined ? { fontSize: s.fontSize } : {}), ...(s.bold === true ? { fontWeight: 'bold' } : {}), ...(s.fontFamily ? { fontFamily: s.fontFamily } : {}), } })(), manualLayout: extractLegendManualLayout(legend), } } /** * Parse legend/layout/manualLayout to ECharts legend position/size override. */ function extractLegendManualLayout( legendNode: SafeXmlNode ): Partial> { const manual = legendNode.child('layout').child('manualLayout') if (!manual.exists()) return {} const out: Partial> = {} const x = manual.child('x').numAttr('val') const y = manual.child('y').numAttr('val') const w = manual.child('w').numAttr('val') const h = manual.child('h').numAttr('val') if (x !== undefined) out.left = numToPct(x) if (y !== undefined) out.top = numToPct(y) if (w !== undefined) out.width = numToPct(w) if (h !== undefined) out.height = numToPct(h) return out } /** True when legend is positioned at top (t or tr), so plot area should reserve more top space. */ function legendIsAtTop(legendInfo: LegendInfo | undefined): boolean { if (!legendInfo || !legendInfo.option || typeof legendInfo.option !== 'object') return false const o = legendInfo.option as Record // top: 'middle' is used by right/left legends (vertically centered), not "at top" return o.top !== undefined && o.top !== null && o.top !== 'middle' } /** * Grid top reserve in pixels. Use fixed pixels so small chart containers (e.g. in shapes) * don't get oversized percentage reserve and avoid legend overlapping data labels. * When legend overlay=true, don't reserve extra space for legend. */ function getGridTopPx(hasTitle: boolean, legendInfo: LegendInfo | undefined): number { const atTop = legendIsAtTop(legendInfo) const overlayLegend = legendInfo?.overlay ?? false if (hasTitle) return atTop && !overlayLegend ? 52 : 40 return atTop && !overlayLegend ? 32 : 20 } /** Legend top in pixels when legend is at top, so it sits below title and above grid. */ function getLegendTopPx(hasTitle: boolean, legendInfo: LegendInfo | undefined): number | undefined { if (!legendIsAtTop(legendInfo)) return undefined return hasTitle ? 26 : 6 } function getLegendPlacement( legendInfo: LegendInfo | undefined ): 'left' | 'right' | 'top' | 'bottom' | 'none' { if ( !legendInfo || legendInfo.overlay || !legendInfo.option || typeof legendInfo.option !== 'object' ) { return 'none' } const opt = legendInfo.option as Record if (opt.bottom !== undefined) return 'bottom' if (opt.top !== undefined && opt.left === undefined && opt.right === undefined) return 'top' if (opt.left !== undefined) return 'left' if (opt.right !== undefined) return 'right' return 'none' } function computePieLayout( legendInfo: LegendInfo | undefined, isDoughnut: boolean, showLabel: boolean ): { center: [string, string]; radius: [string, string] | string } { const placement = getLegendPlacement(legendInfo) let center: [string, string] = ['50%', '55%'] let outerRadius = showLabel ? 78 : 82 if (placement === 'right') { center = ['38%', '55%'] outerRadius = 82 } else if (placement === 'left') { center = ['62%', '55%'] outerRadius = 82 } else if (placement === 'top') center = ['50%', '60%'] else if (placement === 'bottom') center = ['50%', '45%'] if (placement === 'top' || placement === 'bottom') { outerRadius -= 4 } if (!isDoughnut) { return { center, radius: `${outerRadius}%` } } const innerRadius = Math.round(outerRadius * 0.57) return { center, radius: [`${innerRadius}%`, `${outerRadius}%`] } } function pieExplosionToOffset(explosion: number): number { return Number((explosion * 4.4).toFixed(1)) } /** Grid bottom in pixels — leave more room when the legend sits at the bottom. */ function getGridBottomPx(legendInfo: LegendInfo | undefined): number { if (legendInfo) { const opt = legendInfo.option as Record | undefined if (opt && opt.bottom !== undefined) { // Legend is at bottom — reserve space for it so axis labels don't overlap return 35 } } return 8 } const _GRID_BOTTOM_PX = 8 // kept as default for chart types that don't call the function /** Map OOXML c:marker > c:symbol values to ECharts symbol names. */ const OOXML_SYMBOL_MAP: Record = { circle: 'circle', square: 'rect', diamond: 'diamond', triangle: 'triangle', none: 'none', // Less common symbols — fallback to circle star: 'circle', dash: 'circle', dot: 'circle', plus: 'circle', x: 'circle', } function mapOoxmlSymbol(symbol: string | undefined): string | undefined { if (!symbol) return undefined return OOXML_SYMBOL_MAP[symbol] ?? 'circle' } function buildLegendOption( legendOpt: echarts.EChartsOption['legend'] | undefined, legendInfo: LegendInfo | undefined, legendTopPx: number | undefined, data: (string | { name: string; icon?: string })[], textStyle: { color?: string fontSize?: number fontWeight?: 'normal' | 'bold' | 'bolder' | 'lighter' | number fontFamily?: string } ): echarts.EChartsOption['legend'] { if (!legendOpt) return { show: false } const manual = legendInfo?.manualLayout ?? {} const top = manual.top !== undefined ? manual.top : legendTopPx !== undefined ? legendTopPx : undefined // PowerPoint legend icons are sharp-cornered squares; ECharts default is 25×14 roundRect. // Set icon to 'rect' (no rounded corners) and both dimensions equal for square icons. // However, if individual data items carry their own icon (e.g. line/radar marker symbols), // respect those per-item icons instead of forcing 'rect' globally. const iconSize = textStyle.fontSize ?? 10 const hasPerItemIcons = data.some((d) => typeof d === 'object' && d.icon) const sharedIcon = hasPerItemIcons && data.every( (d) => typeof d === 'object' && typeof d.icon === 'string' && d.icon === (data[0] as { icon?: string }).icon ) ? (data[0] as { icon?: string }).icon : undefined const useSharedIcon = sharedIcon !== undefined && !sharedIcon.startsWith('path://') const legendData = useSharedIcon ? data.map((d) => (typeof d === 'string' ? d : d.name)) : data const hasLineLikeIcons = data.some( (d) => typeof d === 'object' && typeof d.icon === 'string' && d.icon.startsWith('path://') ) return { ...legendOpt, ...manual, ...(top !== undefined ? { top } : {}), ...(useSharedIcon ? { icon: sharedIcon } : hasPerItemIcons ? {} : { icon: 'rect' }), itemWidth: hasLineLikeIcons ? Math.max(24, Math.round(iconSize * 2.2)) : iconSize, itemHeight: hasLineLikeIcons ? Math.max(8, Math.round(iconSize * 0.9)) : iconSize, data: legendData, textStyle, } } type LegendOptionObject = { show?: boolean data?: (string | { name: string; icon?: string })[] orient?: 'horizontal' | 'vertical' left?: string | number right?: string | number top?: string | number bottom?: string | number icon?: string itemWidth?: number itemHeight?: number textStyle?: { color?: string fontSize?: number fontWeight?: 'normal' | 'bold' | 'bolder' | 'lighter' | number fontFamily?: string } } function getLegendOptionObject(legend: echarts.EChartsOption['legend']): LegendOptionObject | null { if (!legend) return null return Array.isArray(legend) ? ((legend[0] as LegendOptionObject | undefined) ?? null) : (legend as LegendOptionObject) } function pickSeriesStringColor(color: string | object | undefined, fallback: string): string { return typeof color === 'string' ? color : fallback } function lineLegendIconPath(): string { return 'path://M2 8 L22 8' } function buildSmoothScatterLineData(data: number[][], stepsPerSegment = 24): number[][] { if (data.length < 3) return data for (let i = 1; i < data.length; i++) { if (data[i][0] <= data[i - 1][0]) return data } const tangentScale = 0.3 const endTangentScale = 1.2 const n = data.length const slopes = new Array(n - 1) for (let i = 0; i < n - 1; i++) { slopes[i] = (data[i + 1][1] - data[i][1]) / (data[i + 1][0] - data[i][0]) } const tangents = new Array(n) tangents[0] = slopes[0] tangents[n - 1] = slopes[n - 2] * endTangentScale for (let i = 1; i < n - 1; i++) { tangents[i] = ((slopes[i - 1] + slopes[i]) / 2) * tangentScale } const out: number[][] = [[data[0][0], data[0][1]]] for (let i = 0; i < n - 1; i++) { const [x0, y0] = data[i] const [x1, y1] = data[i + 1] const dx = x1 - x0 const m0 = tangents[i] const m1 = tangents[i + 1] for (let step = 1; step <= stepsPerSegment; step++) { const t = step / stepsPerSegment const h00 = 2 * t ** 3 - 3 * t ** 2 + 1 const h10 = t ** 3 - 2 * t ** 2 + t const h01 = -2 * t ** 3 + 3 * t ** 2 const h11 = t ** 3 - t ** 2 const x = x0 + dx * t const y = h00 * y0 + h10 * dx * m0 + h01 * y1 + h11 * dx * m1 out.push([Number(x.toFixed(4)), Number(y.toFixed(4))]) } } return out } function hasManualGrid( manualGrid: Partial> ): boolean { return ( manualGrid.left !== undefined || manualGrid.top !== undefined || manualGrid.width !== undefined || manualGrid.height !== undefined ) } // --------------------------------------------------------------------------- // Axis Parsing // --------------------------------------------------------------------------- const DEFAULT_AXIS_INFO: AxisInfo = { deleted: false, tickLblPos: 'nextTo', hasMajorGridlines: false, orientation: 'minMax', } /** * Extract label color from axis txPr: txPr > p > pPr > defRPr > solidFill. */ function extractAxisLabelColor(ax: SafeXmlNode, ctx: RenderContext): string | undefined { const txPr = ax.child('txPr') if (!txPr.exists()) return undefined // Navigate: txPr > a:p > a:pPr > a:defRPr > a:solidFill for (const p of txPr.children('p')) { const pPr = p.child('pPr') if (!pPr.exists()) continue const defRPr = pPr.child('defRPr') if (!defRPr.exists()) continue const fill = defRPr.child('solidFill') if (fill.exists()) { return resolveColorToHex(fill, ctx) } } return undefined } /** * Extract axis line color from axis spPr: spPr > ln > solidFill. */ function extractAxisLineColor(ax: SafeXmlNode, ctx: RenderContext): string | undefined { const ln = ax.child('spPr').child('ln') if (!ln.exists()) return undefined const fill = ln.child('solidFill') if (!fill.exists()) return undefined return resolveColorToHex(fill, ctx) } /** * Parse a single axis node (c:valAx, c:catAx, or c:dateAx) into AxisInfo. */ function parseAxisNode(ax: SafeXmlNode, ctx: RenderContext): AxisInfo { if (!ax.exists()) return { ...DEFAULT_AXIS_INFO } const deleted = ax.child('delete').attr('val') === '1' const tickLblPos = ax.child('tickLblPos').attr('val') || 'nextTo' const numFmtNode = ax.child('numFmt') const numFmt = numFmtNode.exists() ? numFmtNode.attr('formatCode') || undefined : undefined const scaling = ax.child('scaling') const minNode = scaling.child('min') const maxNode = scaling.child('max') const min = minNode.exists() ? Number.parseFloat(minNode.attr('val') || '') : undefined const max = maxNode.exists() ? Number.parseFloat(maxNode.attr('val') || '') : undefined const hasMajorGridlines = ax.child('majorGridlines').exists() const orientation = scaling.child('orientation').attr('val') || 'minMax' const txStyle = extractTxPrStyle(ax, ctx) const labelColor = txStyle?.color ?? extractAxisLabelColor(ax, ctx) const labelFontSize = txStyle?.fontSize const lineColor = extractAxisLineColor(ax, ctx) return { deleted, tickLblPos, numFmt: numFmt && numFmt !== 'General' ? numFmt : undefined, min: min !== undefined && !Number.isNaN(min) ? min : undefined, max: max !== undefined && !Number.isNaN(max) ? max : undefined, hasMajorGridlines, orientation, labelColor, labelFontSize, lineColor, } } /** Parse value axis and category axis from plotArea. Also checks dateAx as category fallback. */ function parseAxes( plotArea: SafeXmlNode, ctx: RenderContext ): { valueAxis: AxisInfo; categoryAxis: AxisInfo } { const valAx = plotArea.child('valAx') const catAx = plotArea.child('catAx') const dateAx = plotArea.child('dateAx') return { valueAxis: parseAxisNode(valAx, ctx), categoryAxis: catAx.exists() ? parseAxisNode(catAx, ctx) : parseAxisNode(dateAx, ctx), } } /** * Parse scatter/bubble axes: two valAx nodes differentiated by axPos. * Returns X axis (bottom/top) and Y axis (left/right) separately. */ function parseScatterAxes( plotArea: SafeXmlNode, ctx: RenderContext ): { xAxis: AxisInfo; yAxis: AxisInfo } { const allValAx = plotArea.children('valAx') let xAxis: AxisInfo = { ...DEFAULT_AXIS_INFO } let yAxis: AxisInfo = { ...DEFAULT_AXIS_INFO } for (const ax of allValAx) { const axPos = ax.child('axPos').attr('val') ?? '' const info = parseAxisNode(ax, ctx) if (axPos === 'b' || axPos === 't') { xAxis = info } else if (axPos === 'l' || axPos === 'r') { yAxis = info } } // Fallback: if only one valAx found, use first as Y axis (value) if (allValAx.length === 1) { yAxis = parseAxisNode(allValAx[0], ctx) } return { xAxis, yAxis } } /** * Apply axis visibility and styling to an ECharts axis definition. * Handles: delete (hide everything), tickLblPos=none (hide only labels), * min/max (scaling), numFmt (label formatter), majorGridlines (splitLine). */ function applyAxisInfo( axisDef: Record, info: AxisInfo, kind: 'value' | 'category' ): void { // Fully deleted axis: hide everything if (info.deleted) { axisDef.axisLabel = { ...((axisDef.axisLabel as object) || {}), show: false } axisDef.axisLine = { show: false } axisDef.axisTick = { show: false } if (kind === 'value') axisDef.splitLine = { show: false } return } // tickLblPos=none: hide labels only, keep axis line/tick if (info.tickLblPos === 'none') { axisDef.axisLabel = { ...((axisDef.axisLabel as object) || {}), show: false } } // Scaling min/max if (kind === 'value') { if (info.min !== undefined) axisDef.min = info.min if (info.max !== undefined) axisDef.max = info.max } // Axis numFmt → label formatter (only for value axis, and only if not already set by series pctFormat) if (kind === 'value' && info.numFmt && !info.deleted && info.tickLblPos !== 'none') { const existingLabel = (axisDef.axisLabel as Record) || {} if (!existingLabel.formatter) { const nf = info.numFmt axisDef.axisLabel = { ...existingLabel, formatter: (val: number) => formatValue(val, nf), } } } // Major gridlines → splitLine if (kind === 'value') { if (!info.hasMajorGridlines) { axisDef.splitLine = { show: false } } // If has gridlines, ECharts shows them by default — no action needed } // Axis label color from txPr if (info.labelColor) { const existingLabel = (axisDef.axisLabel as Record) || {} axisDef.axisLabel = { ...existingLabel, color: info.labelColor } } if (info.labelFontSize !== undefined) { const existingLabel = (axisDef.axisLabel as Record) || {} axisDef.axisLabel = { ...existingLabel, fontSize: info.labelFontSize } } // Axis line color from spPr > ln if (info.lineColor) { const existingLine = (axisDef.axisLine as Record) || {} const existingLineStyle = (existingLine.lineStyle as Record) || {} axisDef.axisLine = { ...existingLine, show: existingLine.show ?? true, lineStyle: { ...existingLineStyle, color: info.lineColor }, } } } // --------------------------------------------------------------------------- // ECharts Option Builders // --------------------------------------------------------------------------- /** * Convert OOXML data label position to ECharts bar label position. */ function mapBarLabelPosition(pos: string | undefined, isStacked: boolean): string { switch (pos) { case 'outEnd': return 'top' case 'inEnd': return 'insideTop' case 'ctr': return 'inside' case 'inBase': return 'insideBottom' default: return isStacked ? 'inside' : 'top' } } function buildBarChartOption( chartTypeNode: SafeXmlNode, chartNode: SafeXmlNode, seriesArr: SeriesData[], ctx: RenderContext ): echarts.EChartsOption { const barDir = chartTypeNode.child('barDir').attr('val') || chartTypeNode.attr('barDir') || 'col' const groupingNode = chartTypeNode.child('grouping') const grouping = groupingNode.exists() ? groupingNode.attr('val') || 'clustered' : 'clustered' const isHorizontal = barDir === 'bar' // Layout parameters const gapWidth = chartTypeNode.child('gapWidth').numAttr('val') const overlap = chartTypeNode.child('overlap').numAttr('val') // Use categories from the first series that has them const categories = seriesArr.find((s) => s.categories.length > 0)?.categories || [] const title = extractChartTitle(chartNode, seriesArr) const titleStyle = extractTxPrStyle(chartNode.child('title'), ctx) const titleLayout = extractTitleManualLayout(chartNode) const legendInfo = extractLegendInfo(chartNode, ctx) const legendOpt = legendInfo?.option const legendTextStyle = { fontSize: 10, ...(legendInfo?.textStyle ?? {}) } const isStacked = grouping === 'stacked' || grouping === 'percentStacked' // Parse data labels: in OOXML they can be on chart type (barChart) or on series (ser); try both let sharedLabels = parseDataLabels(chartTypeNode, ctx) if (!sharedLabels) { const firstSer = chartTypeNode.children('ser')[0] if (firstSer?.exists()) sharedLabels = parseDataLabels(firstSer, ctx) } const serNodesByOrder = chartTypeNode .children('ser') .map((ser, i) => ({ ser, order: ser.child('order').numAttr('val') ?? i })) .sort((a, b) => a.order - b.order) .map((x) => x.ser) const series: echarts.BarSeriesOption[] = seriesArr.map((s, idx) => { // Capture formatCode for use in label formatter closure const fc = s.formatCode const perSeriesLabels = parseDataLabels(serNodesByOrder[idx] ?? chartTypeNode, ctx) ?? sharedLabels const buildLabel = ( cfg: DataLabelConfig | Partial | undefined ): echarts.BarSeriesOption['label'] => cfg?.showVal ? { show: true, // eslint-disable-next-line @typescript-eslint/no-explicit-any position: mapBarLabelPosition(cfg.position, isStacked) as any, fontSize: cfg.fontSize ?? 9, ...(cfg.color ? { color: cfg.color } : {}), ...(cfg.bold === true ? { fontWeight: 'bold' } : {}), // eslint-disable-next-line @typescript-eslint/no-explicit-any formatter: (params: any) => { const rawVal = params?.value const val = rawVal && typeof rawVal === 'object' && 'value' in rawVal ? rawVal.value : rawVal if (val === 0 || val === null) return '' return formatValue(val, fc) }, } : undefined // Per-series label config (override shared) const label: echarts.BarSeriesOption['label'] = buildLabel(perSeriesLabels) const dLblsNode = (serNodesByOrder[idx] ?? chartTypeNode).child('dLbls') const pointOverrides = parsePointDataLabelOverrides(dLblsNode, ctx) const data: echarts.BarSeriesOption['data'] = s.values.map((v, pointIdx) => { const ov = pointOverrides.get(pointIdx) if (!ov) return v const merged: DataLabelConfig = { showVal: perSeriesLabels?.showVal ?? false, showCatName: perSeriesLabels?.showCatName ?? false, showSerName: perSeriesLabels?.showSerName ?? false, showPercent: perSeriesLabels?.showPercent ?? false, position: perSeriesLabels?.position, color: perSeriesLabels?.color, fontSize: perSeriesLabels?.fontSize, bold: perSeriesLabels?.bold, ...ov, } return { value: v, label: buildLabel(merged), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any }) return { type: 'bar' as const, name: s.name, data, stack: isStacked ? 'total' : undefined, itemStyle: s.colorHex ? { color: s.colorHex } : undefined, label, barGap: overlap !== undefined ? `${-overlap}%` : undefined, // OOXML gapWidth = gap-between-groups / single-bar-width × 100. // For N clustered bars: categoryBand = N × barWidth + gap, gap = gapWidth/100 × barWidth. // ECharts barCategoryGap = gap / categoryBand = gapWidth / (100×N + gapWidth). // For stacked bars N=1 since all series share one bar slot. barCategoryGap: gapWidth !== undefined ? `${Math.round((gapWidth * 100) / (100 * (isStacked ? 1 : seriesArr.length) + gapWidth))}%` : undefined, } }) const plotArea = chartNode.child('plotArea') const { valueAxis, categoryAxis } = parseAxes(plotArea, ctx) const categoryAxisDef: Record = { type: 'category', data: categories, axisLabel: { interval: 0, rotate: categories.length > 6 ? 30 : 0, fontSize: 10 }, } applyAxisInfo(categoryAxisDef, categoryAxis, 'category') // Check if any series uses percentage format; axis numFmt takes priority const pctFormat = valueAxis.numFmt || seriesArr.find((s) => s.formatCode?.includes('%'))?.formatCode const valueAxisDef: Record = { type: 'value', ...(pctFormat ? { axisLabel: { formatter: (val: number) => formatValue(val, pctFormat), }, } : {}), } applyAxisInfo(valueAxisDef, valueAxis, 'value') const gridTop = getGridTopPx(!!title, legendInfo) const legendTopPx = getLegendTopPx(!!title, legendInfo) // When value axis is hidden, reduce left/right padding so bars use full width const gridLeft = valueAxis.deleted && !isHorizontal ? 4 : 10 const gridRight = 10 // Determine a shared format code for tooltips: prefer axis numFmt, then first series formatCode const tooltipFmt = pctFormat || seriesArr.find((s) => s.formatCode)?.formatCode const gridBottom = getGridBottomPx(legendInfo) const manualGrid = extractManualLayoutGrid(chartNode) const containLabel = !hasManualGrid(manualGrid) return { title: title ? { text: title, left: 'center', ...titleLayout, textStyle: { fontSize: 12, ...(titleStyle ?? {}) }, } : undefined, tooltip: { trigger: 'axis' as const, ...(tooltipFmt ? { valueFormatter: (value: unknown) => formatValue( Array.isArray(value) ? (value[0] as number) : (value as number), tooltipFmt ), } : {}), }, legend: buildLegendOption( legendOpt, legendInfo, legendTopPx, seriesArr.map((s) => s.name), legendTextStyle ), grid: { containLabel, left: gridLeft, right: gridRight, top: gridTop, bottom: gridBottom, ...manualGrid, }, xAxis: isHorizontal ? valueAxisDef : categoryAxisDef, yAxis: isHorizontal ? categoryAxisDef : valueAxisDef, series, } as echarts.EChartsOption } function buildLineChartOption( chartTypeNode: SafeXmlNode, chartNode: SafeXmlNode, seriesArr: SeriesData[], ctx: RenderContext, isArea: boolean ): echarts.EChartsOption { const categories = seriesArr.find((s) => s.categories.length > 0)?.categories || [] const title = extractChartTitle(chartNode, seriesArr) const titleStyle = extractTxPrStyle(chartNode.child('title'), ctx) const titleLayout = extractTitleManualLayout(chartNode) const legendInfo = extractLegendInfo(chartNode, ctx) const legendOpt = legendInfo?.option const legendTextStyle = { fontSize: 10, ...(legendInfo?.textStyle ?? {}) } const series: echarts.LineSeriesOption[] = seriesArr.map((s) => { const echartsSymbol = mapOoxmlSymbol(s.markerSymbol) const showSymbol = echartsSymbol !== undefined ? echartsSymbol !== 'none' : undefined const lineWidth = s.lineWidth ?? 3 const lineStyle = s.colorHex ? { color: s.colorHex, width: lineWidth, cap: 'round' as const, join: 'round' as const } : { width: lineWidth, cap: 'round' as const, join: 'round' as const } return { type: 'line' as const, name: s.name, data: s.values, areaStyle: isArea ? (s.colorHex ? { color: s.colorHex } : {}) : undefined, itemStyle: s.colorHex ? { color: s.colorHex } : undefined, lineStyle, ...(echartsSymbol && echartsSymbol !== 'none' ? { symbol: echartsSymbol } : {}), ...(s.markerSize ? { symbolSize: s.markerSize } : {}), ...(showSymbol !== undefined ? { showSymbol } : {}), z: 3, } }) const plotArea = chartNode.child('plotArea') const { valueAxis, categoryAxis } = parseAxes(plotArea, ctx) const pctFormat = valueAxis.numFmt || seriesArr.find((s) => s.formatCode?.includes('%'))?.formatCode const yAxisDef: Record = { type: 'value', ...(pctFormat ? { axisLabel: { formatter: (val: number) => formatValue(val, pctFormat), }, } : {}), } applyAxisInfo(yAxisDef, valueAxis, 'value') const xAxisDef: Record = { type: 'category', data: categories, axisLabel: { interval: 0, rotate: categories.length > 6 ? 30 : 0 }, } applyAxisInfo(xAxisDef, categoryAxis, 'category') const gridTop = getGridTopPx(!!title, legendInfo) const legendTopPx = getLegendTopPx(!!title, legendInfo) const gridLeft = valueAxis.deleted ? 4 : 10 const tooltipFmt = pctFormat || seriesArr.find((s) => s.formatCode)?.formatCode const gridBottom = getGridBottomPx(legendInfo) const manualGrid = extractManualLayoutGrid(chartNode) const containLabel = !hasManualGrid(manualGrid) return { title: title ? { text: title, left: 'center', ...titleLayout, textStyle: { fontSize: 14, ...(titleStyle ?? {}) }, } : undefined, tooltip: { trigger: 'axis' as const, ...(tooltipFmt ? { valueFormatter: (value: unknown) => formatValue( Array.isArray(value) ? (value[0] as number) : (value as number), tooltipFmt ), } : {}), }, legend: buildLegendOption( legendOpt, legendInfo, legendTopPx, seriesArr.map((s) => { const icon = mapOoxmlSymbol(s.markerSymbol) return icon && icon !== 'none' ? { name: s.name, icon } : { name: s.name, icon: lineLegendIconPath() } }), legendTextStyle ), grid: { containLabel, left: gridLeft, right: 10, top: gridTop, bottom: gridBottom, ...manualGrid, }, xAxis: xAxisDef, yAxis: yAxisDef, series, } } function buildPieChartOption( chartTypeNode: SafeXmlNode, chartNode: SafeXmlNode, seriesArr: SeriesData[], isDoughnut: boolean, ctx: RenderContext ): echarts.EChartsOption { const title = extractChartTitle(chartNode, seriesArr) const titleStyle = extractTxPrStyle(chartNode.child('title'), ctx) const titleLayout = extractTitleManualLayout(chartNode) const legendInfo = extractLegendInfo(chartNode, ctx) const legendOpt = legendInfo?.option const legendTextStyle = { fontSize: 10, ...(legendInfo?.textStyle ?? {}) } // Pie charts typically use the first series const firstSeries = seriesArr[0] if (!firstSeries) { return { title: title ? { text: title } : undefined } } // Parse data labels: for pie, prefer first series (ser) over chart-type — left/right pies may differ const firstSer = chartTypeNode.children('ser')[0] let sharedLabels = firstSer?.exists() ? parseDataLabels(firstSer, ctx) : undefined if (!sharedLabels) sharedLabels = parseDataLabels(chartTypeNode, ctx) // Check if dLbls explicitly exists — if it does but parseDataLabels returned undefined, // that means all show flags are explicitly false → labels should be hidden. const hasDLblsNode = (firstSer?.exists() && firstSer.child('dLbls').exists()) || chartTypeNode.child('dLbls').exists() const dLblsExplicitlyOff = hasDLblsNode && !sharedLabels // Parse explosion from the first c:ser element const explosions = firstSer ? parseExplosion(firstSer, firstSeries.categories.length) : undefined const pieData = firstSeries.categories.map((cat, i) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const item: any = { name: cat || `Item ${i + 1}`, value: firstSeries.values[i] ?? 0, } // Per-point color if (firstSeries.dataPointColors?.[i]) { item.itemStyle = { color: firstSeries.dataPointColors[i] } } // Explosion (selected offset) if (explosions?.[i] && explosions[i] > 0) { item.selected = true item.selectedOffset = pieExplosionToOffset(explosions[i]) } return item }) // Build label formatter based on data label config; show value and percent when requested const fc = firstSeries.formatCode // eslint-disable-next-line @typescript-eslint/no-explicit-any let labelFormatter: string | ((params: any) => string) = '{b}: {c} ({d}%)' if (sharedLabels) { if (sharedLabels.showVal && fc && fc.includes('%')) { // eslint-disable-next-line @typescript-eslint/no-explicit-any labelFormatter = (params: any) => { const parts: string[] = [] if (sharedLabels!.showCatName) parts.push(params.name) parts.push(formatValue(params.value, fc)) if (sharedLabels!.showPercent) parts.push(`${params.percent}%`) return parts.join(', ') } } else { const parts: string[] = [] if (sharedLabels.showCatName) parts.push('{b}') if (sharedLabels.showVal) parts.push('{c}') if (sharedLabels.showPercent) parts.push('{d}%') if (parts.length > 0) { labelFormatter = parts.join(' ') } else { // All show* flags are false — hide labels entirely labelFormatter = '' } } } // Determine whether labels should be shown: // - If dLbls exists with all show flags=false → labels explicitly disabled // - If no dLbls exist → keep labels hidden by default, matching PowerPoint's default pie output // - If sharedLabels has any show flag true → show labels const showLabel = !dLblsExplicitlyOff && !!sharedLabels && (sharedLabels.showVal || sharedLabels.showCatName || sharedLabels.showSerName || sharedLabels.showPercent) const pieLayout = computePieLayout(legendInfo, isDoughnut, showLabel) const series: echarts.PieSeriesOption[] = [ { type: 'pie' as const, name: firstSeries.name, radius: pieLayout.radius, center: pieLayout.center, data: pieData, selectedMode: explosions ? 'multiple' : false, label: { show: showLabel, formatter: labelFormatter, fontSize: sharedLabels?.fontSize ?? 10, ...(sharedLabels?.bold === true ? { fontWeight: 'bold' as const } : {}), position: sharedLabels?.position === 'outEnd' ? 'outside' : sharedLabels?.position === 'ctr' ? 'inside' : 'outside', }, }, ] const legendTopPx = getLegendTopPx(!!title, legendInfo) const tooltipFmt = fc return { title: title ? { text: title, left: 'center', ...titleLayout, textStyle: { fontSize: 12, ...(titleStyle ?? {}) }, } : undefined, tooltip: { trigger: 'item' as const, ...(tooltipFmt ? { valueFormatter: (value: unknown) => formatValue( Array.isArray(value) ? (value[0] as number) : (value as number), tooltipFmt ), } : {}), }, legend: buildLegendOption( legendOpt, legendInfo, legendTopPx, firstSeries.categories, legendTextStyle ), series, } } function buildRadarChartOption( chartTypeNode: SafeXmlNode, chartNode: SafeXmlNode, seriesArr: SeriesData[], ctx: RenderContext ): echarts.EChartsOption { const title = extractChartTitle(chartNode, seriesArr) const titleStyle = extractTxPrStyle(chartNode.child('title'), ctx) const titleLayout = extractTitleManualLayout(chartNode) const legendInfo = extractLegendInfo(chartNode, ctx) const legendOpt = legendInfo?.option const legendTextStyle = { fontSize: 10, ...(legendInfo?.textStyle ?? {}) } // Categories come from the first series that has them const categories = seriesArr.find((s) => s.categories.length > 0)?.categories || [] // Read valAx scaling for explicit min/max on radar const plotArea = chartNode.child('plotArea') const { valueAxis } = parseAxes(plotArea, ctx) // Determine indicator max: prefer explicit valAx max, else compute from data + padding let indicatorMax: number if (valueAxis.max !== undefined) { indicatorMax = valueAxis.max } else { let maxVal = 0 for (const s of seriesArr) { for (const v of s.values) { if (v > maxVal) maxVal = v } } indicatorMax = Math.ceil(maxVal * 1.1) || 100 } // PowerPoint radar charts place categories clockwise from top, // but ECharts places indicators counterclockwise. To match PowerPoint, // keep the first category at top and reverse the rest. const cwCategories = categories.length > 1 ? [categories[0], ...categories.slice(1).reverse()] : categories const indicator = cwCategories.map((cat) => ({ name: cat, max: indicatorMax, })) // Read radar style to determine default marker behavior const radarStyle = chartTypeNode.child('radarStyle').attr('val') // 'marker' | 'filled' | undefined const radarData = seriesArr.map((s) => { // Reorder values to match the reversed category order const cwValues = s.values.length > 1 ? [s.values[0], ...s.values.slice(1).reverse()] : s.values const echartsSymbol = mapOoxmlSymbol(s.markerSymbol) // Show symbols if radarStyle is 'marker' or series has explicit marker const showSymbol = radarStyle === 'marker' || (echartsSymbol !== undefined && echartsSymbol !== 'none') // PowerPoint radar charts fill the area with a semi-transparent version of the line color const isFilled = radarStyle === 'filled' return { name: s.name, value: cwValues, ...(s.colorHex ? { lineStyle: { color: s.colorHex, width: s.lineWidth ?? 3, cap: 'round' as const, join: 'round' as const, }, itemStyle: { color: s.colorHex }, } : { lineStyle: { width: s.lineWidth ?? 3, cap: 'round' as const, join: 'round' as const }, }), areaStyle: isFilled ? { ...(s.colorHex ? { color: s.colorHex } : {}), opacity: 0.5 } : { ...(s.colorHex ? { color: s.colorHex } : {}), opacity: 0.15 }, ...(echartsSymbol && echartsSymbol !== 'none' ? { symbol: echartsSymbol } : {}), ...(s.markerSize ? { symbolSize: s.markerSize } : {}), ...(showSymbol ? { symbolSize: s.markerSize ?? 6 } : {}), } }) const legendTopPx = getLegendTopPx(!!title, legendInfo) return { title: title ? { text: title, left: 'center', ...titleLayout, textStyle: { fontSize: 12, ...(titleStyle ?? {}) }, } : undefined, tooltip: {}, legend: buildLegendOption( legendOpt, legendInfo, legendTopPx, seriesArr.map((s) => { const icon = mapOoxmlSymbol(s.markerSymbol) return icon && icon !== 'none' ? { name: s.name, icon } : s.name }), legendTextStyle ), radar: { indicator, radius: '58%', center: ['50%', '55%'] }, series: [ { type: 'radar' as const, data: radarData, }, ], } } function buildScatterChartOption( chartTypeNode: SafeXmlNode, chartNode: SafeXmlNode, seriesArr: SeriesData[], ctx: RenderContext ): echarts.EChartsOption { const title = extractChartTitle(chartNode, seriesArr) const titleStyle = extractTxPrStyle(chartNode.child('title'), ctx) const titleLayout = extractTitleManualLayout(chartNode) const legendInfo = extractLegendInfo(chartNode, ctx) const legendOpt = legendInfo?.option const legendTextStyle = { fontSize: 10, ...(legendInfo?.textStyle ?? {}) } // Parse scatter-specific marker defaults from scatterStyle const scatterStyle = chartTypeNode.child('scatterStyle').attr('val') ?? 'lineMarker' // Default scatter marker symbol per OOXML: lineMarker → diamond, smoothMarker → diamond const defaultScatterSymbol = scatterStyle === 'lineMarker' || scatterStyle === 'smoothMarker' ? 'diamond' : 'circle' const series = seriesArr.map((s) => { // Use xValues if available (parsed from c:xVal), otherwise fall back to index const data = s.values.map((v, i) => { const x = s.xValues && i < s.xValues.length ? s.xValues[i] : i return [x, v] }) const echartsSymbol = mapOoxmlSymbol(s.markerSymbol) ?? defaultScatterSymbol const showSymbol = echartsSymbol !== 'none' const renderAsLine = scatterStyle === 'smoothMarker' || s.smooth if (renderAsLine) { const lineData = scatterStyle === 'smoothMarker' || s.smooth ? buildSmoothScatterLineData(data) : data const lineWidth = s.lineWidth ?? 4 return { type: 'line' as const, name: s.name, data: lineData, smooth: false, showSymbol, ...(showSymbol ? { symbol: echartsSymbol, symbolSize: s.markerSize ?? 8 } : {}), ...(s.colorHex ? { lineStyle: { color: s.colorHex, width: lineWidth, cap: 'round' as const, join: 'round' as const, }, itemStyle: { color: s.colorHex }, } : { lineStyle: { width: lineWidth, cap: 'round' as const, join: 'round' as const } }), } } return { type: 'scatter' as const, name: s.name, data, symbol: echartsSymbol, symbolSize: s.markerSize ?? 8, itemStyle: s.colorHex ? { color: s.colorHex } : undefined, } }) const legendData = seriesArr.map((s) => { const echartsSymbol = mapOoxmlSymbol(s.markerSymbol) ?? defaultScatterSymbol const showSymbol = echartsSymbol !== 'none' const renderAsLine = scatterStyle === 'smoothMarker' || s.smooth if (renderAsLine) { return showSymbol && echartsSymbol ? { name: s.name, icon: echartsSymbol } : { name: s.name, icon: lineLegendIconPath() } } return echartsSymbol && echartsSymbol !== 'none' ? { name: s.name, icon: echartsSymbol } : s.name }) const plotArea = chartNode.child('plotArea') const { xAxis: xAxisInfo, yAxis: yAxisInfo } = parseScatterAxes(plotArea, ctx) const gridTop = getGridTopPx(!!title, legendInfo) const legendTopPx = getLegendTopPx(!!title, legendInfo) const manualGrid = extractManualLayoutGrid(chartNode) const containLabel = !hasManualGrid(manualGrid) const scatterGridLeft = yAxisInfo.deleted ? 4 : 24 const scatterGridTop = title ? gridTop + 12 : gridTop const scatterGridBottom = Math.max(getGridBottomPx(legendInfo), 20) const xAxisDef: Record = { type: 'value' } const yAxisDef: Record = { type: 'value' } applyAxisInfo(xAxisDef, xAxisInfo, 'value') applyAxisInfo(yAxisDef, yAxisInfo, 'value') return { title: title ? { text: title, left: 'center', ...titleLayout, textStyle: { fontSize: 14, ...(titleStyle ?? {}) }, } : undefined, tooltip: { trigger: 'item' }, legend: buildLegendOption(legendOpt, legendInfo, legendTopPx, legendData, legendTextStyle), grid: { containLabel, left: scatterGridLeft, right: 10, top: scatterGridTop, bottom: scatterGridBottom, ...manualGrid, }, xAxis: xAxisDef, yAxis: yAxisDef, series, } } // --------------------------------------------------------------------------- // Bubble Chart // --------------------------------------------------------------------------- function buildBubbleChartOption( chartTypeNode: SafeXmlNode, chartNode: SafeXmlNode, seriesArr: SeriesData[], ctx: RenderContext ): echarts.EChartsOption { const title = extractChartTitle(chartNode, seriesArr) const titleStyle = extractTxPrStyle(chartNode.child('title'), ctx) const titleLayout = extractTitleManualLayout(chartNode) const legendInfo = extractLegendInfo(chartNode, ctx) const legendOpt = legendInfo?.option const legendTextStyle = { fontSize: 10, ...(legendInfo?.textStyle ?? {}) } const bubbleScale = Math.max(chartTypeNode.child('bubbleScale').numAttr('val') ?? 100, 0) const maxBubbleDiameter = 100 * (bubbleScale / 100 || 1) // Bubble charts scale bubble area by value. In screen space that means diameter // should follow sqrt(value / maxValue), not a linear min-max interpolation. let maxSize = Number.NEGATIVE_INFINITY for (const s of seriesArr) { if (s.bubbleSizes) { for (const sz of s.bubbleSizes) { if (sz > maxSize) maxSize = sz } } } const safeMaxBubbleSize = maxSize > 0 ? maxSize : 1 const series: echarts.ScatterSeriesOption[] = seriesArr.map((s) => { const data = s.values.map((v, i) => { const x = s.xValues && i < s.xValues.length ? s.xValues[i] : i const bub = s.bubbleSizes && i < s.bubbleSizes.length ? s.bubbleSizes[i] : 0 return [x, v, bub] }) return { type: 'scatter' as const, name: s.name, data, symbolSize: (val: number[]) => { const bubbleValue = Math.max(Number(val[2]) || 0, 0) return Math.sqrt(bubbleValue / safeMaxBubbleSize) * maxBubbleDiameter }, itemStyle: s.colorHex ? { color: s.colorHex } : undefined, } }) const plotArea = chartNode.child('plotArea') const { xAxis: xAxisInfo, yAxis: yAxisInfo } = parseScatterAxes(plotArea, ctx) const gridTop = getGridTopPx(!!title, legendInfo) const legendTopPx = getLegendTopPx(!!title, legendInfo) const manualGrid = extractManualLayoutGrid(chartNode) const containLabel = !hasManualGrid(manualGrid) const scatterGridLeft = yAxisInfo.deleted ? 4 : 24 const scatterGridTop = title ? gridTop + 12 : gridTop const scatterGridBottom = Math.max(getGridBottomPx(legendInfo), 20) const xAxisDef: Record = { type: 'value' } const yAxisDef: Record = { type: 'value' } applyAxisInfo(xAxisDef, xAxisInfo, 'value') applyAxisInfo(yAxisDef, yAxisInfo, 'value') return { title: title ? { text: title, left: 'center', ...titleLayout, textStyle: { fontSize: 14, ...(titleStyle ?? {}) }, } : undefined, tooltip: { trigger: 'item', formatter: (params: unknown) => { const p = params as { seriesName: string; value: number[] } return `${p.seriesName}
x: ${p.value[0]}, y: ${p.value[1]}, size: ${p.value[2]}` }, }, legend: buildLegendOption( legendOpt, legendInfo, legendTopPx, seriesArr.map((s) => s.name), legendTextStyle ), grid: { containLabel, left: scatterGridLeft, right: 10, top: scatterGridTop, bottom: scatterGridBottom, ...manualGrid, }, xAxis: xAxisDef, yAxis: yAxisDef, series, } } // --------------------------------------------------------------------------- // Stock Chart (Candlestick) // --------------------------------------------------------------------------- function buildStockChartOption( _chartTypeNode: SafeXmlNode, chartNode: SafeXmlNode, seriesArr: SeriesData[], ctx: RenderContext ): echarts.EChartsOption { const title = extractChartTitle(chartNode, seriesArr) const titleStyle = extractTxPrStyle(chartNode.child('title'), ctx) const titleLayout = extractTitleManualLayout(chartNode) const legendInfo = extractLegendInfo(chartNode, ctx) // Stock charts have 3 (HLC) or 4 (OHLC) series: // OHLC order: open, high, low, close // HLC order: high, low, close (open defaults to close → collapsed body) const categories = seriesArr.find((s) => s.categories.length > 0)?.categories || [] // ECharts candlestick expects [open, close, low, high] per data point const dataLen = categories.length || Math.max(...seriesArr.map((s) => s.values.length), 0) const candleData: number[][] = [] if (seriesArr.length >= 4) { // OHLC: series 0=open, 1=high, 2=low, 3=close for (let i = 0; i < dataLen; i++) { candleData.push([ seriesArr[0].values[i] ?? 0, // open seriesArr[3].values[i] ?? 0, // close seriesArr[2].values[i] ?? 0, // low seriesArr[1].values[i] ?? 0, // high ]) } } else if (seriesArr.length >= 3) { // HLC: series 0=high, 1=low, 2=close; open=close (collapsed body) for (let i = 0; i < dataLen; i++) { const close = seriesArr[2].values[i] ?? 0 candleData.push([ close, // open = close close, // close seriesArr[1].values[i] ?? 0, // low seriesArr[0].values[i] ?? 0, // high ]) } } else { // Fallback: single series treated as close values with zero open for (let i = 0; i < dataLen; i++) { const val = seriesArr[0]?.values[i] ?? 0 candleData.push([0, val, 0, val]) } } const plotArea = chartNode.child('plotArea') const { valueAxis, categoryAxis } = parseAxes(plotArea, ctx) const gridTop = getGridTopPx(!!title, legendInfo) const manualGrid = extractManualLayoutGrid(chartNode) const containLabel = !hasManualGrid(manualGrid) const xAxisDef: Record = { type: 'category', data: categories, axisLabel: { interval: 0, rotate: categories.length > 4 ? 30 : 0, fontSize: 10 }, splitLine: { show: false }, } applyAxisInfo(xAxisDef, categoryAxis, 'category') const yAxisDef: Record = { type: 'value' } applyAxisInfo(yAxisDef, valueAxis, 'value') const stockValues = candleData.flatMap((d) => [d[2], d[3]]).filter((v) => Number.isFinite(v)) if (stockValues.length > 0) { const stockMin = Math.min(...stockValues) const stockMax = Math.max(...stockValues) if (yAxisDef.min === undefined && stockMin >= 0) { yAxisDef.min = 0 } if (yAxisDef.interval === undefined) { yAxisDef.interval = niceAxisInterval(stockMax, stockMin, 7) } if (yAxisDef.max === undefined) { const interval = Number(yAxisDef.interval) || niceAxisInterval(stockMax, stockMin, 7) yAxisDef.max = Math.ceil(stockMax / interval) * interval + interval } } const legendOpt = legendInfo?.option const legendTextStyle = { fontSize: 10, ...(legendInfo?.textStyle ?? {}) } const legendTopPx = getLegendTopPx(!!title, legendInfo) const isHlc = seriesArr.length >= 3 && seriesArr.length < 4 const legendData = isHlc ? seriesArr.slice(0, 3).map((s) => ({ name: s.name, icon: 'none' })) : seriesArr.map((s) => s.name) const series: echarts.SeriesOption[] = isHlc ? [ { type: 'custom', name: seriesArr[2].name, coordinateSystem: 'cartesian2d', // data: [categoryIndex, high, low, close] data: Array.from({ length: dataLen }, (_, i) => [ i, seriesArr[0].values[i] ?? 0, seriesArr[1].values[i] ?? 0, seriesArr[2].values[i] ?? 0, ]), // eslint-disable-next-line @typescript-eslint/no-explicit-any renderItem: (params: any, api: any) => { const xValue = api.value(0) const high = api.value(1) const low = api.value(2) const close = api.value(3) const highPoint = api.coord([xValue, high]) const lowPoint = api.coord([xValue, low]) const closePoint = api.coord([xValue, close]) const bandWidth = Math.max(8, api.size([1, 0])[0] || 12) // Office HLC close marks stay as short ticks; scaling them with the full // category band makes them look like stray mid-plot marker lines. const tickWidth = Math.min(4, Math.max(2, Math.round(bandWidth * 0.04))) const stemColor = pickSeriesStringColor(seriesArr[0].colorHex, '#000000') const closeColor = pickSeriesStringColor(seriesArr[2].colorHex, '#00B050') return { type: 'group', children: [ { type: 'line', shape: { x1: highPoint[0], y1: highPoint[1], x2: lowPoint[0], y2: lowPoint[1], }, style: { stroke: stemColor, lineWidth: 1, }, }, { type: 'line', shape: { x1: closePoint[0], y1: closePoint[1], x2: closePoint[0] + tickWidth, y2: closePoint[1], }, style: { stroke: closeColor, lineWidth: 1, }, }, ], } }, silent: true, } as echarts.SeriesOption, ] : [ { type: 'candlestick' as const, name: seriesArr.length >= 3 ? seriesArr[2].name : seriesArr[0]?.name, data: candleData, itemStyle: { // OOXML up/down colors from series spPr; fallback to standard financial convention color: pickSeriesStringColor( seriesArr[seriesArr.length >= 4 ? 3 : 2]?.colorHex, '#ec0000' ), color0: pickSeriesStringColor(seriesArr[0]?.colorHex, '#00da3c'), borderColor: pickSeriesStringColor( seriesArr[seriesArr.length >= 4 ? 3 : 2]?.colorHex, '#ec0000' ), borderColor0: pickSeriesStringColor(seriesArr[0]?.colorHex, '#00da3c'), }, }, ] return { title: title ? { text: title, left: 'center', ...titleLayout, textStyle: { fontSize: 14, ...(titleStyle ?? {}) }, } : undefined, tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, legend: buildLegendOption(legendOpt, legendInfo, legendTopPx, legendData, legendTextStyle), grid: { containLabel, // Stock charts with rotated date labels need extra left inset so the // first category label is not clipped by the plot boundary. left: 24, right: 10, top: gridTop, bottom: getGridBottomPx(legendInfo), ...manualGrid, }, xAxis: xAxisDef, yAxis: yAxisDef, series, } } // --------------------------------------------------------------------------- // Data Table (c:dTable) // --------------------------------------------------------------------------- /** Parsed c:dTable info for building the chart data table. */ interface DataTableInfo { seriesArr: SeriesData[] showKeys: boolean formatCode?: string } /** * Check if plotArea has c:dTable and parse showKeys. */ function parseDataTable(plotArea: SafeXmlNode): { showKeys: boolean } | undefined { const dTable = plotArea.child('dTable') if (!dTable.exists()) return undefined const showKeys = dTable.child('showKeys').attr('val') !== '0' return { showKeys } } /** * Build HTML table element from series data for chart data table (c:dTable). */ function buildDataTableElement(info: DataTableInfo, seriesColors?: string[]): HTMLTableElement { const table = document.createElement('table') table.style.width = '100%' table.style.borderCollapse = 'collapse' table.style.fontSize = '10px' table.style.marginTop = '8px' const { seriesArr, showKeys, formatCode } = info const categories = seriesArr.find((s) => s.categories.length > 0)?.categories || [] const fc = formatCode || seriesArr.find((s) => s.formatCode)?.formatCode // Header row: empty cell + category names (columns = categories, matching X-axis) const thead = document.createElement('thead') const headerRow = document.createElement('tr') const emptyTh = document.createElement('th') emptyTh.style.border = '1px solid #ccc' emptyTh.style.padding = '2px 6px' emptyTh.style.textAlign = 'left' emptyTh.style.fontWeight = 'bold' headerRow.appendChild(emptyTh) for (let i = 0; i < categories.length; i++) { const th = document.createElement('th') th.style.border = '1px solid #ccc' th.style.padding = '2px 6px' th.style.textAlign = 'right' th.style.fontWeight = 'bold' th.textContent = categories[i] ?? '' headerRow.appendChild(th) } thead.appendChild(headerRow) table.appendChild(thead) // Data rows: series name (with optional legend key) + values across categories const tbody = document.createElement('tbody') for (let si = 0; si < seriesArr.length; si++) { const s = seriesArr[si] const tr = document.createElement('tr') const nameTd = document.createElement('td') nameTd.style.border = '1px solid #ccc' nameTd.style.padding = '2px 6px' nameTd.style.textAlign = 'left' nameTd.style.fontWeight = 'bold' if (showKeys && seriesColors && seriesColors[si]) { const key = document.createElement('span') key.style.display = 'inline-block' key.style.width = '8px' key.style.height = '8px' key.style.marginRight = '4px' key.style.verticalAlign = 'middle' key.style.backgroundColor = seriesColors[si] nameTd.appendChild(key) } nameTd.appendChild(document.createTextNode(s.name || '')) tr.appendChild(nameTd) for (let ci = 0; ci < categories.length; ci++) { const td = document.createElement('td') td.style.border = '1px solid #ccc' td.style.padding = '2px 6px' td.style.textAlign = 'right' const val = s.values[ci] td.textContent = val !== undefined ? formatValue(val, fc ?? s.formatCode) : '' tr.appendChild(td) } tbody.appendChild(tr) } table.appendChild(tbody) return table } // --------------------------------------------------------------------------- // Main Chart XML Parser // --------------------------------------------------------------------------- /** * Extract background colors from chartSpace and plotArea. * Returns { chartBg, plotAreaBg } hex color strings or undefined. */ function extractBackgroundColors( chartXml: SafeXmlNode, chartNode: SafeXmlNode, ctx: RenderContext ): { chartBg?: string; plotAreaBg?: string } { let chartBg: string | undefined let plotAreaBg: string | undefined // chartSpace > spPr > solidFill (overall chart background) const chartSpaceSpPr = chartXml.child('spPr') if (chartSpaceSpPr.exists()) { const noFill = chartSpaceSpPr.child('noFill') if (noFill.exists()) { // Explicit noFill — leave chartBg undefined (transparent) } else { const fill = chartSpaceSpPr.child('solidFill') if (fill.exists()) { chartBg = resolveColorToHex(fill, ctx) } else { // No fill specified — use white so chart area is visible chartBg = '#ffffff' } } } // chart > plotArea > spPr > solidFill (plot area background) const plotArea = chartNode.child('plotArea') if (plotArea.exists()) { const plotSpPr = plotArea.child('spPr') if (plotSpPr.exists()) { const noFill = plotSpPr.child('noFill') if (!noFill.exists()) { const fill = plotSpPr.child('solidFill') if (fill.exists()) { plotAreaBg = resolveColorToHex(fill, ctx) } } } } return { chartBg, plotAreaBg } } /** * Parse chartSpace-level clrMapOvr attributes into a color-map override. * Example: */ function parseChartColorMapOverride(chartXml: SafeXmlNode): Map | undefined { const clrMapOvr = chartXml.child('clrMapOvr') if (!clrMapOvr.exists()) return undefined // Common forms: // 1) // 2) // 3) (no override) let sourceEl = clrMapOvr.element const override = clrMapOvr.child('overrideClrMapping') if (override.exists() && override.element) { sourceEl = override.element } else { const master = clrMapOvr.child('masterClrMapping') if (master.exists()) return undefined } if (!sourceEl) return undefined const attrs = sourceEl.attributes const map = new Map() for (let i = 0; i < attrs.length; i++) { const attr = attrs[i] map.set(attr.localName, attr.value) } return map.size > 0 ? map : undefined } /** * Create a chart-local render context that applies chartSpace clrMapOvr. */ function createChartRenderContext(chartXml: SafeXmlNode, ctx: RenderContext): RenderContext { const colorMapOverride = parseChartColorMapOverride(chartXml) if (!colorMapOverride) return ctx return { ...ctx, layout: { ...ctx.layout, colorMapOverride }, // color cache depends on color map; isolate chart-local cache. colorCache: new Map(), } } function parseChartStyleId(chartXml: SafeXmlNode): number | undefined { // c:chartSpace > c:style val="N" const styleNode = chartXml.child('style') const direct = styleNode.numAttr('val') if (direct !== undefined) return direct // Some files use mc:AlternateContent > mc:Choice(c14) > c14:style const alt = chartXml.child('AlternateContent') if (!alt.exists()) return undefined for (const branch of alt.allChildren()) { const s = branch.child('style') const v = s.numAttr('val') if (v !== undefined) return v } return undefined } function clamp01(v: number): number { if (v < 0) return 0 if (v > 1) return 1 return v } function tintHex(hex: string, amount: number): string { const normalized = hex.startsWith('#') ? hex.slice(1) : hex if (normalized.length !== 6) return hex.startsWith('#') ? hex : `#${hex}` const r = Number.parseInt(normalized.slice(0, 2), 16) const g = Number.parseInt(normalized.slice(2, 4), 16) const b = Number.parseInt(normalized.slice(4, 6), 16) if ([r, g, b].some((n) => Number.isNaN(n))) return hex.startsWith('#') ? hex : `#${hex}` const a = clamp01(amount) const mix = (c: number) => Math.round(c + (255 - c) * a) return `#${[mix(r), mix(g), mix(b)].map((n) => n.toString(16).padStart(2, '0')).join('')}` } /** * Build a chart color palette from theme accents and chart style id. * This improves parity with Office chart styles when series colors are implicit. */ function buildChartPalette(chartXml: SafeXmlNode, ctx: RenderContext): string[] | undefined { const accents = ['accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6'] .map((k) => ctx.theme.colorScheme.get(k)) .filter((v): v is string => !!v) .map((hex) => (hex.startsWith('#') ? hex : `#${hex}`)) if (accents.length === 0) return undefined const styleId = parseChartStyleId(chartXml) if (styleId === undefined) return accents // Style ids 100+ use the same accent order as the base palette. // No rotation needed — OOXML chart styles control visual appearance // (e.g. 3D, transparency) but don't reorder series colors. return accents } // --------------------------------------------------------------------------- // Chart-Space Default Font Size + Legend Grid Adjustment // --------------------------------------------------------------------------- /** * Apply chart-space default font size to all text elements in the ECharts option * that still use hardcoded small defaults. Only overrides when no explicit OOXML * font size was set on that element (i.e., value matches our hardcoded defaults). */ function applyDefaultFontSizes(option: echarts.EChartsOption, defaultFs: number): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any const opt = option as any // Title: our defaults are 12 or 14 — replace with the chart-space default if (opt.title?.textStyle?.fontSize) { const cur = opt.title.textStyle.fontSize if (cur <= 14) { opt.title.textStyle.fontSize = defaultFs } } // Radar indicator font size if (opt.radar) { const radar = Array.isArray(opt.radar) ? opt.radar[0] : opt.radar if (radar?.name?.textStyle) { if (!radar.name.textStyle.fontSize || radar.name.textStyle.fontSize <= 10) { radar.name.textStyle.fontSize = defaultFs } } } // Series data label font sizes: apply default when no explicit OOXML font was set const seriesArr = Array.isArray(opt.series) ? opt.series : opt.series ? [opt.series] : [] for (const s of seriesArr) { if (s?.label?.fontSize && (s.label.fontSize as number) <= 10) { s.label.fontSize = defaultFs } } const applyAxisDefaultFontSize = (axis: any) => { if (!axis?.axisLabel) return const current = axis.axisLabel.fontSize if (current === undefined || current <= 10) { axis.axisLabel.fontSize = defaultFs } } const xAxes = Array.isArray(opt.xAxis) ? opt.xAxis : opt.xAxis ? [opt.xAxis] : [] const yAxes = Array.isArray(opt.yAxis) ? opt.yAxis : opt.yAxis ? [opt.yAxis] : [] for (const axis of [...xAxes, ...yAxes]) applyAxisDefaultFontSize(axis) if (opt.legend?.textStyle) { const current = opt.legend.textStyle.fontSize if (current === undefined || current <= 10) { opt.legend.textStyle.fontSize = defaultFs } } } function applyDefaultFontFamily(option: echarts.EChartsOption, fontFamily: string): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any const opt = option as any if (opt.title?.textStyle && !opt.title.textStyle.fontFamily) { opt.title.textStyle.fontFamily = fontFamily } if (opt.title?.textStyle && !opt.title.textStyle.fontWeight) { opt.title.textStyle.fontWeight = 'bold' } const applyAxisFontFamily = (axis: any) => { if (!axis) return const axisLabel = axis.axisLabel ?? (axis.axisLabel = {}) if (!axisLabel.fontFamily) { axisLabel.fontFamily = fontFamily } } const xAxes = Array.isArray(opt.xAxis) ? opt.xAxis : opt.xAxis ? [opt.xAxis] : [] const yAxes = Array.isArray(opt.yAxis) ? opt.yAxis : opt.yAxis ? [opt.yAxis] : [] for (const axis of [...xAxes, ...yAxes]) applyAxisFontFamily(axis) if (opt.legend?.textStyle && !opt.legend.textStyle.fontFamily) { opt.legend.textStyle.fontFamily = fontFamily } } /** * Adjust grid margins to prevent legend/chart overlap. * When legend is at right or left with overlay=false, the grid needs a larger * margin so that chart bars/lines don't extend into the legend area. */ function applyLegendGridMargins( option: echarts.EChartsOption, chartNode: SafeXmlNode, defaultFs: number | undefined ): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any const opt = option as any if (!opt.grid || !opt.legend) return if (opt.legend.show === false) return const legend = chartNode.child('legend') if (!legend.exists()) return const overlay = legend.child('overlay').attr('val') === '1' if (overlay) return const posVal = legend.child('legendPos').attr('val') || 'r' // Only adjust for side-positioned legends if (posVal === 'r' || posVal === 'l') { // Estimate legend width based on legend names and font size const legendData = opt.legend.data as (string | { name: string })[] | undefined if (!legendData || legendData.length === 0) return const names = legendData.map((d: string | { name: string }) => typeof d === 'string' ? d : d.name ) const fs = opt.legend?.textStyle?.fontSize ?? defaultFs ?? 12 const iconWidth = Number(opt.legend?.itemWidth) || fs // Estimate legend width: icon (~fontSize) + gap + text + padding // Measure max text width considering CJK (wider) vs Latin (narrower) chars let maxTextPx = 0 for (const n of names) { let w = 0 for (const ch of n) { w += ch.charCodeAt(0) > 0x2e80 ? fs : fs * 0.55 } if (w > maxTextPx) maxTextPx = w } // icon + gap + text + left/right padding const estimatedLegendPx = iconWidth + 8 + maxTextPx + 14 const gridMarginPx = Math.max(84, Math.round(estimatedLegendPx + 18)) // Check if manual grid layout was applied — don't override if (typeof opt.grid.left === 'string' && opt.grid.left.includes('%')) return if (typeof opt.grid.right === 'string' && opt.grid.right.includes('%')) return if (posVal === 'r') { opt.grid.right = gridMarginPx } else { opt.grid.left = gridMarginPx } } } /** * Compute a "nice" axis max/min that PowerPoint would auto-calculate. * PowerPoint rounds the value axis range to tidy tick marks (e.g., data max 5 → axis max 6). * This post-processes the ECharts option to set axis max when not explicitly provided. */ function applyNiceAxisRange(option: echarts.EChartsOption): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any const opt = option as any // Only applies to cartesian charts with axes if (!opt.xAxis && !opt.yAxis) return // Collect all numeric data values from series, accounting for stacking const allValues: number[] = [] const xValues: number[] = [] const yValues: number[] = [] const seriesArr = Array.isArray(opt.series) ? opt.series : opt.series ? [opt.series] : [] // Group series by stack key to compute stacked totals const stackGroups = new Map() const unstackedValues: number[] = [] for (const s of seriesArr) { if (!s.data) continue const vals: number[] = [] for (const d of s.data) { if (typeof d === 'number') { vals.push(d) } else if (d && typeof d === 'object' && 'value' in d && typeof d.value === 'number') { vals.push(d.value) } else if (Array.isArray(d)) { if (d.length >= 2 && typeof d[0] === 'number' && typeof d[1] === 'number') { xValues.push(d[0]) yValues.push(d[1]) } for (const v of d) { if (typeof v === 'number') vals.push(v) } } else { vals.push(0) } } if (s.stack) { const key = String(s.stack) if (!stackGroups.has(key)) stackGroups.set(key, []) stackGroups.get(key)!.push(vals) } else { unstackedValues.push(...vals) } } // For stacked series, compute per-category sums for (const group of stackGroups.values()) { const maxLen = Math.max(...group.map((v) => v.length)) for (let i = 0; i < maxLen; i++) { let sum = 0 for (const vals of group) { sum += vals[i] ?? 0 } allValues.push(sum) } } allValues.push(...unstackedValues) if (allValues.length === 0) return const cartesianScatter = xValues.length > 0 && yValues.length > 0 && (Array.isArray(opt.xAxis) ? opt.xAxis[0] : opt.xAxis)?.type === 'value' && (Array.isArray(opt.yAxis) ? opt.yAxis[0] : opt.yAxis)?.type === 'value' const applyAxisExtent = (axis: any, values: number[], desiredTicks: number) => { if (!axis || axis.type !== 'value' || values.length === 0) return if (axis.min !== undefined && axis.max !== undefined) return const dataMin = Math.min(...values) const dataMax = Math.max(...values) const interval = niceAxisInterval(dataMax, dataMin, desiredTicks) if (axis.max === undefined) { axis.max = niceAxisMax(dataMax, dataMin, desiredTicks) } if (axis.min === undefined && dataMin >= 0) { axis.min = 0 } if (axis.interval === undefined) { axis.interval = interval } } if (cartesianScatter) { const xAxes = (Array.isArray(opt.xAxis) ? opt.xAxis : [opt.xAxis]) as Record[] const yAxes = (Array.isArray(opt.yAxis) ? opt.yAxis : [opt.yAxis]) as Record[] xAxes.forEach((ax) => applyAxisExtent(ax, xValues, 3)) yAxes.forEach((ax) => applyAxisExtent(ax, yValues, 7)) return } // Find the value axes (could be xAxis or yAxis depending on bar direction) const processAxis = (axis: unknown) => { if (!axis) return const axes = Array.isArray(axis) ? axis : [axis] for (const ax of axes) { if (!ax || ax.type !== 'value') continue // Skip if explicit min/max already set if (ax.min !== undefined && ax.max !== undefined) continue const dataMin = Math.min(...allValues) const dataMax = Math.max(...allValues) // Only set max when not already specified if (ax.max === undefined) { ax.max = niceAxisMax(dataMax, dataMin) } // Set min to 0 when all values are non-negative and no explicit min if (ax.min === undefined && dataMin >= 0) { ax.min = 0 } } } processAxis(opt.xAxis) processAxis(opt.yAxis) } /** * Calculate a "nice" axis maximum, similar to PowerPoint's algorithm. * Given data max, returns a rounded-up value that gives clean tick marks with headroom. * PowerPoint always adds at least one tick interval above the data max. */ function niceAxisMax(dataMax: number, dataMin: number, desiredTicks = 5): number { const niceInterval = niceAxisInterval(dataMax, dataMin, desiredTicks) const niceMax = Math.ceil(dataMax / niceInterval) * niceInterval return niceMax <= dataMax ? niceMax + niceInterval : niceMax } function niceAxisInterval(dataMax: number, dataMin: number, desiredTicks = 5): number { if (dataMax === 0 && dataMin === 0) return 1 const range = dataMax - Math.min(0, dataMin) if (range === 0) return dataMax > 0 ? dataMax * 1.2 : 1 const rawInterval = range / desiredTicks const magnitude = 10 ** Math.floor(Math.log10(rawInterval)) const residual = rawInterval / magnitude let niceInterval: number if (residual <= 1) niceInterval = magnitude else if (residual <= 2) niceInterval = 2 * magnitude else if (residual <= 5) niceInterval = 5 * magnitude else niceInterval = 10 * magnitude return niceInterval } /** * Extract chart-space default font size from chartSpace > txPr > defRPr@sz. * Returns size in pixels (OOXML sz is 1/100 pt; we convert to px at 96 DPI: 1pt = 1.333px). * PowerPoint uses this as the default text size for all chart text elements. */ function extractChartDefaultFontSize(chartSpaceNode: SafeXmlNode): number | undefined { const txPr = chartSpaceNode.child('txPr') if (!txPr.exists()) return undefined for (const p of txPr.children('p')) { const pPr = p.child('pPr') if (!pPr.exists()) continue const defRPr = pPr.child('defRPr') if (!defRPr.exists()) continue const sz = defRPr.numAttr('sz') if (sz !== undefined && sz > 0) { // sz is 1/100 pt → convert to px at 96 DPI (1pt = 96/72 px ≈ 1.333px) return Math.round((sz / 100) * (96 / 72)) } } return undefined } /** * Estimate legend width as a percentage of chart width based on legend text length and font size. * Used to reserve grid space when legend is at right or left (non-overlay). */ function estimateLegendWidthPct( legendInfo: LegendInfo | undefined, legendNames: string[], baseFontSize: number ): string { if (!legendInfo || legendInfo.overlay) return '2%' const opt = legendInfo.option as Record | undefined if (!opt) return '2%' const isRight = opt.right !== undefined && opt.top !== undefined && opt.bottom === undefined const isLeft = opt.left !== undefined && opt.top !== undefined && opt.bottom === undefined if (!isRight && !isLeft) return '2%' // Estimate based on longest label + icon + padding const maxLen = Math.max(1, ...legendNames.map((n) => n.length)) // Approximate: each char ≈ 0.6 * fontSize, plus icon (≈ fontSize) and padding (≈ fontSize) const estimatedPx = maxLen * baseFontSize * 0.6 + baseFontSize * 3 // Convert to percentage of typical chart width (assume ~600px as base) const pct = Math.min(40, Math.max(15, Math.round((estimatedPx / 600) * 100))) return `${pct}%` } function createLegendIcon( icon: string | undefined, color: string, width: number, height: number, strokeWidth = 2 ): SVGSVGElement { const ns = 'http://www.w3.org/2000/svg' const svg = document.createElementNS(ns, 'svg') svg.setAttribute('width', String(width)) svg.setAttribute('height', String(height)) svg.setAttribute('viewBox', `0 0 ${width} ${height}`) svg.style.display = 'block' const normalized = icon ?? 'rect' if (normalized.startsWith('path://')) { const path = document.createElementNS(ns, 'path') path.setAttribute('d', normalized.slice('path://'.length)) path.setAttribute('fill', 'none') path.setAttribute('stroke', color) path.setAttribute('stroke-width', String(strokeWidth)) path.setAttribute('stroke-linecap', 'round') svg.appendChild(path) return svg } if (normalized === 'diamond') { const path = document.createElementNS(ns, 'path') path.setAttribute( 'd', `M${width / 2} 1 L${width - 1} ${height / 2} L${width / 2} ${height - 1} L1 ${height / 2} Z` ) path.setAttribute('fill', color) svg.appendChild(path) return svg } if (normalized === 'circle') { const circle = document.createElementNS(ns, 'circle') circle.setAttribute('cx', String(width / 2)) circle.setAttribute('cy', String(height / 2)) circle.setAttribute('r', String(Math.max(2, Math.min(width, height) / 2 - 1))) circle.setAttribute('fill', color) svg.appendChild(circle) return svg } const rect = document.createElementNS(ns, 'rect') rect.setAttribute('x', '1') rect.setAttribute('y', '1') rect.setAttribute('width', String(Math.max(2, width - 2))) rect.setAttribute('height', String(Math.max(2, height - 2))) rect.setAttribute('fill', color) svg.appendChild(rect) return svg } function resolveInsetToPx(value: string | number, total: number): string { if (typeof value === 'number') return `${value}px` const trimmed = value.trim() if (trimmed.endsWith('%')) { const pct = Number.parseFloat(trimmed.slice(0, -1)) if (!Number.isNaN(pct)) return `${(pct / 100) * total}px` } return trimmed } function buildCustomLegendOverlay( option: echarts.EChartsOption, size: { w: number; h: number } ): HTMLElement | null { const legend = getLegendOptionObject(option.legend) if (!legend || legend.show === false || legend.orient !== 'vertical') return null if (legend.left === undefined && legend.right === undefined) return null const palette = Array.isArray(option.color) ? option.color.filter((entry): entry is string => typeof entry === 'string') : [] const rawData = legend.data ?? [] type LegendOverlayEntry = { name: string icon: string | undefined color: string lineWidth: number } const entries = rawData .map((item, index) => { const name = typeof item === 'string' ? item : item.name const itemIcon = typeof item === 'string' ? undefined : item.icon if (!name) return null const series = ( Array.isArray(option.series) ? option.series : option.series ? [option.series] : [] )[index] as Record | undefined const lineStyle = (series?.lineStyle as Record | undefined) ?? {} const itemStyle = (series?.itemStyle as Record | undefined) ?? {} const color = (typeof lineStyle.color === 'string' ? lineStyle.color : undefined) ?? (typeof itemStyle.color === 'string' ? itemStyle.color : undefined) ?? palette[index] ?? '#2f6f8f' const lineWidth = typeof lineStyle.width === 'number' && Number.isFinite(lineStyle.width) ? Math.max(1, lineStyle.width) : 2 return { name, icon: itemIcon ?? legend.icon, color, lineWidth } }) .filter((entry): entry is LegendOverlayEntry => entry !== null) if (entries.length === 0) return null const overlay = document.createElement('div') overlay.className = 'pptx-chart-custom-legend' overlay.style.position = 'absolute' overlay.style.display = 'flex' overlay.style.flexDirection = 'column' overlay.style.gap = '6px' overlay.style.pointerEvents = 'none' overlay.style.zIndex = '1' overlay.style.whiteSpace = 'nowrap' if (legend.left !== undefined) overlay.style.left = resolveInsetToPx(legend.left, size.w) if (legend.right !== undefined) overlay.style.right = resolveInsetToPx(legend.right, size.w) const sideLegend = legend.orient === 'vertical' && (legend.left !== undefined || legend.right !== undefined) if (sideLegend) { overlay.style.top = `${size.h / 2}px` overlay.style.transform = 'translateY(-50%)' } else if (legend.top !== undefined) { overlay.style.top = resolveInsetToPx(legend.top, size.h) } if (legend.bottom !== undefined) overlay.style.bottom = resolveInsetToPx(legend.bottom, size.h) const fontSize = legend.textStyle?.fontSize ?? 10 const itemWidth = legend.itemWidth ?? fontSize const itemHeight = legend.itemHeight ?? fontSize for (const entry of entries) { const row = document.createElement('div') row.style.display = 'flex' row.style.alignItems = 'center' row.style.gap = '6px' row.appendChild( createLegendIcon(entry.icon, entry.color, itemWidth, itemHeight, entry.lineWidth) ) const label = document.createElement('span') label.textContent = entry.name label.style.color = legend.textStyle?.color ?? '#000000' label.style.fontSize = `${fontSize}px` if (legend.textStyle?.fontFamily) { label.style.fontFamily = legend.textStyle.fontFamily } if (legend.textStyle?.fontWeight !== undefined) { label.style.fontWeight = String(legend.textStyle.fontWeight) } row.appendChild(label) overlay.appendChild(row) } return overlay } function numToPct(val: number): string { const n = Math.round(val * 10000) / 100 return `${Number.isInteger(n) ? n.toFixed(0) : n}%`.replace(/\.0%$/, '%') } /** * Parse plotArea/layout/manualLayout to ECharts grid override. */ function extractManualLayoutGrid( chartNode: SafeXmlNode ): Partial> { const manual = chartNode.child('plotArea').child('layout').child('manualLayout') if (!manual.exists()) return {} const out: Partial> = {} const x = manual.child('x').numAttr('val') const y = manual.child('y').numAttr('val') const w = manual.child('w').numAttr('val') const h = manual.child('h').numAttr('val') if (x !== undefined) out.left = numToPct(x) if (y !== undefined) out.top = numToPct(y) if (w !== undefined) out.width = numToPct(w) if (h !== undefined) out.height = numToPct(h) return out } /** Result of parsing chart XML: option for ECharts, optional data table info. */ interface ParseChartResult { option: echarts.EChartsOption dataTable?: DataTableInfo } function buildOptionForChartType( typeName: OoxmlChartType, chartTypeNode: SafeXmlNode, chartNode: SafeXmlNode, seriesArr: SeriesData[], ctx: RenderContext ): echarts.EChartsOption | undefined { switch (typeName) { case 'barChart': case 'bar3DChart': return buildBarChartOption(chartTypeNode, chartNode, seriesArr, ctx) case 'lineChart': case 'line3DChart': return buildLineChartOption(chartTypeNode, chartNode, seriesArr, ctx, false) case 'areaChart': case 'area3DChart': case 'surface3DChart': return buildLineChartOption(chartTypeNode, chartNode, seriesArr, ctx, true) case 'pieChart': case 'pie3DChart': return buildPieChartOption(chartTypeNode, chartNode, seriesArr, false, ctx) case 'doughnutChart': return buildPieChartOption(chartTypeNode, chartNode, seriesArr, true, ctx) case 'radarChart': return buildRadarChartOption(chartTypeNode, chartNode, seriesArr, ctx) case 'scatterChart': return buildScatterChartOption(chartTypeNode, chartNode, seriesArr, ctx) case 'bubbleChart': return buildBubbleChartOption(chartTypeNode, chartNode, seriesArr, ctx) case 'stockChart': return buildStockChartOption(chartTypeNode, chartNode, seriesArr, ctx) default: return undefined } } function isCartesianComboCapable(typeName: OoxmlChartType): boolean { return ( typeName === 'barChart' || typeName === 'bar3DChart' || typeName === 'lineChart' || typeName === 'line3DChart' || typeName === 'areaChart' || typeName === 'area3DChart' || typeName === 'surface3DChart' ) } function mergeLegendData( primaryLegend: echarts.EChartsOption['legend'], secondaryLegend: echarts.EChartsOption['legend'] ): echarts.EChartsOption['legend'] { const primary = getLegendOptionObject(primaryLegend) const secondary = getLegendOptionObject(secondaryLegend) if (!primary) return secondaryLegend if (!secondary) return primaryLegend const mergedData = [...(primary.data ?? []), ...(secondary.data ?? [])] const seen = new Set() const deduped = mergedData.filter((entry) => { const key = typeof entry === 'string' ? entry : entry.name if (seen.has(key)) return false seen.add(key) return true }) const merged: LegendOptionObject = { ...primary, data: deduped, } if (deduped.some((entry) => typeof entry === 'object' && entry.icon)) { merged.icon = undefined } return merged } function mergeCartesianComboOptions( primary: echarts.EChartsOption, secondary: echarts.EChartsOption ): echarts.EChartsOption { const primarySeries = Array.isArray(primary.series) ? primary.series : [] const secondarySeries = Array.isArray(secondary.series) ? secondary.series : [] return { ...primary, legend: mergeLegendData(primary.legend, secondary.legend), series: [...primarySeries, ...secondarySeries], } } /** * Parse a chart XML (chartSpace root) into an ECharts option object and optional data table info. * Exported for unit testing. */ export function parseChartXml(chartXml: SafeXmlNode, ctx: RenderContext): ParseChartResult { const chartCtx = createChartRenderContext(chartXml, ctx) const chartPalette = buildChartPalette(chartXml, chartCtx) // Navigate: chartSpace > chart > plotArea const chart = chartXml.child('chart') const plotArea = chart.child('plotArea') if (!plotArea.exists()) { return { option: { title: { text: 'Unsupported chart', left: 'center' } } } } // Extract background colors const { chartBg, plotAreaBg } = extractBackgroundColors(chartXml, chart, chartCtx) const chartTypeEntries = CHART_TYPE_ELEMENTS.map((typeName) => { const chartTypeNode = plotArea.child(typeName) if (!chartTypeNode.exists()) return null const seriesArr = parseSeries(chartTypeNode, chartCtx) if (seriesArr.length === 0) return null return { typeName, chartTypeNode, seriesArr } }).filter( ( entry ): entry is { typeName: OoxmlChartType; chartTypeNode: SafeXmlNode; seriesArr: SeriesData[] } => entry !== null ) for (const [index, entry] of chartTypeEntries.entries()) { let option = buildOptionForChartType( entry.typeName, entry.chartTypeNode, chart, entry.seriesArr, chartCtx ) if (!option) continue if (index === 0 && chartTypeEntries.length > 1 && isCartesianComboCapable(entry.typeName)) { for (const comboEntry of chartTypeEntries.slice(1)) { if (!isCartesianComboCapable(comboEntry.typeName)) continue const comboOption = buildOptionForChartType( comboEntry.typeName, comboEntry.chartTypeNode, chart, comboEntry.seriesArr, chartCtx ) if (!comboOption) continue option = mergeCartesianComboOptions(option, comboOption) } } // Apply chart-space default font sizes to text elements that use hardcoded defaults const defaultFs = extractChartDefaultFontSize(chartXml) if (defaultFs) { applyDefaultFontSizes(option, defaultFs) } const defaultFontFamily = getChartThemeFontFamily(chartCtx) if (defaultFontFamily) { applyDefaultFontFamily(option, defaultFontFamily) } // Adjust grid margins for legend placement (non-overlay) applyLegendGridMargins(option, chart, defaultFs) // Apply PowerPoint-like nice axis range (adds headroom beyond data max) applyNiceAxisRange(option) // Apply background colors if (chartBg) { option.backgroundColor = chartBg } if (chartPalette && chartPalette.length > 0) { option.color = chartPalette } if (plotAreaBg && option.grid) { // Apply plot area background via grid (for cartesian charts) // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(option.grid as any).backgroundColor = plotAreaBg // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(option.grid as any).show = true } const dataTableSeries = index === 0 && chartTypeEntries.length > 1 && isCartesianComboCapable(entry.typeName) ? chartTypeEntries .filter((candidate) => isCartesianComboCapable(candidate.typeName)) .flatMap((candidate) => candidate.seriesArr) .sort((a, b) => a.order - b.order) : entry.seriesArr // Build data table info when c:dTable exists const dTableMeta = parseDataTable(plotArea) const dataTable: DataTableInfo | undefined = dTableMeta ? { seriesArr: dataTableSeries, showKeys: dTableMeta.showKeys, formatCode: dataTableSeries.find((s) => s.formatCode)?.formatCode, } : undefined return { option, dataTable } } return { option: { title: { text: 'Unsupported chart type', left: 'center', textStyle: { fontSize: 12 } }, }, } } // --------------------------------------------------------------------------- // Public Render Function // --------------------------------------------------------------------------- /** * Render a chart node into an HTML element with an ECharts instance. */ export function renderChart(node: ChartNodeData, ctx: RenderContext): HTMLElement { const wrapper = document.createElement('div') wrapper.style.position = 'absolute' wrapper.style.left = `${node.position.x}px` wrapper.style.top = `${node.position.y}px` wrapper.style.width = `${node.size.w}px` wrapper.style.height = `${node.size.h}px` wrapper.style.overflow = 'hidden' wrapper.style.display = 'flex' wrapper.style.flexDirection = 'column' const chartXml = ctx.presentation.charts?.get(node.chartPath) if (!chartXml) { wrapper.style.border = '1px dashed #ccc' wrapper.style.display = 'flex' wrapper.style.alignItems = 'center' wrapper.style.justifyContent = 'center' wrapper.style.color = '#999' wrapper.style.fontSize = '12px' wrapper.textContent = 'Chart not found' return wrapper } // Create chart container (clip content so legend/title stay inside) const chartDiv = document.createElement('div') chartDiv.style.width = '100%' chartDiv.style.flex = '1' chartDiv.style.minWidth = '0' chartDiv.style.minHeight = '0' chartDiv.style.overflow = 'hidden' wrapper.appendChild(chartDiv) // Parse chart data and create ECharts option const { option, dataTable } = parseChartXml(chartXml, ctx) const customLegend = buildCustomLegendOverlay(option, node.size) const legendOption = getLegendOptionObject(option.legend) if (customLegend && legendOption) { legendOption.show = false wrapper.appendChild(customLegend) } // Append data table below chart when c:dTable exists if (dataTable) { const seriesColors = dataTable.seriesArr.map((s) => s.colorHex).filter(Boolean) as string[] const tableEl = buildDataTableElement( dataTable, seriesColors.length > 0 ? seriesColors : undefined ) wrapper.appendChild(tableEl) } // Initialize ECharts after the element is attached to the DOM. // Use requestAnimationFrame to ensure the container has dimensions. const chartSet = ctx.chartInstances requestAnimationFrame(() => { if (!chartDiv.isConnected) return // Guard against 0-size containers (e.g. hidden tabs); defer until non-zero. if (chartDiv.offsetWidth === 0 || chartDiv.offsetHeight === 0) { const sizeObserver = new ResizeObserver((entries) => { const { width, height } = entries[0].contentRect if (width > 0 && height > 0) { sizeObserver.disconnect() initChart(chartDiv, option, chartSet) } }) sizeObserver.observe(chartDiv) return } initChart(chartDiv, option, chartSet) }) return wrapper } /** Actually create ECharts instance, set option, and wire up resize + dispose. */ function initChart( container: HTMLElement, option: echarts.EChartsOption, chartInstances?: Set ): void { try { const chart = echarts.init(container) chart.setOption(option) chartInstances?.add(chart) // Handle container resize const ro = new ResizeObserver(() => { if (container.isConnected) { chart.resize() } else { // Container removed from DOM — dispose to prevent leaks ro.disconnect() if (!chart.isDisposed()) { chart.dispose() } chartInstances?.delete(chart) } }) ro.observe(container) } catch (error) { logger.warn('Failed to initialize ECharts', { error }) container.style.display = 'flex' container.style.alignItems = 'center' container.style.justifyContent = 'center' container.style.color = '#999' container.style.fontSize = '12px' container.textContent = 'Chart render error' } }