From 6903645be6525f59331bce680d719848abb4c792 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sun, 25 Oct 2026 11:29:41 +0900 Subject: [PATCH 1/4] fix(preview): lay out chart plot areas and line labels as LibreOffice does The plot area kept a fixed 8 px margin and an 18 px legend strip, so a chart with a bottom legend drew its plot about 30 px taller than LibreOffice, and line labels above a point sat on the marker. Use LibreOffice's chart2 rules: 2% of the frame per side, a bottom legend box 1.85 mm from the edge and from the plot, half a value-label line above the plot, a category-label row sized to its font, and line labels 1 mm clear of the marker. --- .changeset/preview-chart-auto-layout.md | 5 + packages/preview/src/render-slide.ts | 124 +++++++++++++++++++---- test/preview-chart-auto-layout.test.ts | 116 +++++++++++++++++++++ test/preview-chart-axis-controls.test.ts | 14 ++- test/preview-chart-fill-opacity.test.ts | 13 ++- test/preview-chart-inner-layout.test.ts | 2 +- 6 files changed, 248 insertions(+), 26 deletions(-) create mode 100644 .changeset/preview-chart-auto-layout.md create mode 100644 test/preview-chart-auto-layout.test.ts diff --git a/.changeset/preview-chart-auto-layout.md b/.changeset/preview-chart-auto-layout.md new file mode 100644 index 000000000..044cbed18 --- /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. A chart with a bottom legend drew its plot about 30 px taller (at 1280 px wide) than LibreOffice, and line labels above a point 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 follows LibreOffice's: 2% of the chart's width and height stays clear on each side, a bottom legend sits 1.85 mm above the chart's bottom edge with another 1.85 mm before the plot, the plot leaves half a line above its top value label and a category-label row sized to the label font, and 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 6a2ea0424..79351bbd6 100644 --- a/packages/preview/src/render-slide.ts +++ b/packages/preview/src/render-slide.ts @@ -3179,6 +3179,32 @@ interface SeriesGeometry { readonly dPath: string; } +// LibreOffice's automatic chart layout (chart2 ChartView.cxx, VLegend.cxx): +// the plot area, axis labels included, keeps 2% of the chart's width and +// height from each edge, and a bottom legend sits 1.85 mm above the chart's +// bottom edge with another 1.85 mm before the plot area. The legend box pads +// its text by 0.2 of the font size above and below, at least 1 mm. +const CHART_AREA_INSET_RATIO = 0.02; +const PX_PER_MM = 96 / 25.4; +const LEGEND_EDGE_GAP_PX = 1.85 * PX_PER_MM; +const LEGEND_PADDING_RATIO = 0.2; +const LEGEND_MIN_PADDING_PX = PX_PER_MM; +const DEFAULT_LEGEND_PT = 11; +// Category tick labels are centered this far below the plot area. +const CATEGORY_LABEL_OFFSET_PX = 12; + +// Room a cartesian chart's plot area gives up to its labels, as LibreOffice +// lays it out: half a line above the top value label, the category labels' +// line below the axis, and a bottom legend with its gaps. +interface ChartLabelRoom { + readonly topValueLabelPx: number; + readonly bottomLegendPx: number | null; +} + +const legendBoxPx = (fontPx: number): number => + fontPx * CATEGORY_LABEL_LINE_HEIGHT + + 2 * Math.max(LEGEND_MIN_PADDING_PX, fontPx * LEGEND_PADDING_RATIO); + const layoutChart = ( xEmu: number, yEmu: number, @@ -3193,6 +3219,7 @@ const layoutChart = ( plotAreaLayout?: ReadChartSpec['plotAreaLayout'], yAxisGutter = hasAxes ? DEFAULT_Y_AXIS_GUTTER : 0, xAxisGutter = hasAxes ? BAR_VALUE_LABEL_ROW : 0, + labelRoom?: ChartLabelRoom, ): ChartFrame => { const x = xEmu / EMU_PER_PX; const y = yEmu / EMU_PER_PX; @@ -3205,12 +3232,19 @@ const layoutChart = ( // matching LibreOffice's title box + gap) instead of a fixed strip, so a // 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 bottomLegendPx = hasLegend && !legendOverlay ? (labelRoom?.bottomLegendPx ?? null) : null; + const legendBox = bottomLegendPx === null ? 0 : legendBoxPx(bottomLegendPx); + const legendStrip = + bottomLegendPx !== null ? legendBox + 2 * LEGEND_EDGE_GAP_PX : hasLegend && !legendOverlay ? 18 : 0; + const paddingX = labelRoom ? w * CHART_AREA_INSET_RATIO : 8; + const paddingY = labelRoom ? h * CHART_AREA_INSET_RATIO : 8; + const topLabel = labelRoom ? (labelRoom.topValueLabelPx * CATEGORY_LABEL_LINE_HEIGHT) / 2 : 0; // 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 + topLabel; return { x, y, @@ -3220,15 +3254,16 @@ 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 - topLabel), // 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), - legendY: y + h - (legendOverlay ? 18 : legendStrip / 2), + titleY: y + (titleOverlay ? 14 : paddingY + titlePx * 0.7), + legendY: + y + h - (legendOverlay ? 18 : bottomLegendPx !== null ? LEGEND_EDGE_GAP_PX + legendBox / 2 : legendStrip / 2), }; }; @@ -3550,7 +3585,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 +3778,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 +4019,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 +4614,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 +4641,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 +4917,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 +4940,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 +5477,27 @@ 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 labelRoom: ChartLabelRoom | undefined = isCartesian + ? { + topValueLabelPx: + vertical && !spec.valueAxisHidden ? categoryLabelFontPx(spec.valueAxisLabelStyle) : 0, + bottomLegendPx: + (spec.legend?.position ?? 'b') === 'b' + ? (spec.legend?.textStyle?.sizePt ?? DEFAULT_LEGEND_PT) * PX_PER_PT + : null, + } + : undefined; + 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 +5517,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, + labelRoom, ), }; 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 000000000..b32fe331d --- /dev/null +++ b/test/preview-chart-auto-layout.test.ts @@ -0,0 +1,116 @@ +// Automatic plot-area layout of cartesian charts, following LibreOffice's +// chart2 view (ChartView.cxx, VLegend.cxx): 2% of the frame clear on each +// side, a bottom legend 1.85 mm above the frame edge with 1.85 mm more before +// the plot, half a value-label line above the plot, 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 and half a value-label line above the plot', async () => { + const plot = plotOf(await render(column)); + const halfLabelLine = (10 * PX_PER_PT * 1.2) / 2; + + expect(plot.x).toBeCloseTo(FRAME.x + FRAME.w * 0.02 + 32, 1); + expect(plot.y).toBeCloseTo(FRAME.y + FRAME.h * 0.02 + halfLabelLine, 1); + expect(plot.x + plot.w).toBeCloseTo(FRAME.x + FRAME.w * 0.98, 1); + }); + + it('reserves a bottom legend box with its gaps and centers the legend in it', async () => { + const legendPt = 10; + const svg = await render({ + ...column, + legend: { position: 'b', textStyle: { sizePt: legendPt } }, + }); + const plot = plotOf(svg); + const legendPx = legendPt * PX_PER_PT; + const box = legendPx * 1.2 + 2 * PX_PER_MM; + const gap = 1.85 * PX_PER_MM; + const categoryRow = 12 + (10 * PX_PER_PT * 1.2) / 2; + + expect(plot.bottom).toBeCloseTo( + FRAME.y + FRAME.h - gap - box - gap - FRAME.h * 0.02 - categoryRow, + 1, + ); + const legendText = [...svg.matchAll(/]*>Sessions<\/text>/g)]; + expect(legendText).toHaveLength(1); + expect(Number(legendText[0]![1])).toBeCloseTo(FRAME.y + FRAME.h - gap - box / 2, 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, 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 71768750d..e3b2cc866 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 half a +// value-label line above the plot and a category-label row (20 px at 10 pt) +// below it, a bar chart an 18 px value-label row. +const plotFrame = (kind: string, valueLabels: boolean) => { + const left = 96 + 768 * 0.02 + 32; + const width = 768 * 0.96 - 32; + const top = 96 + 480 * 0.02 + (kind !== 'bar' && valueLabels ? 8 : 0); + 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, false); 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, true); 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 ecad0a45e..c7a39cefe 100644 --- a/test/preview-chart-fill-opacity.test.ts +++ b/test/preview-chart-fill-opacity.test.ts @@ -20,6 +20,11 @@ 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, half a value-label line above a column plot and a 20 px +// category-label row below it. +const PLOT = { left: 143.36, width: 705.28, height: 432.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 +59,11 @@ 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 9645d5065..a43bbef5e 100644 --- a/test/preview-chart-inner-layout.test.ts +++ b/test/preview-chart-inner-layout.test.ts @@ -85,7 +85,7 @@ 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, 113.6, 705.28, 432.8]); }, ); }); From 60bf818adfdc249f1fe796cd7db24ff74a40101f Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sun, 25 Oct 2026 11:30:42 +0900 Subject: [PATCH 2/4] style: format chart layout changes --- packages/preview/src/render-slide.ts | 17 +++++++++++++---- test/preview-chart-auto-layout.test.ts | 4 +--- test/preview-chart-fill-opacity.test.ts | 4 +++- test/preview-chart-inner-layout.test.ts | 4 +++- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/preview/src/render-slide.ts b/packages/preview/src/render-slide.ts index 79351bbd6..d3207e661 100644 --- a/packages/preview/src/render-slide.ts +++ b/packages/preview/src/render-slide.ts @@ -3235,7 +3235,11 @@ const layoutChart = ( const bottomLegendPx = hasLegend && !legendOverlay ? (labelRoom?.bottomLegendPx ?? null) : null; const legendBox = bottomLegendPx === null ? 0 : legendBoxPx(bottomLegendPx); const legendStrip = - bottomLegendPx !== null ? legendBox + 2 * LEGEND_EDGE_GAP_PX : hasLegend && !legendOverlay ? 18 : 0; + bottomLegendPx !== null + ? legendBox + 2 * LEGEND_EDGE_GAP_PX + : hasLegend && !legendOverlay + ? 18 + : 0; const paddingX = labelRoom ? w * CHART_AREA_INSET_RATIO : 8; const paddingY = labelRoom ? h * CHART_AREA_INSET_RATIO : 8; const topLabel = labelRoom ? (labelRoom.topValueLabelPx * CATEGORY_LABEL_LINE_HEIGHT) / 2 : 0; @@ -3263,7 +3267,13 @@ const layoutChart = ( // edge to match where LibreOffice paints the title line. titleY: y + (titleOverlay ? 14 : paddingY + titlePx * 0.7), legendY: - y + h - (legendOverlay ? 18 : bottomLegendPx !== null ? LEGEND_EDGE_GAP_PX + legendBox / 2 : legendStrip / 2), + y + + h - + (legendOverlay + ? 18 + : bottomLegendPx !== null + ? LEGEND_EDGE_GAP_PX + legendBox / 2 + : legendStrip / 2), }; }; @@ -5478,8 +5488,7 @@ const renderChart = ( 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 categoryLabelsShown = !spec.categoryAxisHidden && spec.categoryAxisTickLabelPos !== 'none'; const labelRoom: ChartLabelRoom | undefined = isCartesian ? { topValueLabelPx: diff --git a/test/preview-chart-auto-layout.test.ts b/test/preview-chart-auto-layout.test.ts index b32fe331d..c84ef9209 100644 --- a/test/preview-chart-auto-layout.test.ts +++ b/test/preview-chart-auto-layout.test.ts @@ -84,9 +84,7 @@ describe('automatic cartesian chart layout', () => { 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 } }), - ); + const large = plotOf(await render({ ...column, categoryAxisLabelStyle: { sizePt: 18 } })); expect(small.bottom - large.bottom).toBeCloseTo(((18 - 10) * PX_PER_PT * 1.2) / 2, 1); }); diff --git a/test/preview-chart-fill-opacity.test.ts b/test/preview-chart-fill-opacity.test.ts index c7a39cefe..f6c34db42 100644 --- a/test/preview-chart-fill-opacity.test.ts +++ b/test/preview-chart-fill-opacity.test.ts @@ -60,7 +60,9 @@ describe.each(['column', 'bar'] as const)('%s fill opacity', (kind) => { const fraction = grouping === 'percentStacked' ? 0.4 : 0.2; if (kind === 'column') { expect(Number(base.height)).toBeCloseTo(PLOT.height * fraction); - expect(Number(visible.y) + Number(visible.height)).toBeCloseTo(PLOT.bottom - PLOT.height * fraction); + expect(Number(visible.y) + Number(visible.height)).toBeCloseTo( + PLOT.bottom - PLOT.height * fraction, + ); } else { 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 a43bbef5e..73c04c67b 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([143.36, 113.6, 705.28, 432.8]); + expect([plot.x, plot.y, plot.width, plot.height].map(Number)).toEqual([ + 143.36, 113.6, 705.28, 432.8, + ]); }, ); }); From 9d8b01500cbf065266b7917711642c15fd5f2b0b Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sun, 25 Oct 2026 14:35:47 +0900 Subject: [PATCH 3/4] test: satisfy the chart spec types in the new tests --- test/preview-chart-auto-layout.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/preview-chart-auto-layout.test.ts b/test/preview-chart-auto-layout.test.ts index c84ef9209..96a23a311 100644 --- a/test/preview-chart-auto-layout.test.ts +++ b/test/preview-chart-auto-layout.test.ts @@ -97,7 +97,14 @@ describe('automatic cartesian chart layout', () => { categories: ['A', 'B'], series: [{ name: 'Rate', values: [50, 50], markerSymbol: 'circle', markerSizePt: markerPt }], valueAxis: { min: 0, max: 100 }, - dataLabels: { showValue: true, position: 't', textStyle: { sizePt: labelPt } }, + dataLabels: { + showValue: true, + showCategory: false, + showSeriesName: false, + showPercent: false, + position: 't', + textStyle: { sizePt: labelPt }, + }, plotAreaFill: '#FFFFCC', }); const plot = plotOf(svg); From 9d406d3f42e293723574c3c1017e517bc1b19a9f Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sun, 25 Oct 2026 15:02:34 +0900 Subject: [PATCH 4/4] fix(preview): keep the bottom legend strip and plot top as before The legend band and the half line above the plot followed a newer LibreOffice than the fidelity reference and drew three sample slides further from it. --- .changeset/preview-chart-auto-layout.md | 2 +- packages/preview/src/render-slide.ts | 65 ++++-------------------- test/preview-chart-auto-layout.test.ts | 33 ++---------- test/preview-chart-axis-controls.test.ts | 14 ++--- test/preview-chart-fill-opacity.test.ts | 5 +- test/preview-chart-inner-layout.test.ts | 2 +- 6 files changed, 26 insertions(+), 95 deletions(-) diff --git a/.changeset/preview-chart-auto-layout.md b/.changeset/preview-chart-auto-layout.md index 044cbed18..66cc16174 100644 --- a/.changeset/preview-chart-auto-layout.md +++ b/.changeset/preview-chart-auto-layout.md @@ -2,4 +2,4 @@ '@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. A chart with a bottom legend drew its plot about 30 px taller (at 1280 px wide) than LibreOffice, and line labels above a point 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 follows LibreOffice's: 2% of the chart's width and height stays clear on each side, a bottom legend sits 1.85 mm above the chart's bottom edge with another 1.85 mm before the plot, the plot leaves half a line above its top value label and a category-label row sized to the label font, and 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. +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 d3207e661..8c552da09 100644 --- a/packages/preview/src/render-slide.ts +++ b/packages/preview/src/render-slide.ts @@ -3179,32 +3179,14 @@ interface SeriesGeometry { readonly dPath: string; } -// LibreOffice's automatic chart layout (chart2 ChartView.cxx, VLegend.cxx): -// the plot area, axis labels included, keeps 2% of the chart's width and -// height from each edge, and a bottom legend sits 1.85 mm above the chart's -// bottom edge with another 1.85 mm before the plot area. The legend box pads -// its text by 0.2 of the font size above and below, at least 1 mm. +// 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 LEGEND_EDGE_GAP_PX = 1.85 * PX_PER_MM; -const LEGEND_PADDING_RATIO = 0.2; -const LEGEND_MIN_PADDING_PX = PX_PER_MM; const DEFAULT_LEGEND_PT = 11; // Category tick labels are centered this far below the plot area. const CATEGORY_LABEL_OFFSET_PX = 12; -// Room a cartesian chart's plot area gives up to its labels, as LibreOffice -// lays it out: half a line above the top value label, the category labels' -// line below the axis, and a bottom legend with its gaps. -interface ChartLabelRoom { - readonly topValueLabelPx: number; - readonly bottomLegendPx: number | null; -} - -const legendBoxPx = (fontPx: number): number => - fontPx * CATEGORY_LABEL_LINE_HEIGHT + - 2 * Math.max(LEGEND_MIN_PADDING_PX, fontPx * LEGEND_PADDING_RATIO); - const layoutChart = ( xEmu: number, yEmu: number, @@ -3219,7 +3201,7 @@ const layoutChart = ( plotAreaLayout?: ReadChartSpec['plotAreaLayout'], yAxisGutter = hasAxes ? DEFAULT_Y_AXIS_GUTTER : 0, xAxisGutter = hasAxes ? BAR_VALUE_LABEL_ROW : 0, - labelRoom?: ChartLabelRoom, + proportionalInset = false, ): ChartFrame => { const x = xEmu / EMU_PER_PX; const y = yEmu / EMU_PER_PX; @@ -3232,23 +3214,13 @@ const layoutChart = ( // matching LibreOffice's title box + gap) instead of a fixed strip, so a // larger authored title reserves proportionally more room. const titleStrip = hasTitle && !titleOverlay ? Math.round(titlePx * 2.4) : 0; - const bottomLegendPx = hasLegend && !legendOverlay ? (labelRoom?.bottomLegendPx ?? null) : null; - const legendBox = bottomLegendPx === null ? 0 : legendBoxPx(bottomLegendPx); - const legendStrip = - bottomLegendPx !== null - ? legendBox + 2 * LEGEND_EDGE_GAP_PX - : hasLegend && !legendOverlay - ? 18 - : 0; - const paddingX = labelRoom ? w * CHART_AREA_INSET_RATIO : 8; - const paddingY = labelRoom ? h * CHART_AREA_INSET_RATIO : 8; - const topLabel = labelRoom ? (labelRoom.topValueLabelPx * CATEGORY_LABEL_LINE_HEIGHT) / 2 : 0; + const legendStrip = hasLegend && !legendOverlay ? 18 : 0; + 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 + paddingX + yAxisGutter; - const plotY = inner - ? y + Math.max(0, Math.min(1, inner.y)) * h - : y + titleStrip + paddingY + topLabel; + const plotY = inner ? y + Math.max(0, Math.min(1, inner.y)) * h : y + titleStrip + paddingY; return { x, y, @@ -3261,19 +3233,12 @@ const layoutChart = ( : 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 * paddingY - topLabel), + : 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 : paddingY + titlePx * 0.7), - legendY: - y + - h - - (legendOverlay - ? 18 - : bottomLegendPx !== null - ? LEGEND_EDGE_GAP_PX + legendBox / 2 - : legendStrip / 2), + legendY: y + h - (legendOverlay ? 18 : legendStrip / 2), }; }; @@ -5489,16 +5454,6 @@ const renderChart = ( spec.kind === 'bar' && spec.categoryAxisTitle ? axisTitleRowPx(spec.categoryAxisTitleStyle) : 0; const vertical = isCartesian && spec.kind !== 'bar'; const categoryLabelsShown = !spec.categoryAxisHidden && spec.categoryAxisTickLabelPos !== 'none'; - const labelRoom: ChartLabelRoom | undefined = isCartesian - ? { - topValueLabelPx: - vertical && !spec.valueAxisHidden ? categoryLabelFontPx(spec.valueAxisLabelStyle) : 0, - bottomLegendPx: - (spec.legend?.position ?? 'b') === 'b' - ? (spec.legend?.textStyle?.sizePt ?? DEFAULT_LEGEND_PT) * PX_PER_PT - : null, - } - : undefined; const categoryLabelRow = vertical && categoryLabelsShown ? Math.max( @@ -5527,7 +5482,7 @@ const renderChart = ( barCategoryTitleRow : undefined, barValueTitleRow > 0 ? BAR_VALUE_LABEL_ROW + barValueTitleRow : categoryLabelRow, - labelRoom, + isCartesian, ), }; const allNamesForLegend: string[] = diff --git a/test/preview-chart-auto-layout.test.ts b/test/preview-chart-auto-layout.test.ts index 96a23a311..07a38c526 100644 --- a/test/preview-chart-auto-layout.test.ts +++ b/test/preview-chart-auto-layout.test.ts @@ -1,8 +1,7 @@ // Automatic plot-area layout of cartesian charts, following LibreOffice's -// chart2 view (ChartView.cxx, VLegend.cxx): 2% of the frame clear on each -// side, a bottom legend 1.85 mm above the frame edge with 1.85 mm more before -// the plot, half a value-label line above the plot, and line-chart labels kept -// 1 mm clear of their markers. +// 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'; @@ -52,36 +51,14 @@ const plotOf = (svg: string) => { }; describe('automatic cartesian chart layout', () => { - it('keeps 2% of the frame clear and half a value-label line above the plot', async () => { + it('keeps 2% of the frame clear on each side', async () => { const plot = plotOf(await render(column)); - const halfLabelLine = (10 * PX_PER_PT * 1.2) / 2; expect(plot.x).toBeCloseTo(FRAME.x + FRAME.w * 0.02 + 32, 1); - expect(plot.y).toBeCloseTo(FRAME.y + FRAME.h * 0.02 + halfLabelLine, 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('reserves a bottom legend box with its gaps and centers the legend in it', async () => { - const legendPt = 10; - const svg = await render({ - ...column, - legend: { position: 'b', textStyle: { sizePt: legendPt } }, - }); - const plot = plotOf(svg); - const legendPx = legendPt * PX_PER_PT; - const box = legendPx * 1.2 + 2 * PX_PER_MM; - const gap = 1.85 * PX_PER_MM; - const categoryRow = 12 + (10 * PX_PER_PT * 1.2) / 2; - - expect(plot.bottom).toBeCloseTo( - FRAME.y + FRAME.h - gap - box - gap - FRAME.h * 0.02 - categoryRow, - 1, - ); - const legendText = [...svg.matchAll(/]*>Sessions<\/text>/g)]; - expect(legendText).toHaveLength(1); - expect(Number(legendText[0]![1])).toBeCloseTo(FRAME.y + FRAME.h - gap - box / 2, 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 } })); diff --git a/test/preview-chart-axis-controls.test.ts b/test/preview-chart-axis-controls.test.ts index e3b2cc866..0d847ec27 100644 --- a/test/preview-chart-axis-controls.test.ts +++ b/test/preview-chart-axis-controls.test.ts @@ -16,13 +16,13 @@ import { attrsOf } from './lib/svg-query.ts'; const CATEGORY_COLOR = '#11AA11'; const VALUE_COLOR = '#AA1111'; // 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 half a -// value-label line above the plot and a category-label row (20 px at 10 pt) -// below it, a bar chart an 18 px value-label row. -const plotFrame = (kind: string, valueLabels: boolean) => { +// 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 + (kind !== 'bar' && valueLabels ? 8 : 0); + const top = 96 + 480 * 0.02; const bottom = 576 - 480 * 0.02 - (kind === 'bar' ? 18 : 20); return { left, top, width, height: bottom - top, bottom }; }; @@ -166,7 +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, false); + const frame = plotFrame(kind); const length = level === 'major' ? 5 : 3; const outward = mark === 'in' ? 0 : length; const inward = mark === 'out' ? 0 : -length; @@ -259,7 +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, true); + 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 f6c34db42..caa8bcb4f 100644 --- a/test/preview-chart-fill-opacity.test.ts +++ b/test/preview-chart-fill-opacity.test.ts @@ -21,9 +21,8 @@ async function render(spec: ChartSpec) { } // The 8 × 5 in chart's plot area: 2% of the frame clear on each side, a 32 px -// value-label gutter, half a value-label line above a column plot and a 20 px -// category-label row below it. -const PLOT = { left: 143.36, width: 705.28, height: 432.8, bottom: 546.4 }; +// 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) => { diff --git a/test/preview-chart-inner-layout.test.ts b/test/preview-chart-inner-layout.test.ts index 73c04c67b..a443645ac 100644 --- a/test/preview-chart-inner-layout.test.ts +++ b/test/preview-chart-inner-layout.test.ts @@ -86,7 +86,7 @@ describe('manual inner chart layout', () => { }); const plot = attrsOf(svg, 'rect').find((r) => r.fill === '#FFFFCC')!; expect([plot.x, plot.y, plot.width, plot.height].map(Number)).toEqual([ - 143.36, 113.6, 705.28, 432.8, + 143.36, 105.6, 705.28, 440.8, ]); }, );