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)
})
})