Files
workspace/code/one-designer/packages/report-layout/tests/font.test.ts
T
2026-09-20 17:34:40 +08:00

244 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 字体解析与真实度量测试(对应 §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)
})
})