244 lines
8.5 KiB
TypeScript
244 lines
8.5 KiB
TypeScript
/**
|
||
* 字体解析与真实度量测试(对应 §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)
|
||
})
|
||
})
|