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, + ]); }, ); });