diff --git a/__tests__/unit/utils/measure-text.ssr.test.ts b/__tests__/unit/utils/measure-text.ssr.test.ts new file mode 100644 index 000000000..d9ce74159 --- /dev/null +++ b/__tests__/unit/utils/measure-text.ssr.test.ts @@ -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), + }); + }); +}); diff --git a/__tests__/unit/utils/measure-text.test.ts b/__tests__/unit/utils/measure-text.test.ts index 37a5304c4..9450160ff 100644 --- a/__tests__/unit/utils/measure-text.test.ts +++ b/__tests__/unit/utils/measure-text.test.ts @@ -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( diff --git a/__tests__/unit/utils/text.test.ts b/__tests__/unit/utils/text.test.ts index 689720960..cfec7ac17 100644 --- a/__tests__/unit/utils/text.test.ts +++ b/__tests__/unit/utils/text.test.ts @@ -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(() => { @@ -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; @@ -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', () => { @@ -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(); + }); + }); +}); diff --git a/src/renderer/composites/text.ts b/src/renderer/composites/text.ts index 5a74cfc53..42546b5df 100644 --- a/src/renderer/composites/text.ts +++ b/src/renderer/composites/text.ts @@ -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 - | undefined; + Record | undefined; const attrs = Object.assign( {}, themeConfig.base?.text, @@ -109,6 +108,7 @@ function getTextAttributes(textElement: SVGTextElement) { 'font-style', 'font-variant', 'letter-spacing', + 'word-spacing', 'line-height', 'fill', 'stroke', diff --git a/src/types/attrs.ts b/src/types/attrs.ts index d43ed899b..61e7b9996 100644 --- a/src/types/attrs.ts +++ b/src/types/attrs.ts @@ -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; diff --git a/src/utils/measure-text.ts b/src/utils/measure-text.ts index d7728d3b1..addbc86a4 100644 --- a/src/utils/measure-text.ts +++ b/src/utils/measure-text.ts @@ -89,6 +89,33 @@ 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, { @@ -96,24 +123,30 @@ function measureTextInBrowser( 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) }; } @@ -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; @@ -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, { diff --git a/src/utils/text.ts b/src/utils/text.ts index 668eba93b..d225a4ec4 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -51,7 +51,14 @@ export function updateTextElement( Object.assign(entity.style, getTextStyle(attributes)); if (!width || !height) { const content = textContent ?? getTextContent(text); - const { fontFamily, fontSize, fontWeight, lineHeight } = entity.style; + const { + fontFamily, + fontSize, + fontWeight, + lineHeight, + letterSpacing, + wordSpacing, + } = entity.style; const fSize = fontSize ? parseFloat(String(fontSize)) : 12; const rect = measureText(content, { fontFamily, @@ -60,6 +67,8 @@ export function updateTextElement( lineHeight: lineHeight.endsWith('px') ? parseFloat(lineHeight) : (parseFloat(lineHeight) || 1.4) * fSize, + letterSpacing, + wordSpacing, }); if (!width && !text.hasAttribute('width')) width = String(rect.width); @@ -138,6 +147,7 @@ export function getTextStyle(attributes: TextAttributes) { ['data-vertical-align']: verticalAlign, // omit ['font-size']: fontSize, ['letter-spacing']: letterSpacing, + ['word-spacing']: wordSpacing, ['line-height']: lineHeight, fill, ['stroke-width']: strokeWidth, @@ -164,13 +174,20 @@ export function getTextStyle(attributes: TextAttributes) { typeof lineHeight === 'string' && lineHeight.endsWith('px') ? lineHeight : +lineHeight; - if (letterSpacing) style.letterSpacing = `${letterSpacing}px`; + if (letterSpacing != null) style.letterSpacing = formatSpacing(letterSpacing); + if (wordSpacing != null) style.wordSpacing = formatSpacing(wordSpacing); if (strokeWidth) style.strokeWidth = `${strokeWidth}px`; if (fontFamily) style.fontFamily = encodeFontFamily(fontFamily); return style; } +function formatSpacing(value: number | string): string { + if (typeof value === 'number') return `${value}px`; + const trimmed = value.trim(); + return /^[-+]?\d*\.?\d+$/.test(trimmed) ? `${trimmed}px` : trimmed; +} + export function getTextContent(text: TextElement): string { const entity = getTextEntity(text); if (!entity) return ''; @@ -199,6 +216,8 @@ export function getTextElementProps(text: TextElement): Partial { justifyContent, alignContent, fontWeight, + letterSpacing, + wordSpacing, } = entity.style; const [horizontal, vertical] = flexToAlign(justifyContent, alignContent); @@ -212,6 +231,8 @@ export function getTextElementProps(text: TextElement): Partial { if (fontWeight) attrs['font-weight'] = fontWeight; if (fontSize) attrs['font-size'] = String(parseInt(fontSize)); if (color) attrs['fill'] = color; + if (letterSpacing) attrs['letter-spacing'] = letterSpacing; + if (wordSpacing) attrs['word-spacing'] = wordSpacing; return { attributes: attrs, textContent: getTextContent(text) }; }