Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/preview-chart-auto-layout.md
Original file line number Diff line number Diff line change
@@ -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.
84 changes: 66 additions & 18 deletions packages/preview/src/render-slide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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),
};
};
Expand Down Expand Up @@ -3550,7 +3560,7 @@ const renderValueAxis = (f: ChartFrame, axis: AxisSpec): string => {
`<line x1="${px(xp)}" y1="${px(ty1)}" x2="${px(xp)}" y2="${px(ty2)}" stroke="${axisColor}" stroke-width="1"/>`,
);
}
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(
Expand Down Expand Up @@ -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)}…`
Expand Down Expand Up @@ -3984,7 +3994,7 @@ const renderChartLegend = (
): string => {
if (names.length === 0) return '';
// Authored <c:txPr> 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"' : '';
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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)));
Expand All @@ -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)}`,
),
);
Expand Down Expand Up @@ -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),
Expand All @@ -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[] =
Expand Down
98 changes: 98 additions & 0 deletions test/preview-chart-auto-layout.test.ts
Original file line number Diff line number Diff line change
@@ -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(/<text x="[\d.]+" y="([\d.]+)"[^>]*>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,
);
});
});
14 changes: 13 additions & 1 deletion test/preview-chart-axis-controls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 10 additions & 4 deletions test/preview-chart-fill-opacity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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);
}
}
},
Expand Down
4 changes: 3 additions & 1 deletion test/preview-chart-inner-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
},
);
});
Loading