From 13f81b897eb120d88d0ebcb7eb64e7e274e9aea2 Mon Sep 17 00:00:00 2001 From: Suliman Abdulrazzaq Date: Mon, 10 Aug 2026 21:03:45 +0300 Subject: [PATCH 1/6] fix: support text spacing --- __tests__/unit/utils/measure-text.test.ts | 34 +++++++++++++ __tests__/unit/utils/text.test.ts | 2 + src/renderer/composites/text.ts | 4 +- src/types/attrs.ts | 1 + src/utils/measure-text.ts | 62 +++++++++++++++++++++-- src/utils/text.ts | 26 +++++++++- 6 files changed, 122 insertions(+), 7 deletions(-) diff --git a/__tests__/unit/utils/measure-text.test.ts b/__tests__/unit/utils/measure-text.test.ts index 37a5304c4..3a72a72f7 100644 --- a/__tests__/unit/utils/measure-text.test.ts +++ b/__tests__/unit/utils/measure-text.test.ts @@ -88,6 +88,40 @@ 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('includes spacing in the server-side fallback measurement', async () => { + vi.stubGlobal('window', undefined); + vi.stubGlobal('document', undefined); + + 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(16 * 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..92f669031 100644 --- a/__tests__/unit/utils/text.test.ts +++ b/__tests__/unit/utils/text.test.ts @@ -47,6 +47,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 +56,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', () => { 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..9b69290f3 100644 --- a/src/utils/measure-text.ts +++ b/src/utils/measure-text.ts @@ -89,6 +89,31 @@ 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 amount = Number.parseFloat(value); + if (!Number.isFinite(amount)) return 0; + return value.endsWith('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 +121,33 @@ function measureTextInBrowser( fontSize, fontWeight, lineHeight, + letterSpacing, + wordSpacing, }: { fontFamily: string; fontSize: number; fontWeight: string | number; lineHeight: number | string | undefined; + letterSpacing: number | string | undefined; + wordSpacing: number | string | undefined; }, ) { const lines = content.split(/\r?\n/); const normalizedFamily = encodeFontFamily(fontFamily); const normalizedWeight = fontWeight || 'normal'; const lineHeightPx = resolveLineHeight(fontSize, lineHeight); + const letterSpacingPx = resolveSpacing(letterSpacing, fontSize); + const wordSpacingPx = resolveSpacing(wordSpacing, fontSize); 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, letterSpacingPx, wordSpacingPx), + ); }, 0); return { width, height: lineHeightPx * Math.max(lines.length, 1) }; } @@ -124,6 +158,8 @@ function measureTextInBrowser( span.style.fontSize = `${fontSize}px`; span.style.fontWeight = String(normalizedWeight); span.style.lineHeight = `${lineHeightPx}px`; + span.style.letterSpacing = `${letterSpacingPx}px`; + span.style.wordSpacing = `${wordSpacingPx}px`; span.textContent = content; const rect = span.getBoundingClientRect(); if (content && rect.width <= 0 && rect.height <= 0) return null; @@ -145,6 +181,8 @@ export function measureText( fontSize = 14, fontWeight = 'normal', lineHeight = 1.4, + letterSpacing, + wordSpacing, } = attrs; const content = text.toString(); @@ -154,12 +192,30 @@ export function measureText( fontSize: parseFloat(fontSize.toString()), fontWeight, lineHeight, + letterSpacing, + wordSpacing, }; - const fallback = () => - measure(content, { + const fallback = () => { + const metrics = measure(content, { ...options, fontFamily: decodeFontFamily(fontFamily), }); + const spacingWidth = content + .split(/\r?\n/) + .reduce( + (maxWidth, line) => + Math.max( + maxWidth, + lineSpacing( + line, + resolveSpacing(letterSpacing, options.fontSize), + resolveSpacing(wordSpacing, options.fontSize), + ), + ), + 0, + ); + return { ...metrics, width: metrics.width + spacingWidth }; + }; const metrics = measureTextInBrowser(content, options) ?? fallback(); // 额外添加 1% 宽高 diff --git a/src/utils/text.ts b/src/utils/text.ts index 668eba93b..65197a448 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,21 @@ export function getTextStyle(attributes: TextAttributes) { typeof lineHeight === 'string' && lineHeight.endsWith('px') ? lineHeight : +lineHeight; - if (letterSpacing) style.letterSpacing = `${letterSpacing}px`; + if (letterSpacing !== undefined) + style.letterSpacing = formatSpacing(letterSpacing); + if (wordSpacing !== undefined) 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 +217,8 @@ export function getTextElementProps(text: TextElement): Partial { justifyContent, alignContent, fontWeight, + letterSpacing, + wordSpacing, } = entity.style; const [horizontal, vertical] = flexToAlign(justifyContent, alignContent); @@ -212,6 +232,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) }; } From 3a04307c2a31df2da698245bbe30b8d06287f45f Mon Sep 17 00:00:00 2001 From: Suliman Abdulrazzaq <144490671+SulimanAbdulrazzaq@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:34:12 +0300 Subject: [PATCH 2/6] fix: normalize spacing before text measurement --- src/utils/measure-text.ts | 46 +++++++++++++-------------------------- 1 file changed, 15 insertions(+), 31 deletions(-) diff --git a/src/utils/measure-text.ts b/src/utils/measure-text.ts index 9b69290f3..addbc86a4 100644 --- a/src/utils/measure-text.ts +++ b/src/utils/measure-text.ts @@ -99,9 +99,11 @@ function resolveSpacing( const value = spacing.trim(); if (!value) return 0; - const amount = Number.parseFloat(value); + 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 value.endsWith('em') ? amount * fontSize : amount; + return match[2]?.toLowerCase() === 'em' ? amount * fontSize : amount; } function lineSpacing( @@ -128,17 +130,14 @@ function measureTextInBrowser( fontSize: number; fontWeight: string | number; lineHeight: number | string | undefined; - letterSpacing: number | string | undefined; - wordSpacing: 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 letterSpacingPx = resolveSpacing(letterSpacing, fontSize); - const wordSpacingPx = resolveSpacing(wordSpacing, fontSize); - const context = getCanvasContext(); if (context) { context.font = `${normalizedWeight} ${fontSize}px ${normalizedFamily}`; @@ -146,7 +145,7 @@ function measureTextInBrowser( const metrics = context.measureText(line); return Math.max( maxWidth, - metrics.width + lineSpacing(line, letterSpacingPx, wordSpacingPx), + metrics.width + lineSpacing(line, letterSpacing, wordSpacing), ); }, 0); return { width, height: lineHeightPx * Math.max(lines.length, 1) }; @@ -158,8 +157,8 @@ function measureTextInBrowser( span.style.fontSize = `${fontSize}px`; span.style.fontWeight = String(normalizedWeight); span.style.lineHeight = `${lineHeightPx}px`; - span.style.letterSpacing = `${letterSpacingPx}px`; - span.style.wordSpacing = `${wordSpacingPx}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; @@ -187,35 +186,20 @@ export function measureText( const content = text.toString(); ensureMeasuryFont(fontFamily); + const normalizedFontSize = parseFloat(fontSize.toString()); const options = { fontFamily, - fontSize: parseFloat(fontSize.toString()), + fontSize: normalizedFontSize, fontWeight, lineHeight, - letterSpacing, - wordSpacing, + letterSpacing: resolveSpacing(letterSpacing, normalizedFontSize), + wordSpacing: resolveSpacing(wordSpacing, normalizedFontSize), }; - const fallback = () => { - const metrics = measure(content, { + const fallback = () => + measure(content, { ...options, fontFamily: decodeFontFamily(fontFamily), }); - const spacingWidth = content - .split(/\r?\n/) - .reduce( - (maxWidth, line) => - Math.max( - maxWidth, - lineSpacing( - line, - resolveSpacing(letterSpacing, options.fontSize), - resolveSpacing(wordSpacing, options.fontSize), - ), - ), - 0, - ); - return { ...metrics, width: metrics.width + spacingWidth }; - }; const metrics = measureTextInBrowser(content, options) ?? fallback(); // 额外添加 1% 宽高 From 4c1bf42a54c3451fddbc1820f1742dc1eb20a19d Mon Sep 17 00:00:00 2001 From: Suliman Abdulrazzaq <144490671+SulimanAbdulrazzaq@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:34:20 +0300 Subject: [PATCH 3/6] test: remove mocked SSR spacing assertion --- __tests__/unit/utils/measure-text.test.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/__tests__/unit/utils/measure-text.test.ts b/__tests__/unit/utils/measure-text.test.ts index 3a72a72f7..9450160ff 100644 --- a/__tests__/unit/utils/measure-text.test.ts +++ b/__tests__/unit/utils/measure-text.test.ts @@ -106,22 +106,6 @@ describe('measureText', () => { expect(metrics.width).toBe(Math.ceil(107 * 1.015)); }); - it('includes spacing in the server-side fallback measurement', async () => { - vi.stubGlobal('window', undefined); - vi.stubGlobal('document', undefined); - - 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(16 * 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( From 3ef8e01a3c81417db1f110cda96aabd791bcc8e6 Mon Sep 17 00:00:00 2001 From: Suliman Abdulrazzaq <144490671+SulimanAbdulrazzaq@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:34:28 +0300 Subject: [PATCH 4/6] test: cover real measury SSR spacing --- __tests__/unit/utils/measure-text.ssr.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 __tests__/unit/utils/measure-text.ssr.test.ts 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), + }); + }); +}); From 680885e7d46604ba08042b06755a1a8869b7cb92 Mon Sep 17 00:00:00 2001 From: SulimanAbdulrazzaq Date: Wed, 16 Sep 2026 12:53:50 +0000 Subject: [PATCH 5/6] test: cover spacing normalization and measured text boxes Codecov flagged 11 uncovered lines on this patch, all in src/utils/text.ts: the measurement branch of updateTextElement, the CSS-length branch of formatSpacing, and the spacing read-back in getTextElementProps. Adds cases for each: bare numbers and numeric strings normalized to px, explicit units left untouched, an explicit zero preserved, absent spacing omitted, the measured box widening with letter spacing, explicitly sized elements left unmeasured, and the spacing round-trip through getTextElementProps. Patch coverage for src/utils/text.ts is now complete. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bvrwu6iCpKP3C2rh8tBsdJ --- __tests__/unit/utils/text.test.ts | 130 +++++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/__tests__/unit/utils/text.test.ts b/__tests__/unit/utils/text.test.ts index 92f669031..c44ea29d9 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(() => { @@ -173,3 +179,125 @@ 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(); + }); + }); + + 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(); + }); + }); +}); From ae10e84a45e3142671c9f4f6070f9775f14a6ce4 Mon Sep 17 00:00:00 2001 From: Suliman Abdulrazzaq Date: Thu, 17 Sep 2026 13:16:56 +0000 Subject: [PATCH 6/6] fix: skip null text spacing values getTextStyle now checks letter and word spacing with `!= null` instead of `!== undefined`. UpdateElementCommand records `null` for attributes the element did not have, so undoing a letter-spacing edit on unspaced text passed `null` to formatSpacing, which threw. Main skipped such values. --- __tests__/unit/utils/text.test.ts | 10 ++++++++++ src/utils/text.ts | 5 ++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/__tests__/unit/utils/text.test.ts b/__tests__/unit/utils/text.test.ts index c44ea29d9..cfec7ac17 100644 --- a/__tests__/unit/utils/text.test.ts +++ b/__tests__/unit/utils/text.test.ts @@ -219,6 +219,16 @@ describe('text spacing', () => { 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', () => { diff --git a/src/utils/text.ts b/src/utils/text.ts index 65197a448..d225a4ec4 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -174,9 +174,8 @@ export function getTextStyle(attributes: TextAttributes) { typeof lineHeight === 'string' && lineHeight.endsWith('px') ? lineHeight : +lineHeight; - if (letterSpacing !== undefined) - style.letterSpacing = formatSpacing(letterSpacing); - if (wordSpacing !== undefined) style.wordSpacing = formatSpacing(wordSpacing); + 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);