feat: add Chinese font options (#2905)

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
Platinum1154
2026-04-26 06:32:42 +08:00
committed by GitHub
parent 77ad14b359
commit a4e7d6680d
11 changed files with 474 additions and 94 deletions
+98
View File
@@ -0,0 +1,98 @@
{
"name": "Reactive Resume",
"short_name": "Reactive Resume",
"description": "A free and open-source resume builder.",
"start_url": "/?source=pwa",
"display": "standalone",
"background_color": "#09090B",
"theme_color": "#09090B",
"lang": "en",
"scope": "/",
"id": "/?source=pwa",
"orientation": "portrait",
"icons": [
{ "src": "favicon.ico", "sizes": "128x128", "type": "image/x-icon" },
{ "src": "pwa-64x64.png", "sizes": "64x64", "type": "image/png" },
{ "src": "pwa-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "pwa-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "maskable-icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"screenshots": [
{
"src": "screenshots/web/1-landing-page.webp",
"sizes": "1920x1080 any",
"type": "image/webp",
"form_factor": "wide",
"label": "Landing Page"
},
{
"src": "screenshots/web/2-resume-dashboard.webp",
"sizes": "1920x1080 any",
"type": "image/webp",
"form_factor": "wide",
"label": "Resume Dashboard"
},
{
"src": "screenshots/web/3-builder-screen.webp",
"sizes": "1920x1080 any",
"type": "image/webp",
"form_factor": "wide",
"label": "Builder Screen"
},
{
"src": "screenshots/web/4-template-gallery.webp",
"sizes": "1920x1080 any",
"type": "image/webp",
"form_factor": "wide",
"label": "Template Gallery"
},
{
"src": "screenshots/mobile/1-landing-page.webp",
"sizes": "1284x2778 any",
"type": "image/webp",
"form_factor": "narrow",
"label": "Landing Page"
},
{
"src": "screenshots/mobile/2-resume-dashboard.webp",
"sizes": "1284x2778 any",
"type": "image/webp",
"form_factor": "narrow",
"label": "Resume Dashboard"
},
{
"src": "screenshots/mobile/3-builder-screen.webp",
"sizes": "1284x2778 any",
"type": "image/webp",
"form_factor": "narrow",
"label": "Builder Screen"
},
{
"src": "screenshots/mobile/4-template-gallery.webp",
"sizes": "1284x2778 any",
"type": "image/webp",
"form_factor": "narrow",
"label": "Template Gallery"
}
],
"categories": [
"ai",
"builder",
"business",
"career",
"cv",
"editor",
"free",
"generator",
"job-search",
"multilingual",
"open-source",
"privacy",
"productivity",
"resume",
"self-hosted",
"templates",
"utilities",
"writing"
]
}
+5 -7
View File
@@ -2,7 +2,7 @@ import { Trans } from "@lingui/react/macro";
import { ArrowLeftIcon, WarningIcon } from "@phosphor-icons/react";
import { Link, type NotFoundRouteProps } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { BrandIcon } from "../ui/brand-icon";
@@ -20,12 +20,10 @@ export function NotFoundScreen({ routeId }: NotFoundRouteProps) {
<AlertDescription>{routeId}</AlertDescription>
</Alert>
<Button>
<Link to="..">
<ArrowLeftIcon />
<Trans>Go Back</Trans>
</Link>
</Button>
<Link to=".." className={buttonVariants()}>
<ArrowLeftIcon />
<Trans>Go Back</Trans>
</Link>
</div>
);
}
@@ -5,6 +5,7 @@ import { useMemo } from "react";
import type { resumeDataSchema } from "@/schema/resume/data";
import { pageDimensionsAsMillimeters } from "@/schema/page";
import { buildResumeFontFamily } from "@/utils/fonts";
type UseCssVariablesProps = Pick<z.infer<typeof resumeDataSchema>, "picture" | "metadata">;
@@ -36,12 +37,12 @@ export const useCSSVariables = ({ picture, metadata }: UseCssVariablesProps) =>
"--page-text-color": metadata.design.colors.text,
"--page-primary-color": metadata.design.colors.primary,
"--page-background-color": metadata.design.colors.background,
"--page-body-font-family": `'${metadata.typography.body.fontFamily}', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
"--page-body-font-family": buildResumeFontFamily(metadata.typography.body.fontFamily),
"--page-body-font-weight": fontWeightStyles.lowestBodyFontWeight,
"--page-body-font-weight-bold": fontWeightStyles.highestBodyFontWeight,
"--page-body-font-size": metadata.typography.body.fontSize,
"--page-body-line-height": metadata.typography.body.lineHeight,
"--page-heading-font-family": `'${metadata.typography.heading.fontFamily}', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
"--page-heading-font-family": buildResumeFontFamily(metadata.typography.heading.fontFamily),
"--page-heading-font-weight": fontWeightStyles.lowestHeadingFontWeight,
"--page-heading-font-weight-bold": fontWeightStyles.highestHeadingFontWeight,
"--page-heading-font-size": metadata.typography.heading.fontSize,
+30 -6
View File
@@ -5,7 +5,7 @@ import { useIsMounted } from "usehooks-ts";
import type { typographySchema } from "@/schema/resume/data";
import webfontlist from "@/components/typography/webfontlist.json";
import { getFallbackWebFontFamilies, getLoadableWebFontWeights, webFontMap } from "@/utils/fonts";
export function useWebfonts(typography: z.infer<typeof typographySchema>) {
const isMounted = useIsMounted();
@@ -17,7 +17,7 @@ export function useWebfonts(typography: z.infer<typeof typographySchema>) {
if (body) body.setAttribute("data-wf-loaded", "false");
async function loadFont(family: string, weights: string[]) {
const font = webfontlist.find((font) => font.family === family);
const font = webFontMap.get(family);
if (!font) return;
type FontUrl = { url: string; weight: string; style: "italic" | "normal" };
@@ -49,11 +49,35 @@ export function useWebfonts(typography: z.infer<typeof typographySchema>) {
const bodyTypography = typography.body;
const headingTypography = typography.heading;
const fontWeightsByFamily = new Map<string, Set<string>>();
void Promise.allSettled([
loadFont(bodyTypography.fontFamily, bodyTypography.fontWeights),
loadFont(headingTypography.fontFamily, headingTypography.fontWeights),
]).then(() => {
const addFontLoadPlan = (family: string, weights: string[]) => {
const loadableWeights = getLoadableWebFontWeights(family, weights);
if (loadableWeights.length === 0) return;
const existingWeights = fontWeightsByFamily.get(family) ?? new Set<string>();
for (const weight of loadableWeights) {
existingWeights.add(weight);
}
fontWeightsByFamily.set(family, existingWeights);
};
addFontLoadPlan(bodyTypography.fontFamily, bodyTypography.fontWeights);
addFontLoadPlan(headingTypography.fontFamily, headingTypography.fontWeights);
for (const fallbackFamily of getFallbackWebFontFamilies(bodyTypography.fontFamily)) {
addFontLoadPlan(fallbackFamily, bodyTypography.fontWeights);
}
for (const fallbackFamily of getFallbackWebFontFamilies(headingTypography.fontFamily)) {
addFontLoadPlan(fallbackFamily, headingTypography.fontWeights);
}
void Promise.all(
Array.from(fontWeightsByFamily.entries()).map(([family, weights]) => loadFont(family, Array.from(weights))),
).then(() => {
if (isMounted() && body) body.setAttribute("data-wf-loaded", "true");
});
+19 -36
View File
@@ -1,46 +1,22 @@
import { useMemo } from "react";
import {
fontList,
getFont,
getFontDisplayName,
getFontSearchKeywords,
localFontList,
webFontMap,
} from "@/utils/fonts";
import { cn } from "@/utils/style";
import type { LocalFont, WebFont } from "./types";
import { Combobox, type MultiComboboxProps, type SingleComboboxProps } from "../ui/combobox";
import { FontDisplay } from "./font-display";
import webFontListJSON from "./webfontlist.json";
type Weight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
const localFontList = [
{ type: "local", category: "sans-serif", family: "Arial", weights: ["400", "600", "700"] },
{ type: "local", category: "sans-serif", family: "Calibri", weights: ["400", "600", "700"] },
{ type: "local", category: "sans-serif", family: "Helvetica", weights: ["400", "600", "700"] },
{ type: "local", category: "sans-serif", family: "Tahoma", weights: ["400", "600", "700"] },
{ type: "local", category: "sans-serif", family: "Trebuchet MS", weights: ["400", "600", "700"] },
{ type: "local", category: "sans-serif", family: "Verdana", weights: ["400", "600", "700"] },
{ type: "local", category: "serif", family: "Bookman", weights: ["400", "600", "700"] },
{ type: "local", category: "serif", family: "Cambria", weights: ["400", "600", "700"] },
{ type: "local", category: "serif", family: "Garamond", weights: ["400", "600", "700"] },
{ type: "local", category: "serif", family: "Georgia", weights: ["400", "600", "700"] },
{ type: "local", category: "serif", family: "Palatino", weights: ["400", "600", "700"] },
{ type: "local", category: "serif", family: "Times New Roman", weights: ["400", "600", "700"] },
] as LocalFont[];
const webFontList = webFontListJSON as WebFont[];
function buildWebFontMap() {
const webFontMap = new Map<string, WebFont>();
for (const font of webFontList) {
webFontMap.set(font.family, font);
}
return webFontMap;
}
const webFontMap: Map<string, WebFont> = buildWebFontMap();
export function getNextWeights(fontFamily: string): Weight[] | null {
const fontData = webFontMap.get(fontFamily);
const fontData = getFont(fontFamily);
if (!fontData || !Array.isArray(fontData.weights) || fontData.weights.length === 0) return null;
const uniqueWeights = Array.from(new Set(fontData.weights)) as Weight[];
@@ -67,10 +43,17 @@ type FontFamilyComboboxProps = Omit<SingleComboboxProps, "options">;
export function FontFamilyCombobox({ className, ...props }: FontFamilyComboboxProps) {
const options = useMemo(() => {
return [...webFontList, ...localFontList].map((font: LocalFont | WebFont) => ({
return fontList.map((font) => ({
value: font.family,
keywords: [font.family],
label: <FontDisplay name={font.family} type={font.type} url={"preview" in font ? font.preview : undefined} />,
keywords: getFontSearchKeywords(font.family),
label: (
<FontDisplay
family={font.family}
label={getFontDisplayName(font.family)}
type={font.type}
url={"preview" in font ? font.preview : undefined}
/>
),
}));
}, []);
+8 -6
View File
@@ -4,15 +4,16 @@ import { useEffect, useRef, useState } from "react";
import { cn } from "@/utils/style";
interface FontDisplayProps {
name: string;
url?: string;
family: string;
label: string;
type: "local" | "web";
url?: string;
}
const loadedFonts = new Set<string>();
export function FontDisplay({ name, url, type }: FontDisplayProps) {
const previewName = type === "local" ? name : `${name} Preview`;
export function FontDisplay({ family, label, type, url }: FontDisplayProps) {
const previewName = type === "local" ? family : `${family} Preview`;
const containerRef = useRef<HTMLDivElement>(null);
const [isLoaded, setIsLoaded] = useState(() => type === "local" || loadedFonts.has(previewName));
@@ -31,13 +32,14 @@ export function FontDisplay({ name, url, type }: FontDisplayProps) {
}, [isInView, isLoaded, previewName, url]);
return (
<div ref={containerRef} className="inline">
<div ref={containerRef} className="inline-flex items-baseline gap-2">
<span
style={{ fontFamily: isLoaded ? `'${previewName}', sans-serif` : "sans-serif" }}
className={cn(isLoaded ? "opacity-100" : "opacity-50", "transition-opacity duration-200 ease-in")}
>
{name}
{label}
</span>
{label !== family && <span className="text-xs text-muted-foreground">{family}</span>}
</div>
);
}
+3 -4
View File
@@ -1,7 +1,6 @@
import { ORPCError } from "@orpc/client";
import { type } from "@orpc/server";
import { AISDKError, type UIMessage } from "ai";
import { OllamaError } from "ai-sdk-ollama";
import z, { flattenError, ZodError } from "zod";
import { jobResultSchema } from "@/schema/jobs";
@@ -21,7 +20,7 @@ function isInvalidAiBaseUrlError(error: unknown): boolean {
}
function isAiProviderGatewayError(error: unknown): boolean {
return error instanceof AISDKError || error instanceof OllamaError;
return error instanceof AISDKError;
}
function throwAiProviderGatewayError(): never {
@@ -111,7 +110,7 @@ export const aiRouter = {
return await aiService.parsePdf(input);
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (error instanceof AISDKError) throwAiProviderGatewayError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) throwResumeStructureError(error);
throw error;
@@ -155,7 +154,7 @@ export const aiRouter = {
return await aiService.parseDocx(input);
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (error instanceof AISDKError) throwAiProviderGatewayError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) throwResumeStructureError(error);
+2 -17
View File
@@ -1,4 +1,3 @@
import { t } from "@lingui/core/macro";
import { ORPCError } from "@orpc/client";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
@@ -16,9 +15,7 @@ type LoaderData = Omit<RouterOutput["resume"]["getBySlug"], "data"> & { data: Re
export const Route = createFileRoute("/$username/$slug")({
component: RouteComponent,
loader: async ({ context, params, ...rest }) => {
console.log("$username/$slug loader", JSON.stringify({ params, context, rest }, null, 2));
loader: async ({ context, params }) => {
const { username, slug } = params;
const resume = await context.queryClient.ensureQueryData(
orpc.resume.getBySlug.queryOptions({ input: { username, slug } }),
@@ -27,19 +24,7 @@ export const Route = createFileRoute("/$username/$slug")({
return { resume: resume as LoaderData };
},
head: ({ loaderData }) => ({
meta: [
{
title: loaderData
? `${loaderData.resume.name} - ${t({
comment: "Brand name suffix in browser tab title for public resume pages",
message: "Reactive Resume",
})}`
: t({
comment: "Browser tab title before the public resume finishes loading",
message: "Reactive Resume",
}),
},
],
meta: [{ title: loaderData ? `${loaderData.resume.name} - Reactive Resume` : "Reactive Resume" }],
}),
onError: (error) => {
if (error instanceof ORPCError && error.code === "NEED_PASSWORD") {
+14 -16
View File
@@ -3,43 +3,41 @@ import { FastResponse } from "srvx";
globalThis.Response = FastResponse;
function setIfMissing(headers: Headers, key: string, value: string) {
if (headers.has(key)) return;
headers.set(key, value);
const fontSrc = "'self' https://cdn.jsdelivr.net https://fonts.gstatic.com";
const scriptSrc = "'self' 'unsafe-inline' https://cdn.jsdelivr.net";
const styleSrc = "'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net";
function setIfAbsent(headers: Headers, key: string, value: string) {
if (!headers.has(key)) headers.set(key, value);
}
const allowedWebfontOrigins = ["https://cdn.jsdelivr.net", "https://fonts.gstatic.com"] as const;
const fontSrcWithWebfonts = ["'self'", ...allowedWebfontOrigins].join(" ");
export default createServerEntry({
async fetch(request) {
const response = await handler.fetch(request);
const headers = new Headers(response.headers);
const contentType = headers.get("content-type") ?? "";
if (request.url.includes("/printer/")) {
headers.set(
"Content-Security-Policy",
`default-src 'self'; img-src 'self' data:; font-src ${fontSrcWithWebfonts}; style-src 'self' 'unsafe-inline'; connect-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self';`,
"Content-Security-Policy-Report-Only",
`default-src 'self'; img-src 'self' data:; font-src ${fontSrc}; style-src ${styleSrc}; connect-src 'self'; script-src ${scriptSrc}; worker-src 'self' blob:; frame-ancestors 'none'; base-uri 'self';`,
);
}
if (contentType.includes("text/html")) {
setIfMissing(headers, "Cross-Origin-Opener-Policy", "same-origin");
setIfMissing(headers, "Cross-Origin-Resource-Policy", "same-site");
setIfMissing(
setIfAbsent(headers, "Cross-Origin-Opener-Policy", "same-origin");
setIfAbsent(headers, "Cross-Origin-Resource-Policy", "same-site");
setIfAbsent(
headers,
"Content-Security-Policy",
`default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src ${fontSrcWithWebfonts} data:; connect-src 'self' https: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';`,
"Content-Security-Policy-Report-Only",
`default-src 'self'; script-src ${scriptSrc}; worker-src 'self' blob:; style-src ${styleSrc}; img-src 'self' data: blob: https:; font-src ${fontSrc} data:; connect-src 'self' https: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';`,
);
}
return new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
headers,
});
},
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vite-plus/test";
import {
buildResumeFontFamily,
fontList,
getFallbackWebFontFamilies,
getFontDisplayName,
getLoadableWebFontWeights,
localFontList,
} from "./fonts";
function splitFontStack(fontStack: string) {
return fontStack.split(", ").map((font) => font.replace(/^'(.*)'$/, "$1"));
}
describe("fontList", () => {
it("sorts font families alphabetically by display label", () => {
const labels = fontList.map((font) => getFontDisplayName(font.family));
const sortedLabels = [...labels].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
expect(labels).toEqual(sortedLabels);
});
});
describe("localFontList", () => {
it("includes common local Chinese font families", () => {
expect(localFontList.some((font) => font.family === "PingFang SC")).toBe(true);
expect(localFontList.some((font) => font.family === "Microsoft YaHei")).toBe(true);
expect(localFontList.some((font) => font.family === "KaiTi")).toBe(true);
expect(localFontList.some((font) => font.family === "FangSong")).toBe(true);
});
});
describe("getFontDisplayName", () => {
it("returns Chinese display labels for curated Chinese fonts", () => {
expect(getFontDisplayName("Noto Sans SC")).toBe("思源黑体");
expect(getFontDisplayName("Microsoft YaHei")).toBe("微软雅黑");
expect(getFontDisplayName("ZCOOL QingKe HuangYou")).toBe("站酷庆科黄油体");
});
});
describe("buildResumeFontFamily", () => {
it("uses serif-oriented Chinese fallbacks for serif body fonts", () => {
const fonts = splitFontStack(buildResumeFontFamily("IBM Plex Serif"));
expect(fonts.slice(0, 5)).toEqual([
"IBM Plex Serif",
"Noto Serif SC",
"Songti SC",
"SimSun",
"Source Han Serif SC",
]);
expect(fonts.at(-1)).toBe("serif");
});
it("uses sans-oriented Chinese fallbacks for sans body fonts", () => {
const fonts = splitFontStack(buildResumeFontFamily("Inter"));
expect(fonts.slice(0, 5)).toEqual([
"Inter",
"Noto Sans SC",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
]);
expect(fonts.at(-1)).toBe("sans-serif");
});
});
describe("getFallbackWebFontFamilies", () => {
it("loads Noto Serif SC as the web fallback for serif fonts", () => {
expect(getFallbackWebFontFamilies("IBM Plex Serif")).toEqual(["Noto Serif SC"]);
});
it("does not duplicate the selected web fallback font", () => {
expect(getFallbackWebFontFamilies("Noto Sans SC")).toEqual([]);
});
});
describe("getLoadableWebFontWeights", () => {
it("returns matching weights when the webfont provides them", () => {
expect(getLoadableWebFontWeights("Noto Sans SC", ["400", "700"])).toEqual(["400", "700"]);
});
it("falls back to the nearest supported weight when needed", () => {
expect(getLoadableWebFontWeights("ZCOOL QingKe HuangYou", ["600", "700"])).toEqual(["400"]);
});
});
+204
View File
@@ -0,0 +1,204 @@
import type { LocalFont, WebFont } from "@/components/typography/types";
import webFontListJSON from "@/components/typography/webfontlist.json";
type FontCategory = LocalFont["category"];
type FontWeight = LocalFont["weights"][number];
export type FontRecord = LocalFont | WebFont;
const preferredChineseFontFamilies = [
"Noto Sans SC",
"Noto Serif SC",
"PingFang SC",
"Microsoft YaHei",
"Source Han Sans SC",
"Source Han Serif SC",
"Songti SC",
"SimSun",
"SimHei",
"KaiTi",
"FangSong",
"ZCOOL QingKe HuangYou",
] as const;
const baseLocalFontList = [
{ type: "local", category: "sans-serif", family: "Arial", weights: ["400", "600", "700"] },
{ type: "local", category: "sans-serif", family: "Calibri", weights: ["400", "600", "700"] },
{ type: "local", category: "sans-serif", family: "Helvetica", weights: ["400", "600", "700"] },
{ type: "local", category: "sans-serif", family: "Tahoma", weights: ["400", "600", "700"] },
{ type: "local", category: "sans-serif", family: "Trebuchet MS", weights: ["400", "600", "700"] },
{ type: "local", category: "sans-serif", family: "Verdana", weights: ["400", "600", "700"] },
{ type: "local", category: "sans-serif", family: "PingFang SC", weights: ["300", "400", "500", "600", "700"] },
{ type: "local", category: "sans-serif", family: "Hiragino Sans GB", weights: ["300", "400", "500", "600", "700"] },
{ type: "local", category: "sans-serif", family: "Microsoft YaHei", weights: ["400", "700"] },
{ type: "local", category: "sans-serif", family: "SimHei", weights: ["400", "700"] },
{ type: "local", category: "sans-serif", family: "Source Han Sans SC", weights: ["400", "500", "700"] },
{ type: "local", category: "serif", family: "Bookman", weights: ["400", "600", "700"] },
{ type: "local", category: "serif", family: "Cambria", weights: ["400", "600", "700"] },
{ type: "local", category: "serif", family: "Garamond", weights: ["400", "600", "700"] },
{ type: "local", category: "serif", family: "Georgia", weights: ["400", "600", "700"] },
{ type: "local", category: "serif", family: "Palatino", weights: ["400", "600", "700"] },
{ type: "local", category: "serif", family: "Times New Roman", weights: ["400", "600", "700"] },
{ type: "local", category: "serif", family: "Songti SC", weights: ["400", "600", "700"] },
{ type: "local", category: "serif", family: "SimSun", weights: ["400", "700"] },
{ type: "local", category: "serif", family: "KaiTi", weights: ["400", "700"] },
{ type: "local", category: "serif", family: "FangSong", weights: ["400", "700"] },
{ type: "local", category: "serif", family: "Source Han Serif SC", weights: ["400", "600", "700"] },
] satisfies LocalFont[];
const fontDisplayNames: Partial<Record<string, string>> = {
FangSong: "仿宋",
"Hiragino Sans GB": "冬青黑体简体中文",
KaiTi: "楷体",
"Microsoft YaHei": "微软雅黑",
"Noto Sans SC": "思源黑体",
"Noto Sans TC": "思源黑体(繁中)",
"Noto Serif SC": "思源宋体",
"Noto Serif TC": "思源宋体(繁中)",
"PingFang SC": "苹方",
SimHei: "黑体",
SimSun: "宋体",
"Songti SC": "华文宋体",
"Source Han Sans SC": "思源黑体(本地)",
"Source Han Serif SC": "思源宋体(本地)",
"ZCOOL QingKe HuangYou": "站酷庆科黄油体",
};
const resumeCjkSansFontFallbacks = [
"Noto Sans SC",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"SimHei",
"Source Han Sans SC",
"WenQuanYi Micro Hei",
] as const;
const resumeCjkSerifFontFallbacks = [
"Noto Serif SC",
"Songti SC",
"SimSun",
"Source Han Serif SC",
"KaiTi",
"FangSong",
] as const;
const genericFontFamilies = new Set([
"-apple-system",
"BlinkMacSystemFont",
"cursive",
"emoji",
"fantasy",
"fangsong",
"math",
"monospace",
"sans-serif",
"serif",
"system-ui",
"ui-monospace",
"ui-rounded",
"ui-sans-serif",
"ui-serif",
]);
export const webFontList = webFontListJSON as WebFont[];
export const webFontMap = new Map<string, WebFont>(webFontList.map((font) => [font.family, font]));
const webFontFamilies = new Set(webFontList.map((font) => font.family));
const chinesePrioritySet = new Set<string>(preferredChineseFontFamilies);
export const localFontList = baseLocalFontList.filter((font) => !webFontFamilies.has(font.family));
function orderFonts(fonts: FontRecord[]) {
return [...fonts].sort((a, b) => {
const aLabel = getFontDisplayName(a.family);
const bLabel = getFontDisplayName(b.family);
const labelComparison = aLabel.localeCompare(bLabel, undefined, { sensitivity: "base" });
if (labelComparison !== 0) return labelComparison;
return a.family.localeCompare(b.family, undefined, { sensitivity: "base" });
});
}
export const fontList = orderFonts([...webFontList, ...localFontList]);
const fontMap = new Map(fontList.map((font) => [font.family, font]));
function unique<T>(items: T[]) {
return items.filter((item, index) => items.indexOf(item) === index);
}
function toCSSFontFamilyToken(fontFamily: string) {
if (genericFontFamilies.has(fontFamily)) return fontFamily;
return `'${fontFamily.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
}
export function getFont(family: string) {
return fontMap.get(family);
}
export function getFontCategory(family: string): FontCategory | null {
return getFont(family)?.category ?? null;
}
export function getFontDisplayName(family: string) {
return fontDisplayNames[family] ?? family;
}
export function getFontSearchKeywords(family: string) {
return unique(
[family, fontDisplayNames[family], chinesePrioritySet.has(family) ? "中文" : undefined].filter(
(keyword): keyword is string => Boolean(keyword),
),
);
}
function getCjkFallbacksByCategory(category: FontCategory | null) {
return category === "serif" ? resumeCjkSerifFontFallbacks : resumeCjkSansFontFallbacks;
}
export function getPrimaryCjkWebFont(family: string) {
const category = getFontCategory(family);
return category === "serif" ? "Noto Serif SC" : "Noto Sans SC";
}
export function getFallbackWebFontFamilies(family: string) {
const fallback = getPrimaryCjkWebFont(family);
return fallback === family ? [] : [fallback];
}
export function getLoadableWebFontWeights(family: string, preferredWeights: string[]) {
const font = webFontMap.get(family);
if (!font) return [];
const availableWeights = new Set<FontWeight>(font.weights);
const matchingWeights = unique(preferredWeights).filter(
(weight): weight is FontWeight => availableWeights.has(weight as FontWeight),
);
if (matchingWeights.length > 0) return matchingWeights;
const defaultWeights = ["400", "500", "600", "700"].filter((weight): weight is FontWeight =>
availableWeights.has(weight as FontWeight),
);
if (defaultWeights.length > 0) return defaultWeights.slice(0, 2);
return font.weights.slice(0, 2);
}
export function buildResumeFontFamily(fontFamily: string) {
const category = getFontCategory(fontFamily);
const genericFallback = category === "serif" ? "serif" : "sans-serif";
return unique([
fontFamily,
...getCjkFallbacksByCategory(category),
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
genericFallback,
])
.map(toCSSFontFamilyToken)
.join(", ");
}