diff --git a/.changeset/preview-chart-auto-layout.md b/.changeset/preview-chart-auto-layout.md
new file mode 100644
index 00000000..66cc1617
--- /dev/null
+++ b/.changeset/preview-chart-auto-layout.md
@@ -0,0 +1,5 @@
+---
+'@office-kit/pptx-preview': patch
+---
+
+Fixed the preview giving column, line, area and combo charts a taller plot area than presentation apps do, which hid data labels that overlap in the real chart. Line labels above a point also sat on top of the marker, so a bar label and a line label that collide in LibreOffice were drawn apart. The automatic layout of bar, column, line, area and combo charts now keeps 2% of the chart's width and height clear on each side, as LibreOffice does, and leaves a category-label row sized to the label font; a line label above or below its point stays 1 mm clear of the marker. Charts render with a slightly smaller plot area than before; charts with a manual inner plot-area layout are unchanged.
diff --git a/packages/preview/src/render-slide.ts b/packages/preview/src/render-slide.ts
index 6a2ea042..8c552da0 100644
--- a/packages/preview/src/render-slide.ts
+++ b/packages/preview/src/render-slide.ts
@@ -3179,6 +3179,14 @@ interface SeriesGeometry {
readonly dPath: string;
}
+// LibreOffice's automatic chart layout (chart2 ChartView.cxx): the plot area,
+// axis labels included, keeps 2% of the chart's width and height from each edge.
+const CHART_AREA_INSET_RATIO = 0.02;
+const PX_PER_MM = 96 / 25.4;
+const DEFAULT_LEGEND_PT = 11;
+// Category tick labels are centered this far below the plot area.
+const CATEGORY_LABEL_OFFSET_PX = 12;
+
const layoutChart = (
xEmu: number,
yEmu: number,
@@ -3193,6 +3201,7 @@ const layoutChart = (
plotAreaLayout?: ReadChartSpec['plotAreaLayout'],
yAxisGutter = hasAxes ? DEFAULT_Y_AXIS_GUTTER : 0,
xAxisGutter = hasAxes ? BAR_VALUE_LABEL_ROW : 0,
+ proportionalInset = false,
): ChartFrame => {
const x = xEmu / EMU_PER_PX;
const y = yEmu / EMU_PER_PX;
@@ -3206,11 +3215,12 @@ const layoutChart = (
// larger authored title reserves proportionally more room.
const titleStrip = hasTitle && !titleOverlay ? Math.round(titlePx * 2.4) : 0;
const legendStrip = hasLegend && !legendOverlay ? 18 : 0;
- const padding = 8;
+ const paddingX = proportionalInset ? w * CHART_AREA_INSET_RATIO : 8;
+ const paddingY = proportionalInset ? h * CHART_AREA_INSET_RATIO : 8;
// Outer layouts include axis labels and titles, so their gutters still need automatic layout.
const inner = plotAreaLayout?.target === 'inner' ? plotAreaLayout : undefined;
- const plotX = inner ? x + Math.max(0, Math.min(1, inner.x)) * w : x + padding + yAxisGutter;
- const plotY = inner ? y + Math.max(0, Math.min(1, inner.y)) * h : y + titleStrip + padding;
+ const plotX = inner ? x + Math.max(0, Math.min(1, inner.x)) * w : x + paddingX + yAxisGutter;
+ const plotY = inner ? y + Math.max(0, Math.min(1, inner.y)) * h : y + titleStrip + paddingY;
return {
x,
y,
@@ -3220,14 +3230,14 @@ const layoutChart = (
plotY,
plotW: inner
? Math.max(0, Math.min(x + w, x + (inner.x + inner.w) * w) - plotX)
- : Math.max(0, w - 2 * padding - yAxisGutter),
+ : Math.max(0, w - 2 * paddingX - yAxisGutter),
plotH: inner
? Math.max(0, Math.min(y + h, y + (inner.y + inner.h) * h) - plotY)
- : Math.max(0, h - titleStrip - legendStrip - xAxisGutter - 2 * padding),
+ : Math.max(0, h - titleStrip - legendStrip - xAxisGutter - 2 * paddingY),
// The title sits near the TOP of its (now taller) strip, not its bottom —
// dominant-baseline:middle, so center it ~0.7 of the title px below the top
// edge to match where LibreOffice paints the title line.
- titleY: y + (titleOverlay ? 14 : padding + titlePx * 0.7),
+ titleY: y + (titleOverlay ? 14 : paddingY + titlePx * 0.7),
legendY: y + h - (legendOverlay ? 18 : legendStrip / 2),
};
};
@@ -3550,7 +3560,7 @@ const renderValueAxis = (f: ChartFrame, axis: AxisSpec): string => {
``,
);
}
- const horizLabelY = f.plotY + f.plotH + 12;
+ const horizLabelY = f.plotY + f.plotH + CATEGORY_LABEL_OFFSET_PX;
const rotH = axis.labelRotationDeg ?? 0;
const transformH = rotH ? ` transform="rotate(${rotH} ${px(xp)} ${px(horizLabelY)})"` : '';
out.push(
@@ -3743,7 +3753,7 @@ const renderCategoryAxis = (
if (skip > 1 && i % skip !== 0) continue;
// Center labels under each category slot.
const cx = f.plotX + (i + 0.5) * step;
- const cy = f.plotY + f.plotH + 12;
+ const cy = f.plotY + f.plotH + CATEGORY_LABEL_OFFSET_PX;
const truncated =
labels[i] !== undefined && labels[i]!.length > truncLen
? `${labels[i]!.slice(0, truncLen - 2)}…`
@@ -3984,7 +3994,7 @@ const renderChartLegend = (
): string => {
if (names.length === 0) return '';
// Authored font / weight / color overrides the 11pt #374151 default.
- const sz = textStyle?.sizePt ?? 11;
+ const sz = textStyle?.sizePt ?? DEFAULT_LEGEND_PT;
const fill = textStyle?.color ?? '#374151';
const weight = textStyle?.bold ? ' font-weight="600"' : '';
const italic = textStyle?.italic ? ' font-style="italic"' : '';
@@ -4579,6 +4589,25 @@ const formatDataLabelValue = (
// Point label text style wins over the series and chart defaults.
// Falls back to the renderer's hardcoded size / caller-supplied fill /
// weight so existing layouts don't shift when no textStyle is authored.
+const LINE_DATA_LABEL_PT = 9;
+const DEFAULT_MARKER_PT = 5;
+// Line box of the bundled sans substitute (Carlito: win ascent 0.952 em,
+// descent 0.269 em): where a label's baseline sits from its box edges.
+const LABEL_ASCENT_RATIO = 0.952;
+const LABEL_DESCENT_RATIO = 0.269;
+
+const dataLabelStyle = (
+ spec: ReadChartSpec,
+ seriesIdx: number,
+ pointIdx: number | undefined,
+): ChartTextStyle | undefined => {
+ const point =
+ pointIdx === undefined ? undefined : spec.series[seriesIdx]?.pointDataLabels?.[pointIdx];
+ return (
+ point?.textStyle ?? spec.series[seriesIdx]?.dataLabels?.textStyle ?? spec.dataLabels?.textStyle
+ );
+};
+
const dataLabelTextAttrs = (
spec: ReadChartSpec,
seriesIdx: number,
@@ -4587,10 +4616,7 @@ const dataLabelTextAttrs = (
fallbackBold = false,
pointIdx?: number,
): string => {
- const point =
- pointIdx === undefined ? undefined : spec.series[seriesIdx]?.pointDataLabels?.[pointIdx];
- const style =
- point?.textStyle ?? spec.series[seriesIdx]?.dataLabels?.textStyle ?? spec.dataLabels?.textStyle;
+ const style = dataLabelStyle(spec, seriesIdx, pointIdx);
const sz = style?.sizePt ?? fallbackSizePt;
const fill = style?.color ?? fallbackFill;
const isBold = style?.bold ?? fallbackBold;
@@ -4866,9 +4892,12 @@ const renderLineChart = (
series.markerSymbol !== undefined &&
series.markerSymbol !== 'auto' &&
series.markerSymbol !== 'none';
- if (series.markerSymbol !== 'none' && !fill && (spec.lineMarkers === true || explicitSymbol)) {
+ const markerShown =
+ series.markerSymbol !== 'none' && !fill && (spec.lineMarkers === true || explicitSymbol);
+ const markerHalfPx = ((series.markerSizePt ?? DEFAULT_MARKER_PT) * PX_PER_PT) / 2;
+ if (markerShown) {
const symbol = autoMarkerSymbol(series.markerSymbol, s);
- const size = series.markerSizePt ?? 5;
+ const size = series.markerSizePt ?? DEFAULT_MARKER_PT;
const r = Math.max(1, size * 0.5);
for (const [xp, yp] of pts) {
out.push(seriesMarker(symbol, xp, yp, r, ...markerColors(series, color)));
@@ -4886,15 +4915,23 @@ const renderLineChart = (
const [xp, yp] = p;
const pos = chartPointLabelOptions(spec, s, c).position;
const lx = pos === 'l' ? xp - 6 : pos === 'r' ? xp + 6 : xp;
+ // Above or below a point, LibreOffice keeps the label's line box 1 mm
+ // clear of the marker.
+ const labelPx = (dataLabelStyle(spec, s, c)?.sizePt ?? LINE_DATA_LABEL_PT) * PX_PER_PT;
+ const clearance = (markerShown ? markerHalfPx : 0) + PX_PER_MM;
const ly =
- pos === 'b' ? yp + 13 : pos === 'ctr' || pos === 'l' || pos === 'r' ? yp + 3 : yp - 5;
+ pos === 'b'
+ ? yp + clearance + labelPx * LABEL_ASCENT_RATIO
+ : pos === 'ctr' || pos === 'l' || pos === 'r'
+ ? yp + 3
+ : yp - clearance - labelPx * LABEL_DESCENT_RATIO;
const anchor = pos === 'l' ? 'end' : pos === 'r' ? 'start' : 'middle';
out.push(
chartText(
f,
lx,
ly,
- `text-anchor="${anchor}" ${dataLabelTextAttrs(spec, s, '#374151', 9, false, c)}`,
+ `text-anchor="${anchor}" ${dataLabelTextAttrs(spec, s, '#374151', LINE_DATA_LABEL_PT, false, c)}`,
`${escapeXml(labelText)}`,
),
);
@@ -5415,6 +5452,16 @@ const renderChart = (
spec.kind === 'bar' && spec.valueAxisTitle ? axisTitleRowPx(spec.valueAxisTitleStyle) : 0;
const barCategoryTitleRow =
spec.kind === 'bar' && spec.categoryAxisTitle ? axisTitleRowPx(spec.categoryAxisTitleStyle) : 0;
+ const vertical = isCartesian && spec.kind !== 'bar';
+ const categoryLabelsShown = !spec.categoryAxisHidden && spec.categoryAxisTickLabelPos !== 'none';
+ const categoryLabelRow =
+ vertical && categoryLabelsShown
+ ? Math.max(
+ BAR_VALUE_LABEL_ROW,
+ CATEGORY_LABEL_OFFSET_PX +
+ (categoryLabelFontPx(spec.categoryAxisLabelStyle) * CATEGORY_LABEL_LINE_HEIGHT) / 2,
+ )
+ : undefined;
const flip = getShapeFlip(shape);
const f: ChartFrame = {
reflected: groupReflected !== Boolean(flip && flip.horizontal !== flip.vertical),
@@ -5434,7 +5481,8 @@ const renderChart = (
? Math.max(DEFAULT_Y_AXIS_GUTTER, barLabels.widthPx + CATEGORY_LABEL_GAP) +
barCategoryTitleRow
: undefined,
- barValueTitleRow > 0 ? BAR_VALUE_LABEL_ROW + barValueTitleRow : undefined,
+ barValueTitleRow > 0 ? BAR_VALUE_LABEL_ROW + barValueTitleRow : categoryLabelRow,
+ isCartesian,
),
};
const allNamesForLegend: string[] =
diff --git a/test/preview-chart-auto-layout.test.ts b/test/preview-chart-auto-layout.test.ts
new file mode 100644
index 00000000..07a38c52
--- /dev/null
+++ b/test/preview-chart-auto-layout.test.ts
@@ -0,0 +1,98 @@
+// Automatic plot-area layout of cartesian charts, following LibreOffice's
+// chart2 view (ChartView.cxx): 2% of the frame clear on each side, a
+// category-label row sized to the label font, and line-chart labels kept 1 mm
+// clear of their markers.
+
+import { readFile } from 'node:fs/promises';
+import { describe, expect, it } from 'vitest';
+import {
+ addSlide,
+ addSlideChart,
+ findSlideLayout,
+ inches,
+ loadPresentation,
+ type ChartSpec,
+ type ComboChartSpec,
+} from '../src/api/index.ts';
+import { renderSlideToSvg } from '../packages/preview/src/index.ts';
+import { attrsOf } from './lib/svg-query.ts';
+
+const PX_PER_PT = 96 / 72;
+const PX_PER_MM = 96 / 25.4;
+// The chart frame: 8 × 5 in at (1 in, 1 in).
+const FRAME = { x: 96, y: 96, w: 768, h: 480 };
+
+async function render(spec: ChartSpec) {
+ const pres = await loadPresentation(
+ await readFile(new URL('./fixtures/minimal/blank.pptx', import.meta.url)),
+ );
+ const slide = addSlide(pres, { layout: findSlideLayout(pres, 'Blank')! });
+ addSlideChart(slide, { x: inches(1), y: inches(1), w: inches(8), h: inches(5), spec });
+ return renderSlideToSvg(pres, slide);
+}
+
+const column: ComboChartSpec = {
+ kind: 'column',
+ categories: ['A', 'B'],
+ series: [{ name: 'Sessions', values: [60, 80], color: '#123456' }],
+ valueAxis: { min: 0, max: 100 },
+ plotAreaFill: '#FFFFCC',
+};
+
+const plotOf = (svg: string) => {
+ const plot = attrsOf(svg, 'rect').find((r) => r.fill === '#FFFFCC')!;
+ const [x, y, w, h] = [plot.x, plot.y, plot.width, plot.height].map(Number) as [
+ number,
+ number,
+ number,
+ number,
+ ];
+ return { x, y, w, h, bottom: y + h };
+};
+
+describe('automatic cartesian chart layout', () => {
+ it('keeps 2% of the frame clear on each side', async () => {
+ const plot = plotOf(await render(column));
+
+ expect(plot.x).toBeCloseTo(FRAME.x + FRAME.w * 0.02 + 32, 1);
+ expect(plot.y).toBeCloseTo(FRAME.y + FRAME.h * 0.02, 1);
+ expect(plot.x + plot.w).toBeCloseTo(FRAME.x + FRAME.w * 0.98, 1);
+ });
+
+ it('gives larger category labels a taller row under the axis', async () => {
+ const small = plotOf(await render(column));
+ const large = plotOf(await render({ ...column, categoryAxisLabelStyle: { sizePt: 18 } }));
+
+ expect(small.bottom - large.bottom).toBeCloseTo(((18 - 10) * PX_PER_PT * 1.2) / 2, 1);
+ });
+
+ it('keeps a line label above its point 1 mm clear of the marker', async () => {
+ const labelPt = 12;
+ const markerPt = 7;
+ const svg = await render({
+ kind: 'line',
+ categories: ['A', 'B'],
+ series: [{ name: 'Rate', values: [50, 50], markerSymbol: 'circle', markerSizePt: markerPt }],
+ valueAxis: { min: 0, max: 100 },
+ dataLabels: {
+ showValue: true,
+ showCategory: false,
+ showSeriesName: false,
+ showPercent: false,
+ position: 't',
+ textStyle: { sizePt: labelPt },
+ },
+ plotAreaFill: '#FFFFCC',
+ });
+ const plot = plotOf(svg);
+ const pointY = plot.y + plot.h / 2;
+ const label = [...svg.matchAll(/]*>50<\/text>/g)];
+ const descent = labelPt * PX_PER_PT * 0.269;
+
+ expect(label).toHaveLength(2);
+ expect(Number(label[0]![1])).toBeCloseTo(
+ pointY - (markerPt * PX_PER_PT) / 2 - PX_PER_MM - descent,
+ 1,
+ );
+ });
+});
diff --git a/test/preview-chart-axis-controls.test.ts b/test/preview-chart-axis-controls.test.ts
index 71768750..0d847ec2 100644
--- a/test/preview-chart-axis-controls.test.ts
+++ b/test/preview-chart-axis-controls.test.ts
@@ -15,7 +15,17 @@ import { attrsOf } from './lib/svg-query.ts';
const CATEGORY_COLOR = '#11AA11';
const VALUE_COLOR = '#AA1111';
-const frame = { left: 136, top: 104, width: 720, height: 446, bottom: 550 };
+// The 8 × 5 in chart at (1 in, 1 in) keeps 2% of its width and height clear on
+// each side, as LibreOffice does; a column-style chart also leaves a
+// category-label row (20 px at 10 pt) below the plot, a bar chart an 18 px
+// value-label row.
+const plotFrame = (kind: string) => {
+ const left = 96 + 768 * 0.02 + 32;
+ const width = 768 * 0.96 - 32;
+ const top = 96 + 480 * 0.02;
+ const bottom = 576 - 480 * 0.02 - (kind === 'bar' ? 18 : 20);
+ return { left, top, width, height: bottom - top, bottom };
+};
async function render(spec: ChartSpec) {
const pres = await loadPresentation(
await readFile(new URL('./fixtures/minimal/blank.pptx', import.meta.url)),
@@ -156,6 +166,7 @@ describe.each(['column', 'bar', 'combo'] as const)('%s category ticks', (kind) =
const ticks = attrsOf(svg, 'line');
const count = level === 'major' ? 3 : 2;
expect(ticks).toHaveLength(mark === 'none' ? 0 : count);
+ const frame = plotFrame(kind);
const length = level === 'major' ? 5 : 3;
const outward = mark === 'in' ? 0 : length;
const inward = mark === 'out' ? 0 : -length;
@@ -248,6 +259,7 @@ describe.each(['column', 'bar', 'line', 'area', 'combo'] as const)('%s minor gri
);
const minor = attrsOf(svg, 'line').filter((line) => line.stroke === '#123456');
expect(minor).toHaveLength(20);
+ const frame = plotFrame(kind);
minor.forEach((line, i) => {
const fraction = (Math.floor(i / 4) * 20 + ((i % 4) + 1) * 4) / 100;
expect(Number(line['stroke-width'])).toBe(1);
diff --git a/test/preview-chart-fill-opacity.test.ts b/test/preview-chart-fill-opacity.test.ts
index ecad0a45..caa8bcb4 100644
--- a/test/preview-chart-fill-opacity.test.ts
+++ b/test/preview-chart-fill-opacity.test.ts
@@ -20,6 +20,10 @@ async function render(spec: ChartSpec) {
return renderSlideToSvg(pres, slide);
}
+// The 8 × 5 in chart's plot area: 2% of the frame clear on each side, a 32 px
+// value-label gutter and a 20 px category-label row below a column plot.
+const PLOT = { left: 143.36, width: 705.28, height: 440.8, bottom: 546.4 };
+
describe.each(['column', 'bar'] as const)('%s fill opacity', (kind) => {
describe.each(['clustered', 'stacked', 'percentStacked'] as const)('%s grouping', (grouping) => {
it.each([
@@ -54,11 +58,13 @@ describe.each(['column', 'bar'] as const)('%s fill opacity', (kind) => {
if (grouping !== 'clustered') {
const fraction = grouping === 'percentStacked' ? 0.4 : 0.2;
if (kind === 'column') {
- expect(Number(base.height)).toBeCloseTo(446 * fraction);
- expect(Number(visible.y) + Number(visible.height)).toBeCloseTo(550 - 446 * fraction);
+ expect(Number(base.height)).toBeCloseTo(PLOT.height * fraction);
+ expect(Number(visible.y) + Number(visible.height)).toBeCloseTo(
+ PLOT.bottom - PLOT.height * fraction,
+ );
} else {
- expect(Number(base.width)).toBeCloseTo(720 * fraction);
- expect(Number(visible.x)).toBeCloseTo(136 + 720 * fraction);
+ expect(Number(base.width)).toBeCloseTo(PLOT.width * fraction);
+ expect(Number(visible.x)).toBeCloseTo(PLOT.left + PLOT.width * fraction);
}
}
},
diff --git a/test/preview-chart-inner-layout.test.ts b/test/preview-chart-inner-layout.test.ts
index 9645d506..a443645a 100644
--- a/test/preview-chart-inner-layout.test.ts
+++ b/test/preview-chart-inner-layout.test.ts
@@ -85,7 +85,9 @@ describe('manual inner chart layout', () => {
...(target ? { plotAreaLayout: { target, x: 0.2, y: 0.2, w: 0.6, h: 0.6 } } : {}),
});
const plot = attrsOf(svg, 'rect').find((r) => r.fill === '#FFFFCC')!;
- expect([plot.x, plot.y, plot.width, plot.height].map(Number)).toEqual([136, 104, 720, 446]);
+ expect([plot.x, plot.y, plot.width, plot.height].map(Number)).toEqual([
+ 143.36, 105.6, 705.28, 440.8,
+ ]);
},
);
});