diff --git a/common/changes/@visactor/vrender-core/codex-fix-text-font-measurement_2026-10-11-12-25.json b/common/changes/@visactor/vrender-core/codex-fix-text-font-measurement_2026-10-11-12-25.json new file mode 100644 index 000000000..271d043a8 --- /dev/null +++ b/common/changes/@visactor/vrender-core/codex-fix-text-font-measurement_2026-10-11-12-25.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Fix font variant and style measurements for wrapped, clipped and vertical text.", + "type": "none", + "packageName": "@visactor/vrender-core" + } + ], + "packageName": "@visactor/vrender-core", + "email": "111864431+dvd233@users.noreply.github.com" +} \ No newline at end of file diff --git a/packages/vrender-core/__tests__/unit/graphic/text-font-layout.test.ts b/packages/vrender-core/__tests__/unit/graphic/text-font-layout.test.ts new file mode 100644 index 000000000..9a2572d27 --- /dev/null +++ b/packages/vrender-core/__tests__/unit/graphic/text-font-layout.test.ts @@ -0,0 +1,120 @@ +import { application } from '../../../src/application'; +import type { IGraphicUtil, TextOptionsType } from '../../../src/interface'; +import { ATextMeasure } from '../../../src/core/contributions/textMeasure/AtextMeasure'; +import { DefaultGraphicService } from '../../../src/graphic/graphic-service/graphic-service'; +import { createText } from '../../../src/graphic/text'; +import { createWrapText } from '../../../src/graphic/wrap-text'; +import { createGroup } from '../../../src/graphic/group'; + +describe('text layout uses the rendered font', () => { + const graphicUtil = application.graphicUtil; + const graphicService = application.graphicService; + + beforeEach(() => { + let font: TextOptionsType = {}; + // 使用确定的字体宽度隔离宿主字体差异,仍经过真实 ATextMeasure 的截断和测量算法。 + const context = { + setTextStyleWithoutAlignBaseline: (options: TextOptionsType) => (font = options), + measureText: (text: string) => { + const advance = font.fontVariant === 'small-caps' ? 20 : font.fontStyle === 'italic' ? 15 : 10; + const width = text.length * advance; + return { + width, + actualBoundingBoxAscent: 16, + actualBoundingBoxDescent: 4, + fontBoundingBoxAscent: 16, + fontBoundingBoxDescent: 4 + }; + } + }; + const measure = new ATextMeasure(); + measure.configure({ context, bindTextMeasure: (): void => undefined } as unknown as IGraphicUtil, 'browser'); + application.graphicUtil = { getTextMeasureInstance: () => measure } as unknown as IGraphicUtil; + application.graphicService = new DefaultGraphicService(); + }); + + afterEach(() => { + application.graphicUtil = graphicUtil; + application.graphicService = graphicService; + }); + + function wrapped(attributes = {}, create = createText) { + const text = create({ + text: 'abcdefgh', + fontFamily: 'Arial', + fontSize: 20, + lineHeight: 20, + whiteSpace: 'normal', + wordBreak: 'break-all', + maxLineWidth: 55, + ...attributes + }); + return text; + } + + test.each([createText, createWrapText])('wraps small-caps at the available width (%#)', create => { + const text = wrapped({ fontVariant: 'small-caps' }, create); + expect(text.multilineLayout.lines.map(line => line.str)).toEqual(['ab', 'cd', 'ef', 'gh']); + expect(text.multilineLayout.lines.map(line => line.width)).toEqual([40, 40, 40, 40]); + }); + + test('measures explicit lines with the configured font', () => { + const text = createText({ text: ['abcd', 'ab'], fontSize: 20, fontVariant: 'small-caps' }); + expect(text.multilineLayout.lines.map(line => line.width)).toEqual([80, 40]); + }); + + test('clips a single line including its suffix using the configured font', () => { + const text = createText({ + text: 'abcdefgh', + fontSize: 20, + fontVariant: 'small-caps', + maxLineWidth: 100, + ellipsis: '...' + }); + expect(text.clipedText).toBe('ab...'); + expect(text.clipedWidth).toBe(100); + }); + + test.each([createText, createWrapText])('invalidates wrapped layout when the variant changes (%#)', create => { + const text = wrapped({}, create); + expect(text.multilineLayout.lines.map(line => line.str)).toEqual(['abcde', 'fgh']); + text.setAttribute('fontVariant', 'small-caps'); + expect(text.multilineLayout.lines.map(line => line.str)).toEqual(['ab', 'cd', 'ef', 'gh']); + text.setAttributes({ fontVariant: '' }); + expect(text.multilineLayout.lines.map(line => line.str)).toEqual(['abcde', 'fgh']); + }); + + test.each([createText, createWrapText])('invalidates wrapped layout when the style changes (%#)', create => { + const text = wrapped({}, create); + expect(text.multilineLayout.lines.map(line => line.str)).toEqual(['abcde', 'fgh']); + text.setAttributes({ fontStyle: 'italic' }); + expect(text.multilineLayout.lines.map(line => line.str)).toEqual(['abc', 'def', 'gh']); + expect(text.multilineLayout.lines.map(line => line.width)).toEqual([45, 45, 30]); + }); + + test.each([createText, createWrapText])('recomputes layout when entering and leaving a font state (%#)', create => { + const text = wrapped({}, create); + text.states = { caps: { fontVariant: 'small-caps' } }; + expect(text.multilineLayout.lines.map(line => line.str)).toEqual(['abcde', 'fgh']); + text.useStates(['caps'], false); + expect(text.multilineLayout.lines.map(line => line.str)).toEqual(['ab', 'cd', 'ef', 'gh']); + text.useStates([], false); + expect(text.multilineLayout.lines.map(line => line.str)).toEqual(['abcde', 'fgh']); + }); + + test('inherits the font variant from the group theme while respecting an explicit override', () => { + const group = createGroup({}); + group.setTheme({ text: { fontVariant: 'small-caps' } }); + const inherited = wrapped(); + const overridden = wrapped({ fontVariant: '' }); + group.add(inherited); + group.add(overridden); + expect(inherited.multilineLayout.lines.map(line => line.str)).toEqual(['ab', 'cd', 'ef', 'gh']); + expect(overridden.multilineLayout.lines.map(line => line.str)).toEqual(['abcde', 'fgh']); + }); + + test('measures vertical Latin text with the configured font', () => { + const text = createText({ text: 'abcd', direction: 'vertical', fontSize: 20, fontVariant: 'small-caps' }); + expect(text.AABBBounds.height()).toBe(80); + }); +}); diff --git a/packages/vrender-core/src/graphic/text.ts b/packages/vrender-core/src/graphic/text.ts index cda8e5f4f..bf61d5caa 100644 --- a/packages/vrender-core/src/graphic/text.ts +++ b/packages/vrender-core/src/graphic/text.ts @@ -29,6 +29,8 @@ const TEXT_UPDATE_TAG_KEY = [ 'fontSize', 'fontFamily', 'fontWeight', + 'fontStyle', + 'fontVariant', 'ellipsis', 'lineHeight', 'direction', @@ -279,6 +281,8 @@ export class Text extends Graphic implements IText { textBaseline = textTheme.textBaseline, fontSize = textTheme.fontSize, fontWeight = textTheme.fontWeight, + fontStyle = textTheme.fontStyle, + fontVariant = textTheme.fontVariant, ellipsis = textTheme.ellipsis, maxLineWidth, stroke = textTheme.stroke, @@ -306,7 +310,12 @@ export class Text extends Graphic implements IText { return this._AABBBounds; } const textMeasure = application.graphicUtil.getTextMeasureInstance(this.textMeasureId || this.stage?.textMeasureId); - const layoutObj = new CanvasTextLayout(fontFamily, { fontSize, fontWeight, fontFamily, lineHeight }, textMeasure); + // 测量与绘制必须使用同一字体,否则 small-caps 等变体会越过换行宽度和包围盒。 + const layoutObj = new CanvasTextLayout( + fontFamily, + { fontSize, fontWeight, fontFamily, fontStyle, fontVariant, lineHeight }, + textMeasure + ); const layoutData = layoutObj.GetLayoutByLines( text, textAlign, @@ -352,6 +361,8 @@ export class Text extends Graphic implements IText { lineWidth = textTheme.lineWidth, wordBreak = textTheme.wordBreak, fontWeight = textTheme.fontWeight, + fontStyle = textTheme.fontStyle, + fontVariant = textTheme.fontVariant, // widthLimit, ignoreBuf = textTheme.ignoreBuf, measureMode = textTheme.measureMode, @@ -374,7 +385,7 @@ export class Text extends Graphic implements IText { } const textMeasure = application.graphicUtil.getTextMeasureInstance(this.textMeasureId || this.stage?.textMeasureId); - const textOptions = { fontSize, fontWeight, fontFamily, lineHeight }; + const textOptions = { fontSize, fontWeight, fontFamily, fontStyle, fontVariant, lineHeight }; const layoutObj = new CanvasTextLayout(fontFamily, textOptions, textMeasure as any); // layoutObj内逻辑 @@ -555,6 +566,8 @@ export class Text extends Graphic implements IText { fontFamily = textTheme.fontFamily, fontSize = textTheme.fontSize, fontWeight = textTheme.fontWeight, + fontStyle = textTheme.fontStyle, + fontVariant = textTheme.fontVariant, stroke = textTheme.stroke, lineWidth = textTheme.lineWidth, // wordBreak = textTheme.wordBreak, @@ -595,7 +608,7 @@ export class Text extends Graphic implements IText { const strEllipsis = (ellipsis === true ? textTheme.ellipsis : ellipsis) as string; const data = textMeasure.clipTextWithSuffixVertical( verticalData, - { fontSize, fontWeight, fontFamily }, + { fontSize, fontWeight, fontFamily, fontStyle, fontVariant }, maxLineWidth, strEllipsis, false, @@ -606,7 +619,7 @@ export class Text extends Graphic implements IText { } else { const data = textMeasure.clipTextVertical( verticalData, - { fontSize, fontWeight, fontFamily }, + { fontSize, fontWeight, fontFamily, fontStyle, fontVariant }, maxLineWidth, false ); @@ -621,7 +634,7 @@ export class Text extends Graphic implements IText { const w = t.direction === TextDirection.HORIZONTAL ? fontSize - : textMeasure.measureTextWidth(t.text, { fontSize, fontWeight, fontFamily }); + : textMeasure.measureTextWidth(t.text, { fontSize, fontWeight, fontFamily, fontStyle, fontVariant }); width += w; t.width = w; diff --git a/packages/vrender-core/src/graphic/wrap-text.ts b/packages/vrender-core/src/graphic/wrap-text.ts index b2c8ada94..4bb3a5909 100644 --- a/packages/vrender-core/src/graphic/wrap-text.ts +++ b/packages/vrender-core/src/graphic/wrap-text.ts @@ -51,6 +51,8 @@ export class WrapText extends Text { lineWidth = textTheme.lineWidth, wordBreak = textTheme.wordBreak, fontWeight = textTheme.fontWeight, + fontStyle = textTheme.fontStyle, + fontVariant = textTheme.fontVariant, // widthLimit, ignoreBuf = textTheme.ignoreBuf, heightLimit = 0, @@ -71,7 +73,11 @@ export class WrapText extends Text { } const textMeasure = application.graphicUtil.getTextMeasureInstance(this.textMeasureId || this.stage?.textMeasureId); - const layoutObj = new CanvasTextLayout(fontFamily, { fontSize, fontWeight, fontFamily }, textMeasure as any) as any; + const layoutObj = new CanvasTextLayout( + fontFamily, + { fontSize, fontWeight, fontFamily, fontStyle, fontVariant }, + textMeasure as any + ) as any; // layoutObj内逻辑 const lines = text.map(l => l.toString()) as string[];