fix: register language-specific Noto fallback fonts for non-Latin scripts (#3158)

* fix: use language-specific Noto fonts for CJK PDF fallback

* feat: extend fallback to Arabic/Hebrew/Thai

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
roberto
2026-06-17 20:37:09 +09:00
committed by GitHub
parent a523e13bfd
commit 2317a82106
6 changed files with 429 additions and 34 deletions
+3 -1
View File
@@ -5,7 +5,7 @@ import type { ComponentType } from "react";
import type { SectionTitleResolver } from "./section-title";
import { useMemo } from "react";
import { RenderProvider } from "./context";
import { registerFonts, resumeContentContainsCJK } from "./hooks/use-register-fonts";
import { registerFonts, resumeContentContainsCJK, resumeContentScripts } from "./hooks/use-register-fonts";
import { Document } from "./renderer";
import { getTemplatePage } from "./templates";
@@ -29,10 +29,12 @@ export const ResumeDocument = ({ data, template, resolveSectionTitle }: ResumeDo
const TemplatePageComponent = getTemplatePage(template);
const creationDate = useMemo(() => new Date(), []);
const hasCjkContent = useMemo(() => resumeContentContainsCJK(data), [data]);
const scripts = useMemo(() => resumeContentScripts(data), [data]);
const typography = registerFonts(
data.metadata.typography,
data.metadata.page.locale as Locale,
hasCjkContent,
scripts,
) as Typography;
// `registerFonts` widens `fontFamily` to `string | string[]` for CJK
@@ -71,6 +71,107 @@ describe("registerFonts", () => {
);
});
it("registers the Korean Noto fallback for the ko-KR locale so Hangul renders", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts(typography, "ko-KR");
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif KR", "Noto Serif SC"]);
expect(pdfTypography.heading.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif KR", "Noto Serif SC"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif KR" }));
});
it("registers the Japanese Noto fallback for the ja-JP locale", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts(typography, "ja-JP");
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif JP", "Noto Serif SC"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif JP" }));
});
it("registers the Traditional Chinese Noto fallback for the zh-TW locale", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts(typography, "zh-TW");
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif TC", "Noto Serif SC"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif TC" }));
});
it("registers the Korean Noto fallback for Latin locale when content contains Hangul", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts(typography, "en-US", true, new Set(["hangul"]));
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif KR", "Noto Serif SC"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif KR" }));
});
it("registers the Arabic Noto fallback for the fa-IR (Persian) locale", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts(typography, "fa-IR");
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Naskh Arabic"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Naskh Arabic" }));
});
it("registers the Arabic Noto fallback for Latin locale when content contains Arabic", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts(typography, "en-US", false, new Set(["arabic"]));
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Naskh Arabic"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Naskh Arabic" }));
});
it("registers the Hebrew Noto fallback for the he-IL locale (no SC safety net)", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts(typography, "he-IL");
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Sans Hebrew"]);
expect(registerSpy).not.toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif SC" }));
});
it("registers the Thai Noto fallback for the th-TH locale", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts(typography, "th-TH");
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Sans Thai"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Sans Thai" }));
});
it("does NOT enable CJK per-character line breaking for non-CJK fallback scripts", async () => {
const registerHyphenationSpy = vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
vi.spyOn(Font, "register").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
registerFonts(typography, "fa-IR");
const hyphenationCallback = registerHyphenationSpy.mock.calls.at(-1)?.[0];
// Arabic is cursive — words must NOT be split per character.
expect(hyphenationCallback?.("سلام")).toEqual(["سلام"]);
});
it("registers bold CJK fallback variants so strong text keeps bold glyphs", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
@@ -216,3 +317,63 @@ describe("resumeContentContainsCJK", () => {
expect(resumeContentContainsCJK(data)).toBe(false);
});
});
describe("resumeContentScripts", () => {
const withSummary = (content: string): ResumeData => ({
...defaultResumeData,
summary: { ...defaultResumeData.summary, content: `<p>${content}</p>` },
});
it("detects Hangul", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
expect([...resumeContentScripts(withSummary("안녕하세요"))]).toEqual(["hangul"]);
});
it("detects Kana", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
expect([...resumeContentScripts(withSummary("こんにちは"))]).toEqual(["kana"]);
});
it("detects Han as han-simplified", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
expect([...resumeContentScripts(withSummary("翠翠红红"))]).toEqual(["han-simplified"]);
});
it("detects Arabic (incl. Persian)", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
expect([...resumeContentScripts(withSummary("پژوهشگر امنیت"))]).toEqual(["arabic"]);
});
it("detects Hebrew", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
expect([...resumeContentScripts(withSummary("שלום עולם"))]).toEqual(["hebrew"]);
});
it("detects Thai", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
expect([...resumeContentScripts(withSummary("สวัสดี"))]).toEqual(["thai"]);
});
it("detects multiple scripts in mixed content", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
const scripts = resumeContentScripts(withSummary("안녕 翠翠 سلام"));
expect(scripts.has("hangul")).toBe(true);
expect(scripts.has("han-simplified")).toBe(true);
expect(scripts.has("arabic")).toBe(true);
});
it("returns an empty set for Latin-only content", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
expect(resumeContentScripts(withSummary("Reactive Resume")).size).toBe(0);
});
it("does not scan private metadata notes", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
const data = {
...defaultResumeData,
metadata: { ...defaultResumeData.metadata, notes: "안녕하세요" },
} satisfies ResumeData;
expect(resumeContentScripts(data).size).toBe(0);
});
});
+97 -27
View File
@@ -1,10 +1,10 @@
import type { FontWeight } from "@reactive-resume/fonts";
import type { ResumeData, Typography } from "@reactive-resume/schema/resume/data";
import type { Locale } from "@reactive-resume/utils/locale";
import type { Locale, Script } from "@reactive-resume/utils/locale";
import { letters as cjkLetters } from "cjk-regex";
import {
getFont,
getPdfCjkFallbackFontFamily,
getPdfFallbackFontFamilies,
getWebFontSource,
isStandardPdfFontFamily,
resolveLegacyFontAlias,
@@ -157,11 +157,72 @@ export const resumeContentContainsCJK = (data: ResumeData): boolean => {
});
};
export const registerFonts = (typography: Typography, locale: Locale, hasCjkContent = false): PdfTypography => {
const needsCjkTextSupport = isCJKLocale(locale) || hasCjkContent;
// Detect which non-Latin writing systems actually appear in the content so we
// only register (and correctly order) the fallback fonts that are needed.
// Codepoints cannot distinguish Simplified from Traditional Han, so Han maps to
// "han-simplified"; Traditional ordering instead comes from the zh-TW locale.
const hangulRegex = /[가-힯ᄀ-ᇿ㄰-㆏ꥠ-꥿]/;
const kanaRegex = /[぀-ゟ゠-ヿㇰ-ㇿ]/;
const hanRegex = /[㐀-䶿一-鿿豈-﫿]/;
// Arabic + Supplement + Extended-A + Presentation Forms-A/B (covers Persian).
const arabicRegex = /[؀-ۿݐ-ݿࢠ-ࣿﭐ-﷿ﹰ-ﻼ]/;
const hebrewRegex = /[֐-׿יִ-ﭏ]/;
const thaiRegex = /[฀-๿]/;
const scriptDetectors: { script: Script; regex: RegExp }[] = [
{ script: "hangul", regex: hangulRegex },
{ script: "kana", regex: kanaRegex },
{ script: "han-simplified", regex: hanRegex },
{ script: "arabic", regex: arabicRegex },
{ script: "hebrew", regex: hebrewRegex },
{ script: "thai", regex: thaiRegex },
];
const collectScripts = (value: unknown, scripts: Set<Script>): void => {
if (typeof value === "string") {
for (const { script, regex } of scriptDetectors) {
if (regex.test(value)) scripts.add(script);
}
return;
}
if (!value || typeof value !== "object") return;
if (Array.isArray(value)) {
for (const item of value) collectScripts(item, scripts);
return;
}
for (const item of Object.values(value as Record<string, unknown>)) collectScripts(item, scripts);
};
export const resumeContentScripts = (data: ResumeData): Set<Script> => {
const scripts = new Set<Script>();
collectScripts(
{
basics: data.basics,
summary: data.summary,
sections: data.sections,
customSections: data.customSections,
},
scripts,
);
return scripts;
};
export const registerFonts = (
typography: Typography,
locale: Locale,
hasCjkContent = false,
scripts?: Set<Script>,
): PdfTypography => {
// CJK needs per-character line breaking. This must stay CJK-only: applying
// it to Arabic (cursive, joined letters) or Thai (combining marks) would
// break shaping, so non-CJK fallbacks below do not enable it.
const needsCjkLineBreaking = isCJKLocale(locale) || hasCjkContent;
Font.registerHyphenationCallback((word) => {
if (needsCjkTextSupport) {
if (needsCjkLineBreaking) {
if (word === " ") return ["\u200C "];
// Only break at every character for words that contain CJK characters.
// Latin/non-CJK words must stay intact even in a CJK-locale resume.
@@ -201,41 +262,50 @@ export const registerFonts = (typography: Typography, locale: Locale, hasCjkCont
registerFont(headingFontFamily, headingRange.highest, italic);
}
// Register a CJK fallback so textkit can substitute per-codepoint for
// characters the primary font lacks (#2986). Register the regular and
// bold ranges so CJK glyph fallback preserves <strong>/font-weight styles.
const bodyCjkFallback = needsCjkTextSupport ? getPdfCjkFallbackFontFamily(bodyFontFamily) : null;
const headingCjkFallback = needsCjkTextSupport ? getPdfCjkFallbackFontFamily(headingFontFamily) : null;
// Register script fallbacks so textkit can substitute per-codepoint for
// characters the primary font lacks (#2986). One Noto font per writing
// system is registered (ordered by locale + detected scripts) so Hangul,
// Kana, Han, Arabic, Hebrew and Thai each resolve against a font that
// actually contains them. Register the regular and bold ranges so glyph
// fallback preserves <strong>/font-weight.
const fallbackScripts = new Set<Script>(scripts ?? []);
// `hasCjkContent` is a script-agnostic flag (cjk-regex); when it is set
// without an explicit script set, assume Han so a SC fallback is registered.
if (hasCjkContent) fallbackScripts.add("han-simplified");
const registerCjkFallback = (family: string, ranges: FontWeightRange[]) => {
const bodyFallbacks = getPdfFallbackFontFamilies(bodyFontFamily, { locale, scripts: fallbackScripts });
const headingFallbacks = getPdfFallbackFontFamilies(headingFontFamily, { locale, scripts: fallbackScripts });
const registerFallbacks = (families: string[], ranges: FontWeightRange[]) => {
const weights = collectFontRangeWeights(ranges);
for (const weight of weights) {
registerFont(family, weight, false);
registerFont(family, weight, true);
for (const family of families) {
for (const weight of weights) {
registerFont(family, weight, false);
registerFont(family, weight, true);
}
}
};
if (bodyCjkFallback && bodyCjkFallback === headingCjkFallback) {
registerCjkFallback(bodyCjkFallback, [bodyRange, headingRange]);
const sameStack =
bodyFallbacks.length === headingFallbacks.length &&
bodyFallbacks.every((family, index) => family === headingFallbacks[index]);
if (sameStack) {
registerFallbacks(bodyFallbacks, [bodyRange, headingRange]);
} else {
if (bodyCjkFallback) {
registerCjkFallback(bodyCjkFallback, [bodyRange]);
}
if (headingCjkFallback) {
registerCjkFallback(headingCjkFallback, [headingRange]);
}
registerFallbacks(bodyFallbacks, [bodyRange]);
registerFallbacks(headingFallbacks, [headingRange]);
}
// Latin-only path: no fallback registered, return as-is.
if (!bodyCjkFallback && !headingCjkFallback) {
if (bodyFallbacks.length === 0 && headingFallbacks.length === 0) {
return pdfTypography as PdfTypography;
}
const bodyStack: string | string[] = bodyCjkFallback ? [bodyFontFamily, bodyCjkFallback] : bodyFontFamily;
const headingStack: string | string[] = headingCjkFallback
? [headingFontFamily, headingCjkFallback]
: headingFontFamily;
const bodyStack: string | string[] = bodyFallbacks.length > 0 ? [bodyFontFamily, ...bodyFallbacks] : bodyFontFamily;
const headingStack: string | string[] =
headingFallbacks.length > 0 ? [headingFontFamily, ...headingFallbacks] : headingFontFamily;
return {
body: { ...pdfTypography.body, fontFamily: bodyStack },