diff --git a/__tests__/unit/designs/items/badge-card.test.tsx b/__tests__/unit/designs/items/badge-card.test.tsx
new file mode 100644
index 000000000..3022071a0
--- /dev/null
+++ b/__tests__/unit/designs/items/badge-card.test.tsx
@@ -0,0 +1,100 @@
+/** @jsxImportSource ../../../../src */
+import { describe, expect, it } from 'vitest';
+import { getElementBounds } from '../../../../src';
+import { BadgeCard } from '../../../../src/designs/items/BadgeCard';
+import type { ThemeColors } from '../../../../src/themes';
+import type { ItemDatum, ParsedData } from '../../../../src/types';
+
+const themeColors: ThemeColors = {
+ colorPrimary: '#ce422b',
+ colorBg: '#ffffff',
+ colorWhite: '#ffffff',
+ isDarkMode: false,
+ colorPrimaryBg: '#ce422b1a',
+ colorText: '#262626',
+ colorTextSecondary: '#5a5a5a',
+ colorPrimaryText: '#ffffff',
+ colorBgElevated: '#ffffff',
+};
+
+const LONG_DESC =
+ 'Collections implement IntoIterator to produce iterators via into_iter, iter, or iter_mut.';
+const SHORT_DESC = 'Store closures as struct fields.';
+
+const makeData = (items: ItemDatum[]): ParsedData => ({ items });
+
+// 卡片背景 Rect 是 Group 的第一个 shape,覆盖范围即卡片高度
+const getCardHeight = (data: ParsedData, index = 0) =>
+ getElementBounds(
+ ,
+ ).height;
+
+describe('BadgeCard', () => {
+ it('grows tall enough to contain a description that wraps past two lines', () => {
+ const data = makeData([{ label: 'Iterator Basics', desc: LONG_DESC }]);
+
+ // descY(48) + 3 行 × 1.2 × 12 = 48 + 44 + gap(8) = 100
+ expect(getCardHeight(data)).toBe(100);
+ });
+
+ it('keeps the compact height for descriptions that fit in two lines', () => {
+ const data = makeData([{ label: 'Closure in Struct', desc: SHORT_DESC }]);
+
+ // descY(48) + 2 行 × 1.2 × 12 = 48 + 29 + gap(8) = 85
+ expect(getCardHeight(data)).toBe(85);
+ });
+
+ it('gives every item the same height so grid cells stay aligned', () => {
+ const data = makeData([
+ { label: 'Closure in Struct', desc: SHORT_DESC },
+ { label: 'Iterator Basics', desc: LONG_DESC },
+ ]);
+
+ // 首项 desc 较短,但高度必须按整批最长的 desc 统一
+ expect(getCardHeight(data, 0)).toBe(getCardHeight(data, 1));
+ expect(getCardHeight(data, 0)).toBe(100);
+ });
+
+ it('falls back to the compact height when no item has a description', () => {
+ const data = makeData([{ label: 'Closure Basics' }]);
+
+ expect(getCardHeight(data)).toBe(80);
+ });
+
+ it('respects an explicitly provided height', () => {
+ const data = makeData([{ label: 'Iterator Basics', desc: LONG_DESC }]);
+
+ expect(
+ getElementBounds(
+ ,
+ ).height,
+ ).toBe(120);
+ });
+
+ it('reserves two lines when the datum is not one of data.items', () => {
+ // sequence-interaction 的泳道标题即如此:datum 由结构临时拼装,不在 data.items 中
+ const data = makeData([{ label: 'Node' }]);
+
+ expect(
+ getElementBounds(
+ ,
+ ).height,
+ ).toBe(85);
+ });
+});
diff --git a/__tests__/unit/utils/measure-text-lines.test.ts b/__tests__/unit/utils/measure-text-lines.test.ts
new file mode 100644
index 000000000..b9c0d0007
--- /dev/null
+++ b/__tests__/unit/utils/measure-text-lines.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+import { measureTextLines } from '../../../src/utils/measure-text';
+
+// 与 BadgeCard 描述文本一致的排版参数:字号 12(不低于浏览器最小字号下限)、卡片内容宽 184px
+const attrs = { fontSize: 12, lineHeight: 1.2, maxWidth: 184 };
+
+describe('measureTextLines', () => {
+ it('reports the extra lines a long description actually needs', () => {
+ expect(
+ measureTextLines(
+ 'Collections implement IntoIterator to produce iterators via into_iter, iter, or iter_mut.',
+ attrs,
+ ),
+ ).toBe(3);
+ });
+
+ it('keeps a short description within two lines', () => {
+ expect(
+ measureTextLines('Store closures as struct fields.', attrs),
+ ).toBeLessThanOrEqual(2);
+ });
+
+ it('keeps a label-sized string on one line', () => {
+ expect(measureTextLines('Closure Basics', attrs)).toBe(1);
+ });
+
+ it('wraps CJK text character by character', () => {
+ expect(
+ measureTextLines(
+ '这是一段比较长的中文描述文本用于验证换行测量是否正确',
+ attrs,
+ ),
+ ).toBeGreaterThan(1);
+ });
+
+ it('breaks a single word that overflows the container', () => {
+ expect(measureTextLines('A'.repeat(200), attrs)).toBeGreaterThan(1);
+ });
+
+ it('scales the line count with the available width', () => {
+ const text = 'Consumers like sum and collect consume iterators lazily.';
+
+ expect(measureTextLines(text, { ...attrs, maxWidth: 90 })).toBeGreaterThan(
+ measureTextLines(text, { ...attrs, maxWidth: 300 }),
+ );
+ });
+});
diff --git a/__tests__/unit/utils/measure-text.test.ts b/__tests__/unit/utils/measure-text.test.ts
index 37a5304c4..b0e75926b 100644
--- a/__tests__/unit/utils/measure-text.test.ts
+++ b/__tests__/unit/utils/measure-text.test.ts
@@ -235,3 +235,59 @@ describe('measureText', () => {
expect(fallbackMeasureText).toHaveBeenCalledTimes(1);
});
});
+
+describe('measureTextLines', () => {
+ const attrs = { fontSize: 12, lineHeight: 1.2, maxWidth: 184 };
+
+ it('returns 0 for empty or non-text content', async () => {
+ const { measureTextLines } =
+ await import('../../../src/utils/measure-text');
+
+ expect(measureTextLines('', attrs)).toBe(0);
+ expect(measureTextLines(undefined, attrs)).toBe(0);
+ expect(measureTextLines({} as any, attrs)).toBe(0);
+ });
+
+ it('counts explicit newlines as separate lines', async () => {
+ const { measureTextLines } =
+ await import('../../../src/utils/measure-text');
+
+ expect(measureTextLines('a\nb\nc', attrs)).toBe(3);
+ });
+
+ it('falls back to newline splitting when maxWidth is unusable', async () => {
+ const { measureTextLines } =
+ await import('../../../src/utils/measure-text');
+
+ expect(measureTextLines('a\nb', { ...attrs, maxWidth: 0 })).toBe(2);
+ });
+
+ it('caches the line count within a withTextLinesCache scope', async () => {
+ const { measureTextLines, withTextLinesCache } =
+ await import('../../../src/utils/measure-text');
+
+ fallbackMeasureText.mockClear();
+ const callsAfterFirst = withTextLinesCache(() => {
+ measureTextLines('cache probe', attrs);
+ const calls = fallbackMeasureText.mock.calls.length;
+ measureTextLines('cache probe', attrs);
+ expect(fallbackMeasureText).toHaveBeenCalledTimes(calls);
+ return calls;
+ });
+
+ expect(callsAfterFirst).toBeGreaterThan(0);
+ });
+
+ it('does not cache across withTextLinesCache scopes', async () => {
+ const { measureTextLines, withTextLinesCache } =
+ await import('../../../src/utils/measure-text');
+
+ fallbackMeasureText.mockClear();
+ withTextLinesCache(() => measureTextLines('cross scope probe', attrs));
+ const callsAfterFirst = fallbackMeasureText.mock.calls.length;
+ withTextLinesCache(() => measureTextLines('cross scope probe', attrs));
+
+ expect(callsAfterFirst).toBeGreaterThan(0);
+ expect(fallbackMeasureText.mock.calls.length).toBe(callsAfterFirst * 2);
+ });
+});
diff --git a/src/designs/items/BadgeCard.tsx b/src/designs/items/BadgeCard.tsx
index 74a1fc905..202516fea 100644
--- a/src/designs/items/BadgeCard.tsx
+++ b/src/designs/items/BadgeCard.tsx
@@ -1,5 +1,6 @@
import tinycolor from 'tinycolor2';
import { ComponentType, Defs, Ellipse, Group, Rect } from '../../jsx';
+import { measureTextLines } from '../../utils';
import { ItemDesc, ItemIcon, ItemLabel, ItemValue } from '../components';
import { FlexLayout } from '../layouts';
import { getItemProps } from '../utils';
@@ -14,13 +15,19 @@ export interface BadgeCardProps extends BaseItemProps {
gap?: number;
}
+const DESC_FONT_SIZE = 12;
+const DESC_LINE_HEIGHT = 1.2;
+const MIN_DESC_LINE_NUMBER = 2;
+const DEFAULT_HEIGHT = 80;
+
export const BadgeCard: ComponentType = (props) => {
const [
{
datum,
+ data,
indexes,
width = 200,
- height = 80,
+ height,
iconSize = 24,
badgeSize = 32,
gap = 8,
@@ -49,10 +56,32 @@ export const BadgeCard: ComponentType = (props) => {
const descY = gap + 14 + 18 + 8; // label(14) + value(18) + gap(8)
const contentAreaHeight = descY - gap; // label 和 value 占据的总高度
+ // 描述按实际折行行数占位,避免长文本溢出卡片背景。
+ // 网格类结构以首项尺寸作为所有单元格基准,因此高度需按整批 item 统一计算。
+ // NOTE: 层级结构中 data.items 是根节点、渲染的却是其 children,此处取不到同级集合,
+ // 这类结构暂时维持原有的固定两行占位。
+ const siblings = data.items;
+ const descLineNumber = Math.max(
+ ...siblings.map((item) =>
+ measureTextLines(item.desc, {
+ fontSize: DESC_FONT_SIZE,
+ lineHeight: DESC_LINE_HEIGHT,
+ maxWidth: fullWidth,
+ }),
+ ),
+ MIN_DESC_LINE_NUMBER,
+ );
+ const descHeight = Math.ceil(
+ descLineNumber * DESC_LINE_HEIGHT * DESC_FONT_SIZE,
+ );
+ const hasAnyDesc = siblings.some((item) => !!item.desc) || hasDesc;
+ const finalHeight =
+ height ?? (hasAnyDesc ? descY + descHeight + gap : DEFAULT_HEIGHT);
+
// 当没有 desc 时,徽章和内容区域垂直居中
- const badgeY = !hasDesc ? (height - badgeSize) / 2 : gap;
+ const badgeY = !hasDesc ? (finalHeight - badgeSize) / 2 : gap;
// 没有 value 时,label 在整个内容区域垂直居中;有 value 时从顶部开始
- const contentY = !hasValue && !hasDesc ? (height - 14) / 2 : gap;
+ const contentY = !hasValue && !hasDesc ? (finalHeight - 14) / 2 : gap;
const textAlign =
!hasIcon && positionH === 'center'
@@ -62,7 +91,7 @@ export const BadgeCard: ComponentType = (props) => {
: 'left';
return (
-
+
@@ -81,7 +110,7 @@ export const BadgeCard: ComponentType = (props) => {
x={0}
y={0}
width={width}
- height={height}
+ height={finalHeight}
fill={themeColors.colorPrimaryBg}
rx={8}
ry={8}
@@ -156,10 +185,10 @@ export const BadgeCard: ComponentType = (props) => {
y={descY}
width={fullWidth}
alignHorizontal={textAlign}
- fontSize={11}
+ fontSize={DESC_FONT_SIZE}
fill={themeColors.colorTextSecondary}
- lineNumber={2}
- lineHeight={1.2}
+ lineNumber={descLineNumber}
+ lineHeight={DESC_LINE_HEIGHT}
wordWrap={true}
>
{datum.desc}
diff --git a/src/designs/structures/sequence-interaction.tsx b/src/designs/structures/sequence-interaction.tsx
index 9150200db..3842b9e4e 100644
--- a/src/designs/structures/sequence-interaction.tsx
+++ b/src/designs/structures/sequence-interaction.tsx
@@ -364,6 +364,7 @@ export const SequenceInteractionFlow: ComponentType<
,
@@ -440,6 +441,7 @@ export const SequenceInteractionFlow: ComponentType<
icon: lane.icon,
desc: lane.desc,
}}
+ data={data}
x={centerX - itemWidth / 2}
y={padding}
width={itemWidth}
diff --git a/src/runtime/Infographic.tsx b/src/runtime/Infographic.tsx
index 1db3845f7..29d142891 100644
--- a/src/runtime/Infographic.tsx
+++ b/src/runtime/Infographic.tsx
@@ -15,7 +15,7 @@ import { DEFAULT_FONT, Renderer, setDefaultFont } from '../renderer';
import { waitForSvgLoads } from '../resource';
import { parseSyntax, type SyntaxError } from '../syntax';
import { IEventEmitter } from '../types';
-import { getTypes, parseSVG } from '../utils';
+import { getTypes, parseSVG, withTextLinesCache } from '../utils';
import { DEFAULT_OPTIONS } from './options';
import {
cloneOptions,
@@ -146,15 +146,19 @@ export class Infographic {
if (themeFontFamily) setDefaultFont(themeFontFamily);
try {
- const svg = renderSVG(
- ,
+ // Text measurements are memoized per compose pass only — font metrics can
+ // change between renders once web fonts finish loading.
+ const svg = withTextLinesCache(() =>
+ renderSVG(
+ ,
+ ),
);
const template = parseSVG(svg);
diff --git a/src/utils/measure-text.ts b/src/utils/measure-text.ts
index d7728d3b1..cb48f94a9 100644
--- a/src/utils/measure-text.ts
+++ b/src/utils/measure-text.ts
@@ -168,3 +168,138 @@ export function measureText(
height: Math.ceil(metrics.height * FONT_EXTEND_FACTOR),
};
}
+
+// 断行单元:CJK 逐字断行,其余按连续非空白片段(单词)断行
+const CJK_RANGE =
+ '\\u2e80-\\u9fff\\uac00-\\ud7ff\\uf900-\\ufaff\\ufe30-\\ufe4f\\uff00-\\uffef';
+const BREAK_UNIT_REGEX = new RegExp(
+ `\\s*(?:[${CJK_RANGE}]|[^\\s${CJK_RANGE}]+)`,
+ 'g',
+);
+
+// 单个断行单元仍超出容器宽度时,模拟 word-break: break-word 强制断开
+function breakLongUnit(
+ unit: string,
+ maxWidth: number,
+ widthOf: (value: string) => number,
+) {
+ let rest = unit;
+ let extraLines = 0;
+
+ while (rest.length > 1 && widthOf(rest) > maxWidth) {
+ let fitted = 1;
+ while (
+ fitted < rest.length &&
+ widthOf(rest.slice(0, fitted + 1)) <= maxWidth
+ ) {
+ fitted++;
+ }
+ if (fitted >= rest.length) break;
+ extraLines++;
+ rest = rest.slice(fitted);
+ }
+
+ return { extraLines, rest };
+}
+
+function countWrappedLines(
+ line: string,
+ maxWidth: number,
+ widthOf: (value: string) => number,
+) {
+ const units = line.match(BREAK_UNIT_REGEX);
+ if (!units) return 1;
+
+ let lines = 1;
+ let current = '';
+
+ for (const unit of units) {
+ const candidate = current ? current + unit : unit.trimStart();
+ if (!candidate) continue;
+
+ if (!current || widthOf(candidate) <= maxWidth) {
+ current = candidate;
+ } else {
+ lines++;
+ current = unit.trimStart();
+ }
+
+ const { extraLines, rest } = breakLongUnit(current, maxWidth, widthOf);
+ lines += extraLines;
+ current = rest;
+ }
+
+ return lines;
+}
+
+// 同一批 item 会互相测量彼此的文本,缓存行数避免 O(n²) 的重复折行计算。
+// 仅在单次渲染内复用:Web 字体是渲染后才注入的,加载完成前后同一段文字的
+// 度量结果不同,跨渲染复用会让行数停留在回退字体的测量值。
+let lineCountCache: Map | null = null;
+
+/** 在一次渲染范围内复用折行测量结果,作用域外不缓存 */
+export function withTextLinesCache(render: () => T): T {
+ const previous = lineCountCache;
+ lineCountCache = new Map();
+ try {
+ return render();
+ } finally {
+ lineCountCache = previous;
+ }
+}
+
+function getLineCountCacheKey(
+ content: string,
+ attrs: TextProps & { maxWidth: number },
+) {
+ const {
+ maxWidth,
+ fontFamily = DEFAULT_FONT,
+ fontSize = 14,
+ fontWeight = 'normal',
+ lineHeight = 1.4,
+ } = attrs;
+ return [
+ maxWidth,
+ fontFamily,
+ fontSize,
+ fontWeight,
+ lineHeight,
+ FONT_EXTEND_FACTOR,
+ content,
+ ].join('|');
+}
+
+/**
+ * 测量文本在给定宽度内折行后的行数,与渲染层的换行行为对齐。
+ */
+export function measureTextLines(
+ text: JSXNode = '',
+ attrs: TextProps & { maxWidth: number },
+): number {
+ if (typeof text !== 'string' && typeof text !== 'number') return 0;
+ const content = text.toString();
+ if (!content) return 0;
+
+ const { maxWidth } = attrs;
+ const lines = content.split(/\r?\n/);
+ if (!Number.isFinite(maxWidth) || maxWidth <= 0) return lines.length;
+
+ const compute = () => {
+ // 测量单个片段的自然宽度,不能带入容器宽高(会短路 measureText)
+ const textAttrs = { ...attrs, width: undefined, height: undefined };
+ const widthOf = (value: string) => measureText(value, textAttrs).width;
+ return lines.reduce(
+ (count, line) => count + countWrappedLines(line, maxWidth, widthOf),
+ 0,
+ );
+ };
+
+ if (!lineCountCache) return compute();
+
+ const cacheKey = getLineCountCacheKey(content, attrs);
+ let total = lineCountCache.get(cacheKey);
+ if (total === undefined) lineCountCache.set(cacheKey, (total = compute()));
+
+ return total;
+}