Files
Reactive-Resume/packages/fonts/src/index.test.ts
T
Santhi PrakashandAmruth Pillai 5fc9c3ee04 fix(pdf): register Noto punctuation fallback for missing glyphs (#3294)
* fix(pdf): register Noto punctuation fallback for missing glyphs

- Problem: U+2022 bullet characters render as garbled glyphs when the body
  font (e.g. IBM Plex Serif) lacks the glyph and no PDF fallback is registered.
- Fix: append Noto Serif/Sans to the PDF fallback stack as a general-purpose
  punctuation source covering General Punctuation (U+2000–U+206F).
- Verification: pnpm --filter @reactive-resume/fonts test;
  pnpm --filter @reactive-resume/pdf test src/hooks/use-register-fonts.test.ts

* test(fonts): clarify zh-CN fallback test description

- Problem: getPdfFallbackFontFamilies("Times-Roman", { locale: "zh-CN" }) now
  returns ["Noto Serif SC", "Noto Serif"] (the general-purpose punctuation
  fallback is appended), so the test description "returns only the Simplified
  Chinese font for zh-CN (unchanged behavior)" is no longer accurate.
- Fix: rename the test to describe that it uses the Simplified Chinese font
  plus the punctuation fallback. The assertion is unchanged.
- Verification: pnpm --filter @reactive-resume/fonts test -> 45/45 passing.

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:50:58 +02:00

292 lines
9.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
fontList,
getFont,
getFontDisplayName,
getFontSearchKeywords,
getPdfFallbackFontFamilies,
getWebFont,
getWebFontSource,
isStandardPdfFontFamily,
resolveLegacyFontAlias,
standardFontList,
webFontList,
webFontMap,
} from "./index";
const sortFontFamilies = (families: string[]) => {
return [...families].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
};
describe("fontList", () => {
it("is ordered by font family name instead of localized display name", () => {
const families = fontList.map((font) => font.family);
expect(families).toEqual(sortFontFamilies(families));
});
it("contains standard PDF fonts", () => {
const families = fontList.map((font) => font.family);
expect(families).toContain("Helvetica");
expect(families).toContain("Courier");
expect(families).toContain("Times-Roman");
});
it("merges standardFontList and webFontList without duplicates", () => {
const expectedSize = standardFontList.length + webFontList.length;
expect(fontList).toHaveLength(expectedSize);
});
});
describe("standardFontList", () => {
it("contains only fonts not present in webFontMap", () => {
for (const font of standardFontList) {
expect(webFontMap.has(font.family)).toBe(false);
}
});
it("marks all standard fonts with type='standard'", () => {
for (const font of standardFontList) {
expect(font.type).toBe("standard");
}
});
});
describe("getFont", () => {
it("returns a font record by family", () => {
expect(getFont("Helvetica")).toBeDefined();
expect(getFont("Helvetica")?.family).toBe("Helvetica");
});
it("returns undefined for unknown families", () => {
expect(getFont("DefinitelyNotAFont")).toBeUndefined();
});
});
describe("getFontDisplayName", () => {
it("returns localized name when available", () => {
expect(getFontDisplayName("Noto Sans SC")).toBe("思源黑体");
expect(getFontDisplayName("PingFang SC")).toBe("苹方");
});
it("returns the family name when no localized name exists", () => {
expect(getFontDisplayName("Helvetica")).toBe("Helvetica");
expect(getFontDisplayName("Roboto")).toBe("Roboto");
});
});
describe("getFontSearchKeywords", () => {
it("returns family for non-CJK fonts", () => {
const keywords = getFontSearchKeywords("Helvetica");
expect(keywords).toContain("Helvetica");
expect(keywords).not.toContain("中文");
});
it("includes localized display name and 中文 marker for CJK fonts", () => {
const keywords = getFontSearchKeywords("Noto Sans SC");
expect(keywords).toContain("Noto Sans SC");
expect(keywords).toContain("思源黑体");
expect(keywords).toContain("中文");
});
it("deduplicates entries", () => {
const keywords = getFontSearchKeywords("Helvetica");
expect(new Set(keywords).size).toBe(keywords.length);
});
});
describe("isStandardPdfFontFamily", () => {
it("returns true for Helvetica, Courier, Times-Roman", () => {
expect(isStandardPdfFontFamily("Helvetica")).toBe(true);
expect(isStandardPdfFontFamily("Courier")).toBe(true);
expect(isStandardPdfFontFamily("Times-Roman")).toBe(true);
});
it("returns false for unknown families", () => {
expect(isStandardPdfFontFamily("Not-A-Font")).toBe(false);
});
it("returns false for non-standard web fonts", () => {
// Roboto is a web font, not a standard PDF font
const roboto = getWebFont("Roboto");
if (roboto) expect(isStandardPdfFontFamily("Roboto")).toBe(false);
});
});
describe("getWebFont", () => {
it("returns webFont record for known web font family", () => {
// Spot-check a font that should be in the list
const font = getWebFont("Roboto");
if (font) {
expect(font.type).toBe("web");
expect(font.family).toBe("Roboto");
}
});
it("returns undefined for non-web families", () => {
expect(getWebFont("Helvetica")).toBeUndefined();
expect(getWebFont("definitely-not-a-font")).toBeUndefined();
});
});
describe("getWebFontSource", () => {
it("uses the full normal font source when an italic variant is unavailable", () => {
expect(getWebFontSource("Noto Serif SC", "400", true)).toBe(getWebFontSource("Noto Serif SC", "400", false));
});
it("returns null for unknown fonts", () => {
expect(getWebFontSource("definitely-not-a-font", "400")).toBeNull();
});
it("defaults to weight 400 when not specified", () => {
const roboto = getWebFont("Roboto");
if (roboto) {
const source = getWebFontSource("Roboto");
expect(source).toBeTruthy();
}
});
});
describe("getPdfFallbackFontFamilies", () => {
it("appends a Noto punctuation fallback for Latin-only PDF fonts (#3190)", () => {
expect(getPdfFallbackFontFamilies("Times-Roman")).toEqual(["Noto Serif"]);
expect(getPdfFallbackFontFamilies("Helvetica")).toEqual(["Noto Sans"]);
expect(getPdfFallbackFontFamilies("IBM Plex Serif")).toEqual(["Noto Serif"]);
});
it("puts the Korean Noto font first for the ko-KR locale (Hangul needs KR, not SC)", () => {
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "ko-KR" })).toEqual([
"Noto Serif KR",
"Noto Serif SC",
"Noto Serif",
]);
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "ko-KR" })).toEqual([
"Noto Sans KR",
"Noto Sans SC",
"Noto Sans",
]);
});
it("uses the Japanese Noto font for the ja-JP locale", () => {
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "ja-JP" })).toEqual([
"Noto Serif JP",
"Noto Serif SC",
"Noto Serif",
]);
});
it("uses the Traditional Chinese Noto font for the zh-TW locale", () => {
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "zh-TW" })).toEqual([
"Noto Serif TC",
"Noto Serif SC",
"Noto Serif",
]);
});
it("uses the Simplified Chinese font and punctuation fallback for zh-CN", () => {
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "zh-CN" })).toEqual(["Noto Serif SC", "Noto Serif"]);
});
it("uses the Arabic Noto font for the fa-IR (Persian) and ar-SA locales", () => {
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "fa-IR" })).toEqual(["Noto Sans Arabic", "Noto Sans"]);
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "ar-SA" })).toEqual(["Noto Naskh Arabic", "Noto Serif"]);
});
it("uses the Hebrew Noto font for he-IL, reusing the sans font for serif (no Noto Serif Hebrew)", () => {
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "he-IL" })).toEqual(["Noto Sans Hebrew", "Noto Sans"]);
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "he-IL" })).toEqual(["Noto Sans Hebrew", "Noto Serif"]);
});
it("uses the Thai Noto font for th-TH", () => {
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "th-TH" })).toEqual(["Noto Sans Thai", "Noto Sans"]);
});
it("does not append the Simplified Chinese safety net for non-CJK scripts", () => {
expect(getPdfFallbackFontFamilies("Helvetica", { scripts: ["arabic"] })).toEqual(["Noto Sans Arabic", "Noto Sans"]);
expect(getPdfFallbackFontFamilies("Helvetica", { scripts: ["thai"] })).not.toContain("Noto Sans SC");
});
it("orders the locale script first, then content scripts (mixed RTL + CJK resume)", () => {
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "ko-KR", scripts: ["arabic"] })).toEqual([
"Noto Sans KR",
"Noto Sans Arabic",
"Noto Sans SC",
"Noto Sans",
]);
});
it("includes a Korean font before SC when Hangul is detected in Latin-locale content", () => {
expect(getPdfFallbackFontFamilies("Helvetica", { scripts: ["hangul"] })).toEqual([
"Noto Sans KR",
"Noto Sans SC",
"Noto Sans",
]);
});
it("dedupes the locale script and content scripts", () => {
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "ko-KR", scripts: ["hangul", "han-simplified"] })).toEqual(
["Noto Sans KR", "Noto Sans SC", "Noto Sans"],
);
});
it("excludes the family itself when it already is a fallback", () => {
expect(getPdfFallbackFontFamilies("Noto Sans KR", { locale: "ko-KR" })).toEqual(["Noto Sans SC", "Noto Sans"]);
});
it("omits the punctuation fallback when the primary family is already the punctuation font", () => {
expect(getPdfFallbackFontFamilies("Noto Serif")).toEqual([]);
expect(getPdfFallbackFontFamilies("Noto Sans")).toEqual([]);
});
it("only returns fonts that exist in the webfontlist", () => {
const families = getPdfFallbackFontFamilies("Helvetica", {
locale: "ko-KR",
scripts: ["hangul", "kana", "han-simplified", "arabic", "hebrew", "thai"],
});
for (const family of families) {
expect(getWebFont(family)).toBeDefined();
}
});
});
describe("legacy font compatibility (#2989)", () => {
it.each([
["Times New Roman", "Times-Roman"],
["Arial", "Arimo"],
["Garamond", "EB Garamond"],
["Calibri", "Carlito"],
["Cambria", "Tinos"],
])("aliases %s → %s", (legacy, target) => {
expect(resolveLegacyFontAlias(legacy)).toBe(target);
});
it("returns null for non-aliased families", () => {
expect(resolveLegacyFontAlias("Roboto")).toBeNull();
expect(resolveLegacyFontAlias("IBM Plex Serif")).toBeNull();
expect(resolveLegacyFontAlias("UnknownFont")).toBeNull();
});
it("every alias target is actually registered as a known font", () => {
const aliasTargets = ["Times-Roman", "Tinos", "Arimo", "EB Garamond", "Carlito"];
for (const target of aliasTargets) {
expect(getFont(target), `alias target ${target} must be a known font`).toBeDefined();
}
});
it("getFont resolves a legacy family to its alias target", () => {
const tnr = getFont("Times New Roman");
expect(tnr).toBeDefined();
expect(tnr?.family).toBe("Times-Roman");
});
it("getFont still returns the direct font when both legacy and direct lookup would succeed", () => {
// Sanity check: for non-aliased families the direct path is used.
expect(getFont("Roboto")?.family).toBe("Roboto");
});
it("getFontDisplayName preserves the legacy family name (UI is not rewritten)", () => {
// Users who picked "Times New Roman" should keep seeing that label
// in the typography sidebar — the alias is a render-time concern only.
expect(getFontDisplayName("Times New Roman")).toBe("Times New Roman");
});
});