mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
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:
@@ -8,6 +8,7 @@ import {
|
||||
getFontSearchKeywords,
|
||||
getLoadableWebFontWeights,
|
||||
getPdfCjkFallbackFontFamily,
|
||||
getPdfFallbackFontFamilies,
|
||||
getWebFont,
|
||||
getWebFontSource,
|
||||
isStandardPdfFontFamily,
|
||||
@@ -163,6 +164,76 @@ describe("getPdfCjkFallbackFontFamily", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPdfFallbackFontFamilies", () => {
|
||||
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"]);
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "ko-KR" })).toEqual(["Noto Sans KR", "Noto Sans SC"]);
|
||||
});
|
||||
|
||||
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"]);
|
||||
});
|
||||
|
||||
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"]);
|
||||
});
|
||||
|
||||
it("returns only the Simplified Chinese font for zh-CN (unchanged behavior)", () => {
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "zh-CN" })).toEqual(["Noto Serif SC"]);
|
||||
});
|
||||
|
||||
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"]);
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "ar-SA" })).toEqual(["Noto Naskh Arabic"]);
|
||||
});
|
||||
|
||||
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"]);
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "he-IL" })).toEqual(["Noto Sans Hebrew"]);
|
||||
});
|
||||
|
||||
it("uses the Thai Noto font for th-TH", () => {
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "th-TH" })).toEqual(["Noto Sans Thai"]);
|
||||
});
|
||||
|
||||
it("does not append the Simplified Chinese safety net for non-CJK scripts", () => {
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { scripts: ["arabic"] })).toEqual(["Noto Sans Arabic"]);
|
||||
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",
|
||||
]);
|
||||
});
|
||||
|
||||
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"]);
|
||||
});
|
||||
|
||||
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"],
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes the family itself when it already is a fallback", () => {
|
||||
expect(getPdfFallbackFontFamilies("Noto Sans KR", { locale: "ko-KR" })).toEqual(["Noto Sans SC"]);
|
||||
});
|
||||
|
||||
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("getFallbackWebFontFamilies", () => {
|
||||
it("returns empty array for standard PDF fonts", () => {
|
||||
expect(getFallbackWebFontFamilies("Helvetica")).toEqual([]);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Locale, Script } from "@reactive-resume/utils/locale";
|
||||
import { unique } from "@reactive-resume/utils/field";
|
||||
import { getLocaleScript, isCjkScript } from "@reactive-resume/utils/locale";
|
||||
import webFontListJSON from "./webfontlist.json";
|
||||
|
||||
export type FontCategory = "display" | "handwriting" | "monospace" | "serif" | "sans-serif";
|
||||
@@ -81,6 +83,23 @@ const resumeCjkSerifFontFallbacks = [
|
||||
"FangSong",
|
||||
] as const;
|
||||
|
||||
// Per-script Noto web font, split by serif/sans category. These match the
|
||||
// actual writing system: Hangul lives only in the KR fonts, Kana only in JP,
|
||||
// Arabic glyphs only in the Arabic fonts, etc. — so a Latin or Simplified-
|
||||
// Chinese font cannot render them and produces tofu. Where Noto ships no serif
|
||||
// variant for a script (Hebrew, Thai), the serif slot reuses the sans font so
|
||||
// serif resumes still render real glyphs instead of nothing. All entries are
|
||||
// present in webfontlist.json.
|
||||
const scriptFonts: Record<Script, { serif: string; sansSerif: string }> = {
|
||||
hangul: { serif: "Noto Serif KR", sansSerif: "Noto Sans KR" },
|
||||
kana: { serif: "Noto Serif JP", sansSerif: "Noto Sans JP" },
|
||||
"han-traditional": { serif: "Noto Serif TC", sansSerif: "Noto Sans TC" },
|
||||
"han-simplified": { serif: "Noto Serif SC", sansSerif: "Noto Sans SC" },
|
||||
arabic: { serif: "Noto Naskh Arabic", sansSerif: "Noto Sans Arabic" },
|
||||
hebrew: { serif: "Noto Sans Hebrew", sansSerif: "Noto Sans Hebrew" },
|
||||
thai: { serif: "Noto Sans Thai", sansSerif: "Noto Sans Thai" },
|
||||
};
|
||||
|
||||
const genericFontFamilies = new Set([
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
@@ -172,6 +191,11 @@ function getPrimaryCjkWebFont(family: string) {
|
||||
return category === "serif" ? "Noto Serif SC" : "Noto Sans SC";
|
||||
}
|
||||
|
||||
function getScriptFont(script: Script, category: FontCategory | null) {
|
||||
const variants = scriptFonts[script];
|
||||
return category === "serif" ? variants.serif : variants.sansSerif;
|
||||
}
|
||||
|
||||
export function isStandardPdfFontFamily(family: string) {
|
||||
return standardFontList.some((font) => font.family === family);
|
||||
}
|
||||
@@ -200,13 +224,39 @@ export function getFallbackWebFontFamilies(family: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a CJK web font (Noto Sans/Serif SC) to register as a glyph-level
|
||||
* fallback for PDF rendering, or `null` when no fallback is needed
|
||||
* (primary already is the fallback).
|
||||
* Returns an ordered stack of Noto web fonts to register as glyph-level
|
||||
* fallbacks for PDF rendering. react-pdf resolves the font per-codepoint
|
||||
* left-to-right across the stack, so listing one font per writing system lets
|
||||
* a single resume mix Latin with Hangul, Kana, Han, Arabic, Hebrew or Thai.
|
||||
*
|
||||
* Source Han Sans/Serif SC covers all CJK-Unified ideographs, so a single
|
||||
* font handles Simplified/Traditional Chinese, Japanese kanji and Korean
|
||||
* hanja — the locales reporting #2986 / #3006.
|
||||
* Ordering: the locale's primary script first (the dominant language), then
|
||||
* any other scripts actually detected in the content. When the stack contains
|
||||
* a CJK script, a Simplified Chinese entry is appended as a safety net for
|
||||
* stray CJK-Unified ideographs (preserving prior behavior); non-CJK scripts
|
||||
* get no such net. The result is deduped, has the primary family removed, and
|
||||
* only keeps fonts that exist in the webfontlist.
|
||||
*/
|
||||
export function getPdfFallbackFontFamilies(
|
||||
family: string,
|
||||
options: { locale?: Locale; scripts?: Iterable<Script> } = {},
|
||||
): string[] {
|
||||
const category = getFontCategory(family);
|
||||
|
||||
const ordered: Script[] = [];
|
||||
const localeScript = getLocaleScript(options.locale);
|
||||
if (localeScript) ordered.push(localeScript);
|
||||
if (options.scripts) ordered.push(...options.scripts);
|
||||
if (ordered.some(isCjkScript)) ordered.push("han-simplified");
|
||||
|
||||
return unique(ordered.map((script) => getScriptFont(script, category)))
|
||||
.filter((candidate) => candidate !== family)
|
||||
.filter((candidate) => Boolean(getWebFont(candidate)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-compat single-font resolver kept for the public `@reactive-resume/fonts`
|
||||
* surface: returns the Simplified Chinese fallback (Noto Sans/Serif SC) for a
|
||||
* family, or `null` when the family already is that fallback.
|
||||
*/
|
||||
export function getPdfCjkFallbackFontFamily(family: string): string | null {
|
||||
const fallback = getPrimaryCjkWebFont(family);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -70,6 +70,47 @@ export function isCJKLocale(locale: Locale): boolean {
|
||||
return locale === "zh-CN" || locale === "zh-TW" || locale === "ja-JP" || locale === "ko-KR";
|
||||
}
|
||||
|
||||
// A writing system that needs a dedicated fallback font in the PDF renderer,
|
||||
// because react-pdf (unlike a browser) has no automatic system-font fallback:
|
||||
// a glyph only renders if a registered font contains it. We pick the matching
|
||||
// Noto font per script so e.g. Hangul → Noto KR, Arabic → Noto Arabic, instead
|
||||
// of falling back to a Latin/Han-only font and producing tofu.
|
||||
export type Script = "hangul" | "kana" | "han-traditional" | "han-simplified" | "arabic" | "hebrew" | "thai";
|
||||
|
||||
// The CJK subset of `Script`. CJK needs extra per-character line breaking that
|
||||
// must NOT be applied to Arabic (cursive, joined letters) or Thai (combining
|
||||
// marks), so callers gate line-breaking on this rather than on `Script`.
|
||||
export const cjkScripts: readonly Script[] = ["hangul", "kana", "han-traditional", "han-simplified"];
|
||||
|
||||
export function isCjkScript(script: Script): boolean {
|
||||
return cjkScripts.includes(script);
|
||||
}
|
||||
|
||||
// The script a locale primarily uses, used to order the fallback stack so the
|
||||
// dominant language renders with its native font. Persian (fa-IR) uses the
|
||||
// Arabic script.
|
||||
export function getLocaleScript(locale?: Locale): Script | null {
|
||||
switch (locale) {
|
||||
case "ko-KR":
|
||||
return "hangul";
|
||||
case "ja-JP":
|
||||
return "kana";
|
||||
case "zh-TW":
|
||||
return "han-traditional";
|
||||
case "zh-CN":
|
||||
return "han-simplified";
|
||||
case "ar-SA":
|
||||
case "fa-IR":
|
||||
return "arabic";
|
||||
case "he-IL":
|
||||
return "hebrew";
|
||||
case "th-TH":
|
||||
return "thai";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const RTL_LANGUAGES = new Set([
|
||||
"ar", // Arabic
|
||||
"ckb", // Kurdish (Sorani)
|
||||
|
||||
Reference in New Issue
Block a user