From 137f5b6047eda0498568feb0a2601292d2e10c7d Mon Sep 17 00:00:00 2001 From: Abdul Zahid Date: Mon, 2 Mar 2026 13:05:12 +0100 Subject: [PATCH 01/11] Add story to confirm/assert canvas based text measure takes into account open font attributes like `font-feature-settings` and `font-variant-numeric`. --- .../test_cases/34_font_measurement.story.tsx | 169 ++++++++++++++++++ .../stories/test_cases/test_cases.stories.tsx | 1 + 2 files changed, 170 insertions(+) create mode 100644 storybook/stories/test_cases/34_font_measurement.story.tsx diff --git a/storybook/stories/test_cases/34_font_measurement.story.tsx b/storybook/stories/test_cases/34_font_measurement.story.tsx new file mode 100644 index 00000000000..251e17ee11e --- /dev/null +++ b/storybook/stories/test_cases/34_font_measurement.story.tsx @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { select, number, boolean } from '@storybook/addon-knobs'; +import React from 'react'; + +import type { PartialTheme } from '@elastic/charts'; +import { + Axis, + BarSeries, + Chart, + LabelOverflowConstraint, + Metric, + Position, + ScaleType, + Settings, +} from '@elastic/charts'; + +import type { ChartsStory } from '../../types'; +import { useBaseTheme } from '../../use_base_theme'; + +const barData = [ + { x: '2021', y: 1234567.89 }, + { x: '2022', y: 2345678.9 }, + { x: '2023', y: 3456789.01 }, + { x: '2024', y: 4567890.12 }, + { x: '2025', y: 5678901.23 }, +]; + +const metricData = [ + [ + { + color: '#3c3c3c', + title: 'Revenue 2025', + subtitle: 'Total Revenue', + value: 5678901.23, + valueFormatter: (v: number) => `$${v.toFixed(2)}`, + }, + ], +]; + +export const Example: ChartsStory = (_, { description }) => { + // ── Layout ─────────────────────────────────────────────────────────────── + const chartWidth = number('Chart width (px)', 300, { min: 100, max: 1200, step: 1 }); + + // ── Font ───────────────────────────────────────────────────────────────── + const fontFamily = select( + 'Font: family', + { Inter: 'Inter', Arial: 'Arial', 'Times New Roman': 'Times New Roman', Courier: 'Courier' }, + 'Inter', + ); + const fontSize = number('Font: size (px)', 20, { range: true, min: 8, max: 48, step: 1 }); + + // ── font-feature-settings ───────────────────────────────────────────────── + const tnum = boolean("font-feature-settings: 'tnum' — tabular digits", true); + const zero = boolean("font-feature-settings: 'zero' — slashed zero", true); + const ss01 = boolean("font-feature-settings: 'ss01' — open digits", true); + const ss07 = boolean("font-feature-settings: 'ss07' — squared punctuation", true); + + // ── font-variant-numeric ────────────────────────────────────────────────── + const tabularNums = boolean('font-variant-numeric: tabular-nums', true); + const slashedZero = boolean('font-variant-numeric: slashed-zero', true); + + const fontFeatureParts: string[] = []; + if (tnum) fontFeatureParts.push("'tnum'"); + if (zero) fontFeatureParts.push("'zero'"); + if (ss01) fontFeatureParts.push("'ss01'"); + if (ss07) fontFeatureParts.push("'ss07'"); + + const fontVariantNumericParts: string[] = []; + if (tabularNums) fontVariantNumericParts.push('tabular-nums'); + if (slashedZero) fontVariantNumericParts.push('slashed-zero'); + + const containerStyle: React.CSSProperties = { + ...(fontFeatureParts.length > 0 ? { fontFeatureSettings: fontFeatureParts.join(', ') } : {}), + ...(fontVariantNumericParts.length > 0 ? { fontVariantNumeric: fontVariantNumericParts.join(' ') } : {}), + }; + + const theme: PartialTheme = { + barSeriesStyle: { + displayValue: { + fontSize: fontSize + 2, + fontFamily, + fill: '#000', + }, + }, + axes: { + tickLabel: { + fontSize, + fontFamily, + fill: '#000', + }, + }, + }; + + const anyFeaturesActive = fontFeatureParts.length > 0 || fontVariantNumericParts.length > 0; + + return ( +
+
+ + + + +
+ +
+ + + + `$${(d / 1_000_000).toFixed(1)}M`} + /> + + +
+ + {anyFeaturesActive && ( +
+ Active features:{' '} + {[ + ...(fontFeatureParts.length > 0 ? [`font-feature-settings: ${fontFeatureParts.join(', ')}`] : []), + ...(fontVariantNumericParts.length > 0 + ? [`font-variant-numeric: ${fontVariantNumericParts.join(' ')}`] + : []), + ].join(' | ')} +
0123456789 · $1,234,567.89 · 100.00%
+
+ )} + +

+ Tests whether canvas text measurement respects rendered text when font variants (e.g. OpenType features) are + applied via CSS inheritance. Text measurement should account for all computed font properties that affect + rendered dimensions, ensuring text is neither clipped nor overlapping. +

+
+ ); +}; + +Example.parameters = { + resize: false, +}; diff --git a/storybook/stories/test_cases/test_cases.stories.tsx b/storybook/stories/test_cases/test_cases.stories.tsx index 2da5430f2a5..014d570f690 100644 --- a/storybook/stories/test_cases/test_cases.stories.tsx +++ b/storybook/stories/test_cases/test_cases.stories.tsx @@ -28,3 +28,4 @@ export { Example as pointStyleOverrides } from './13_point_style_overrides.story export { Example as errorBoundary } from './14_error_boundary.story'; export { Example as linearNicing } from './15_linear_nicing.story'; export { Example as lensStressTest } from './33_lens_stress.story'; +export { Example as fontMeasurementTest } from './34_font_measurement.story'; From 52b33aab0988bbfd0fe52c36566d52904ee199e3 Mon Sep 17 00:00:00 2001 From: Abdul Zahid Date: Mon, 2 Mar 2026 13:08:36 +0100 Subject: [PATCH 02/11] Use in-Dom canvas, rendered within .echChart, instead of off-screen canvas to make sure canvas based text measurement takes into account font features like `font-feature-settings` and `font-variant-numeric`. --- packages/charts/src/components/chart.tsx | 2 + .../src/components/chart_measure_canvas.tsx | 39 +++++++++++++++++++ .../utils/bbox/canvas_text_bbox_calculator.ts | 29 ++++++++++++-- 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 packages/charts/src/components/chart_measure_canvas.tsx diff --git a/packages/charts/src/components/chart.tsx b/packages/charts/src/components/chart.tsx index d7f88d125b2..63e6d68f3ba 100644 --- a/packages/charts/src/components/chart.tsx +++ b/packages/charts/src/components/chart.tsx @@ -16,6 +16,7 @@ import { v4 as uuidv4 } from 'uuid'; import { ChartBackground } from './chart_background'; import { ChartContainer } from './chart_container'; +import { ChartMeasureCanvas } from './chart_measure_canvas'; import { ChartResizer } from './chart_resizer'; import { ChartStatus } from './chart_status'; import { Legend } from './legend/legend'; @@ -175,6 +176,7 @@ export class Chart extends React.Component { return (
+