From 30dace8d3ceb10b91f82bca37990431960130c38 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sat, 1 Aug 2026 09:41:48 +0900 Subject: [PATCH 1/3] feat(chart): combo charts with per-series kind and secondary value axis --- .changeset/combo-chart-secondary-axis.md | 16 ++ packages/preview/src/render-slide.ts | 200 +++++++++++++++++------ src/internal/chartml/chart-builder.ts | 179 +++++++++++++++++--- src/internal/chartml/chart-reader.ts | 93 +++++++++-- src/internal/chartml/types.ts | 17 ++ test/fn-chart-combo.test.ts | 125 ++++++++++++++ test/preview-chart-combo.test.ts | 70 ++++++++ 7 files changed, 612 insertions(+), 88 deletions(-) create mode 100644 .changeset/combo-chart-secondary-axis.md create mode 100644 test/fn-chart-combo.test.ts create mode 100644 test/preview-chart-combo.test.ts diff --git a/.changeset/combo-chart-secondary-axis.md b/.changeset/combo-chart-secondary-axis.md new file mode 100644 index 00000000..e0d42ed4 --- /dev/null +++ b/.changeset/combo-chart-secondary-axis.md @@ -0,0 +1,16 @@ +--- +'@office-kit/pptx': minor +'@office-kit/pptx-preview': minor +--- + +Combo charts: per-series `chartKind` overrides and a secondary value axis. + +`ChartSeries` gains `chartKind` (`'bar' | 'column' | 'line' | 'area'`) to +overlay e.g. a line series on a column chart, and `secondaryAxis: true` to +plot a series against a right-hand secondary value axis — the standard +PowerPoint combo layout for series with mixed units (counts vs. rates). +The builder splits series into plot groups (`` + `` +…) and emits the secondary ``/`` pair on demand; +`getShapeChartSpec` round-trips both fields. The preview renderer paints +bars below line/area overlays, scales each axis from its own series, and +draws the secondary ticks on the plot's right edge. diff --git a/packages/preview/src/render-slide.ts b/packages/preview/src/render-slide.ts index b34a1c6b..fffea73f 100644 --- a/packages/preview/src/render-slide.ts +++ b/packages/preview/src/render-slide.ts @@ -127,6 +127,7 @@ import { isTableShape, type PresentationData, type PresentationTheme, + type ChartKind, type ChartSeries, type ChartSpec, type ChartTextStyle, @@ -3381,6 +3382,12 @@ interface AxisSpec { readonly orientation: 'vertical' | 'horizontal'; readonly min: number; readonly max: number; + /** + * Which plot edge a vertical value axis hugs. `right` is the combo + * chart's secondary axis; ticks and labels flip to the plot's right + * edge. Defaults to `left`. + */ + readonly side?: 'left' | 'right'; /** percentStacked value axis: ticks are formatted as 0%..100%. */ readonly percent?: boolean; readonly majorUnit?: number; @@ -3498,31 +3505,31 @@ const renderValueAxis = (f: ChartFrame, axis: AxisSpec): string => { for (const t of ticks) { if (axis.orientation === 'vertical') { const yp = f.plotY + f.plotH - ((t - axis.min) / range) * f.plotH; + const onRight = axis.side === 'right'; + const edgeX = onRight ? f.plotX + f.plotW : f.plotX; if (showGrid) { out.push( ``, ); } if (tickMark !== 'none') { - const tx1 = tickMark === 'in' ? f.plotX : f.plotX - tickLen; - const tx2 = - tickMark === 'out' - ? f.plotX - : tickMark === 'cross' - ? f.plotX + tickLen - : f.plotX + tickLen; + const outward = onRight ? tickLen : -tickLen; + const inward = onRight ? -tickLen : tickLen; + const tx1 = tickMark === 'in' ? edgeX : edgeX + outward; + const tx2 = tickMark === 'out' ? edgeX : edgeX + inward; out.push( ``, ); } - // Numeric label, right-aligned to the plot's left edge. + // Numeric label — right-aligned to the plot's left edge, or + // left-aligned to the right edge for a secondary (right) axis. // Authored rotates around the // label anchor. - const labelX = f.plotX - 4; + const labelX = onRight ? edgeX + 4 : edgeX - 4; const rot = axis.labelRotationDeg ?? 0; const transform = rot ? ` transform="rotate(${rot} ${px(labelX)} ${px(yp)})"` : ''; out.push( - `${escapeXml(fmtTick(t))}`, + `${escapeXml(fmtTick(t))}`, ); } else { const xp = f.plotX + ((t - axis.min) / range) * f.plotW; @@ -5095,7 +5102,104 @@ const renderChart = ( let plot = ''; let axes = ''; - if (isCartesian) { + // Combo chart: per-series kind overrides and/or a secondary value + // axis. Split the series into plot groups, bake palette colors by + // original index (filtered specs would otherwise re-index), scale + // each axis from its own series, and paint bars below lines. The + // horizontal `bar` base kind is excluded — its value axis is + // horizontal and a line overlay has no meaningful geometry. + const isCombo = + isCartesian && + spec.kind !== 'bar' && + spec.series.some( + (s) => (s.chartKind !== undefined && s.chartKind !== spec.kind) || s.secondaryAxis === true, + ); + if (isCombo) { + const baked = spec.series.map((s, i) => ({ + ...s, + color: s.color ?? colors[i % colors.length] ?? '#888', + })); + const primarySeries = baked.filter((s) => s.secondaryAxis !== true); + const secondarySeries = baked.filter((s) => s.secondaryAxis === true); + const primaryScale = seriesMinMax({ ...spec, series: primarySeries }); + const secondaryScale = + secondarySeries.length > 0 + ? seriesMinMax({ ...spec, series: secondarySeries, valueAxis: undefined }) + : null; + + const N = pointCount(spec); + if (!spec.valueAxisHidden) { + axes = renderValueAxis(f, { + orientation: 'vertical', + min: primaryScale.min, + max: primaryScale.max, + majorUnit: spec.valueAxis?.majorUnit ?? primaryScale.step, + ...(spec.valueAxis?.numberFormat !== undefined + ? { numberFormat: spec.valueAxis.numberFormat } + : {}), + ...(spec.valueAxisMajorGridlines !== undefined + ? { majorGridlines: spec.valueAxisMajorGridlines } + : {}), + ...(spec.valueAxisLabelStyle !== undefined ? { labelStyle: spec.valueAxisLabelStyle } : {}), + }); + } + if (secondaryScale) { + axes += renderValueAxis(f, { + orientation: 'vertical', + side: 'right', + min: secondaryScale.min, + max: secondaryScale.max, + majorUnit: secondaryScale.step, + // Gridlines stay on the primary axis only — a second lattice + // with a different pitch reads as noise. + majorGridlines: false, + ...(spec.valueAxisLabelStyle !== undefined ? { labelStyle: spec.valueAxisLabelStyle } : {}), + }); + } + if (N > 0 && !(spec.categoryAxisHidden || spec.categoryAxisTickLabelPos === 'none')) { + axes += renderCategoryAxis( + f, + 'horizontal', + spec.categories, + N, + spec.categoryAxisTickLabelSkip ?? 1, + spec.categoryAxisLabelStyle, + spec.categoryAxisLabelRotationDeg, + spec.categoryAxisLabelAlign, + spec.categoryAxisLineColor, + ); + } + + // Group by (effective kind, axis); bars first, then line/area overlays. + const groups = new Map< + string, + { kind: ChartKind; secondary: boolean; series: ChartSeries[] } + >(); + for (const s of baked) { + const kind = s.chartKind ?? spec.kind; + const secondary = s.secondaryAxis === true; + const key = `${kind}|${secondary ? '1' : '0'}`; + const group = groups.get(key); + if (group) group.series.push(s); + else groups.set(key, { kind, secondary, series: [s] }); + } + const paintOrder = (g: { kind: ChartKind; secondary: boolean }): number => + (g.secondary ? 2 : 0) + (g.kind === 'line' || g.kind === 'area' ? 1 : 0); + for (const group of [...groups.values()].sort((a, b) => paintOrder(a) - paintOrder(b))) { + const scale = group.secondary && secondaryScale ? secondaryScale : primaryScale; + const groupSpec: ChartSpec = { + ...spec, + series: group.series, + valueAxis: { min: scale.min, max: scale.max }, + }; + if (group.kind === 'line' || group.kind === 'area') { + plot += renderLineChart(f, groupSpec, colors, group.kind === 'area'); + } else { + plot += renderColumnChart(f, groupSpec, colors); + } + } + } + if (!isCombo && isCartesian) { const { min, max, step } = seriesMinMax(spec); const N = pointCount(spec); const majorUnit = spec.valueAxis?.majorUnit ?? step; @@ -5145,42 +5249,44 @@ const renderChart = ( ); } } - switch (spec.kind) { - case 'column': - case 'bar': - // @office-kit/pptx reports both as `bar` / `column` via separate `kind`; - // legacy `barDir` distinction. We branch on `kind`. - plot = - spec.kind === 'column' - ? renderColumnChart(f, spec, colors) - : renderBarChart(f, spec, colors); - break; - case 'line': - plot = renderLineChart(f, spec, colors, false); - break; - case 'area': - plot = renderLineChart(f, spec, colors, true); - break; - case 'pie': - plot = renderPieChart(f, spec, colors, false); - break; - case 'doughnut': - plot = renderPieChart(f, spec, colors, true); - break; - case 'scatter': - plot = renderScatterChart(f, spec, colors); - break; - case 'radar': - plot = renderRadarChart(f, spec, colors); - break; - case 'bubble': - plot = renderBubbleChart(f, spec, colors); - break; - default: - // stock / surface / 3D variants the reader still folds into a - // modeled kind never reach here; truly unmodeled kinds (resolved - // to `null` spec) are handled earlier. Anything left falls back. - return null; + if (!isCombo) { + switch (spec.kind) { + case 'column': + case 'bar': + // @office-kit/pptx reports both as `bar` / `column` via separate `kind`; + // legacy `barDir` distinction. We branch on `kind`. + plot = + spec.kind === 'column' + ? renderColumnChart(f, spec, colors) + : renderBarChart(f, spec, colors); + break; + case 'line': + plot = renderLineChart(f, spec, colors, false); + break; + case 'area': + plot = renderLineChart(f, spec, colors, true); + break; + case 'pie': + plot = renderPieChart(f, spec, colors, false); + break; + case 'doughnut': + plot = renderPieChart(f, spec, colors, true); + break; + case 'scatter': + plot = renderScatterChart(f, spec, colors); + break; + case 'radar': + plot = renderRadarChart(f, spec, colors); + break; + case 'bubble': + plot = renderBubbleChart(f, spec, colors); + break; + default: + // stock / surface / 3D variants the reader still folds into a + // modeled kind never reach here; truly unmodeled kinds (resolved + // to `null` spec) are handled earlier. Anything left falls back. + return null; + } } const emptyHint = diff --git a/src/internal/chartml/chart-builder.ts b/src/internal/chartml/chart-builder.ts index 1517302c..18ea6a31 100644 --- a/src/internal/chartml/chart-builder.ts +++ b/src/internal/chartml/chart-builder.ts @@ -342,6 +342,46 @@ const seriesElement = (spec: ChartSpec, seriesIdx: number, sheet: string): XmlEl // needs them stable within the chart for the `` back-pointer. const CAT_AX_ID = 111111111; const VAL_AX_ID = 222222222; +// Secondary axis pair for combo charts (series with `secondaryAxis: true`). +const SEC_CAT_AX_ID = 333333333; +const SEC_VAL_AX_ID = 444444444; + +interface AxisIdPair { + readonly cat: number; + readonly val: number; +} + +const PRIMARY_AXES: AxisIdPair = { cat: CAT_AX_ID, val: VAL_AX_ID }; +const SECONDARY_AXES: AxisIdPair = { cat: SEC_CAT_AX_ID, val: SEC_VAL_AX_ID }; + +/** Category kinds that can participate in a combo plot-group split. */ +const COMBO_KINDS = new Set(['bar', 'column', 'line', 'area']); + +const effectiveSeriesKind = (spec: ChartSpec, seriesIdx: number): string => + spec.series[seriesIdx]?.chartKind ?? spec.kind; + +/** + * Splits the series into plot groups keyed by (effective kind, axis). + * Primary-axis groups come first so secondary overlays paint on top, + * and bar groups precede line/area within each axis for the same reason + * (matching PowerPoint's combo emit order). + */ +const comboPlotGroups = ( + spec: ChartSpec, +): { kind: string; secondary: boolean; indices: number[] }[] => { + const groups = new Map(); + for (let i = 0; i < spec.series.length; i++) { + const kind = effectiveSeriesKind(spec, i); + const secondary = spec.series[i]?.secondaryAxis === true; + const key = `${kind}|${secondary ? '1' : '0'}`; + const group = groups.get(key); + if (group) group.indices.push(i); + else groups.set(key, { kind, secondary, indices: [i] }); + } + const paintOrder = (g: { kind: string; secondary: boolean }): number => + (g.secondary ? 2 : 0) + (g.kind === 'line' || g.kind === 'area' ? 1 : 0); + return [...groups.values()].sort((a, b) => paintOrder(a) - paintOrder(b)); +}; // Build a `` block carrying axis tick-label font / color and an // optional `` rotation. Returns null when neither @@ -554,8 +594,14 @@ const buildDLblsFromLabels = (dl: ChartSpec['dataLabels'] | undefined): XmlEleme const dLblsElement = (spec: ChartSpec): XmlElement | null => buildDLblsFromLabels(spec.dataLabels); -const buildBarChart = (spec: ChartSpec, sheet: string, direction: 'col' | 'bar'): XmlElement => { - const ser = spec.series.map((_, i) => seriesElement(spec, i, sheet)); +const buildBarChart = ( + spec: ChartSpec, + sheet: string, + direction: 'col' | 'bar', + seriesIndices: ReadonlyArray, + axes: AxisIdPair, +): XmlElement => { + const ser = seriesIndices.map((i) => seriesElement(spec, i, sheet)); const dl = dLblsElement(spec); const grouping = spec.grouping ?? 'clustered'; const children: XmlElement[] = [ @@ -578,12 +624,17 @@ const buildBarChart = (spec: ChartSpec, sheet: string, direction: 'col' | 'bar') if (overlapPct !== undefined) { children.push(valNode(c('overlap'), overlapPercent(overlapPct, 'chart: overlapPct'))); } - children.push(valNode(c('axId'), CAT_AX_ID), valNode(c('axId'), VAL_AX_ID)); + children.push(valNode(c('axId'), axes.cat), valNode(c('axId'), axes.val)); return elem(c(direction === 'col' ? 'barChart' : 'barChart'), { children }); }; -const buildLineChart = (spec: ChartSpec, sheet: string): XmlElement => { - const ser = spec.series.map((_, i) => seriesElement(spec, i, sheet)); +const buildLineChart = ( + spec: ChartSpec, + sheet: string, + seriesIndices: ReadonlyArray, + axes: AxisIdPair, +): XmlElement => { + const ser = seriesIndices.map((i) => seriesElement(spec, i, sheet)); const dl = dLblsElement(spec); const children: XmlElement[] = [ valNode(c('grouping'), spec.grouping ?? 'standard'), @@ -598,8 +649,8 @@ const buildLineChart = (spec: ChartSpec, sheet: string): XmlElement => { // authors opt out of markers with `lineMarkers: false`. children.push( valNode(c('marker'), spec.lineMarkers === false ? '0' : '1'), - valNode(c('axId'), CAT_AX_ID), - valNode(c('axId'), VAL_AX_ID), + valNode(c('axId'), axes.cat), + valNode(c('axId'), axes.val), ); return elem(c('lineChart'), { children }); }; @@ -643,8 +694,13 @@ const buildDoughnutChart = (spec: ChartSpec, sheet: string): XmlElement => { return elem(c('doughnutChart'), { children }); }; -const buildAreaChart = (spec: ChartSpec, sheet: string): XmlElement => { - const ser = spec.series.map((_, i) => seriesElement(spec, i, sheet)); +const buildAreaChart = ( + spec: ChartSpec, + sheet: string, + seriesIndices: ReadonlyArray, + axes: AxisIdPair, +): XmlElement => { + const ser = seriesIndices.map((i) => seriesElement(spec, i, sheet)); const dl = dLblsElement(spec); return elem(c('areaChart'), { children: [ @@ -652,12 +708,66 @@ const buildAreaChart = (spec: ChartSpec, sheet: string): XmlElement => { valNode(c('varyColors'), spec.varyColors ? '1' : '0'), ...ser, ...(dl ? [dl] : []), - valNode(c('axId'), CAT_AX_ID), - valNode(c('axId'), VAL_AX_ID), + valNode(c('axId'), axes.cat), + valNode(c('axId'), axes.val), ], }); }; +/** One combo plot group, dispatched by its effective kind. */ +const buildComboGroupChart = ( + spec: ChartSpec, + sheet: string, + kind: string, + seriesIndices: ReadonlyArray, + axes: AxisIdPair, +): XmlElement => { + switch (kind) { + case 'column': + return buildBarChart(spec, sheet, 'col', seriesIndices, axes); + case 'bar': + return buildBarChart(spec, sheet, 'bar', seriesIndices, axes); + case 'line': + return buildLineChart(spec, sheet, seriesIndices, axes); + case 'area': + return buildAreaChart(spec, sheet, seriesIndices, axes); + default: + throw new Error(`combo chart: series chartKind '${kind}' is not authorable`); + } +}; + +/** + * Secondary value axis (`axPos="r"`, crossing at the category maximum) — + * the right-hand axis PowerPoint pairs with `secondaryAxis` series. + */ +const secondaryValAxis = (): XmlElement => + elem(c('valAx'), { + children: [ + valNode(c('axId'), SEC_VAL_AX_ID), + elem(c('scaling'), { children: [valNode(c('orientation'), 'minMax')] }), + valNode(c('delete'), '0'), + valNode(c('axPos'), 'r'), + valNode(c('crossAx'), SEC_CAT_AX_ID), + valNode(c('crosses'), 'max'), + ], + }); + +/** + * Deleted companion category axis for the secondary pair. PowerPoint + * requires every plot group's axId pair to resolve to a cat+val pair, + * so the secondary group gets its own (hidden) category axis. + */ +const secondaryCatAxis = (): XmlElement => + elem(c('catAx'), { + children: [ + valNode(c('axId'), SEC_CAT_AX_ID), + elem(c('scaling'), { children: [valNode(c('orientation'), 'minMax')] }), + valNode(c('delete'), '1'), + valNode(c('axPos'), 'b'), + valNode(c('crossAx'), SEC_VAL_AX_ID), + ], + }); + // Builds an // payload from a ChartTextStyle. Returns the rPr children to splice // into the parent run / def-run-properties node. @@ -741,25 +851,45 @@ const titleElement = (title: string, style?: ChartTextStyle, rotationDeg?: numbe export const buildChartSpaceDoc = (spec: ChartSpec): XmlDocument => { const sheet = 'Sheet1'; - let plotted: XmlElement; + const usesComboFields = spec.series.some( + (series) => series.chartKind !== undefined || series.secondaryAxis === true, + ); + if (usesComboFields && !COMBO_KINDS.has(spec.kind)) { + throw new Error( + `chart kind '${spec.kind}' does not support per-series chartKind / secondaryAxis (combo charts require a bar / column / line / area base kind)`, + ); + } + + const allIndices = spec.series.map((_, i) => i); + let plottedGroups: XmlElement[]; + let hasSecondary = false; switch (spec.kind) { case 'column': - plotted = buildBarChart(spec, sheet, 'col'); - break; case 'bar': - plotted = buildBarChart(spec, sheet, 'bar'); - break; case 'line': - plotted = buildLineChart(spec, sheet); + case 'area': { + if (usesComboFields) { + const groups = comboPlotGroups(spec); + hasSecondary = groups.some((group) => group.secondary); + plottedGroups = groups.map((group) => + buildComboGroupChart( + spec, + sheet, + group.kind, + group.indices, + group.secondary ? SECONDARY_AXES : PRIMARY_AXES, + ), + ); + } else { + plottedGroups = [buildComboGroupChart(spec, sheet, spec.kind, allIndices, PRIMARY_AXES)]; + } break; + } case 'pie': - plotted = buildPieChart(spec, sheet); + plottedGroups = [buildPieChart(spec, sheet)]; break; case 'doughnut': - plotted = buildDoughnutChart(spec, sheet); - break; - case 'area': - plotted = buildAreaChart(spec, sheet); + plottedGroups = [buildDoughnutChart(spec, sheet)]; break; case 'scatter': case 'radar': @@ -779,9 +909,12 @@ export const buildChartSpaceDoc = (spec: ChartSpec): XmlDocument => { } const axisless = spec.kind === 'pie' || spec.kind === 'doughnut'; - const plotAreaChildren: XmlElement[] = [elem(c('layout')), plotted]; + const plotAreaChildren: XmlElement[] = [elem(c('layout')), ...plottedGroups]; if (!axisless) { plotAreaChildren.push(catAxis(spec), valAxis(spec)); + if (hasSecondary) { + plotAreaChildren.push(secondaryValAxis(), secondaryCatAxis()); + } } // + optional . if (spec.plotAreaFill !== undefined || spec.plotAreaStrokeColor !== undefined) { diff --git a/src/internal/chartml/chart-reader.ts b/src/internal/chartml/chart-reader.ts index 9f784a40..fe257b04 100644 --- a/src/internal/chartml/chart-reader.ts +++ b/src/internal/chartml/chart-reader.ts @@ -64,6 +64,12 @@ interface PlottedKindMap { readonly kind: ChartKind; } +/** Kinds a combo plot group can carry (mirrors `ChartSeries.chartKind`). */ +const isComboSeriesKind = ( + kind: ChartKind, +): kind is 'bar' | 'column' | 'line' | 'area' => + kind === 'bar' || kind === 'column' || kind === 'line' || kind === 'area'; + const KIND_MAP: ReadonlyArray = [ // `barChart` is overloaded; `` vs `"col"` decides. { localName: 'barChart', kind: 'column' }, @@ -613,29 +619,78 @@ export const readChartSpec = (root: XmlElement): ChartSpec | null => { const plotArea = firstChildElement(chart, NAME_PLOT_AREA); if (!plotArea) throw new Error(' has no '); - // Find which "plotted" element the plotArea carries. - let plotted: XmlElement | null = null; - let kind: ChartKind | null = null; - for (const candidate of KIND_MAP) { - const found = findFirst(plotArea, [candidate.localName]); - if (found) { - plotted = found; - kind = candidate.kind; - // Resolve bar vs column on a `barChart` / `bar3DChart`. - if (candidate.localName === 'barChart' || candidate.localName === 'bar3DChart') { - const barDir = firstChildElement(found, qname('c', 'barDir', NS_C)); - const v = barDir !== null ? getAttrValue(barDir, ATTR_VAL) : null; - kind = v === 'bar' ? 'bar' : 'column'; - } - break; + // Collect every "plotted" plot-group element the plotArea carries, in + // document order. Combo charts emit several (`` + + // `` …); single-kind charts emit one. + interface PlotGroup { + readonly element: XmlElement; + readonly kind: ChartKind; + } + const kindByLocalName = new Map(KIND_MAP.map((entry) => [entry.localName, entry] as const)); + const plotGroups: PlotGroup[] = []; + for (const child of plotArea.children) { + if (child.kind !== 'element' || child.name.namespaceURI !== NS_C) continue; + const mapped = kindByLocalName.get(child.name.localName); + if (!mapped) continue; + let groupKind: ChartKind = mapped.kind; + // Resolve bar vs column on a `barChart` / `bar3DChart`. + if (mapped.localName === 'barChart' || mapped.localName === 'bar3DChart') { + const barDir = firstChildElement(child, qname('c', 'barDir', NS_C)); + const v = barDir !== null ? getAttrValue(barDir, ATTR_VAL) : null; + groupKind = v === 'bar' ? 'bar' : 'column'; } + plotGroups.push({ element: child, kind: groupKind }); } - if (!plotted || !kind) return null; + const firstGroup = plotGroups[0]; + if (!firstGroup) return null; + const plotted = firstGroup.element; + const kind = firstGroup.kind; - // Read every in order. + // Secondary-axis detection: a combo chart's secondary plot group + // references a `` whose `axPos` is `r` (or `t` for bar + // charts). Map valAx axIds → axPos so each group can be classified. + const secondaryValAxisIds = new Set(); + for (const child of plotArea.children) { + if ( + child.kind !== 'element' || + child.name.namespaceURI !== NS_C || + child.name.localName !== 'valAx' + ) { + continue; + } + const axIdEl = firstChildElement(child, qname('c', 'axId', NS_C)); + const axPosEl = firstChildElement(child, qname('c', 'axPos', NS_C)); + const axId = axIdEl !== null ? getAttrValue(axIdEl, ATTR_VAL) : null; + const axPos = axPosEl !== null ? getAttrValue(axPosEl, ATTR_VAL) : null; + if (axId !== null && (axPos === 'r' || axPos === 't')) secondaryValAxisIds.add(axId); + } + const groupUsesSecondaryAxis = (group: XmlElement): boolean => { + for (const child of group.children) { + if ( + child.kind === 'element' && + child.name.namespaceURI === NS_C && + child.name.localName === 'axId' + ) { + const id = getAttrValue(child, ATTR_VAL); + if (id !== null && secondaryValAxisIds.has(id)) return true; + } + } + return false; + }; + + // Read every from every plot group, tagging series from + // non-first groups with their group's kind / axis so the round-trip + // preserves the combo layout. const series: ChartSeries[] = []; let categoriesFromFirst: string[] | null = null; - for (const ser of allChildElements(plotted, NAME_SER)) { + const serEntries: { ser: XmlElement; groupKind: ChartKind; secondary: boolean }[] = []; + for (const group of plotGroups) { + const secondary = groupUsesSecondaryAxis(group.element); + for (const ser of allChildElements(group.element, NAME_SER)) { + serEntries.push({ ser, groupKind: group.kind, secondary }); + } + } + for (const { ser, groupKind, secondary } of serEntries) { const name = readSeriesName(ser); const cat = firstChildElement(ser, NAME_CAT); if (cat !== null && categoriesFromFirst === null) { @@ -712,6 +767,8 @@ export const readChartSpec = (root: XmlElement): ChartSpec | null => { series.push({ name, values: values ?? [], + ...(groupKind !== kind && isComboSeriesKind(groupKind) ? { chartKind: groupKind } : {}), + ...(secondary ? { secondaryAxis: true } : {}), ...(xValues !== null ? { xValues } : {}), ...(bubbleSizes !== null ? { bubbleSizes } : {}), ...(color !== undefined ? { color } : {}), diff --git a/src/internal/chartml/types.ts b/src/internal/chartml/types.ts index cef7998b..6052aa9b 100644 --- a/src/internal/chartml/types.ts +++ b/src/internal/chartml/types.ts @@ -51,6 +51,23 @@ export interface ChartSeries { readonly bubbleSizes?: ReadonlyArray; /** Optional `#RRGGBB` fill override. Defaults to the theme's accent palette. */ readonly color?: string; + /** + * Per-series chart-kind override for combo charts (e.g. a `line` + * series overlaid on a `column` chart). Series sharing an effective + * kind are grouped into one plot group (`` / + * `` / ``); the groups share the category + * axis. Only the category kinds can mix — `bar` / `column` / `line` / + * `area`. Absent = plotted with the chart's own `kind`. + */ + readonly chartKind?: 'bar' | 'column' | 'line' | 'area'; + /** + * Plot this series against the secondary value axis — the right-hand + * axis PowerPoint shows for combo charts with mixed units + * (`` pair with `axPos="r"` plus a deleted companion + * ``). The builder emits the secondary axis pair on demand. + * Only meaningful for the category kinds; rejected for pie / doughnut. + */ + readonly secondaryAxis?: boolean; /** * Optional line stroke width in EMU (``). * Only meaningful for line / area / scatter series. Default falls diff --git a/test/fn-chart-combo.test.ts b/test/fn-chart-combo.test.ts new file mode 100644 index 00000000..34360e67 --- /dev/null +++ b/test/fn-chart-combo.test.ts @@ -0,0 +1,125 @@ +// Combo charts — per-series `chartKind` overrides and the secondary +// value axis (`secondaryAxis: true`). +// +// Verifies end-to-end: +// - The builder splits the series into plot groups (`` + +// ``) and emits the secondary axis pair on demand. +// - `getShapeChartSpec` round-trips `chartKind` / `secondaryAxis`. +// - Non-combo base kinds reject the per-series fields. + +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + addSlideChart, + getShapeChartSpec, + getSlides, + inches, + loadPresentation, + readPackagePart, + savePresentation, +} from '../src/api/index.ts'; + +const fixture = (name: string): string => + fileURLToPath(new URL(`./fixtures/minimal/${name}`, import.meta.url)); + +const decoder = new TextDecoder(); + +describe('fn API: combo charts', () => { + it('column + line(secondaryAxis) emits both plot groups and the secondary axis pair', async () => { + const pres = await loadPresentation(await readFile(fixture('two-slides.pptx'))); + const slide = getSlides(pres)[0]!; + + addSlideChart(slide, { + x: inches(0.5), + y: inches(0.5), + w: inches(8), + h: inches(4.5), + spec: { + kind: 'column', + categories: ['速い', '普通', '遅い'], + series: [ + { name: '件数', values: [92, 118, 54] }, + { name: '平均スコア', values: [4.5, 3.8, 2.9], chartKind: 'line', secondaryAxis: true }, + ], + }, + }); + + const bytes = await savePresentation(pres); + const reloaded = await loadPresentation(bytes); + const chartXmlBytes = readPackagePart(reloaded, '/ppt/charts/chart1.xml'); + expect(chartXmlBytes).not.toBeNull(); + const xml = decoder.decode(chartXmlBytes!); + + expect(xml).toContain(''); + expect(xml).toContain(''); + // Secondary value axis on the right, crossing at max, with its + // deleted companion category axis. + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + // The line group must reference the secondary pair, the bar group + // the primary pair. + const lineChartXml = xml.slice(xml.indexOf(''), xml.indexOf('')); + expect(lineChartXml).toContain(''); + expect(lineChartXml).toContain(''); + const barChartXml = xml.slice(xml.indexOf(''), xml.indexOf('')); + expect(barChartXml).toContain(''); + expect(barChartXml).toContain(''); + }); + + it('round-trips chartKind / secondaryAxis through getShapeChartSpec', async () => { + const pres = await loadPresentation(await readFile(fixture('two-slides.pptx'))); + const slide = getSlides(pres)[0]!; + + addSlideChart(slide, { + x: inches(0.5), + y: inches(0.5), + w: inches(8), + h: inches(4.5), + spec: { + kind: 'column', + categories: ['A', 'B'], + series: [ + { name: 'count', values: [100, 200] }, + { name: 'rate', values: [0.5, 0.7], chartKind: 'line', secondaryAxis: true }, + ], + }, + }); + + const bytes = await savePresentation(pres); + const reloaded = await loadPresentation(bytes); + const shapes = getSlides(reloaded)[0]!; + const chartShape = (await import('../src/api/index.ts')).getSlideShapes(shapes).at(-1)!; + const spec = getShapeChartSpec(chartShape); + expect(spec).not.toBeNull(); + expect(spec!.kind).toBe('column'); + expect(spec!.series).toHaveLength(2); + const [count, rate] = spec!.series; + expect(count!.chartKind).toBeUndefined(); + expect(count!.secondaryAxis).toBeUndefined(); + expect(rate!.chartKind).toBe('line'); + expect(rate!.secondaryAxis).toBe(true); + expect(rate!.values).toEqual([0.5, 0.7]); + }); + + it('rejects per-series combo fields on pie charts', async () => { + const pres = await loadPresentation(await readFile(fixture('two-slides.pptx'))); + const slide = getSlides(pres)[0]!; + + expect(() => + addSlideChart(slide, { + x: inches(1), + y: inches(1), + w: inches(4), + h: inches(4), + spec: { + kind: 'pie', + categories: ['A', 'B'], + series: [{ name: 's', values: [1, 2], secondaryAxis: true }], + }, + }), + ).toThrow(/combo/); + }); +}); diff --git a/test/preview-chart-combo.test.ts b/test/preview-chart-combo.test.ts new file mode 100644 index 00000000..c6f989a0 --- /dev/null +++ b/test/preview-chart-combo.test.ts @@ -0,0 +1,70 @@ +// Combo chart rendering — a line overlay on a column chart with a +// secondary (right-hand) value axis. Asserts on the emitted SVG: +// +// - both the bars and the overlay polyline are painted +// - the secondary axis ticks sit on the plot's RIGHT edge and are +// scaled to the secondary series (not squashed by the primary range) + +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + addSlide, + addSlideChart, + findSlideLayout, + inches, + loadPresentation, + type ChartSpec, +} from '../src/api/index.ts'; +import { renderSlideToSvg } from '../packages/preview/src/index.ts'; + +const fixturePath = fileURLToPath(new URL('./fixtures/minimal/blank.pptx', import.meta.url)); + +const renderChart = async (spec: ChartSpec): Promise => { + const pres = await loadPresentation(await readFile(fixturePath)); + const layout = findSlideLayout(pres, 'Blank'); + if (!layout) throw new Error('Blank layout not found'); + const slide = addSlide(pres, { layout }); + addSlideChart(slide, { x: inches(1), y: inches(1), w: inches(8), h: inches(5), spec }); + return renderSlideToSvg(pres, slide); +}; + +describe('preview: combo charts', () => { + it('renders bars + line overlay with a right-hand secondary axis', async () => { + const svg = await renderChart({ + kind: 'column', + categories: ['速い', '普通', '遅い', '非常に遅い'], + series: [ + { name: '件数', values: [92, 118, 54, 17], color: '#4472C4' }, + { + name: '平均スコア', + values: [4.5, 3.8, 2.9, 2.1], + color: '#ED7D31', + chartKind: 'line', + secondaryAxis: true, + }, + ], + }); + + // Bars from the column group… + expect((svg.match(/]*fill="#4472C4"/g) ?? []).length).toBeGreaterThanOrEqual(4); + // …and the line overlay's path in the series color. + expect(svg).toMatch(/]*stroke="#ED7D31"/); + + // Secondary axis ticks: labels anchored `start` (right edge) exist, + // and the secondary scale (0..5-ish) appears — a squashed overlay + // on the primary 0..120 scale would never emit a "4" tick label. + expect(svg).toMatch(/]*text-anchor="start"[^>]*>\s*[45]\s*<\/text>/); + }); + + it('keeps the single-kind path untouched when no combo fields are set', async () => { + const svg = await renderChart({ + kind: 'column', + categories: ['A', 'B'], + series: [{ name: 'v', values: [1, 2], color: '#4472C4' }], + }); + + expect((svg.match(/]*fill="#4472C4"/g) ?? []).length).toBeGreaterThanOrEqual(2); + expect(svg).not.toMatch(/text-anchor="start"[^>]*dominant-baseline="middle"/); + }); +}); From 312ee217895c8ffc4b5d3bf06f70241378918a63 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sat, 1 Aug 2026 09:43:37 +0900 Subject: [PATCH 2/3] style: oxfmt --- src/internal/chartml/chart-reader.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/internal/chartml/chart-reader.ts b/src/internal/chartml/chart-reader.ts index fe257b04..229e1073 100644 --- a/src/internal/chartml/chart-reader.ts +++ b/src/internal/chartml/chart-reader.ts @@ -65,9 +65,7 @@ interface PlottedKindMap { } /** Kinds a combo plot group can carry (mirrors `ChartSeries.chartKind`). */ -const isComboSeriesKind = ( - kind: ChartKind, -): kind is 'bar' | 'column' | 'line' | 'area' => +const isComboSeriesKind = (kind: ChartKind): kind is 'bar' | 'column' | 'line' | 'area' => kind === 'bar' || kind === 'column' || kind === 'line' || kind === 'area'; const KIND_MAP: ReadonlyArray = [ From 8f2ba6874cd5a15aeebd974cf65ebcd477fb8e72 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sat, 1 Aug 2026 09:45:43 +0900 Subject: [PATCH 3/3] fix: exactOptionalPropertyTypes-safe secondary scale computation --- packages/preview/src/render-slide.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/preview/src/render-slide.ts b/packages/preview/src/render-slide.ts index fffea73f..5f5b05e0 100644 --- a/packages/preview/src/render-slide.ts +++ b/packages/preview/src/render-slide.ts @@ -5122,9 +5122,12 @@ const renderChart = ( const primarySeries = baked.filter((s) => s.secondaryAxis !== true); const secondarySeries = baked.filter((s) => s.secondaryAxis === true); const primaryScale = seriesMinMax({ ...spec, series: primarySeries }); + // The authored valueAxis min/max targets the PRIMARY axis; the + // secondary axis always auto-scales from its own series. + const { valueAxis: _primaryOnlyAxis, ...specWithoutAxis } = spec; const secondaryScale = secondarySeries.length > 0 - ? seriesMinMax({ ...spec, series: secondarySeries, valueAxis: undefined }) + ? seriesMinMax({ ...specWithoutAxis, series: secondarySeries }) : null; const N = pointCount(spec);