20260920173439
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user