/** * 文本换行测试(对应设计文档 §14 的"中文字体、长文本"测试)。 * * 使用确定性估算度量器,保证断言与字体环境无关。 */ import { describe, expect, it } from "vitest" import { DEFAULT_RESOLVED_STYLE, type ResolvedStyle } from "@report/core" import { createEstimateMeasurer } from "../src/measurer.js" import { wrapText, segmentText, isTextOverflow, measureTextHeight, isNoLineStart, isNoLineEnd, } from "../src/text-wrap.js" const measurer = createEstimateMeasurer() function style(overrides: Partial = {}): ResolvedStyle { return { ...DEFAULT_RESOLVED_STYLE, fontSize: 10, ...overrides } } /** 10pt 字号下 1em ≈ 3.5278mm */ const EM = (10 / 72) * 25.4 describe("segmentText 分段", () => { it("西文按词切分并保留空格", () => { expect(segmentText("hello world")).toEqual(["hello ", "world"]) }) it("中文逐字切分", () => { expect(segmentText("中文报表")).toEqual(["中", "文", "报", "表"]) }) it("中英混排正确切分", () => { // 空格自身成段:它终结前一个词,同时供避头点逻辑判断 expect(segmentText("销售 abc 单")).toEqual(["销", "售", " ", "abc ", "单"]) }) it("空串返回空数组", () => { expect(segmentText("")).toEqual([]) }) }) describe("wrapText 基本换行", () => { it("短文本不换行", () => { const result = wrapText("abc", { maxWidth: 100, style: style(), measurer }) expect(result.lines).toEqual(["abc"]) }) it("超宽英文在词边界换行", () => { // "hello world" 约 5em;限制到 4em 才能触发词边界换行 const result = wrapText("hello world", { maxWidth: 4 * EM, style: style(), measurer, }) expect(result.lines.length).toBeGreaterThan(1) // 不拆开单词:每行去掉空格后仍应是完整单词 for (const line of result.lines) { expect(["hello", "world"]).toContain(line.trim()) } }) it("中文在任意字之间换行", () => { const result = wrapText("中文报表测试", { maxWidth: 3 * EM, style: style(), measurer }) expect(result.lines.length).toBe(2) expect(result.lines[0]).toBe("中文报") expect(result.lines[1]).toBe("表测试") }) it("保留硬换行", () => { const result = wrapText("a\nb", { maxWidth: 100, style: style(), measurer }) expect(result.lines).toEqual(["a", "b"]) }) it("空文本占一行", () => { const result = wrapText("", { maxWidth: 100, style: style(), measurer }) expect(result.lines).toEqual([""]) expect(result.totalHeight).toBeCloseTo(EM * 1.2, 4) }) it("每行宽度不超过限制(中文)", () => { const result = wrapText("中文报表测试内容", { maxWidth: 2 * EM, style: style(), measurer }) for (const width of result.widths) { expect(width).toBeLessThanOrEqual(2 * EM + 1e-6) } }) it("长文本换行后行数合理", () => { const text = "中".repeat(100) const result = wrapText(text, { maxWidth: 10 * EM, style: style(), measurer }) expect(result.lines.length).toBe(10) }) it("总高度 = 行数 × 行高", () => { const result = wrapText("中".repeat(10), { maxWidth: 5 * EM, style: style(), measurer }) expect(result.totalHeight).toBeCloseTo(result.lineHeight * result.lines.length, 4) }) it("行高随字号变化", () => { const a = wrapText("x", { maxWidth: 100, style: style({ fontSize: 10 }), measurer }) const b = wrapText("x", { maxWidth: 100, style: style({ fontSize: 20 }), measurer }) expect(b.lineHeight).toBeCloseTo(a.lineHeight * 2, 4) }) }) describe("wrapText 强制断词", () => { it("超长单词被强制断开", () => { const result = wrapText("abcdefghijklmnop", { maxWidth: 5 * EM, style: style(), measurer }) expect(result.brokeWords).toBe(true) expect(result.lines.length).toBeGreaterThan(1) }) it("禁用断词时整词放一行(允许溢出)", () => { const result = wrapText("abcdefghijklmnop", { maxWidth: 5 * EM, style: style(), measurer, breakLongWords: false, }) expect(result.brokeWords).toBe(false) expect(result.lines).toEqual(["abcdefghijklmnop"]) }) it("超长中文串不触发 brokeWords(逐字即可换行)", () => { const result = wrapText("中".repeat(20), { maxWidth: 3 * EM, style: style(), measurer }) expect(result.brokeWords).toBe(false) }) }) describe("避头点规则", () => { it("识别不可行首的标点", () => { expect(isNoLineStart(",")).toBe(true) expect(isNoLineStart("。")).toBe(true) expect(isNoLineStart(")")).toBe(true) expect(isNoLineStart("中")).toBe(false) }) it("识别不可行尾的标点", () => { expect(isNoLineEnd("(")).toBe(true) expect(isNoLineEnd("中")).toBe(false) }) it("空串两种规则都返回 false", () => { expect(isNoLineStart("")).toBe(false) expect(isNoLineEnd("")).toBe(false) }) it("行尾不出现左括号", () => { // 宽度刚好让 "(" 落在行尾时,应把它推到下一行 const result = wrapText("中文(测试)", { maxWidth: 3 * EM, style: style(), measurer }) for (const line of result.lines) { if (line === "") continue expect(isNoLineEnd(line)).toBe(false) } }) it("行首不出现逗号", () => { const result = wrapText("中文,测试", { maxWidth: 3 * EM, style: style(), measurer }) for (const line of result.lines) { if (line === "") continue expect(isNoLineStart(line)).toBe(false) } }) }) describe("measureTextHeight", () => { it("返回高度与行数", () => { const result = measureTextHeight("中".repeat(10), { maxWidth: 5 * EM, style: style(), measurer, }) expect(result.lineCount).toBe(2) expect(result.height).toBeGreaterThan(0) }) it("指定最大高度时能识别溢出", () => { const result = measureTextHeight( "中".repeat(10), { maxWidth: 5 * EM, style: style(), measurer }, EM, // 只允许一行 ) expect(result.overflow).toBe(true) }) it("未指定最大高度时 overflow 恒为 false", () => { const result = measureTextHeight("中".repeat(100), { maxWidth: 5 * EM, style: style(), measurer, }) expect(result.overflow).toBe(false) }) }) describe("isTextOverflow", () => { it("内容超出高度返回 true", () => { expect(isTextOverflow("中".repeat(10), 5 * EM, EM, style(), measurer)).toBe(true) }) it("内容未超出返回 false", () => { expect(isTextOverflow("中", 5 * EM, 10 * EM, style(), measurer)).toBe(false) }) }) describe("边界情况", () => { it("零宽度时每字一行", () => { const result = wrapText("中文字", { maxWidth: 0, style: style(), measurer }) expect(result.lines.length).toBe(3) }) it("纯空白文本", () => { const result = wrapText(" ", { maxWidth: 100, style: style(), measurer }) expect(result.lines.length).toBeGreaterThanOrEqual(1) }) it("连续硬换行保留空行", () => { const result = wrapText("a\n\nb", { maxWidth: 100, style: style(), measurer }) expect(result.lines).toEqual(["a", "", "b"]) }) it("标签或特殊字符不崩溃", () => { expect(() => wrapText("粗体 & \"引号\"", { maxWidth: 3 * EM, style: style(), measurer }), ).not.toThrow() }) })