mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-20 21:41:43 +10:00
* 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>
292 lines
9.9 KiB
TypeScript
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");
|
|
});
|
|
});
|