Skip to content
Open
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
57 changes: 57 additions & 0 deletions __tests__/unit/utils/measure-text.ssr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { measureText as measure } from 'measury';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { measureText } from '../../../src/utils/measure-text';

describe('measureText SSR spacing', () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it('applies letter and word spacing exactly once with measury', () => {
vi.stubGlobal('window', undefined);
vi.stubGlobal('document', undefined);

const style = {
fontFamily: 'Arial',
fontSize: 20,
fontWeight: 'normal' as const,
lineHeight: 1.4,
letterSpacing: 2,
wordSpacing: 3,
};
const actual = measureText('A B', style);
const expected = measure('A B', style);

expect(actual).toEqual({
width: Math.ceil(expected.width * 1.015),
height: Math.ceil(expected.height * 1.015),
});
});

it('normalizes em spacing before passing it to measury', () => {
vi.stubGlobal('window', undefined);
vi.stubGlobal('document', undefined);

const actual = measureText('A B', {
fontFamily: 'Arial',
fontSize: 20,
fontWeight: 'normal',
lineHeight: 1.4,
letterSpacing: '0.1em',
wordSpacing: '0.5em',
});
const expected = measure('A B', {
fontFamily: 'Arial',
fontSize: 20,
fontWeight: 'normal',
lineHeight: 1.4,
letterSpacing: 2,
wordSpacing: 10,
});

expect(actual).toEqual({
width: Math.ceil(expected.width * 1.015),
height: Math.ceil(expected.height * 1.015),
});
});
});
18 changes: 18 additions & 0 deletions __tests__/unit/utils/measure-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,24 @@ describe('measureText', () => {
expect(spanLayoutSpy).not.toHaveBeenCalled();
});

it('includes letter and word spacing in canvas measurements', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
font: '',
measureText: () => ({ width: 100 }),
} as unknown as CanvasRenderingContext2D);

const { measureText } = await import('../../../src/utils/measure-text');
const metrics = measureText('A B', {
fontFamily: 'Arial',
fontSize: 20,
fontWeight: 'normal',
letterSpacing: 2,
wordSpacing: 3,
});

expect(metrics.width).toBe(Math.ceil(107 * 1.015));
});

it('falls back to measury when canvas is unavailable and span layout is invalid', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
vi.spyOn(
Expand Down
142 changes: 141 additions & 1 deletion __tests__/unit/utils/text.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { getElementRole, setElementRole } from '../../../src/utils';
import { createTextElement, getTextEntity } from '../../../src/utils/text';
import {
createTextElement,
getTextElementProps,
getTextEntity,
getTextStyle,
updateTextElement,
} from '../../../src/utils/text';

describe('text', () => {
beforeEach(() => {
Expand Down Expand Up @@ -47,6 +53,7 @@ describe('text', () => {
'font-family': 'Arial',
'line-height': 1.2,
'letter-spacing': 1,
'word-spacing': 2,
});

const span = textElement.querySelector('span') as HTMLSpanElement;
Expand All @@ -55,6 +62,7 @@ describe('text', () => {
expect(span.style.fontFamily).toBe('Arial');
expect(span.style.lineHeight).toBe('1.2');
expect(span.style.letterSpacing).toBe('1px');
expect(span.style.wordSpacing).toBe('2px');
});

it('should handle horizontal alignment', () => {
Expand Down Expand Up @@ -171,3 +179,135 @@ describe('text', () => {
});
});
});

describe('text spacing', () => {
beforeEach(() => {
document.body.innerHTML = '';
});

describe('getTextStyle spacing normalization', () => {
it('should treat bare numbers and numeric strings as pixels', () => {
const style = getTextStyle({
'letter-spacing': 1.5,
'word-spacing': '2',
});

expect(style.letterSpacing).toBe('1.5px');
expect(style.wordSpacing).toBe('2px');
});

it('should keep CSS lengths with an explicit unit untouched', () => {
const style = getTextStyle({
'letter-spacing': '0.5em',
'word-spacing': ' 3pt ',
});

expect(style.letterSpacing).toBe('0.5em');
expect(style.wordSpacing).toBe('3pt');
});

it('should keep an explicit zero rather than dropping it', () => {
const style = getTextStyle({ 'letter-spacing': 0, 'word-spacing': 0 });

expect(style.letterSpacing).toBe('0px');
expect(style.wordSpacing).toBe('0px');
});

it('should omit spacing that was not provided', () => {
const style = getTextStyle({});

expect(style.letterSpacing).toBeUndefined();
expect(style.wordSpacing).toBeUndefined();
});

it('should skip null spacing instead of throwing', () => {
const style = getTextStyle({
'letter-spacing': null,
'word-spacing': null,
} as any);

expect(style.letterSpacing).toBeUndefined();
expect(style.wordSpacing).toBeUndefined();
});
});

describe('updateTextElement measurement', () => {
it('should measure the element when width and height are missing', () => {
const textElement = createTextElement('Measured', {
width: '100',
height: '30',
});
textElement.removeAttribute('width');
textElement.removeAttribute('height');

updateTextElement(textElement, {
textContent: 'Measured',
attributes: { 'font-size': 16, 'line-height': 1.5 },
});

expect(Number(textElement.getAttribute('width'))).toBeGreaterThan(0);
expect(Number(textElement.getAttribute('height'))).toBeGreaterThan(0);
});

it('should widen the measured box as letter spacing grows', () => {
const measure = (letterSpacing: number) => {
const textElement = createTextElement('Spacing', {
width: '100',
height: '30',
});
textElement.removeAttribute('width');
textElement.removeAttribute('height');
updateTextElement(textElement, {
textContent: 'Spacing',
attributes: { 'font-size': 16, 'letter-spacing': letterSpacing },
});
return Number(textElement.getAttribute('width'));
};

expect(measure(10)).toBeGreaterThan(measure(0));
});

it('should keep an explicitly sized element unmeasured', () => {
const textElement = createTextElement('Sized', {
width: '120',
height: '40',
});

updateTextElement(textElement, {
textContent: 'Sized',
attributes: { width: '120', height: '40', 'font-size': 16 },
});

expect(textElement.getAttribute('width')).toBe('120');
expect(textElement.getAttribute('height')).toBe('40');
});
});

describe('getTextElementProps spacing round-trip', () => {
it('should read spacing back off the entity', () => {
const textElement = createTextElement('Round trip', {
width: '100',
height: '30',
'letter-spacing': 2,
'word-spacing': '0.25em',
});

const { attributes } = getTextElementProps(textElement);

expect(attributes?.['letter-spacing']).toBe('2px');
expect(attributes?.['word-spacing']).toBe('0.25em');
});

it('should omit spacing when the entity has none', () => {
const textElement = createTextElement('No spacing', {
width: '100',
height: '30',
});

const { attributes } = getTextElementProps(textElement);

expect(attributes?.['letter-spacing']).toBeUndefined();
expect(attributes?.['word-spacing']).toBeUndefined();
});
});
});
4 changes: 2 additions & 2 deletions src/renderer/composites/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ export function renderItemText(
const datum = getDatumByIndexes(data, indexes) as ItemDatum | undefined;
const text = String(get(datum, type, ''));
const dataAttrs = datum?.attributes?.[type] as
| Record<string, any>
| undefined;
Record<string, any> | undefined;
const attrs = Object.assign(
{},
themeConfig.base?.text,
Expand Down Expand Up @@ -109,6 +108,7 @@ function getTextAttributes(textElement: SVGTextElement) {
'font-style',
'font-variant',
'letter-spacing',
'word-spacing',
'line-height',
'fill',
'stroke',
Expand Down
1 change: 1 addition & 0 deletions src/types/attrs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type TextAttributes = {
'font-style'?: NumericalValue;
'font-variant'?: NumericalValue;
'letter-spacing'?: NumericalValue;
'word-spacing'?: NumericalValue;
'line-height'?: NumericalValue;
fill?: TextualValue;
stroke?: TextualValue;
Expand Down
46 changes: 43 additions & 3 deletions src/utils/measure-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,31 +89,64 @@ function resolveLineHeight(
return lineHeight > 4 ? lineHeight : lineHeight * fontSize;
}

function resolveSpacing(
spacing: number | string | undefined,
fontSize: number,
): number {
if (spacing === undefined || spacing === null) return 0;
if (typeof spacing === 'number')
return Number.isFinite(spacing) ? spacing : 0;

const value = spacing.trim();
if (!value) return 0;
const match = value.match(/^([-+]?(?:\d+\.?\d*|\.\d+))(px|em)?$/i);
if (!match) return 0;
const amount = Number(match[1]);
if (!Number.isFinite(amount)) return 0;
return match[2]?.toLowerCase() === 'em' ? amount * fontSize : amount;
}

function lineSpacing(
line: string,
letterSpacing: number,
wordSpacing: number,
): number {
const letterCount = Math.max(line.length - 1, 0);
const wordCount = (line.match(/[ \t]/g) || []).length;
return letterCount * letterSpacing + wordCount * wordSpacing;
}

function measureTextInBrowser(
content: string,
{
fontFamily,
fontSize,
fontWeight,
lineHeight,
letterSpacing,
wordSpacing,
}: {
fontFamily: string;
fontSize: number;
fontWeight: string | number;
lineHeight: number | string | undefined;
letterSpacing: number;
wordSpacing: number;
},
) {
const lines = content.split(/\r?\n/);
const normalizedFamily = encodeFontFamily(fontFamily);
const normalizedWeight = fontWeight || 'normal';
const lineHeightPx = resolveLineHeight(fontSize, lineHeight);

const context = getCanvasContext();
if (context) {
context.font = `${normalizedWeight} ${fontSize}px ${normalizedFamily}`;
const width = lines.reduce((maxWidth, line) => {
const metrics = context.measureText(line);
return Math.max(maxWidth, metrics.width);
return Math.max(
maxWidth,
metrics.width + lineSpacing(line, letterSpacing, wordSpacing),
);
}, 0);
return { width, height: lineHeightPx * Math.max(lines.length, 1) };
}
Expand All @@ -124,6 +157,8 @@ function measureTextInBrowser(
span.style.fontSize = `${fontSize}px`;
span.style.fontWeight = String(normalizedWeight);
span.style.lineHeight = `${lineHeightPx}px`;
span.style.letterSpacing = `${letterSpacing}px`;
span.style.wordSpacing = `${wordSpacing}px`;
span.textContent = content;
const rect = span.getBoundingClientRect();
if (content && rect.width <= 0 && rect.height <= 0) return null;
Expand All @@ -145,15 +180,20 @@ export function measureText(
fontSize = 14,
fontWeight = 'normal',
lineHeight = 1.4,
letterSpacing,
wordSpacing,
} = attrs;

const content = text.toString();
ensureMeasuryFont(fontFamily);
const normalizedFontSize = parseFloat(fontSize.toString());
const options = {
fontFamily,
fontSize: parseFloat(fontSize.toString()),
fontSize: normalizedFontSize,
fontWeight,
lineHeight,
letterSpacing: resolveSpacing(letterSpacing, normalizedFontSize),
wordSpacing: resolveSpacing(wordSpacing, normalizedFontSize),
};
const fallback = () =>
measure(content, {
Expand Down
Loading
Loading