20260920173439

This commit is contained in:
oneao committed 2026-09-20 17:34:40 +08:00
1 parent f4bfc1ddfc
commit 6f03d92425
174 files changed
+23490 -157

No files matched your search

@@ -0,0 +1,230 @@
/**
* 文本换行测试(对应设计文档 §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> = {}): 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("<b>粗体</b> & \"引号\"", { maxWidth: 3 * EM, style: style(), measurer }),
).not.toThrow()
})
})