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,243 @@
/**
* 字体解析与真实度量测试(对应 §14 的"中文字体、长文本"测试)。
*
* 依赖系统字体,缺少字体时相关用例自动跳过,保证在无字体环境仍可运行。
*/
import { describe, expect, it } from "vitest"
import { existsSync } from "node:fs"
import { loadFontFile, FontLibrary, parseFontBuffer } from "../src/load-font.js"
import { createFontMeasurer } from "../src/font-measurer.js"
import { DEFAULT_RESOLVED_STYLE, type ResolvedStyle } from "@report/core"
const ARIAL = "C:\\Windows\\Fonts\\arial.ttf"
const SIMHEI = "C:\\Windows\\Fonts\\simhei.ttf"
const MSYH = "C:\\Windows\\Fonts\\msyh.ttc"
const hasArial = existsSync(ARIAL)
const hasSimHei = existsSync(SIMHEI)
const hasMsyh = existsSync(MSYH)
function style(overrides: Partial<ResolvedStyle> = {}): ResolvedStyle {
return { ...DEFAULT_RESOLVED_STYLE, fontSize: 10, ...overrides }
}
describe("FontLibrary 加载", () => {
it("加载失败时记录而不抛异常", () => {
const library = new FontLibrary()
const ok = library.load({ family: "Missing", path: "C:\\nope\\nope.ttf" })
expect(ok).toBe(false)
expect(library.loadFailures()).toHaveLength(1)
expect(library.size).toBe(0)
})
it("clear 清空字体与失败记录", () => {
const library = new FontLibrary()
library.load({ family: "Missing", path: "C:\\nope\\nope.ttf" })
library.clear()
expect(library.size).toBe(0)
expect(library.loadFailures()).toHaveLength(0)
})
})
describe.skipIf(!hasArial)("Arial 度量", () => {
const font = loadFontFile(ARIAL)
it("解析出正确的字体元信息", () => {
expect(font.metrics.familyName).toBe("Arial")
expect(font.metrics.unitsPerEm).toBe(2048)
expect(font.metrics.numGlyphs).toBeGreaterThan(1000)
})
it("比例字体的宽度各不相同", () => {
const i = font.charAdvance("i".codePointAt(0)!)!
const w = font.charAdvance("W".codePointAt(0)!)!
expect(i).toBeLessThan(w)
expect(i).toBeCloseTo(0.2222, 3)
expect(w).toBeCloseTo(0.9438, 3)
})
it("不覆盖中文(返回 null 而非 .notdef 宽度)", () => {
// 关键正确性检查:.notdef 本身有 advance,直接返回会把缺字当正常字符
expect(font.hasGlyph("中".codePointAt(0)!)).toBe(false)
expect(font.charAdvance("中".codePointAt(0)!)).toBeNull()
})
it("覆盖 ASCII", () => {
for (const ch of ["A", "z", "0", " ", ".", ","]) {
expect(font.hasGlyph(ch.codePointAt(0)!)).toBe(true)
}
})
})
describe.skipIf(!hasSimHei)("SimHei 中文字体度量", () => {
const font = loadFontFile(SIMHEI)
it("解析出中文字体族名", () => {
expect(font.metrics.familyName).toBe("SimHei")
})
it("汉字宽度为全角 1.0 em", () => {
for (const ch of ["中", "文", "报", "表"]) {
expect(font.charAdvance(ch.codePointAt(0)!)).toBeCloseTo(1.0, 4)
}
})
it("中文标点为全角", () => {
expect(font.charAdvance(",".codePointAt(0)!)).toBeCloseTo(1.0, 4)
})
it("拉丁字符为半角", () => {
expect(font.charAdvance("A".codePointAt(0)!)).toBeCloseTo(0.5, 4)
})
it("覆盖 GB 常用汉字", () => {
for (const ch of ["销", "售", "单", "金", "额", "数", "量"]) {
expect(font.hasGlyph(ch.codePointAt(0)!)).toBe(true)
}
})
})
describe.skipIf(!hasMsyh)("TTC 字体集合", () => {
it("可从集合中按索引取字体", () => {
const font0 = loadFontFile(MSYH, { fontIndex: 0 })
expect(font0.metrics.unitsPerEm).toBeGreaterThan(0)
expect(font0.metrics.familyName).toBeTruthy()
})
it("索引越界时报错", () => {
expect(() => loadFontFile(MSYH, { fontIndex: 99 })).toThrow(/越界/)
})
})
describe("parseFontBuffer 容错", () => {
it("拒绝非字体数据", () => {
const junk = Buffer.from("this is definitely not a font file")
expect(() => parseFontBuffer(junk)).toThrow()
})
it("拒绝过短的数据", () => {
expect(() => parseFontBuffer(Buffer.from([0, 1, 0, 0]))).toThrow()
})
})
describe.skipIf(!hasArial || !hasSimHei)("字体库与度量器协作", () => {
function buildLibrary(): FontLibrary {
const library = new FontLibrary()
library.load({ family: "Arial", path: ARIAL })
library.load({ family: "SimHei", path: SIMHEI })
return library
}
it("按字体栈解析", () => {
const library = buildLibrary()
const hit = library.resolveStack('"NoSuchFont", SimHei, sans-serif')
expect(hit?.family).toBe("SimHei")
})
it("全部未命中时返回 undefined", () => {
const library = buildLibrary()
expect(library.resolveStack("NoSuchFont")).toBeUndefined()
})
it("度量纯英文文本", () => {
const library = buildLibrary()
const measurer = createFontMeasurer({ library })
const metrics = measurer.measure("Hello", style({ fontFamily: "Arial", fontSize: 10 }))
// 10pt 约 3.528mm;"Hello" 在 Arial 下约 2.6em
expect(metrics.width).toBeGreaterThan(6)
expect(metrics.width).toBeLessThan(12)
})
it("度量纯中文文本", () => {
const library = buildLibrary()
const measurer = createFontMeasurer({ library })
const metrics = measurer.measure("中文报表", style({ fontFamily: "SimHei", fontSize: 10 }))
const em = (10 / 72) * 25.4
// 4 个全角字符 = 4em
expect(metrics.width).toBeCloseTo(4 * em, 3)
})
it("中英混排按字符回退到 CJK 字体", () => {
const library = buildLibrary()
const fallbacks: string[] = []
const measurer = createFontMeasurer({
library,
fallbackFamilies: ["SimHei"],
onFallback: (e) => fallbacks.push(e.char),
})
const metrics = measurer.measure("A中", style({ fontFamily: "Arial", fontSize: 10 }))
const em = (10 / 72) * 25.4
// A(0.667) + 中(1.0) = 1.667em
expect(metrics.width).toBeCloseTo(1.667 * em, 2)
expect(fallbacks).toContain("中")
})
it("缺失字形提高 fallback 标记", () => {
const library = buildLibrary()
const measurer = createFontMeasurer({ library })
// 用 Arial 度量汉字,且无 CJK 回退
const metrics = measurer.measure("中", style({ fontFamily: "Arial" }))
expect(metrics.fallback).toBe(true)
})
it("完整覆盖时 fallback 为 false", () => {
const library = buildLibrary()
const measurer = createFontMeasurer({ library })
const metrics = measurer.measure("Hello", style({ fontFamily: "Arial" }))
expect(metrics.fallback).toBe(false)
})
it("行高与字号成正比", () => {
const library = buildLibrary()
const measurer = createFontMeasurer({ library })
const a = measurer.measure("x", style({ fontFamily: "Arial", fontSize: 10 }))
const b = measurer.measure("x", style({ fontFamily: "Arial", fontSize: 20 }))
expect(b.height).toBeCloseTo(a.height * 2, 4)
})
it("字体栈中首选命中时不回退", () => {
const library = buildLibrary()
const measurer = createFontMeasurer({ library, fallbackFamilies: ["SimHei"] })
const resolved = measurer.resolver.resolve("Arial, SimHei")
expect(resolved.resolved).toBe("Arial")
expect(resolved.fallback).toBe(false)
})
it("字体栈全部未命中时标记回退", () => {
const library = buildLibrary()
const measurer = createFontMeasurer({ library })
const resolved = measurer.resolver.resolve("Nonexistent")
expect(resolved.fallback).toBe(true)
})
it("长文本宽度随字符数线性增长", () => {
const library = buildLibrary()
const measurer = createFontMeasurer({ library })
const short = measurer.measure("中".repeat(10), style({ fontFamily: "SimHei" }))
const long = measurer.measure("中".repeat(100), style({ fontFamily: "SimHei" }))
expect(long.width).toBeCloseTo(short.width * 10, 3)
})
})
describe("估算度量器(无字体环境)", () => {
it("全角字符为 1em", async () => {
const { createEstimateMeasurer } = await import("../src/measurer.js")
const measurer = createEstimateMeasurer()
const em = (10 / 72) * 25.4
expect(measurer.measure("中", style()).width).toBeCloseTo(em, 4)
})
it("半角字符为 0.5em 附近", async () => {
const { createEstimateMeasurer } = await import("../src/measurer.js")
const measurer = createEstimateMeasurer()
const em = (10 / 72) * 25.4
const width = measurer.measure("n", style()).width
expect(width).toBeGreaterThan(0.4 * em)
expect(width).toBeLessThan(0.7 * em)
})
it("始终标记 fallback", async () => {
const { createEstimateMeasurer } = await import("../src/measurer.js")
expect(createEstimateMeasurer().measure("x", style()).fallback).toBe(true)
})
})
@@ -0,0 +1,560 @@
/**
* 分组测试(对应设计文档 §16 阶段 3 与 §6.1 的分组区域)。
*
* 分组语义:相邻行键值相同即同组,不改变行顺序。
*/
import { describe, expect, it } from "vitest"
import {
createDocument,
createTableElement,
type ReportDocument,
type ReportSection,
} from "@report/core"
import { createEstimateMeasurer, createPassthroughFontResolver } from "../src/measurer.js"
import { layoutDocument } from "../src/engine.js"
import { computeGroups, collectGroupLevels, isSameGroupKey } from "../src/group.js"
import type { GroupLevelSpec } from "../src/group.js"
import { walkLayoutNodes } from "../src/layout-document.js"
import type { RenderContext } from "../src/context.js"
const measurer = createEstimateMeasurer()
const fontResolver = createPassthroughFontResolver()
async function layout(doc: ReportDocument, context: RenderContext) {
return layoutDocument(doc, context, { textMeasurer: measurer, fontResolver })
}
const ROWS = [
{ region: "华北", city: "北京", amount: 100 },
{ region: "华北", city: "天津", amount: 200 },
{ region: "华东", city: "上海", amount: 300 },
{ region: "华东", city: "杭州", amount: 400 },
{ region: "华南", city: "广州", amount: 500 },
]
function baseDoc(): ReportDocument {
const doc = createDocument({ name: "分组报表" })
doc.datasets = { sales: { id: "sales" } }
doc.sections.find((s) => s.kind === "detail")!.children.push(
createTableElement("tbl", { x: 0, y: 0, width: 180, height: 20 }, "sales", [
{ id: "c1", width: 60, header: "地区", binding: { field: "region" } },
{ id: "c2", width: 60, header: "城市", binding: { field: "city" } },
{ id: "c3", width: 60, header: "金额", binding: { field: "amount" }, align: "right" },
]),
)
return doc
}
/** 插入一个分组区域。 */
function addGroupSection(
doc: ReportDocument,
kind: "group-header" | "group-footer",
children: ReportSection["children"],
extra: Partial<ReportSection> = {},
): ReportSection {
const section: ReportSection = {
id: `grp-${kind}`,
kind,
children,
groupExpression: "row.region",
groupLevel: 1,
...extra,
}
// 插在 detail 之前(组头)或之后(组脚)
const detailIndex = doc.sections.findIndex((s) => s.kind === "detail")
const insertAt = kind === "group-header" ? detailIndex : detailIndex + 1
doc.sections.splice(insertAt, 0, section)
return section
}
describe("isSameGroupKey", () => {
it("相同标量为同组", () => {
expect(isSameGroupKey("a", "a")).toBe(true)
expect(isSameGroupKey(1, 1)).toBe(true)
})
it("不同标量不同组", () => {
expect(isSameGroupKey("a", "b")).toBe(false)
expect(isSameGroupKey(1, 2)).toBe(false)
})
it("数字与字符串等价(避免 1 与 '1' 被拆成两组)", () => {
expect(isSameGroupKey(1, "1")).toBe(true)
})
it("null 与 undefined 视为同组", () => {
expect(isSameGroupKey(null, undefined)).toBe(true)
expect(isSameGroupKey(null, null)).toBe(true)
expect(isSameGroupKey(null, 0)).toBe(false)
})
})
describe("computeGroups", () => {
it("按相邻行切分", () => {
const levels: GroupLevelSpec[] = [{ level: 1, expression: "row.region" }]
const tree = computeGroups(ROWS, levels, [])
expect(tree.groups).toHaveLength(3)
expect(tree.groups[0]?.key).toBe("华北")
expect(tree.groups[1]?.key).toBe("华东")
expect(tree.groups[2]?.key).toBe("华南")
})
it("记录每组的行区间", () => {
const tree = computeGroups(ROWS, [{ level: 1, expression: "row.region" }], [])
expect(tree.groups[0]?.start).toBe(0)
expect(tree.groups[0]?.end).toBe(2)
expect(tree.groups[1]?.start).toBe(2)
expect(tree.groups[1]?.end).toBe(4)
expect(tree.groups[2]?.start).toBe(4)
expect(tree.groups[2]?.end).toBe(5)
})
it("每组携带该组行数据", () => {
const tree = computeGroups(ROWS, [{ level: 1, expression: "row.region" }], [])
expect(tree.groups[0]?.rows).toHaveLength(2)
expect(tree.groups[0]?.rows[0]?.city).toBe("北京")
})
it("标记首组与末组", () => {
const tree = computeGroups(ROWS, [{ level: 1, expression: "row.region" }], [])
expect(tree.groups[0]?.isFirst).toBe(true)
expect(tree.groups[0]?.isLast).toBe(false)
expect(tree.groups[2]?.isLast).toBe(true)
})
it("相同键不相邻时视为不同组(不排序)", () => {
const rows = [
{ region: "A" },
{ region: "B" },
{ region: "A" },
]
const tree = computeGroups(rows, [{ level: 1, expression: "row.region" }], [])
// 相邻切分:A / B / A 共 3 组
expect(tree.groups).toHaveLength(3)
})
it("空数据集不产生分组", () => {
const tree = computeGroups([], [{ level: 1, expression: "row.region" }], [])
expect(tree.groups).toHaveLength(0)
})
it("无分组定义时不产生分组", () => {
const tree = computeGroups(ROWS, [], [])
expect(tree.groups).toHaveLength(0)
})
it("坏表达式产生诊断而非抛异常", () => {
const diagnostics: import("@report/core").Diagnostic[] = []
const tree = computeGroups(ROWS, [{ level: 1, expression: "row." }], diagnostics)
expect(tree.groups).toHaveLength(0)
expect(diagnostics.some((d) => d.code === "expression.parse-error")).toBe(true)
})
it("支持多级分组", () => {
const rows = [
{ region: "华北", city: "北京" },
{ region: "华北", city: "北京" },
{ region: "华北", city: "天津" },
{ region: "华东", city: "上海" },
]
const tree = computeGroups(
rows,
[
{ level: 1, expression: "row.region" },
{ level: 2, expression: "row.city" },
],
[],
)
// L1: 华北(0-3), 华东(3-4);L2: 北京(0-2), 天津(2-3), 上海(3-4)
const l1 = tree.groups.filter((g) => g.level === 1)
const l2 = tree.groups.filter((g) => g.level === 2)
expect(l1).toHaveLength(2)
expect(l2).toHaveLength(3)
})
it("多级分组中各级区间相互独立", () => {
const rows = [
{ region: "A", city: "x" },
{ region: "A", city: "y" },
{ region: "B", city: "z" },
]
const tree = computeGroups(
rows,
[
{ level: 1, expression: "row.region" },
{ level: 2, expression: "row.city" },
],
[],
)
const l2 = tree.groups.filter((g) => g.level === 2)
expect(l2[0]?.rows).toHaveLength(1)
expect(l2[1]?.rows).toHaveLength(1)
})
it("分组结果按出现位置排序", () => {
const tree = computeGroups(ROWS, [{ level: 1, expression: "row.region" }], [])
const starts = tree.groups.map((g) => g.start)
expect([...starts].sort((a, b) => a - b)).toEqual(starts)
})
})
describe("collectGroupLevels", () => {
it("从区域收集分组级别", () => {
const doc = baseDoc()
addGroupSection(doc, "group-header", [])
addGroupSection(doc, "group-footer", [])
const levels = collectGroupLevels(doc.sections, [])
expect(levels).toHaveLength(1)
expect(levels[0]?.expression).toBe("row.region")
expect(levels[0]?.headerSection).toBeDefined()
expect(levels[0]?.footerSection).toBeDefined()
})
it("缺少 groupExpression 时产生诊断", () => {
const diagnostics: import("@report/core").Diagnostic[] = []
const section: ReportSection = {
id: "g1",
kind: "group-header",
children: [],
}
collectGroupLevels([section], diagnostics)
expect(diagnostics.some((d) => d.message.includes("缺少 groupExpression"))).toBe(true)
})
it("同级别表达式不一致时告警", () => {
const diagnostics: import("@report/core").Diagnostic[] = []
const sections: ReportSection[] = [
{ id: "h", kind: "group-header", children: [], groupExpression: "row.a", groupLevel: 1 },
{ id: "f", kind: "group-footer", children: [], groupExpression: "row.b", groupLevel: 1 },
]
collectGroupLevels(sections, diagnostics)
expect(diagnostics.some((d) => d.message.includes("不同的"))).toBe(true)
})
it("多级别按 level 升序返回", () => {
const sections: ReportSection[] = [
{ id: "h2", kind: "group-header", children: [], groupExpression: "row.b", groupLevel: 2 },
{ id: "h1", kind: "group-header", children: [], groupExpression: "row.a", groupLevel: 1 },
]
const levels = collectGroupLevels(sections, [])
expect(levels.map((l) => l.level)).toEqual([1, 2])
})
})
describe("分组布局输出", () => {
it("组头渲染到输出中", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-header", [
{
id: "gh",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
binding: { expression: "'地区:' + row.region" },
},
])
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const texts: string[] = []
walkLayoutNodes(result, (node) => {
if (node.content?.kind === "text") texts.push(node.content.text)
})
// 三个地区各输出一次组头
expect(texts.filter((t) => t.startsWith("地区:"))).toHaveLength(3)
expect(texts).toContain("地区:华北")
expect(texts).toContain("地区:华东")
expect(texts).toContain("地区:华南")
})
it("组脚按组聚合(sum)", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-footer", [
{
id: "gf",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
binding: { expression: "sum(group.amount) + ' / ' + group.count + ' 笔'" },
},
])
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const texts: string[] = []
walkLayoutNodes(result, (node) => {
if (node.content?.kind === "text") texts.push(node.content.text)
})
// 华北 100+200=300(2 笔),华东 300+400=700(2 笔),华南 500(1 笔)
expect(texts).toContain("300 / 2 笔")
expect(texts).toContain("700 / 2 笔")
expect(texts).toContain("500 / 1 笔")
})
it("组脚可用 avg 聚合", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-footer", [
{
id: "gf",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
binding: { expression: "avg(group.amount)" },
},
])
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const texts: string[] = []
walkLayoutNodes(result, (node) => {
if (node.content?.kind === "text") texts.push(node.content.text)
})
// 华北均值 150,华东 350,华南 500
expect(texts).toContain("150")
expect(texts).toContain("350")
})
it("组脚可访问 group.key", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-footer", [
{
id: "gf",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
binding: { expression: "string(group.key)" },
},
])
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const texts: string[] = []
walkLayoutNodes(result, (node) => {
if (node.content?.kind === "text") texts.push(node.content.text)
})
expect(texts).toContain("华北")
expect(texts).toContain("华南")
})
it("详细行全部保留(分组不丢行)", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-header", [
{ id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组" } },
])
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
let rowCount = 0
walkLayoutNodes(result, (node) => {
if (node.role === "table-row" && node.content?.kind === "table") rowCount += 1
})
expect(rowCount).toBe(ROWS.length)
})
it("分组后行顺序不变", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-header", [
{ id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组" } },
])
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const cities: string[] = []
walkLayoutNodes(result, (node) => {
if (node.content?.kind === "table" && node.role === "table-row") {
const cell = node.content.rows[0]?.cells[1]
if (cell) cities.push(cell.text)
}
})
expect(cities).toEqual(["北京", "天津", "上海", "杭州", "广州"])
})
it("组头在详细行之前、组脚之后", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-header", [
{ id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 10 }, props: { text: "组头" } },
])
addGroupSection(doc, "group-footer", [
{ id: "gf", type: "text", frame: { x: 0, y: 0, width: 100, height: 10 }, props: { text: "组脚" } },
])
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const order: string[] = []
walkLayoutNodes(result, (node) => {
if (node.id === "gh") order.push("H")
if (node.id === "gf") order.push("F")
if (node.role === "table-row") order.push("R")
})
// 期望:H R R F H R R F H R F(3 组)
expect(order.join("")).toBe("HRRFHRRFHRF")
})
it("无数据时不输出分组区域", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-footer", [
{ id: "gf", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组脚" } },
])
const result = await layout(doc, { data: { sales: [] }, parameters: {} })
const texts: string[] = []
walkLayoutNodes(result, (node) => {
if (node.content?.kind === "text") texts.push(node.content.text)
})
expect(texts).not.toContain("组脚")
expect(result.diagnostics.some((d) => d.code === "data.dataset-missing")).toBe(true)
})
it("没有分组定义时行为与之前一致", async () => {
const doc = baseDoc()
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
let rowCount = 0
walkLayoutNodes(result, (node) => {
if (node.role === "table-row") rowCount += 1
})
expect(rowCount).toBe(ROWS.length)
})
it("表头仍每页重复(分组不影响)", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-header", [
{ id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 6 }, props: { text: "组" } },
])
// 用大量数据迫使多页
const manyRows = Array.from({ length: 200 }, (_, i) => ({
region: `地区${Math.floor(i / 20)}`,
city: `城市${i}`,
amount: i,
}))
const result = await layout(doc, { data: { sales: manyRows }, parameters: {} })
expect(result.pages.length).toBeGreaterThan(1)
for (const page of result.pages) {
expect(page.nodes.some((n) => n.role === "table-header")).toBe(true)
}
})
it("分组跨页时组脚仍输出", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-footer", [
{
id: "gf",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
binding: { expression: "sum(group.amount)" },
},
])
const manyRows = Array.from({ length: 200 }, (_, i) => ({
region: `地区${Math.floor(i / 20)}`,
city: `城市${i}`,
amount: 1,
}))
const result = await layout(doc, { data: { sales: manyRows }, parameters: {} })
const totals: string[] = []
walkLayoutNodes(result, (node) => {
if (node.id === "gf" && node.content?.kind === "text") totals.push(node.content.text)
})
// 10 个组,每组 20 行 × 1 = 20
expect(totals).toHaveLength(10)
expect(totals.every((t) => t === "20")).toBe(true)
})
it("组头不与组内首行分离(keepWithNext)", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-header", [
{ id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组头" } },
])
// 大量行迫使多页;每个组头后面一定紧跟数据行
const manyRows = Array.from({ length: 200 }, (_, i) => ({
region: `地区${Math.floor(i / 20)}`,
city: `城市${i}`,
amount: i,
}))
const result = await layout(doc, { data: { sales: manyRows }, parameters: {} })
// 逐页检查:任何一页里,组头 gh 之后必然存在至少一个数据行
for (const page of result.pages) {
const ids: string[] = []
walkLayoutNodes({ ...result, pages: [page] }, (node) => {
if (node.id === "gh") ids.push("H")
else if (node.role === "table-row") ids.push("R")
})
// 若本页有组头,则其后必须有数据行(组头不孤立)
const headerPos = ids.indexOf("H")
if (headerPos >= 0) {
expect(ids.slice(headerPos + 1)).toContain("R")
}
}
})
it("组脚不与组内末行分离(keepWithPrevious)", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-footer", [
{ id: "gf", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组脚" } },
])
const manyRows = Array.from({ length: 200 }, (_, i) => ({
region: `地区${Math.floor(i / 20)}`,
city: `城市${i}`,
amount: i,
}))
const result = await layout(doc, { data: { sales: manyRows }, parameters: {} })
// 逐页检查:任何一页里,组脚 gf 之前必然存在数据行(组脚不孤立)
for (const page of result.pages) {
const ids: string[] = []
walkLayoutNodes({ ...result, pages: [page] }, (node) => {
if (node.id === "gf") ids.push("F")
else if (node.role === "table-row") ids.push("R")
})
const footerPos = ids.lastIndexOf("F")
if (footerPos >= 0) {
expect(ids.slice(0, footerPos)).toContain("R")
}
}
})
it("组脚不丢失(跨页后每组的组脚都在)", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-footer", [
{ id: "gf", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组脚" } },
])
const manyRows = Array.from({ length: 60 }, (_, i) => ({
region: `地区${Math.floor(i / 6)}`,
city: `城市${i}`,
amount: 1,
}))
const result = await layout(doc, { data: { sales: manyRows }, parameters: {} })
const footers: string[] = []
walkLayoutNodes(result, (node) => {
if (node.id === "gf" && node.content?.kind === "text") footers.push(node.content.text)
})
// 10 个组,每个组的组脚都必须出现一次
expect(footers).toHaveLength(10)
expect(footers.every((t) => t === "组脚")).toBe(true)
})
it("pageBreakBefore 使每组独占一页起始", async () => {
const doc = baseDoc()
addGroupSection(
doc,
"group-header",
[{ id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组头" } }],
{ pageBreakBefore: true },
)
// 3 组,每组若干行,组头声明 pageBreakBefore
const rows = Array.from({ length: 30 }, (_, i) => ({
region: `地区${Math.floor(i / 10)}`,
city: `城市${i}`,
amount: i,
}))
const result = await layout(doc, { data: { sales: rows }, parameters: {} })
// 每个组头应落在其所在页的主体顶部(即该页第一个主体块)
const pageHeaders: string[][] = []
for (const page of result.pages) {
const ids: string[] = []
walkLayoutNodes({ ...result, pages: [page] }, (node) => {
if (node.id === "gh") ids.push("H")
else if (node.role === "table-row") ids.push("R")
else if (node.role === "table-header") ids.push("T")
})
pageHeaders.push(ids)
}
// 至少应有多页;每个含组头的页,组头应是首个非表头块
const pagesWithHeader = pageHeaders.filter((ids) => ids.includes("H"))
expect(pagesWithHeader.length).toBe(3)
for (const ids of pagesWithHeader) {
// 去掉前导表头后,第一个主体块应是组头
const firstBody = ids.find((x) => x !== "T")
expect(firstBody).toBe("H")
}
})
})
@@ -0,0 +1,558 @@
/**
* 分页与布局引擎测试(对应设计文档 §14 的"页眉、页脚和多页分页测试"、
* "固定元素和明细表的布局快照测试")。
*/
import { describe, expect, it } from "vitest"
import {
createDocument,
createTableElement,
type ReportDocument,
} from "@report/core"
import { createEstimateMeasurer, createPassthroughFontResolver } from "../src/measurer.js"
import { layoutDocument } from "../src/engine.js"
import { computePageGeometry } from "../src/paginate.js"
import { countLayoutNodes, walkLayoutNodes } from "../src/layout-document.js"
import type { RenderContext } from "../src/context.js"
const measurer = createEstimateMeasurer()
const fontResolver = createPassthroughFontResolver()
function emptyContext(parameters: Record<string, unknown> = {}): RenderContext {
return { data: {}, parameters }
}
async function layout(doc: ReportDocument, context = emptyContext()) {
return layoutDocument(doc, context, { textMeasurer: measurer, fontResolver })
}
/** 一份含页眉、明细文本、页脚的文档。 */
function documentWith(configure: (doc: ReportDocument) => void): ReportDocument {
const doc = createDocument({ name: "测试报表" })
configure(doc)
return doc
}
describe("页面几何", () => {
it("A4 纵向尺寸正确", () => {
const doc = createDocument({ page: { paper: "A4", orientation: "portrait" } })
const geometry = computePageGeometry(doc)
expect(geometry.width).toBe(210)
expect(geometry.height).toBe(297)
// 默认边距 15mm
expect(geometry.contentWidth).toBe(180)
expect(geometry.contentHeight).toBe(267)
})
it("横向交换宽高", () => {
const doc = createDocument({ page: { paper: "A4", orientation: "landscape" } })
const geometry = computePageGeometry(doc)
expect(geometry.width).toBe(297)
expect(geometry.height).toBe(210)
})
it("A5 尺寸正确", () => {
const doc = createDocument({ page: { paper: "A5" } })
const geometry = computePageGeometry(doc)
expect(geometry.width).toBe(148)
expect(geometry.height).toBe(210)
})
it("自定义纸张生效", () => {
const doc = createDocument({
page: { paper: "custom", customSize: { width: 100, height: 150 }, margin: { top: 5, right: 5, bottom: 5, left: 5 } },
})
const geometry = computePageGeometry(doc)
expect(geometry.width).toBe(100)
expect(geometry.height).toBe(150)
expect(geometry.contentWidth).toBe(90)
})
})
describe("单页布局", () => {
it("空文档产出一页", async () => {
const result = await layout(createDocument())
expect(result.pages).toHaveLength(1)
expect(result.pageCount).toBe(1)
})
it("页面尺寸写入布局结果", async () => {
const result = await layout(createDocument({ page: { paper: "A5" } }))
expect(result.pages[0]?.width).toBe(148)
expect(result.pages[0]?.height).toBe(210)
})
it("页眉元素被渲染", async () => {
const doc = documentWith((d) => {
const header = d.sections.find((s) => s.kind === "page-header")!
header.children.push({
id: "h1",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
props: { text: "销售单" },
})
})
const result = await layout(doc)
const ids: string[] = []
walkLayoutNodes(result, (node) => ids.push(node.id))
expect(ids).toContain("h1")
})
it("文本内容被写入布局节点", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "t1",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
props: { text: "正文" },
})
})
const result = await layout(doc)
let found: string | undefined
walkLayoutNodes(result, (node) => {
if (node.id === "t1" && node.content?.kind === "text") found = node.content.text
})
expect(found).toBe("正文")
})
it("页脚每页重复", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "page-footer")!.children.push({
id: "f1",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 6 },
props: { text: "页脚" },
})
})
const result = await layout(doc)
expect(result.pages).toHaveLength(1)
const footerNode = result.pages[0]?.nodes.find((n) => n.id === "f1")
expect(footerNode).toBeDefined()
})
it("元素坐标为绝对坐标", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "t1",
type: "text",
frame: { x: 10, y: 5, width: 50, height: 8 },
props: { text: "x" },
})
})
const result = await layout(doc)
let frame: { x: number; y: number } | undefined
walkLayoutNodes(result, (node) => {
if (node.id === "t1") frame = node.frame
})
// x 保持元素自身偏移;y = 内容区起点(15) + 页眉高度(0) + 元素 y(5)
expect(frame?.x).toBe(10)
expect(frame?.y).toBeGreaterThanOrEqual(15)
})
it("无 TextMeasurer 时产生诊断", async () => {
const result = await layoutDocument(createDocument(), emptyContext(), { fontResolver })
expect(result.diagnostics.some((d) => d.message.includes("估算度量器"))).toBe(true)
})
it("提供 TextMeasurer 时不产生该诊断", async () => {
const result = await layout(createDocument())
expect(result.diagnostics.some((d) => d.message.includes("估算度量器"))).toBe(false)
})
})
describe("多页分页", () => {
it("内容超出页面时产生多页", async () => {
const doc = documentWith((d) => {
const detail = d.sections.find((s) => s.kind === "detail")!
// 明细区放置一个很高的表格,迫使分页
d.datasets = { ds1: { id: "ds1" } }
detail.children.push(
createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [
{ id: "c1", width: 90, header: "名称" },
{ id: "c2", width: 90, header: "数量" },
]),
)
})
const context: RenderContext = {
data: {
ds1: Array.from({ length: 100 }, (_, i) => ({ name: `项目${i}`, qty: i })),
},
parameters: {},
}
const result = await layout(doc, context)
expect(result.pages.length).toBeGreaterThan(1)
expect(result.pageCount).toBe(result.pages.length)
})
it("页码连续递增", async () => {
const doc = documentWith((d) => {
d.datasets = { ds1: { id: "ds1" } }
d.sections.find((s) => s.kind === "detail")!.children.push(
createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [
{ id: "c1", width: 180, header: "值" },
]),
)
})
const context: RenderContext = {
data: { ds1: Array.from({ length: 60 }, (_, i) => ({ value: i })) },
parameters: {},
}
const result = await layout(doc, context)
result.pages.forEach((page, index) => {
expect(page.pageNumber).toBe(index + 1)
})
})
it("页眉在每一页都出现", async () => {
const doc = documentWith((d) => {
d.datasets = { ds1: { id: "ds1" } }
d.sections.find((s) => s.kind === "page-header")!.children.push({
id: "hdr",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
props: { text: "页眉" },
})
d.sections.find((s) => s.kind === "detail")!.children.push(
createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [
{ id: "c1", width: 180, header: "值" },
]),
)
})
const context: RenderContext = {
data: { ds1: Array.from({ length: 80 }, (_, i) => ({ value: i })) },
parameters: {},
}
const result = await layout(doc, context)
expect(result.pages.length).toBeGreaterThan(1)
for (const page of result.pages) {
expect(page.nodes.some((n) => n.id === "hdr")).toBe(true)
}
})
it("页脚在每一页都出现", async () => {
const doc = documentWith((d) => {
d.datasets = { ds1: { id: "ds1" } }
d.sections.find((s) => s.kind === "page-footer")!.children.push({
id: "ftr",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 6 },
props: { text: "页脚" },
})
d.sections.find((s) => s.kind === "detail")!.children.push(
createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [
{ id: "c1", width: 180, header: "值" },
]),
)
})
const context: RenderContext = {
data: { ds1: Array.from({ length: 80 }, (_, i) => ({ value: i })) },
parameters: {},
}
const result = await layout(doc, context)
for (const page of result.pages) {
expect(page.nodes.some((n) => n.id === "ftr")).toBe(true)
}
})
it("不存在内容溢出的页(除超高大块外)", async () => {
const doc = documentWith((d) => {
d.datasets = { ds1: { id: "ds1" } }
d.sections.find((s) => s.kind === "detail")!.children.push(
createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [
{ id: "c1", width: 180, header: "值" },
]),
)
})
const context: RenderContext = {
data: { ds1: Array.from({ length: 50 }, (_, i) => ({ value: i })) },
parameters: {},
}
const result = await layout(doc, context)
expect(result.diagnostics.filter((d) => d.code === "layout.page-overflow")).toEqual([])
})
// 回归测试:曾出现"表头只出现在第一页"的问题——
// 分页器记录了 isHeader 标记却没有真正在后续页面补插表头。
it("明细表表头在每一页都重复", async () => {
const doc = documentWith((d) => {
d.datasets = { ds1: { id: "ds1" } }
d.sections.find((s) => s.kind === "detail")!.children.push(
createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [
{ id: "c1", width: 180, header: "值" },
]),
)
})
const context: RenderContext = {
data: { ds1: Array.from({ length: 200 }, (_, i) => ({ value: i })) },
parameters: {},
}
const result = await layout(doc, context)
expect(result.pages.length).toBeGreaterThan(1)
for (const page of result.pages) {
const headers = page.nodes.filter((n) => n.role === "table-header")
expect(headers).toHaveLength(1)
}
})
it("表头重复时不超出内容区", async () => {
const doc = documentWith((d) => {
d.datasets = { ds1: { id: "ds1" } }
d.sections.find((s) => s.kind === "detail")!.children.push(
createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [
{ id: "c1", width: 180, header: "值" },
]),
)
})
const context: RenderContext = {
data: { ds1: Array.from({ length: 200 }, (_, i) => ({ value: i })) },
parameters: {},
}
const result = await layout(doc, context)
// 内容区底部 = 15 + 267 = 282mm
const contentBottom = 282
for (const page of result.pages) {
for (const node of page.nodes) {
expect(node.frame.y + node.frame.height).toBeLessThanOrEqual(contentBottom + 0.01)
}
}
})
it("repeatHeaderOnEachPage 为 false 时表头只出现一次", async () => {
const doc = documentWith((d) => {
d.datasets = { ds1: { id: "ds1" } }
const table = createTableElement(
"tbl1",
{ x: 0, y: 0, width: 180, height: 20 },
"ds1",
[{ id: "c1", width: 180, header: "值" }],
)
table.repeatHeaderOnEachPage = false
d.sections.find((s) => s.kind === "detail")!.children.push(table)
})
const context: RenderContext = {
data: { ds1: Array.from({ length: 200 }, (_, i) => ({ value: i })) },
parameters: {},
}
const result = await layout(doc, context)
expect(result.pages.length).toBeGreaterThan(1)
// 仅首页有表头
expect(result.pages[0]!.nodes.some((n) => n.role === "table-header")).toBe(true)
for (let i = 1; i < result.pages.length; i += 1) {
expect(result.pages[i]!.nodes.some((n) => n.role === "table-header")).toBe(false)
}
})
})
describe("页眉页脚过高", () => {
it("页眉页脚占满内容区时报错并返回空页集", async () => {
const doc = documentWith((d) => {
const header = d.sections.find((s) => s.kind === "page-header")!
const footer = d.sections.find((s) => s.kind === "page-footer")!
// 页眉 200mm + 页脚 200mm 远超 267mm 内容区
header.children.push({
id: "big-header",
type: "rect",
frame: { x: 0, y: 0, width: 180, height: 200 },
})
footer.children.push({
id: "big-footer",
type: "rect",
frame: { x: 0, y: 0, width: 180, height: 200 },
})
})
const result = await layout(doc)
expect(result.pages).toHaveLength(0)
expect(result.diagnostics.some((d) => d.code === "layout.page-overflow")).toBe(true)
})
})
describe("Y 坐标分层", () => {
it("页眉在上、页脚在下、主体居中", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "page-header")!.children.push({
id: "hdr",
type: "rect",
frame: { x: 0, y: 0, width: 100, height: 10 },
})
d.sections.find((s) => s.kind === "page-footer")!.children.push({
id: "ftr",
type: "rect",
frame: { x: 0, y: 0, width: 100, height: 10 },
})
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "body",
type: "rect",
frame: { x: 0, y: 0, width: 100, height: 10 },
})
})
const result = await layout(doc)
const page = result.pages[0]!
const hdr = page.nodes.find((n) => n.id === "hdr")!
const body = page.nodes.find((n) => n.id === "body")!
const ftr = page.nodes.find((n) => n.id === "ftr")!
expect(hdr.frame.y).toBeLessThan(body.frame.y)
expect(body.frame.y).toBeLessThan(ftr.frame.y)
// 页脚贴内容区底部:15 + 267 - 10 = 272
expect(ftr.frame.y).toBeCloseTo(272, 4)
})
})
describe("区域记录", () => {
it("页面记录区域落位信息", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "page-header")!.children.push({
id: "hdr",
type: "rect",
frame: { x: 0, y: 0, width: 100, height: 10 },
})
})
const result = await layout(doc)
const kinds = result.pages[0]!.sections.map((s) => s.kind)
expect(kinds).toContain("page-header")
})
})
describe("元素类型渲染", () => {
it("rect 产出 rect 内容", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "r1",
type: "rect",
frame: { x: 0, y: 0, width: 20, height: 10 },
})
})
const result = await layout(doc)
let kind: string | undefined
walkLayoutNodes(result, (node) => {
if (node.id === "r1") kind = node.content?.kind
})
expect(kind).toBe("rect")
})
it("line 产出 line 内容", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "l1",
type: "line",
frame: { x: 0, y: 0, width: 50, height: 0.2 },
props: { direction: "horizontal" },
})
})
const result = await layout(doc)
let content: { kind: string; direction?: string } | undefined
walkLayoutNodes(result, (node) => {
if (node.id === "l1" && node.content?.kind === "line") content = node.content
})
expect(content?.kind).toBe("line")
expect(content?.direction).toBe("horizontal")
})
it("image 产出 image 内容", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "i1",
type: "image",
frame: { x: 0, y: 0, width: 20, height: 20 },
props: { src: "logo.png" },
})
})
const result = await layout(doc)
let src: string | undefined
walkLayoutNodes(result, (node) => {
if (node.id === "i1" && node.content?.kind === "image") src = node.content.src
})
expect(src).toBe("logo.png")
})
it("container 子元素被递归布局", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "c1",
type: "container",
frame: { x: 0, y: 0, width: 50, height: 30 },
children: [
{
id: "child1",
type: "text",
frame: { x: 2, y: 2, width: 20, height: 6 },
props: { text: "子元素" },
},
],
})
})
const result = await layout(doc)
const ids: string[] = []
walkLayoutNodes(result, (node) => ids.push(node.id))
expect(ids).toContain("child1")
})
})
describe("动态高度", () => {
it("长文本自动增高", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "long",
type: "text",
// 高度只给 5mm,但内容需要多行
frame: { x: 0, y: 0, width: 20, height: 5 },
props: { text: "这是一段很长的中文文本需要换行显示" },
})
})
const result = await layout(doc)
let height: number | undefined
walkLayoutNodes(result, (node) => {
if (node.id === "long") height = node.frame.height
})
expect(height).toBeGreaterThan(5)
})
it("短文本保持原高度", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "short",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 20 },
props: { text: "短" },
})
})
const result = await layout(doc)
let height: number | undefined
walkLayoutNodes(result, (node) => {
if (node.id === "short") height = node.frame.height
})
expect(height).toBe(20)
})
})
describe("未知区域处理", () => {
it("V1 之外的区域被跳过并产生提示", async () => {
const doc = createDocument()
doc.sections.push({
id: "sum1",
kind: "summary",
children: [
{ id: "s1", type: "text", frame: { x: 0, y: 0, width: 50, height: 8 }, props: { text: "汇总" } },
],
})
const result = await layout(doc)
const ids: string[] = []
walkLayoutNodes(result, (node) => ids.push(node.id))
expect(ids).not.toContain("s1")
expect(result.diagnostics.some((d) => d.message.includes("不在 V1 渲染范围内"))).toBe(true)
})
})
describe("节点统计", () => {
it("countLayoutNodes 统计所有页节点", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "a",
type: "text",
frame: { x: 0, y: 0, width: 50, height: 8 },
props: { text: "a" },
})
})
const result = await layout(doc)
expect(countLayoutNodes(result)).toBeGreaterThan(0)
})
})
@@ -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()
})
})