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