From 51973cf16e6e2b0dec0723671e5b8daf04a8b26a Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sun, 18 Oct 2026 20:07:03 +0800 Subject: [PATCH] fix: match note image template fonts to their visual styles --- packages/shared/src/note-image-card.test.mjs | 20 +++++++- packages/shared/src/note-image-card.ts | 49 ++++++++++++++++---- packages/shared/src/note-image-fonts.ts | 13 +++--- 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/packages/shared/src/note-image-card.test.mjs b/packages/shared/src/note-image-card.test.mjs index 20cd9b60..9008c004 100644 --- a/packages/shared/src/note-image-card.test.mjs +++ b/packages/shared/src/note-image-card.test.mjs @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { - NOTE_IMAGE_THEME_OPTIONS, NOTE_IMAGE_THEMES, NOTE_IMAGE_BACKGROUND_COLORS, NOTE_IMAGE_FONT_FAMILIES, + NOTE_IMAGE_BUNDLED_FONTS, NOTE_IMAGE_TEMPLATE_TYPOGRAPHY, NOTE_IMAGE_THEME_OPTIONS, NOTE_IMAGE_THEMES, NOTE_IMAGE_BACKGROUND_COLORS, NOTE_IMAGE_FONT_FAMILIES, resolveTheme, resolveNoteImageTypography, buildImageExportBasename, buildNoteImageCardMarkup, estimateReadingStats, generateCardCss, } from "./note-image-card"; @@ -8,8 +8,26 @@ import { zhCN } from "./i18n/zh-CN"; import { enUS } from "./i18n/en-US"; import { ja } from "./i18n/ja"; import { pl } from "./i18n/pl"; +import { NOTE_IMAGE_FONT_SUBSETS } from "./note-image-font-manifest"; describe("note image templates", () => { + test("each template references the font family registered by its bundled assets", () => { + for (const theme of NOTE_IMAGE_THEME_OPTIONS) { + const preset = NOTE_IMAGE_TEMPLATE_TYPOGRAPHY[theme]; + for (const role of ["body", "heading"]) { + const familyStack = resolveNoteImageTypography(theme)[`${role}Family`]; + for (const font of preset[role]) { + const subsets = NOTE_IMAGE_FONT_SUBSETS[font]; + expect(subsets.length).toBeGreaterThan(0); + for (const { family } of subsets) { + expect(family).toBe(NOTE_IMAGE_BUNDLED_FONTS[font].family); + expect(familyStack).toContain(`"${family}"`); + } + } + } + } + }); + test("every selectable template is localized and has renderable colors", () => { for (const theme of NOTE_IMAGE_THEME_OPTIONS) { expect(NOTE_IMAGE_THEMES[theme]).toBeDefined(); diff --git a/packages/shared/src/note-image-card.ts b/packages/shared/src/note-image-card.ts index 63a32154..9086ab14 100644 --- a/packages/shared/src/note-image-card.ts +++ b/packages/shared/src/note-image-card.ts @@ -106,19 +106,50 @@ export const NOTE_IMAGE_FONT_FAMILIES: Record = { - gradient: "Alibaba-PuHuiTi-Regular", glass: "Alibaba-PuHuiTi-Regular", quote: "Huiwen_mingchao", - book: "SourceHanSerifCN_SemiBold", memo: "MiSans-Regular", bento: "Alibaba-PuHuiTi-Regular", - "black-sun": "Alibaba-PuHuiTi-Regular", frame: "Alibaba-PuHuiTi-Regular", handwritten: "Long Cang", code: "Geist Mono Variable", +/** Keep CSS families and asset selection together so defaults cannot silently fall back. */ +export const NOTE_IMAGE_BUNDLED_FONTS = { + puhui: { family: "Alibaba-PuHuiTi-Regular", fallback: "sans" }, + misans: { family: "MiSans", fallback: "sans" }, + mincho: { family: "Huiwen_mingchao", fallback: "serif" }, + song: { family: "SourceHanSerifCN_SemiBold", fallback: "serif" }, + longcang: { family: "Long Cang", fallback: "kai" }, + geist: { family: "Geist Mono Variable", fallback: "mono" }, +} as const; + +type BundledNoteImageFont = keyof typeof NOTE_IMAGE_BUNDLED_FONTS; +type TemplateTypography = { + body: readonly BundledNoteImageFont[]; + heading: readonly BundledNoteImageFont[]; + headingWeight: number; }; +export const NOTE_IMAGE_TEMPLATE_TYPOGRAPHY = { + gradient: { body: ["misans"], heading: ["misans"], headingWeight: 700 }, + glass: { body: ["misans"], heading: ["mincho"], headingWeight: 400 }, + quote: { body: ["mincho"], heading: ["mincho"], headingWeight: 700 }, + book: { body: ["song"], heading: ["song"], headingWeight: 700 }, + memo: { body: ["misans"], heading: ["misans"], headingWeight: 700 }, + bento: { body: ["misans"], heading: ["song"], headingWeight: 600 }, + "black-sun": { body: ["geist", "misans"], heading: ["geist", "misans"], headingWeight: 700 }, + frame: { body: ["song"], heading: ["song"], headingWeight: 600 }, + handwritten: { body: ["longcang"], heading: ["longcang"], headingWeight: 400 }, + code: { body: ["geist"], heading: ["geist"], headingWeight: 700 }, +} as const satisfies Record; + +const templateFontStack = (fonts: readonly BundledNoteImageFont[]) => [ + ...fonts.map(font => `"${NOTE_IMAGE_BUNDLED_FONTS[font].family}"`), + NOTE_IMAGE_FONT_FAMILIES[NOTE_IMAGE_BUNDLED_FONTS[fonts[0]].fallback], +].join(", "); + export const resolveNoteImageTypography = (theme: NoteImageTheme, fontStyle: NoteImageFontStyle = "auto") => { const active = resolveTheme(undefined, theme); - const style = fontStyle !== "auto" ? fontStyle : active === "book" || active === "quote" ? "serif" : active === "handwritten" ? "kai" : active === "code" ? "mono" : "sans"; - const family = fontStyle === "auto" - ? `"${NOTE_IMAGE_TEMPLATE_FONT_FAMILIES[active]}", ${NOTE_IMAGE_FONT_FAMILIES[style]}` - : NOTE_IMAGE_FONT_FAMILIES[style]; - return { bodyFamily: family, headingFamily: family, headingWeight: active === "handwritten" ? 400 : 700, lineHeightAdjustment: 0 }; + const preset = NOTE_IMAGE_TEMPLATE_TYPOGRAPHY[active]; + return { + bodyFamily: fontStyle === "auto" ? templateFontStack(preset.body) : NOTE_IMAGE_FONT_FAMILIES[fontStyle], + headingFamily: fontStyle === "auto" ? templateFontStack(preset.heading) : NOTE_IMAGE_FONT_FAMILIES[fontStyle], + headingWeight: fontStyle === "auto" ? preset.headingWeight : active === "handwritten" ? 400 : 700, + lineHeightAdjustment: 0, + }; }; export const NOTE_IMAGE_FONT_SIZES = { diff --git a/packages/shared/src/note-image-fonts.ts b/packages/shared/src/note-image-fonts.ts index 9ff21461..7399daf1 100644 --- a/packages/shared/src/note-image-fonts.ts +++ b/packages/shared/src/note-image-fonts.ts @@ -1,15 +1,10 @@ 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 { NOTE_IMAGE_TEMPLATE_TYPOGRAPHY, resolveTheme, type NoteImageTheme, type NoteImageFontStyle } from "./note-image-card"; 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", - bento: "puhui", "black-sun": "puhui", frame: "puhui", handwritten: "longcang", code: "geist", -} as const; - type AssetUrl = string | number | { uri: string }; const resources = new Map>(); const loadedFaces = new Map>(); @@ -55,7 +50,11 @@ export const prepareNoteImageAssets = async ({ theme, fontStyle = "auto", text, }; const points = new Set(Array.from(text, char => char.codePointAt(0)!)); const remote = isRemoteNoteImageFont(fontStyle); - const catalog = !remote && fontStyle === "auto" ? NOTE_IMAGE_FONT_SUBSETS[NOTE_IMAGE_THEME_FONTS[active]] : []; + const preset = NOTE_IMAGE_TEMPLATE_TYPOGRAPHY[active]; + const fontIds = [...new Set([...preset.body, ...preset.heading])]; + const catalog = !remote && fontStyle === "auto" + ? fontIds.flatMap(id => [...NOTE_IMAGE_FONT_SUBSETS[id]]) + : []; const blocks = catalog.filter(block => fontSubsetContains(block.range, points)); const fontFacesCss = (await Promise.all(blocks.map(async block => { let loaded = loadedFaces.get(block.asset);