feat(fonts): unify catalog for body and poster editors

This commit is contained in:
tianma-if committed 2026-10-16 22:59:19 +08:00
1 parent 12853c0624
commit 9dc05c2480
29 files changed
+1425 -1191

No files matched your search

+24
View File
@@ -104,6 +104,22 @@ describe("public memo shares", () => {
sqlite.close();
});
test("reads existing fonts and new catalog choices through the same stored preference", async () => {
const { sqlite, environment } = createDatabaseEnvironment();
sqlite.query("INSERT INTO users (id, username, password_hash, note_body_font) VALUES (?, ?, ?, ?)")
.run("usr_font_catalog", "font-catalog", "hash", "wenkai");
sqlite.query("UPDATE memo_shares SET created_by = ? WHERE id = ?").run("usr_font_catalog", "share_source");
const app = new Hono();
registerPublicShareRoutes(app);
for (const id of ["wenkai", "smiley", "puhui", "puhui-heavy", "youshe", "shuhei", "heavy", "zhuque", "source-han-serif"]) {
sqlite.query("UPDATE users SET note_body_font = ? WHERE id = ?").run(id, "usr_font_catalog");
const response = await app.request(`/api/public/shares/${sourceToken}`, {}, environment);
expect(response.status).toBe(200);
expect((await response.json()).share.bodyFont).toBe(id);
}
sqlite.close();
});
test("publishes the author's built-in note font and ignores anything else", async () => {
const { sqlite, environment } = createDatabaseEnvironment();
sqlite.query("INSERT INTO users (id, username, password_hash, note_body_font) VALUES (?, ?, ?, ?)")
@@ -195,6 +211,14 @@ describe("public memo shares", () => {
expect(response.status).toBe(200);
expect(sqlite.query("SELECT note_body_font FROM users WHERE id = ?").get("usr_author").note_body_font).toBe("source-han-serif");
for (const id of ["smiley", "wenkai", "zhuque", "heavy", "puhui", "puhui-heavy", "youshe", "shuhei"]) {
const response = await app.request("/api/v1/me/note-body-font", {
method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ bodyFont: id }),
}, environment);
expect(response.status).toBe(200);
expect(sqlite.query("SELECT note_body_font FROM users WHERE id = ?").get("usr_author").note_body_font).toBe(id);
}
const cleared = await app.request("/api/v1/me/note-body-font", {
method: "PUT",
headers: { "content-type": "application/json" },
File diff suppressed because it is too large. Load diff
+457 -455
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useRef, useState } from "react";
import { FONT_CATALOG, type RemoteNoteImageFont } from "@edgeever/shared/font-catalog";
import { loadCatalogFont } from "@edgeever/shared/font-loader";
/** Radix mounts hidden items too; previews only request fonts for visible menu rows. */
export const FontLabel = ({ label, fontId, fontFamily, registrationFamily, enabled, visibleOnly = false, loadFont }: {
label: string;
fontId?: RemoteNoteImageFont;
fontFamily?: string;
registrationFamily?: string;
enabled: boolean;
visibleOnly?: boolean;
loadFont?: (text: string) => Promise<unknown>;
}) => {
const ref = useRef<HTMLSpanElement>(null);
const [readyKey, setReadyKey] = useState("");
const family = fontFamily ?? (fontId ? `"${registrationFamily ?? FONT_CATALOG[fontId].family}", sans-serif` : undefined);
const key = `${fontId ?? ""}:${family ?? ""}:${label}`;
useEffect(() => {
if (!enabled) return;
let active = true;
let started = false;
const load = () => {
if (started) return;
started = true;
const pending = fontId ? loadCatalogFont(fontId, label, { family: registrationFamily }) : loadFont?.(label) ?? Promise.resolve();
void pending.then(() => { if (active) setReadyKey(key); }).catch(() => { /* Keep names readable and selectable. */ });
};
let observer: IntersectionObserver | undefined;
if (visibleOnly && ref.current) {
observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) { observer?.disconnect(); load(); }
});
observer.observe(ref.current);
} else load();
return () => { active = false; observer?.disconnect(); };
}, [enabled, visibleOnly, fontId, registrationFamily, label, key, loadFont]);
return <span ref={ref} className="text-sm leading-6" style={readyKey === key ? { fontFamily: family } : undefined}>{label}</span>;
};
@@ -1,3 +1,4 @@
import { loadPosterFont } from "@/lib/poster-design/fonts";
import { AI_ASSISTANT_LAUNCHER_POSITION } from "@/lib/ai-assistant-launcher";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -782,6 +783,16 @@ export default function PosterEditorPane({
};
instance.on("object:modified", changed);
instance.on("text:editing:exited", changed);
instance.on("text:changed", ({ target }) => {
if (!(target instanceof Textbox)) return;
const text = target.text;
void loadPosterFont(target.fontFamily, text).then(() => {
if (canvas.current !== instance || target.text !== text) return;
target.initDimensions();
target.dirty = true;
instance.requestRenderAll();
}).catch(() => { /* The current readable fallback remains until another edit or retry. */ });
});
instance.on("object:moving", ({ target }) => {
if (!target || !options.current.snap || target instanceof ActiveSelection)
return;
@@ -1,44 +1,13 @@
import { useEffect, useRef, useState } from "react";
import { useCallback } from "react";
import { prepareNoteImageAssets } from "@edgeever/shared/note-image-fonts";
import { noteImageAssetUrls } from "@edgeever/shared/note-image-assets";
import { isRemoteNoteImageFont } from "@edgeever/shared/font-catalog";
import { resolveNoteImageTypography, type NoteImageFontStyle, type NoteImageTheme } from "@edgeever/shared/note-image-card";
import { FontLabel } from "@/components/FontLabel";
/** Hidden Radix items also mount: only load names when the menu and row are visible. */
export const NoteImageFontLabel = ({ label, fontStyle, theme, enabled, visibleOnly = false }: {
label: string;
fontStyle: NoteImageFontStyle;
theme: NoteImageTheme;
enabled: boolean;
visibleOnly?: boolean;
label: string; fontStyle: NoteImageFontStyle; theme: NoteImageTheme; enabled: boolean; visibleOnly?: boolean;
}) => {
const ref = useRef<HTMLSpanElement>(null);
const [readyKey, setReadyKey] = useState("");
const key = `${fontStyle}:${theme}:${label}`;
useEffect(() => {
if (!enabled) return;
let active = true;
let started = false;
const load = () => {
if (started) return;
started = true;
void prepareNoteImageAssets({ theme, fontStyle, text: label, urls: noteImageAssetUrls, embed: false })
.then(() => { if (active) setReadyKey(key); })
.catch(() => { /* Keep the name readable and selectable if preview loading fails. */ });
};
let observer: IntersectionObserver | undefined;
if (visibleOnly && ref.current) {
observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) {
observer?.disconnect();
load();
}
});
observer.observe(ref.current);
} else {
load();
}
return () => { active = false; observer?.disconnect(); };
}, [enabled, visibleOnly, fontStyle, theme, label, key]);
return <span ref={ref} className="text-sm leading-6" style={readyKey === key ? { fontFamily: resolveNoteImageTypography(theme, fontStyle).bodyFamily } : undefined}>{label}</span>;
const loadFont = useCallback((text: string) => prepareNoteImageAssets({ theme, fontStyle, text, urls: noteImageAssetUrls, embed: false }), [theme, fontStyle]);
return <FontLabel label={label} fontId={isRemoteNoteImageFont(fontStyle) ? fontStyle : undefined} fontFamily={resolveNoteImageTypography(theme, fontStyle).bodyFamily} enabled={enabled} visibleOnly={visibleOnly} loadFont={loadFont} />;
};
@@ -1,3 +1,4 @@
import { FONT_CATALOG, FONT_IDS } from "@edgeever/shared/font-catalog";
import { prepareNoteImageAssets } from "@edgeever/shared/note-image-fonts";
import { noteImageAssetUrls } from "@edgeever/shared/note-image-assets";
import { Check, Copy, Download, LoaderCircle, Palette, Share2, Type } from "lucide-react";
@@ -40,14 +41,8 @@ const FONT_OPTIONS: Array<{ id: NoteImageFontStyle; labelKey: string }> = [
{ id: "serif", labelKey: "editor.imageShare.fontStyles.serif" },
{ id: "sans", labelKey: "editor.imageShare.fontStyles.sans" },
{ id: "mono", labelKey: "editor.imageShare.fontStyles.mono" },
{ id: "smiley", labelKey: "editor.imageShare.fontStyles.smiley" },
{ id: "wenkai", labelKey: "editor.imageShare.fontStyles.wenkai" },
{ id: "zhuque", labelKey: "editor.imageShare.fontStyles.zhuque" },
{ id: "heavy", labelKey: "editor.imageShare.fontStyles.heavy" },
{ id: "puhui", labelKey: "editor.imageShare.fontStyles.puhui" },
{ id: "puhui-heavy", labelKey: "editor.imageShare.fontStyles.puhuiHeavy" },
{ id: "youshe", labelKey: "editor.imageShare.fontStyles.youshe" },
{ id: "shuhei", labelKey: "editor.imageShare.fontStyles.shuhei" },
...FONT_IDS.map(id => ({ id, labelKey: FONT_CATALOG[id].labelKey })),
];
const SIZE_OPTIONS: Array<{ id: NoteImageFontSize; labelKey: string }> = [
@@ -0,0 +1,36 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { findCatalogFont } from "@edgeever/shared/font-catalog";
import { FontLabel } from "@/components/FontLabel";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { POSTER_FONTS, loadPosterFont } from "@/lib/poster-design/fonts";
const PosterFontName = ({ family, label, enabled, visibleOnly = false }: {
family: string; label: string; enabled: boolean; visibleOnly?: boolean;
}) => {
const loadFont = useCallback((text: string) => loadPosterFont(family, text), [family]);
return <FontLabel label={label} fontId={findCatalogFont(family)} registrationFamily={family} fontFamily={["sans-serif", "serif", "monospace"].includes(family) ? family : `"${family}", sans-serif`} enabled={enabled} visibleOnly={visibleOnly} loadFont={loadFont} />;
};
export const PosterFontPicker = ({ value, disabled, zh, onChange }: {
value: string; disabled: boolean; zh: boolean; onChange: (family: string) => void;
}) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const choices = [
{ family: "sans-serif", label: t("poster.sans") },
{ family: "serif", label: t("poster.serif") },
{ family: "monospace", label: t("poster.mono") },
...POSTER_FONTS.map(font => ({ family: font.family, label: zh ? font.zh : font.en })),
];
return <Select value={value} disabled={disabled} onOpenChange={setOpen} onValueChange={onChange}>
<SelectTrigger aria-label={t("poster.fontFamily")} className="h-9 w-full">
<SelectValue><PosterFontName family={value} label={choices.find(font => font.family === value)?.label ?? value} enabled /></SelectValue>
</SelectTrigger>
<SelectContent className="max-h-80">
{choices.map(font => <SelectItem key={font.family} value={font.family}>
<PosterFontName family={font.family} label={font.label} enabled={open} visibleOnly />
</SelectItem>)}
</SelectContent>
</Select>;
};
@@ -24,7 +24,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import type { PosterElement } from "@edgeever/shared";
import { POSTER_FONTS } from "@/lib/poster-design/fonts";
import { PosterFontPicker } from "./PosterFontPicker";
import {
PosterColor,
PosterNumber,
@@ -111,22 +111,12 @@ export function PosterInspector({
</div>
</div>
{active?.type === "text" && (
<select
aria-label={t("poster.fontFamily")}
className="h-9 w-full rounded-md border border-slate-200 bg-card px-2 text-xs"
<PosterFontPicker
value={active.fontFamily}
disabled={!editable}
onChange={(event) => onPatch({ fontFamily: event.target.value })}
>
<option value="sans-serif">{t("poster.sans")}</option>
<option value="serif">{t("poster.serif")}</option>
<option value="monospace">{t("poster.mono")}</option>
{POSTER_FONTS.map((font) => (
<option key={font.family} value={font.family}>
{zh ? font.zh : font.en}
</option>
))}
</select>
zh={zh}
onChange={(family) => onPatch({ fontFamily: family })}
/>
)}
{active && (
<Input
@@ -1,3 +1,5 @@
import { FONT_CATALOG, FONT_IDS, isRemoteNoteImageFont } from "@edgeever/shared/font-catalog";
import { FontLabel } from "@/components/FontLabel";
import { AlignVerticalSpaceAround, AppWindow, BookOpenText, ChartNoAxesCombined, Code2, Image, Keyboard, Languages, ListTree, MousePointerClick, Palette, Sparkles, SpellCheck, SunMoon, Type } from "lucide-react";
import { useState, useEffect, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
@@ -40,6 +42,7 @@ import {
} from "@/i18n";
import {
applyEditorBodyFontPreference,
BUNDLED_FONT_STACKS,
getFontChoicePreviewStack,
readEditorBodyFontPreference,
writeEditorBodyFontPreference,
@@ -79,31 +82,27 @@ const FontChoiceFields = ({
label,
preference,
onChange,
includeCatalog = false,
}: {
includeCatalog?: boolean;
label: string;
preference: EditorBodyFontPreference;
onChange: (preference: EditorBodyFontPreference) => void;
}) => {
const { t } = useTranslation();
const [fontOpen, setFontOpen] = useState(false);
return (
<div className="flex w-full shrink-0 flex-col gap-2 sm:w-80">
<Select
value={preference.choice}
onOpenChange={setFontOpen}
onValueChange={(value) => onChange({ choice: value as EditorBodyFontChoice, customFamily: preference.customFamily })}
>
<SelectTrigger aria-label={label} className="h-9 bg-card">
<SelectValue />
<SelectValue>{includeCatalog && isRemoteNoteImageFont(preference.choice) ? <FontLabel label={t(FONT_CATALOG[preference.choice].labelKey)} fontId={preference.choice} enabled /> : undefined}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectContent className="max-h-80">
<SelectItem value="system">{t("settings.editorBodyFonts.system")}</SelectItem>
<SelectItem
value="wenkai"
style={{ fontFamily: getFontChoicePreviewStack("wenkai") }}
>
<span style={{ fontFamily: getFontChoicePreviewStack("wenkai") }}>
{t("settings.editorBodyFonts.wenkai")}
</span>
</SelectItem>
<SelectItem
value="wenkai-screen"
style={{ fontFamily: getFontChoicePreviewStack("wenkai-screen") }}
@@ -112,14 +111,6 @@ const FontChoiceFields = ({
{t("settings.editorBodyFonts.wenkaiScreen")}
</span>
</SelectItem>
<SelectItem
value="zhuque"
style={{ fontFamily: getFontChoicePreviewStack("zhuque") }}
>
<span style={{ fontFamily: getFontChoicePreviewStack("zhuque") }}>
{t("settings.editorBodyFonts.zhuque")}
</span>
</SelectItem>
<SelectItem
value="source-han-serif"
style={{ fontFamily: getFontChoicePreviewStack("source-han-serif") }}
@@ -152,6 +143,11 @@ const FontChoiceFields = ({
{t("settings.editorBodyFonts.sourceSerif")}
</span>
</SelectItem>
{(includeCatalog ? FONT_IDS : ["wenkai", "zhuque"] as const).map(id => (
<SelectItem key={id} value={id}>
<FontLabel label={t(FONT_CATALOG[id].labelKey)} fontId={includeCatalog ? id : undefined} fontFamily={includeCatalog ? undefined : BUNDLED_FONT_STACKS[id as "wenkai" | "zhuque"]} enabled={fontOpen} visibleOnly />
</SelectItem>
))}
<SelectItem value="custom">{t("settings.editorBodyFonts.custom")}</SelectItem>
</SelectContent>
</Select>
@@ -429,6 +425,7 @@ export const PreferenceCard = ({
<FontChoiceFields
label={t("settings.editorBodyFontTitle")}
preference={editorBodyFont}
includeCatalog
onChange={updateEditorBodyFont}
/>
</div>
@@ -1,3 +1,4 @@
import { FONT_IDS, FONT_CATALOG } from "@edgeever/shared/font-catalog";
import { afterEach, describe, expect, test } from "bun:test";
import {
EDITOR_BODY_FONT_CUSTOM_STORAGE_KEY,
@@ -27,6 +28,19 @@ describe("editor body font preference", () => {
delete globalThis.document;
});
test("all catalog fonts survive saving and restoring while legacy choices remain readable", () => {
const values = installLocalStorage();
for (const id of FONT_IDS) {
writeEditorBodyFontPreference({ choice: id, customFamily: "" });
expect(readEditorBodyFontPreference().choice).toBe(id);
expect(resolveEditorBodyFontStack({ choice: id, customFamily: "" })).toContain(FONT_CATALOG[id].family);
}
for (const id of ["system", "wenkai", "wenkai-screen", "zhuque", "source-han-serif", "neo-zhi-song", "source-han-sans", "source-serif", "custom"]) {
values.set(EDITOR_BODY_FONT_STORAGE_KEY, id);
expect(readEditorBodyFontPreference().choice).toBe(id);
}
});
test("defaults to the system face and ignores unknown choices", () => {
const values = installLocalStorage();
expect(readEditorBodyFontPreference()).toEqual({ choice: "system", customFamily: "" });
+10 -4
View File
@@ -1,3 +1,6 @@
import { FONT_IDS, isRemoteNoteImageFont, catalogFontStack } from "@edgeever/shared/font-catalog";
import { watchEditorCatalogFont } from "./editor-catalog-fonts";
export const EDITOR_BODY_FONT_STORAGE_KEY = "edgeever.editorBodyFont";
export const EDITOR_BODY_FONT_CUSTOM_STORAGE_KEY = "edgeever.editorBodyFontCustom";
@@ -10,6 +13,7 @@ export const EDITOR_BODY_FONT_CHOICES = [
"neo-zhi-song",
"source-han-sans",
"source-serif",
...FONT_IDS.filter(id => id !== "wenkai" && id !== "zhuque"),
"custom",
] as const;
@@ -23,7 +27,7 @@ export type EditorBodyFontPreference = {
const CJK_FALLBACK =
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif';
export const BUNDLED_FONT_STACKS: Record<Exclude<EditorBodyFontChoice, "system" | "custom">, string> = {
export const BUNDLED_FONT_STACKS = {
wenkai: `"EdgeEver Kai", ${CJK_FALLBACK}`,
"wenkai-screen": `"EdgeEver Kai Screen", ${CJK_FALLBACK}`,
zhuque: `"EdgeEver Fangsong", ${CJK_FALLBACK}`,
@@ -31,11 +35,12 @@ export const BUNDLED_FONT_STACKS: Record<Exclude<EditorBodyFontChoice, "system"
"neo-zhi-song": `"EdgeEver Zhi Song", ${CJK_FALLBACK}`,
"source-han-sans": `"EdgeEver Hei", ${CJK_FALLBACK}`,
"source-serif": `"Source Serif 4", ${CJK_FALLBACK}`,
};
} as const;
export const getFontChoicePreviewStack = (choice: EditorBodyFontChoice): string | undefined => {
if (choice === "system" || choice === "custom") return undefined;
return BUNDLED_FONT_STACKS[choice];
const bundled = BUNDLED_FONT_STACKS[choice as keyof typeof BUNDLED_FONT_STACKS];
return isRemoteNoteImageFont(choice) ? `${catalogFontStack(choice)}, ${bundled ?? CJK_FALLBACK}` : bundled;
};
const CUSTOM_FONT_FAMILY_PATTERN = /^[\p{L}\p{N}\s,'"._-]+$/u;
@@ -63,7 +68,7 @@ export const resolveEditorBodyFontStack = (preference: EditorBodyFontPreference)
const family = sanitizeEditorBodyFontFamily(preference.customFamily);
return family ? `${family}, ${CJK_FALLBACK}` : null;
}
return BUNDLED_FONT_STACKS[preference.choice];
return getFontChoicePreviewStack(preference.choice);
};
const readStorage = (key: string) => {
@@ -96,6 +101,7 @@ export const writeEditorBodyFontPreference = (preference: EditorBodyFontPreferen
export const applyEditorBodyFontPreference = (preference = readEditorBodyFontPreference()) => {
if (typeof document === "undefined") return;
watchEditorCatalogFont(isRemoteNoteImageFont(preference.choice) ? preference.choice : undefined);
const root = document.documentElement;
const stack = resolveEditorBodyFontStack(preference);
if (!stack) {
+36
View File
@@ -0,0 +1,36 @@
import type { RemoteNoteImageFont } from "@edgeever/shared/font-catalog";
import { loadCatalogFont } from "@edgeever/shared/font-loader";
let stop: (() => void) | undefined;
/** Refresh character subsets when notes mount, navigation changes them, or text is edited. */
export const watchEditorCatalogFont = (font: RemoteNoteImageFont | undefined) => {
stop?.();
stop = undefined;
if (!font || typeof MutationObserver === "undefined" || !document.body) return;
let timer: ReturnType<typeof setTimeout> | undefined;
let active = true;
let last = "";
const refresh = () => {
const text = Array.from(document.querySelectorAll<HTMLElement>(".edgeever-editor .ProseMirror, .edgeever-mobile-tiptap-content, .edgeever-editor textarea"))
.map(node => node instanceof HTMLTextAreaElement ? node.value : node.textContent ?? "").join(" ");
// Character order and repeated paragraphs do not cause redundant loads.
const characters = Array.from(new Set(Array.from(text))).sort().join("");
if (!characters || characters === last) return;
last = characters;
void loadCatalogFont(font, characters).catch(() => { if (active) last = ""; });
};
const schedule = () => { clearTimeout(timer); timer = setTimeout(refresh, 120); };
const observer = new MutationObserver(schedule);
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
document.addEventListener("input", schedule);
window.addEventListener("online", schedule);
schedule();
stop = () => {
active = false;
clearTimeout(timer);
observer.disconnect();
document.removeEventListener("input", schedule);
window.removeEventListener("online", schedule);
};
};
+1 -1
View File
@@ -119,7 +119,7 @@ export const posterObject = async (
? element.fill
: undefined;
if (element.type === "text") {
await loadPosterFont(element.fontFamily);
await loadPosterFont(element.fontFamily, element.text);
const textOptions = {
...options,
width: element.width,
+13 -24
View File
@@ -1,7 +1,16 @@
import { FONT_CATALOG, findCatalogFont } from "@edgeever/shared/font-catalog";
import { loadCatalogFont } from "@edgeever/shared/font-loader";
import { cache } from "fabric";
import { POSTER_FONTS } from "@edgeever/shared";
export { POSTER_FONTS };
const loads = new Map<string, Promise<void>>();
export function loadPosterFont(family: string) {
export async function loadPosterFont(family: string, text = "") {
const id = findCatalogFont(family);
if (id) {
await loadCatalogFont(id, text || `${FONT_CATALOG[id].zh} ${FONT_CATALOG[id].en}`, { family });
cache.clearFontCache(family);
return;
}
const font = POSTER_FONTS.find((entry) => entry.family === family);
if (!font) return Promise.resolve();
if (!loads.has(family)) {
@@ -13,6 +22,7 @@ export function loadPosterFont(family: string) {
.load()
.then((loaded) => {
window.document.fonts.add(loaded);
cache.clearFontCache(family);
})
.catch((error) => {
loads.delete(family);
@@ -22,26 +32,5 @@ export function loadPosterFont(family: string) {
}
return loads.get(family)!;
}
export const loadDesignFonts = (zh: boolean) =>
Promise.all(
(zh
? [
"思源黑体",
"思源黑体 Heavy",
"思源宋体",
"思源宋体 Heavy",
"得意黑",
"霞鹜文楷",
"抖音美好体",
"马善政楷书",
"江城圆体",
"站酷庆科黄油体",
"未来荧黑",
"朱雀仿宋",
"站酷快乐体",
"DM Serif Display",
"Anton",
]
: ["Inter", "DM Serif Display", "Bebas Neue", "Space Grotesk"]
).map(loadPosterFont),
);
export const loadDesignFonts = (families: string[], text: string) =>
Promise.all([...new Set(families)].map(family => loadPosterFont(family, text)));
+22 -6
View File
@@ -4,6 +4,7 @@ import {
Ellipse,
FabricObject,
Gradient,
Group,
Line,
Path,
Pattern,
@@ -202,11 +203,9 @@ export async function buildPosterDesign(input: {
zh: boolean;
palette?: { bg?: string; ink?: string; accent?: string };
}): Promise<PosterDocument> {
await loadDesignFonts(input.zh);
const template =
TEMPLATES.find((entry) => entry.id === input.id) ?? TEMPLATES[0]!;
usePairing(
input.zh
const pairing = input.zh
? resolvePair(template.id, (family) =>
POSTER_FONTS.some((font) => font.family === family),
)
@@ -218,10 +217,27 @@ export async function buildPosterDesign(input: {
body: "Inter",
titleBold: true,
missing: [],
},
);
};
await loadDesignFonts([pairing.title, pairing.body, "DM Serif Display"].filter((family): family is string => Boolean(family)), [input.title, input.subtitle, input.badge, ...(input.points ?? []), "No. 01 2026"].join(" "));
const build = () => {
usePairing(pairing);
try { return template.build({ ...input }); }
finally { usePairing(undefined); }
};
// Explicit families and fixed template labels may differ from the main pairing.
// Load those before the final build so wrapping uses the actual glyph metrics.
const initial = build();
const texts: Array<{ family: string; text: string }> = [];
const collect = (objects: FabricObject[]) => {
for (const object of objects) {
if ("fontFamily" in object && typeof object.fontFamily === "string" && "text" in object && typeof object.text === "string") texts.push({ family: object.fontFamily, text: object.text });
if (object instanceof Group) collect(object.getObjects());
}
};
collect(initial.objects);
await loadDesignFonts(texts.map(object => object.family), texts.map(object => object.text).join(" "));
try {
const built = template.build({ ...input });
const built = build();
const elements = built.objects
.map(posterPrimitiveFromObject)
.filter((value): value is PosterElement => Boolean(value));
+5 -2
View File
@@ -1,4 +1,5 @@
import {
BUNDLED_FONT_STACKS,
type EditorBodyFontChoice,
type EditorBodyFontPreference,
isEditorBodyFontChoice,
@@ -35,7 +36,7 @@ const writeStorage = (key: string, value: string) => {
export const readUiFontPreference = (): UiFontPreference => {
const storedChoice = readStorage(UI_FONT_STORAGE_KEY);
const customFamily = readStorage(UI_FONT_CUSTOM_STORAGE_KEY) ?? "";
if (!isEditorBodyFontChoice(storedChoice)) return { ...DEFAULT_PREFERENCE, customFamily };
if (!isEditorBodyFontChoice(storedChoice) || !["system", "custom", ...Object.keys(BUNDLED_FONT_STACKS)].includes(storedChoice)) return { ...DEFAULT_PREFERENCE, customFamily };
return { choice: storedChoice, customFamily };
};
@@ -47,7 +48,9 @@ export const writeUiFontPreference = (preference: UiFontPreference) => {
export const applyUiFontPreference = (preference = readUiFontPreference()) => {
if (typeof document === "undefined") return;
const root = document.documentElement;
const stack = resolveEditorBodyFontStack(preference);
const stack = preference.choice in BUNDLED_FONT_STACKS
? BUNDLED_FONT_STACKS[preference.choice as keyof typeof BUNDLED_FONT_STACKS]
: resolveEditorBodyFontStack(preference);
if (!stack) {
delete root.dataset.uiFont;
root.style.removeProperty("--ui-font-family");
+11 -1
View File
@@ -17,7 +17,7 @@ The share-image typography picker includes all five fonts in Qiaomu’s designer
Smiley Sans is also a member of the five-font recommended pack. The four additional recommended files are downloaded unchanged, with pinned byte lengths and SHA-256 digests. SHA-256 is checked when Web Crypto is available; byte length and container checks apply in all contexts. They are approximately 8.53 MB, 2.54 MB, 1.41 MB and 1.36 MB respectively. They are not converted or subsetted. Each original file is downloaded when its font is selected or its preview row becomes visible, not by installing the application. Opening and scrolling the picker can therefore download several complete original fonts.
`packages/shared/src/note-image-remote-fonts.ts` defines this catalog. Subsets are provided by the [Chinese Webfont Project](https://github.com/KonghaYao/chinese-free-web-font-storage) and [Fontsource](https://fontsource.org/fonts/noto-sans-sc). Each request first tries jsDelivr, then unpkg. For CSS-backed fonts, only descriptors for WOFF2 files in the pinned package directory are accepted; external styles are never inserted into the page.
`packages/shared/src/font-catalog.ts` defines the catalog used by share images, body typography and poster design. `note-image-remote-fonts.ts` keeps the original share-image exports compatible. Subsets are provided by the [Chinese Webfont Project](https://github.com/KonghaYao/chinese-free-web-font-storage) and [Fontsource](https://fontsource.org/fonts/noto-sans-sc). Each request first tries jsDelivr, then unpkg. For CSS-backed fonts, only descriptors for WOFF2 files in the pinned package directory are accepted; external styles are never inserted into the page.
For CSS-backed fonts, the selected font's manifest is fetched on demand. The renderer matches its Unicode ranges against the card's text and downloads only matching subsets, not the entire family. Note text is matched locally and is never sent in font requests. Manifests and font bytes are cached in the separate, disposable IndexedDB database `edgeever-share-font-cache`. Downloads are deduplicated, failed requests can be retried, and unavailable storage does not prevent online rendering. The application waits for font loading before rendering the image.
@@ -28,3 +28,13 @@ The original four optional fonts use SIL OFL 1.1. The four newly added starter-p
Checks for this change include shared unit tests, Web and mobile type checks, the Web build, the iOS editor type check/build, and Chrome image exports before and after reloading with font CDNs blocked. Android USB-device and iOS-device acceptance must still be performed separately; a desktop browser check does not establish native-platform acceptance.
The Web picker renders each visible name in its corresponding font, including the selected value. Hidden rows do not start preview downloads. Name previews reuse the same face and persistent resource caches as image export, without generating another embedded base64 stylesheet or fetching paper textures. Failed previews keep a readable system-font label and do not prevent selection.
## Shared body and poster typography
`font-loader.ts` owns optional-font downloading, integrity checks, registration and export CSS. All three entry points keep using the existing `edgeever-share-font-cache` database and URL keys, so previously cached fonts remain usable. The Web `FontLabel` component previews only visible rows; each picker reads the same eight catalog entries and names.
Body preferences keep their existing storage keys and legacy choices. Selecting a catalog face applies its shared family with a system or existing local fallback. A debounced observer loads new character subsets as note text changes or another note mounts. Publishing accepts the catalog IDs through the existing body-font setting; no database schema or migration is changed.
Posters preserve existing Chinese family names in saved source. These names resolve to catalog IDs at loading time. Template builds load their selected pairing and any explicit faces/fixed text before final layout; object creation and PNG export await the text's fonts. Reopening a serialized poster follows the same path. Existing non-catalog poster and body fonts remain available through their existing local resources. Interface typography retains its existing choices.
No font binaries are added to client packages. The shared catalog, loader and preview UI add application code. Android and iOS device acceptance remains separate from Web and editor-bundle checks.
+11 -1
View File
@@ -17,7 +17,7 @@
得意黑也是五款推荐包的成员。新增四款推荐字体使用未经修改的原始文件,固定字节长度与 SHA-256 摘要;支持 Web Crypto 时校验 SHA-256,所有环境均检查长度与容器。四个文件分别约 8.53 MB、2.54 MB、1.41 MB、1.36 MB,不转换、不拆分。选中字体或其预览行进入可见区域时,才下载对应原文件,安装应用时不下载。展开并滚动列表可能下载多款完整字体。
字体清单定义在 `packages/shared/src/note-image-remote-fonts.ts`,分片来自[中文网字计划](https://github.com/KonghaYao/chinese-free-web-font-storage)和 [Fontsource](https://fontsource.org/fonts/noto-sans-sc)。每次请求先使用 jsDelivr,失败后尝试 unpkg。对于使用 CSS 分片的字体,只接受固定软件包目录内的 WOFF2 字体描述,不将远程样式直接插入页面。
分享图片、正文排版与海报设计共用的字体清单定义在 `packages/shared/src/font-catalog.ts`,`note-image-remote-fonts.ts` 保留原分享图片导出的兼容入口,分片来自[中文网字计划](https://github.com/KonghaYao/chinese-free-web-font-storage)和 [Fontsource](https://fontsource.org/fonts/noto-sans-sc)。每次请求先使用 jsDelivr,失败后尝试 unpkg。对于使用 CSS 分片的字体,只接受固定软件包目录内的 WOFF2 字体描述,不将远程样式直接插入页面。
对于使用 CSS 分片的字体,选择后才请求其样式清单。渲染器根据卡片文字匹配 Unicode 范围,仅下载命中的分片,不下载整个字体家族。字符匹配在本地进行,字体请求不会发送笔记正文。清单和字体数据保存在独立、可丢弃的 IndexedDB 缓存 `edgeever-share-font-cache` 中;重复下载会合并,失败请求可以重试,存储不可用时仍可联网渲染。生成图片前会等待字体加载完成。
@@ -28,3 +28,13 @@
本次检查包括共享单元测试、Web 与移动端类型检查、Web 构建、iOS 编辑器类型检查与构建,以及 Chrome 中联网导出和刷新页面后阻断字体 CDN 的导出验证。Android USB 真机与 iOS 真机仍需单独验收,桌面浏览器结果不代表原生平台已通过验收。
Web 字体选择器中的可见名称以及当前选中值使用对应字体显示,隐藏行不会启动预览下载。名称预览与图片导出共用已加载字体和持久资源缓存,不额外生成内嵌 base64 样式,也不加载纸张纹理。预览失败时保留可读的系统字体名称,不影响选择。
## 正文与海报共用字体
`font-loader.ts` 统一负责可选字体的下载、完整性检查、字体注册和导出 CSS。三个入口继续使用原有 `edgeever-share-font-cache` 数据库和 URL 缓存键,先前下载的字体仍可复用。Web `FontLabel` 组件只为可见行预览字体,各列表读取相同的八款目录条目及名称。
正文字体沿用原有存储键和旧选项。选择目录字体后,使用共享字体家族,并保留系统或原有本地字体回退。防抖观察器会在正文变化或切换笔记时加载新增字符分片。公开分享通过既有正文字体设置接受目录 ID,不改变数据库结构、不新增迁移。
海报源稿保留既有中文字体家族名称,加载时映射到目录 ID。模板先加载所选搭配,以及实际使用的明确字体和固定文字,再完成最终排版;创建文字对象和导出 PNG 时会等待对应文字的字体就绪。序列化源稿重开时走同一链路。目录之外的现有正文和海报字体继续使用原有本地资源,界面字体保留原有选项。
客户端不新增字体二进制文件,共享目录、加载器与预览控件会增加应用代码。Android 和 iOS 真机验收仍需单独完成,Web 与编辑器资源构建不能代替真机验收。
+2
View File
@@ -21,6 +21,8 @@
"./mobile-ui": "./src/mobile-ui.ts",
"./note-image-card": "./src/note-image-card.ts",
"./note-image-assets": "./src/note-image-asset-urls.ts",
"./font-catalog": "./src/font-catalog.ts",
"./font-loader": "./src/font-loader.ts",
"./note-image-fonts": "./src/note-image-fonts.ts",
"./note-image-preferences": "./src/note-image-preferences.ts"
},
+28
View File
@@ -0,0 +1,28 @@
import { expect, test } from "bun:test";
import { FONT_CATALOG, FONT_IDS, findCatalogFont } from "./font-catalog";
import { NOTE_IMAGE_REMOTE_FONTS } from "./note-image-remote-fonts";
import { NOTE_IMAGE_FONT_FAMILIES } from "./note-image-card";
import { NoteBodyFontUpdateSchema } from "./schemas";
import { parsePublishedNoteBodyFont } from "./sharing";
import { POSTER_FONTS } from "./poster-fonts";
import { createPosterDocument, parsePosterDocument, serializePosterDocument } from "./poster";
test("all eight catalog fonts are available to sharing, body settings and posters without duplicate poster families", () => {
expect(FONT_IDS).toHaveLength(8);
expect(NOTE_IMAGE_REMOTE_FONTS).toBe(FONT_CATALOG);
expect(new Set(POSTER_FONTS.map(font => font.family)).size).toBe(POSTER_FONTS.length);
for (const id of FONT_IDS) {
const font = FONT_CATALOG[id];
expect(NOTE_IMAGE_FONT_FAMILIES[id]).toContain(font.family);
expect(NoteBodyFontUpdateSchema.parse({ bodyFont: id }).bodyFont).toBe(id);
expect(parsePublishedNoteBodyFont(id)).toBe(id);
expect(POSTER_FONTS.filter(entry => entry.family === font.posterFamily)).toHaveLength(1);
expect(findCatalogFont(font.posterFamily)).toBe(id);
expect(findCatalogFont(font.family)).toBe(id);
const document = createPosterDocument("字体目录");
const text = document.elements.find(element => element.type === "text");
text.fontFamily = font.posterFamily;
expect(parsePosterDocument(serializePosterDocument(document))).toEqual(document);
}
expect(NoteBodyFontUpdateSchema.safeParse({ bodyFont: "unknown-font" }).success).toBe(false);
});
+65
View File
@@ -0,0 +1,65 @@
/** Shared optional font catalog. Only metadata ships in clients; files stay on the CDN. */
export const FONT_CATALOG = {
smiley: { zh: "得意黑", en: "Smiley Sans", posterFamily: "得意黑", labelKey: "editor.imageShare.fontStyles.smiley", family: "EdgeEver Share Smiley", path: "@chinese-fonts/dyh@3.0.0/dist/SmileySans-Oblique/result.css" },
wenkai: { zh: "霞鹜文楷", en: "LXGW WenKai", posterFamily: "霞鹜文楷", labelKey: "editor.imageShare.fontStyles.wenkai", family: "EdgeEver Share WenKai", path: "@chinese-fonts/lxgwwenkai@3.0.0/dist/LXGWWenKai-Regular/result.css" },
zhuque: { zh: "朱雀仿宋", en: "Zhuque Fangsong", posterFamily: "朱雀仿宋", labelKey: "editor.imageShare.fontStyles.zhuque", family: "EdgeEver Share Zhuque", path: "@chinese-fonts/zqfs@3.0.0/dist/ZhuqueFangsong-Regular/result.css" },
heavy: { zh: "思源黑体 Heavy", en: "Noto Sans SC Heavy", posterFamily: "思源黑体 Heavy", labelKey: "editor.imageShare.fontStyles.heavy", family: "EdgeEver Share Noto Heavy", path: "@fontsource/noto-sans-sc@5.3.1/900.css" },
// Preserve the original containers for fonts whose terms do not permit subsetting.
puhui: { zh: "普惠体", en: "PuHuiTi", posterFamily: "阿里巴巴普惠体", labelKey: "editor.imageShare.fontStyles.puhui", family: "EdgeEver Share Puhui", path: "@fontpkg/alibaba-pu-hui-ti-3-0@3.1.2/AlibabaPuHuiTi-3-55-Regular.ttf", weight: "400", bytes: 8532824, sha256: "be33bc8d45ced30fa5c542f6f056565baf85003d4e079522110d76016f8a04e2" },
"puhui-heavy": { zh: "普惠体 Heavy", en: "PuHuiTi Heavy", posterFamily: "阿里巴巴普惠体 Heavy", labelKey: "editor.imageShare.fontStyles.puhuiHeavy", family: "EdgeEver Share Puhui Heavy", path: "@fontpkg/alibaba-pu-hui-ti-3-0@3.1.2/AlibabaPuHuiTi-3-105-Heavy.ttf", weight: "900", bytes: 2536300, sha256: "94cbb24e451a68b44d932d343579fb31571c743df2cfcd144ba60e0a9aa86e64" },
youshe: { zh: "优设标题黑", en: "YouShe Title", posterFamily: "优设标题黑", labelKey: "editor.imageShare.fontStyles.youshe", family: "EdgeEver Share Youshe", path: "@fontpkg/you-she-biao-ti-hei@1.0.0/%E4%BC%98%E8%AE%BE%E6%A0%87%E9%A2%98%E9%BB%91.ttf", weight: "400", bytes: 1414784, sha256: "dab883d69fb713233ea5d9aff8d1f27ccc2b71e249143edf6ea08367784c5dba" },
shuhei: { zh: "阿里妈妈数黑体", en: "Alimama ShuHei", posterFamily: "阿里妈妈数黑体", labelKey: "editor.imageShare.fontStyles.shuhei", family: "EdgeEver Share Shuhei", path: "@fontpkg/alimama-shu-hei-ti@1.0.5/AlimamaShuHeiTi-Bold.ttf", weight: "700", bytes: 1360708, sha256: "3ef4ec3f5bac4b71cdee9190aabd5b20995016a59ffee60de87c9a204ac2a684" },
} as const;
/** The five entries in Qiaomu's approximately 15 MB designer starter pack. */
export const NOTE_IMAGE_RECOMMENDED_FONT_IDS = ["puhui", "puhui-heavy", "youshe", "shuhei", "smiley"] as const;
export type RemoteNoteImageFont = keyof typeof FONT_CATALOG;
export const isRemoteNoteImageFont = (value: string): value is RemoteNoteImageFont => Object.hasOwn(FONT_CATALOG, value);
const CDN_ROOTS = ["https://cdn.jsdelivr.net/npm/", "https://unpkg.com/"];
export type RemoteFontSubset = { asset: string; family: string; range: string; weight: string };
/** Parse only font descriptors, never insert third-party styles into the document. */
export const parseRemoteFontCss = (css: string, font: RemoteNoteImageFont): RemoteFontSubset[] => {
const { path, family } = FONT_CATALOG[font];
const base = new URL(path, CDN_ROOTS[0]);
const directory = base.href.slice(0, base.href.lastIndexOf("/") + 1);
const blocks: RemoteFontSubset[] = [];
for (const match of css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/@font-face\s*\{([^}]+)\}/g)) {
const body = match[1];
const file = /url\(\s*["']?([^\s"')]+\.woff2)["']?\s*\)/i.exec(body)?.[1];
const range = /unicode-range\s*:\s*([^;]+)/i.exec(body)?.[1].trim();
const weight = /font-weight\s*:\s*(\d+(?:\s+\d+)?)/i.exec(body)?.[1];
if (!file || !range || !weight || !/^U\+[\da-f?]+(?:-[\da-f]+)?(?:\s*,\s*U\+[\da-f?]+(?:-[\da-f]+)?)*$/i.test(range)) continue;
const url = new URL(file, base);
// A pinned manifest may only reference WOFF2 files inside its own package directory.
if (!url.href.startsWith(directory) || url.search || url.hash) continue;
blocks.push({ asset: url.href, family, range, weight });
}
if (!blocks.length) throw new Error("NOTE_IMAGE_FONT_UNAVAILABLE");
return blocks;
};
/** Mirror paths are equivalent, so both hosts share a single persistent cache key. */
export const remoteFontUrls = (url: string) => {
if (!url.startsWith(CDN_ROOTS[0])) throw new Error("NOTE_IMAGE_FONT_UNAVAILABLE");
return CDN_ROOTS.map(root => root + url.slice(CDN_ROOTS[0].length));
};
export const remoteFontCssUrl = (font: RemoteNoteImageFont) => CDN_ROOTS[0] + FONT_CATALOG[font].path;
export const remoteOriginalFont = (font: RemoteNoteImageFont): RemoteFontSubset | undefined => {
const entry = FONT_CATALOG[font];
return "weight" in entry
? { asset: remoteFontCssUrl(font), family: entry.family, weight: entry.weight, range: "U+0-10FFFF" }
: undefined;
};
export const remoteFontIntegrity = (url: string) => Object.values(FONT_CATALOG).find(entry => "bytes" in entry && CDN_ROOTS[0] + entry.path === url);
export const FONT_IDS = Object.keys(FONT_CATALOG) as RemoteNoteImageFont[];
export const findCatalogFont = (idOrFamily: string) => FONT_IDS.find(id => id === idOrFamily || FONT_CATALOG[id].family === idOrFamily || FONT_CATALOG[id].posterFamily === idOrFamily);
export const catalogFontStack = (id: RemoteNoteImageFont) => `"${FONT_CATALOG[id].family}", sans-serif`;
export const CATALOG_FONT_STACKS = Object.fromEntries(FONT_IDS.map(id => [id, catalogFontStack(id)])) as Record<RemoteNoteImageFont, string>;
+117
View File
@@ -0,0 +1,117 @@
import { readFontCache, writeFontCache } from "./note-image-font-cache";
import { FONT_CATALOG, parseRemoteFontCss, remoteFontCssUrl, remoteFontUrls, remoteOriginalFont, remoteFontIntegrity, type RemoteNoteImageFont } from "./font-catalog";
import { NOTE_IMAGE_REMOTE_FONT_NOTICES } from "./note-image-remote-font-notices";
const validRemoteResource = async (data: ArrayBuffer, css: boolean, url: string) => {
const valid = data.byteLength > (css ? 0 : 48)
&& data.byteLength <= (css ? 1_000_000 : 24_000_000)
&& (css ? new TextDecoder().decode(data).includes("@font-face")
: (new TextDecoder().decode(data.slice(0, 4)) === "wOF2" && new DataView(data).getUint32(8) === data.byteLength)
|| new DataView(data).getUint32(0) === 0x00010000 || new TextDecoder().decode(data.slice(0, 4)) === "OTTO");
if (!valid) return false;
const original = remoteFontIntegrity(url);
if (original && "bytes" in original) {
if (data.byteLength !== original.bytes) return false;
if (globalThis.crypto?.subtle) {
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest), value => value.toString(16).padStart(2, "0")).join("") === original.sha256;
}
}
return true;
};
/** Cache manifests as well as subsets, so a later export works without the network. */
export const createRemoteFontResourceLoader = () => {
const remoteResources = new Map<string, Promise<ArrayBuffer>>();
return (url: string, css = false): Promise<ArrayBuffer> => {
let pending = remoteResources.get(url);
if (!pending) {
pending = (async () => {
const cached = await readFontCache(url);
if (cached && await validRemoteResource(cached, css, url)) return cached;
for (const mirror of remoteFontUrls(url)) {
try {
const data = await new Promise<ArrayBuffer>((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("GET", mirror);
request.responseType = "arraybuffer";
request.timeout = 15_000;
request.onload = () => request.status === 200 && request.response instanceof ArrayBuffer
? resolve(request.response) : reject(new Error("NOTE_IMAGE_FONT_UNAVAILABLE"));
request.onerror = request.ontimeout = () => reject(new Error("NOTE_IMAGE_FONT_UNAVAILABLE"));
request.send();
});
if (!await validRemoteResource(data, css, url)) continue;
await writeFontCache(url, data);
return data;
} catch { /* Try the second pinned mirror. Never send note text to either host. */ }
}
throw new Error("NOTE_IMAGE_FONT_UNAVAILABLE");
})();
remoteResources.set(url, pending);
void pending.catch(() => remoteResources.delete(url));
}
return pending;
};
};
export const loadRemoteFontResource = createRemoteFontResourceLoader();
const remoteDataUrl = async (url: string) => {
const data = await loadRemoteFontResource(url);
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("NOTE_IMAGE_FONT_UNAVAILABLE"));
const signature = new DataView(data).getUint32(0);
reader.readAsDataURL(new Blob([data], { type: signature === 0x00010000 ? "font/ttf" : signature === 0x4f54544f ? "font/otf" : "font/woff2" }));
});
};
export const fontSubsetContains = (range: string, points: ReadonlySet<number>) => {
const sorted = [...points].sort((a, b) => a - b);
return range.split(",").some(part => {
const [start, end = start] = part.trim().replace(/^U\+/i, "").split("-");
const lower = parseInt(start.replace(/\?/g, "0"), 16);
const upper = parseInt(end.replace(/\?/g, "F"), 16);
let left = 0;
let right = sorted.length;
while (left < right) {
const middle = (left + right) >>> 1;
if (sorted[middle] < lower) left = middle + 1;
else right = middle;
}
return left < sorted.length && sorted[left] <= upper;
});
};
const faces = new Map<string, Promise<FontFace>>();
/** All three font pickers/renderers use this loader and the same persistent byte cache. */
export const loadCatalogFont = async (id: RemoteNoteImageFont, text: string, { embed = false, family = FONT_CATALOG[id].family }: { embed?: boolean; family?: string } = {}) => {
const original = remoteOriginalFont(id);
const catalog = original ? [original] : parseRemoteFontCss(new TextDecoder().decode(await loadRemoteFontResource(remoteFontCssUrl(id), true)), id);
const points = new Set(Array.from(text, char => char.codePointAt(0)!));
const blocks = catalog.filter(block => fontSubsetContains(block.range, points));
const css = await Promise.all(blocks.map(async block => {
const key = `${family}:${block.asset}`;
let pending = faces.get(key);
if (!pending) {
pending = (async () => {
const data = await loadRemoteFontResource(block.asset);
const face = new FontFace(family, data, { weight: block.weight, unicodeRange: block.range });
await face.load();
document.fonts.add(face);
return face;
})();
faces.set(key, pending);
void pending.catch(() => faces.delete(key));
}
await pending;
return embed ? `@font-face{font-family:"${family}";font-style:normal;font-weight:${block.weight};src:url("${await remoteDataUrl(block.asset)}");unicode-range:${block.range}}` : "";
}));
if (!embed || !blocks.length) return "";
const notices = original
? `Original font loaded unchanged from ${original.asset}. Original copyright and licensing metadata are retained in the font container. Alibaba: https://fonts.alibabagroup.com/ ; YouShe: https://www.uisdc.com/ . No conversion or subsetting is performed.`
: NOTE_IMAGE_REMOTE_FONT_NOTICES;
return `/*! ${notices.replaceAll("*/", "* /")} */\n${css.join("\n")}`;
};
+3 -8
View File
@@ -1,3 +1,5 @@
import { CATALOG_FONT_STACKS } from "./font-catalog";
export type NoteImageFormat = "jpeg" | "png";
export const NOTE_IMAGE_THEME_OPTIONS = ["gradient", "glass", "quote", "book", "memo", "bento", "black-sun", "frame", "handwritten", "code"] as const;
export type NoteImageActiveTheme = (typeof NOTE_IMAGE_THEME_OPTIONS)[number];
@@ -101,14 +103,7 @@ export const NOTE_IMAGE_FONT_FAMILIES: Record<Exclude<NoteImageFontStyle, "auto"
serif: 'Georgia, "Nimbus Roman No9 L", "Songti SC", "Noto Serif SC", "Source Han Serif SC", "Source Han Serif CN", "STSong", "AR PL New Sung", "SimSun", "Hiragino Mincho ProN", serif',
mono: '"JetBrains Mono", "SF Mono", "Fira Code", "Cascadia Code", Menlo, Monaco, Consolas, "Liberation Mono", monospace',
kai: '"LXGW WenKai", "霞鹜文楷", "Kaiti SC", "STKaiti", "KaiTi", "KaiTi_GB2312", "BiauKai", "楷体", "STFangsong", "FangSong", serif',
smiley: '"EdgeEver Share Smiley", sans-serif',
wenkai: '"EdgeEver Share WenKai", serif',
zhuque: '"EdgeEver Share Zhuque", serif',
heavy: '"EdgeEver Share Noto Heavy", sans-serif',
puhui: '"EdgeEver Share Puhui", sans-serif',
"puhui-heavy": '"EdgeEver Share Puhui Heavy", sans-serif',
youshe: '"EdgeEver Share Youshe", sans-serif',
shuhei: '"EdgeEver Share Shuhei", sans-serif',
...CATALOG_FONT_STACKS,
};
export const NOTE_IMAGE_TEMPLATE_FONT_FAMILIES: Record<NoteImageActiveTheme, string> = {
+8 -94
View File
@@ -1,9 +1,9 @@
import { NOTE_IMAGE_FONT_NOTICES } from "./note-image-font-notices";
import { NOTE_IMAGE_FONT_SUBSETS } from "./note-image-font-manifest";
import { resolveTheme, type NoteImageTheme, type NoteImageFontStyle } from "./note-image-card";
import { readFontCache, writeFontCache } from "./note-image-font-cache";
import { isRemoteNoteImageFont, parseRemoteFontCss, remoteFontCssUrl, remoteFontUrls, remoteOriginalFont, remoteFontIntegrity } from "./note-image-remote-fonts";
import { NOTE_IMAGE_REMOTE_FONT_NOTICES } from "./note-image-remote-font-notices";
import { isRemoteNoteImageFont } from "./font-catalog";
import { fontSubsetContains, loadCatalogFont } from "./font-loader";
export { fontSubsetContains, createRemoteFontResourceLoader, loadRemoteFontResource } from "./font-loader";
export const NOTE_IMAGE_THEME_FONTS = {
gradient: "puhui", glass: "puhui", quote: "mincho", book: "song", memo: "misans",
@@ -14,71 +14,6 @@ type AssetUrl = string | number | { uri: string };
const resources = new Map<string, Promise<string>>();
const loadedFaces = new Map<string, Promise<FontFace>>();
const validRemoteResource = async (data: ArrayBuffer, css: boolean, url: string) => {
const valid = data.byteLength > (css ? 0 : 48)
&& data.byteLength <= (css ? 1_000_000 : 24_000_000)
&& (css ? new TextDecoder().decode(data).includes("@font-face")
: (new TextDecoder().decode(data.slice(0, 4)) === "wOF2" && new DataView(data).getUint32(8) === data.byteLength)
|| new DataView(data).getUint32(0) === 0x00010000 || new TextDecoder().decode(data.slice(0, 4)) === "OTTO");
if (!valid) return false;
const original = remoteFontIntegrity(url);
if (original && "bytes" in original) {
if (data.byteLength !== original.bytes) return false;
if (globalThis.crypto?.subtle) {
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest), value => value.toString(16).padStart(2, "0")).join("") === original.sha256;
}
}
return true;
};
/** Cache manifests as well as subsets, so a later export works without the network. */
export const createRemoteFontResourceLoader = () => {
const remoteResources = new Map<string, Promise<ArrayBuffer>>();
return (url: string, css = false): Promise<ArrayBuffer> => {
let pending = remoteResources.get(url);
if (!pending) {
pending = (async () => {
const cached = await readFontCache(url);
if (cached && await validRemoteResource(cached, css, url)) return cached;
for (const mirror of remoteFontUrls(url)) {
try {
const data = await new Promise<ArrayBuffer>((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("GET", mirror);
request.responseType = "arraybuffer";
request.timeout = 15_000;
request.onload = () => request.status === 200 && request.response instanceof ArrayBuffer
? resolve(request.response) : reject(new Error("NOTE_IMAGE_FONT_UNAVAILABLE"));
request.onerror = request.ontimeout = () => reject(new Error("NOTE_IMAGE_FONT_UNAVAILABLE"));
request.send();
});
if (!await validRemoteResource(data, css, url)) continue;
await writeFontCache(url, data);
return data;
} catch { /* Try the second pinned mirror. Never send note text to either host. */ }
}
throw new Error("NOTE_IMAGE_FONT_UNAVAILABLE");
})();
remoteResources.set(url, pending);
void pending.catch(() => remoteResources.delete(url));
}
return pending;
};
};
export const loadRemoteFontResource = createRemoteFontResourceLoader();
const remoteDataUrl = async (url: string) => {
const data = await loadRemoteFontResource(url);
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("NOTE_IMAGE_FONT_UNAVAILABLE"));
const signature = new DataView(data).getUint32(0);
reader.readAsDataURL(new Blob([data], { type: signature === 0x00010000 ? "font/ttf" : signature === 0x4f54544f ? "font/otf" : "font/woff2" }));
});
};
const asDataUrl = (url: string): Promise<string> => {
if (url.startsWith("data:")) return Promise.resolve(url);
let pending = resources.get(url);
@@ -108,23 +43,6 @@ const asDataUrl = (url: string): Promise<string> => {
return pending;
};
export const fontSubsetContains = (range: string, points: ReadonlySet<number>) => {
const sorted = [...points].sort((a, b) => a - b);
return range.split(",").some(part => {
const [start, end = start] = part.trim().replace(/^U\+/i, "").split("-");
const lower = parseInt(start.replace(/\?/g, "0"), 16);
const upper = parseInt(end.replace(/\?/g, "F"), 16);
let left = 0;
let right = sorted.length;
while (left < right) {
const middle = (left + right) >>> 1;
if (sorted[middle] < lower) left = middle + 1;
else right = middle;
}
return left < sorted.length && sorted[left] <= upper;
});
};
/** Only embed subsets used by this card. The editor never eagerly loads entire CJK fonts. */
export const prepareNoteImageAssets = async ({ theme, fontStyle = "auto", text, urls, embed = true }: {
theme: NoteImageTheme; fontStyle?: NoteImageFontStyle; text: string; urls: Record<string, AssetUrl>; embed?: boolean;
@@ -137,15 +55,12 @@ export const prepareNoteImageAssets = async ({ theme, fontStyle = "auto", text,
};
const points = new Set(Array.from(text, char => char.codePointAt(0)!));
const remote = isRemoteNoteImageFont(fontStyle);
const original = remote ? remoteOriginalFont(fontStyle) : undefined;
const catalog = remote
? original ? [original] : parseRemoteFontCss(new TextDecoder().decode(await loadRemoteFontResource(remoteFontCssUrl(fontStyle), true)), fontStyle)
: fontStyle === "auto" ? NOTE_IMAGE_FONT_SUBSETS[NOTE_IMAGE_THEME_FONTS[active]] : [];
const catalog = !remote && fontStyle === "auto" ? NOTE_IMAGE_FONT_SUBSETS[NOTE_IMAGE_THEME_FONTS[active]] : [];
const blocks = catalog.filter(block => fontSubsetContains(block.range, points));
const fontFacesCss = (await Promise.all(blocks.map(async block => {
let loaded = loadedFaces.get(block.asset);
// Picker labels only need the face registered, not another base64 export copy.
const data = embed || !loaded ? await (remote ? remoteDataUrl(block.asset) : getAsset(block.asset)) : "";
const data = embed || !loaded ? await getAsset(block.asset) : "";
loaded = loadedFaces.get(block.asset);
if (!loaded) {
const face = new FontFace(block.family, `url("${data}")`, { weight: block.weight, unicodeRange: block.range });
@@ -156,10 +71,9 @@ export const prepareNoteImageAssets = async ({ theme, fontStyle = "auto", text,
await loaded;
return embed ? `@font-face{font-family:"${block.family}";font-style:normal;font-weight:${block.weight};src:url("${data}");unicode-range:${block.range}}` : "";
}))).join("\n");
const notices = original
? `Original font file loaded unchanged from ${original.asset}. Original copyright and licensing metadata are retained in the font container. Alibaba PuHuiTi / Alimama ShuHeiTi: https://fonts.alibabagroup.com/ ; YouShe: https://www.uisdc.com/ . These fonts are not licensed under SIL OFL; no conversion or subsetting is performed.`
: remote ? NOTE_IMAGE_REMOTE_FONT_NOTICES : NOTE_IMAGE_FONT_NOTICES;
const fontCss = embed && blocks.length ? `/*! ${notices.replaceAll("*/", "* /")} */\n${fontFacesCss}` : "";
const fontCss = remote
? await loadCatalogFont(fontStyle, text, { embed })
: embed && blocks.length ? `/*! ${NOTE_IMAGE_FONT_NOTICES.replaceAll("*/", "* /")} */\n${fontFacesCss}` : "";
const textureCss = embed && active === "handwritten"
? `.edgeever-image-wrapper{--ee-paper:url("${await getAsset("paper.png")}");--ee-paper-grain:url("${await getAsset("paper-text.png")}")}`
: "";
+13 -60
View File
@@ -1,60 +1,13 @@
/** Optional share-image fonts. Only metadata ships in clients; files stay on the CDN. */
export const NOTE_IMAGE_REMOTE_FONTS = {
smiley: { family: "EdgeEver Share Smiley", path: "@chinese-fonts/dyh@3.0.0/dist/SmileySans-Oblique/result.css" },
wenkai: { family: "EdgeEver Share WenKai", path: "@chinese-fonts/lxgwwenkai@3.0.0/dist/LXGWWenKai-Regular/result.css" },
zhuque: { family: "EdgeEver Share Zhuque", path: "@chinese-fonts/zqfs@3.0.0/dist/ZhuqueFangsong-Regular/result.css" },
heavy: { family: "EdgeEver Share Noto Heavy", path: "@fontsource/noto-sans-sc@5.3.1/900.css" },
// Preserve the original containers for fonts whose terms do not permit subsetting.
puhui: { family: "EdgeEver Share Puhui", path: "@fontpkg/alibaba-pu-hui-ti-3-0@3.1.2/AlibabaPuHuiTi-3-55-Regular.ttf", weight: "400", bytes: 8532824, sha256: "be33bc8d45ced30fa5c542f6f056565baf85003d4e079522110d76016f8a04e2" },
"puhui-heavy": { family: "EdgeEver Share Puhui Heavy", path: "@fontpkg/alibaba-pu-hui-ti-3-0@3.1.2/AlibabaPuHuiTi-3-105-Heavy.ttf", weight: "900", bytes: 2536300, sha256: "94cbb24e451a68b44d932d343579fb31571c743df2cfcd144ba60e0a9aa86e64" },
youshe: { family: "EdgeEver Share Youshe", path: "@fontpkg/you-she-biao-ti-hei@1.0.0/%E4%BC%98%E8%AE%BE%E6%A0%87%E9%A2%98%E9%BB%91.ttf", weight: "400", bytes: 1414784, sha256: "dab883d69fb713233ea5d9aff8d1f27ccc2b71e249143edf6ea08367784c5dba" },
shuhei: { family: "EdgeEver Share Shuhei", path: "@fontpkg/alimama-shu-hei-ti@1.0.5/AlimamaShuHeiTi-Bold.ttf", weight: "700", bytes: 1360708, sha256: "3ef4ec3f5bac4b71cdee9190aabd5b20995016a59ffee60de87c9a204ac2a684" },
} as const;
/** The five entries in Qiaomu's approximately 15 MB designer starter pack. */
export const NOTE_IMAGE_RECOMMENDED_FONT_IDS = ["puhui", "puhui-heavy", "youshe", "shuhei", "smiley"] as const;
export type RemoteNoteImageFont = keyof typeof NOTE_IMAGE_REMOTE_FONTS;
export const isRemoteNoteImageFont = (value: string): value is RemoteNoteImageFont => Object.hasOwn(NOTE_IMAGE_REMOTE_FONTS, value);
const CDN_ROOTS = ["https://cdn.jsdelivr.net/npm/", "https://unpkg.com/"];
export type RemoteFontSubset = { asset: string; family: string; range: string; weight: string };
/** Parse only font descriptors, never insert third-party styles into the document. */
export const parseRemoteFontCss = (css: string, font: RemoteNoteImageFont): RemoteFontSubset[] => {
const { path, family } = NOTE_IMAGE_REMOTE_FONTS[font];
const base = new URL(path, CDN_ROOTS[0]);
const directory = base.href.slice(0, base.href.lastIndexOf("/") + 1);
const blocks: RemoteFontSubset[] = [];
for (const match of css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/@font-face\s*\{([^}]+)\}/g)) {
const body = match[1];
const file = /url\(\s*["']?([^\s"')]+\.woff2)["']?\s*\)/i.exec(body)?.[1];
const range = /unicode-range\s*:\s*([^;]+)/i.exec(body)?.[1].trim();
const weight = /font-weight\s*:\s*(\d+(?:\s+\d+)?)/i.exec(body)?.[1];
if (!file || !range || !weight || !/^U\+[\da-f?]+(?:-[\da-f]+)?(?:\s*,\s*U\+[\da-f?]+(?:-[\da-f]+)?)*$/i.test(range)) continue;
const url = new URL(file, base);
// A pinned manifest may only reference WOFF2 files inside its own package directory.
if (!url.href.startsWith(directory) || url.search || url.hash) continue;
blocks.push({ asset: url.href, family, range, weight });
}
if (!blocks.length) throw new Error("NOTE_IMAGE_FONT_UNAVAILABLE");
return blocks;
};
/** Mirror paths are equivalent, so both hosts share a single persistent cache key. */
export const remoteFontUrls = (url: string) => {
if (!url.startsWith(CDN_ROOTS[0])) throw new Error("NOTE_IMAGE_FONT_UNAVAILABLE");
return CDN_ROOTS.map(root => root + url.slice(CDN_ROOTS[0].length));
};
export const remoteFontCssUrl = (font: RemoteNoteImageFont) => CDN_ROOTS[0] + NOTE_IMAGE_REMOTE_FONTS[font].path;
export const remoteOriginalFont = (font: RemoteNoteImageFont): RemoteFontSubset | undefined => {
const entry = NOTE_IMAGE_REMOTE_FONTS[font];
return "weight" in entry
? { asset: remoteFontCssUrl(font), family: entry.family, weight: entry.weight, range: "U+0-10FFFF" }
: undefined;
};
export const remoteFontIntegrity = (url: string) => Object.values(NOTE_IMAGE_REMOTE_FONTS).find(entry => "bytes" in entry && CDN_ROOTS[0] + entry.path === url);
// Compatibility exports for the original share-image integration.
export {
FONT_CATALOG as NOTE_IMAGE_REMOTE_FONTS,
NOTE_IMAGE_RECOMMENDED_FONT_IDS,
isRemoteNoteImageFont,
parseRemoteFontCss,
remoteFontUrls,
remoteFontCssUrl,
remoteOriginalFont,
remoteFontIntegrity,
type RemoteNoteImageFont,
type RemoteFontSubset,
} from "./font-catalog";
+9 -1
View File
@@ -1,4 +1,6 @@
export const POSTER_FONTS = [
import { FONT_CATALOG, FONT_IDS } from "./font-catalog";
const LEGACY_POSTER_FONTS = [
{
family: "思源宋体",
file: "notoserif",
@@ -85,3 +87,9 @@ export const POSTER_FONTS = [
en: "JetBrains Mono",
},
] as const;
// Persist existing family names; source URLs and display names come from one catalog.
export const POSTER_FONTS = [
...LEGACY_POSTER_FONTS.filter(font => !FONT_IDS.some(id => FONT_CATALOG[id].posterFamily === font.family)),
...FONT_IDS.map(id => ({ family: FONT_CATALOG[id].posterFamily, file: "", zh: FONT_CATALOG[id].zh, en: FONT_CATALOG[id].en })),
];
+2 -1
View File
@@ -1,3 +1,4 @@
import { PUBLISHED_NOTE_BODY_FONTS } from "./sharing";
import { z } from "zod";
import {
MAX_NOTE_PROSE_CSS_BYTES,
@@ -441,7 +442,7 @@ export const MemoShareUpdateSchema = SharePasswordUpdateSchema;
export const NotebookShareUpdateSchema = SharePasswordUpdateSchema;
export const NoteBodyFontUpdateSchema = z.object({
bodyFont: z.enum(["wenkai", "wenkai-screen", "zhuque", "source-han-serif", "neo-zhi-song", "source-han-sans", "source-serif"]).nullable(),
bodyFont: z.enum(PUBLISHED_NOTE_BODY_FONTS).nullable(),
});
const noteProseHexColor = z.string().regex(/^#[0-9a-f]{6}$/i);
+2
View File
@@ -1,3 +1,4 @@
import { FONT_IDS } from "./font-catalog";
import type { TiptapDoc, TiptapNode, TiptapTextNode } from "./content";
import type { PublicNoteProse } from "./note-prose";
import { parseMemoLinkHref } from "./note-links";
@@ -49,6 +50,7 @@ export const PUBLISHED_NOTE_BODY_FONTS = [
"neo-zhi-song",
"source-han-sans",
"source-serif",
...FONT_IDS.filter(id => id !== "wenkai" && id !== "zhuque"),
] as const;
export type PublishedNoteBodyFont = (typeof PUBLISHED_NOTE_BODY_FONTS)[number];