From c4a56f3a9f7ff69d9dc947c1e8bc975cfb50c4c6 Mon Sep 17 00:00:00 2001 From: MuRong Date: Tue, 28 Jul 2026 23:28:27 +0800 Subject: [PATCH] feat(export): add configurable document fonts --- packages/app/src/App.test.tsx | 11 + packages/app/src/App.tsx | 1 + .../app/src/components/SettingsWindow.tsx | 1 + .../components/settings/EditorSettings.tsx | 217 +----------------- .../settings/ExportSettings.test.tsx | 38 +++ .../components/settings/ExportSettings.tsx | 25 ++ .../components/settings/FontFamilySelect.tsx | 199 ++++++++++++++++ packages/app/src/lib/document-export.test.ts | 12 + packages/app/src/lib/document-export.ts | 36 ++- packages/app/src/lib/editor-font.ts | 13 +- .../src/lib/settings/export-settings.test.ts | 8 + .../app/src/lib/settings/export-settings.ts | 5 + .../src/lib/settings/settings-events.test.ts | 1 + packages/app/src/test/app-harness.tsx | 3 + packages/shared/src/i18n/locales/de.ts | 4 + packages/shared/src/i18n/locales/en.ts | 4 + packages/shared/src/i18n/locales/es.ts | 4 + packages/shared/src/i18n/locales/fr.ts | 4 + packages/shared/src/i18n/locales/it.ts | 4 + packages/shared/src/i18n/locales/ja.ts | 4 + packages/shared/src/i18n/locales/ko.ts | 4 + packages/shared/src/i18n/locales/pt-BR.ts | 4 + packages/shared/src/i18n/locales/ru.ts | 4 + packages/shared/src/i18n/locales/types.ts | 4 + packages/shared/src/i18n/locales/zh-CN.ts | 4 + packages/shared/src/i18n/locales/zh-TW.ts | 4 + 26 files changed, 401 insertions(+), 217 deletions(-) create mode 100644 packages/app/src/components/settings/ExportSettings.test.tsx create mode 100644 packages/app/src/components/settings/FontFamilySelect.tsx diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index 7daab612..87e48ed4 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -6,6 +6,7 @@ import { defaultMarkdownShortcuts } from "@markra/editor"; import { it as registerTest } from "vitest"; import desktopPackage from "../package.json"; import { defaultAiQuickActionPrompts } from "./lib/ai-actions"; +import { defaultExportSettings } from "./lib/settings/app-settings"; import { dispatchAiEditorPreviewAction, installAppTestHarness, @@ -8696,6 +8697,10 @@ describe("Markra workspace", () => { }); it("exports the current markdown document as standalone HTML from the native menu", async () => { + mockedGetStoredExportSettings.mockResolvedValue({ + ...defaultExportSettings, + fontFamily: "Example Serif" + }); mockOpenMarkdownFile({ content: "# Exportable\n\nRendered from markdown.", name: "exportable.md", @@ -8731,6 +8736,7 @@ describe("Markra workspace", () => { const exportedHtml = mockedSaveNativeHtmlFile.mock.calls.at(-1)?.[0].contents ?? ""; expect(exportedHtml).toContain("

Rendered from markdown.

"); expect(exportedHtml).toContain("exportable.md"); + expect(exportedHtml).toContain('font-family: "Example Serif", ui-serif'); }); it("exports the current markdown document as PDF from the native menu", async () => { @@ -8740,6 +8746,7 @@ describe("Markra workspace", () => { path: "/mock-files/printable.pdf" }); mockedGetStoredExportSettings.mockResolvedValue({ + fontFamily: null, pandocArgs: "", pandocPath: "", pdfAuthor: "Ada & Co", @@ -8802,6 +8809,7 @@ describe("Markra workspace", () => { path: "/mock-files/portable.docx" }); mockedGetStoredExportSettings.mockResolvedValue({ + fontFamily: null, pandocArgs: "--toc", pandocPath: "/usr/local/bin/pandoc", pdfAuthor: "", @@ -8895,6 +8903,7 @@ describe("Markra workspace", () => { it("opens settings directly to the Pandoc path target", async () => { window.history.pushState({}, "", "/?settings=1&settingsTarget=exportPandocPath"); mockedGetStoredExportSettings.mockResolvedValue({ + fontFamily: null, pandocArgs: "", pandocPath: "", pdfAuthor: "", @@ -8918,6 +8927,7 @@ describe("Markra workspace", () => { window.history.pushState({}, "", "/?settings=1&settingsTarget=exportPandocPath"); mockedDetectNativePandocPath.mockResolvedValue("/opt/homebrew/bin/pandoc"); mockedGetStoredExportSettings.mockResolvedValue({ + fontFamily: null, pandocArgs: "", pandocPath: "", pdfAuthor: "", @@ -8948,6 +8958,7 @@ describe("Markra workspace", () => { it("saves the PDF export margin from the settings export page", async () => { window.history.pushState({}, "", "/?settings"); mockedGetStoredExportSettings.mockResolvedValue({ + fontFamily: null, pandocArgs: "", pandocPath: "", pdfAuthor: "", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 5f26bee3..821dcb76 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -3670,6 +3670,7 @@ function WorkspaceApp() { const pdfSettings = exported.kind === "pdf" ? exportSettings.settings : null; const contents = buildMarkdownHtmlDocument({ bodyHtml: exported.bodyHtml, + fontFamily: exportSettings.settings.fontFamily, language: appLanguage.language, pdfAuthor: pdfSettings?.pdfAuthor, pdfFooter: pdfSettings?.pdfFooter, diff --git a/packages/app/src/components/SettingsWindow.tsx b/packages/app/src/components/SettingsWindow.tsx index 17900f20..06cd6d52 100644 --- a/packages/app/src/components/SettingsWindow.tsx +++ b/packages/app/src/components/SettingsWindow.tsx @@ -387,6 +387,7 @@ export function SettingsWindow() { focusTarget={settingsFocusTarget} pandocEnabled={appFeatures.pandoc} settings={exportSettings} + systemFontFamilies={systemFontFamilies} translate={translate} onDetectPandocPath={handleDetectPandocPath} onFocusTargetHandled={clearSettingsFocusTarget} diff --git a/packages/app/src/components/settings/EditorSettings.tsx b/packages/app/src/components/settings/EditorSettings.tsx index 50013a5b..a79236cf 100644 --- a/packages/app/src/components/settings/EditorSettings.tsx +++ b/packages/app/src/components/settings/EditorSettings.tsx @@ -6,7 +6,6 @@ import { Moon, RotateCcw, Save, - Search, type LucideIcon } from "lucide-react"; import { @@ -14,8 +13,6 @@ import { useMemo, useRef, useState, - type CSSProperties, - type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; import { @@ -47,12 +44,9 @@ import { editorCustomContentWidthMin, normalizeEditorContentWidthPx } from "../../lib/editor-width"; -import { - editorFontFamilyCssValue, - normalizeSystemFontFamilyName -} from "../../lib/editor-font"; import type { AppSystemFontFamily } from "../../runtime"; import { SortableTitlebarAction } from "../SortableTitlebarAction"; +import { FontFamilySelect } from "./FontFamilySelect"; import { SettingsButton, SettingsNumberInput, @@ -131,13 +125,6 @@ const sidebarLayoutOptions: Array<{ const titlebarActionVisibleClassName = "aria-pressed:border-transparent aria-pressed:bg-(--bg-active) aria-pressed:text-(--text-heading) aria-pressed:opacity-100 aria-pressed:hover:bg-(--bg-active)"; const titlebarActionHiddenClassName = "text-(--text-secondary) opacity-55 hover:opacity-100"; -const editorFontFamilyListboxId = "settings-editor-font-family-options"; - -type EditorFontFamilySelectOption = { - fontFamily: string | null; - label: string; -}; - function contentWidthRatioValue(preferences: EditorPreferences) { const contentWidthPx = preferences.contentWidthPx ?? editorContentWidthPixels[preferences.contentWidth]; @@ -151,186 +138,6 @@ function contentWidthPxFromRatio(value: number) { return normalizeEditorContentWidthPx(Math.round(editorCustomContentWidthMin + (contentWidthRatioSpanPx * ratio) / 100)); } -function editorFontFamilySelectOptions( - preferences: EditorPreferences, - systemFontFamilies: readonly AppSystemFontFamily[], - themeLabel: string -): EditorFontFamilySelectOption[] { - const fontFamilyOptions = new Map(); - for (const fontFamily of systemFontFamilies) { - const normalizedFontFamily = normalizeSystemFontFamilyName(fontFamily.family); - if (!normalizedFontFamily) continue; - - const normalizedLabel = normalizeSystemFontFamilyName(fontFamily.label) ?? normalizedFontFamily; - if (!fontFamilyOptions.has(normalizedFontFamily)) { - fontFamilyOptions.set(normalizedFontFamily, { - fontFamily: normalizedFontFamily, - label: normalizedLabel - }); - } - } - if (fontFamilyOptions.size === 0 && preferences.editorFontFamily.source === "system") { - fontFamilyOptions.set(preferences.editorFontFamily.family, { - fontFamily: preferences.editorFontFamily.family, - label: preferences.editorFontFamily.family - }); - } - - return [ - { fontFamily: null, label: themeLabel }, - ...Array.from(fontFamilyOptions.values()) - .sort((first, second) => first.label.localeCompare(second.label) || first.fontFamily!.localeCompare(second.fontFamily!)) - ]; -} - -function optionMatchesQuery(option: EditorFontFamilySelectOption, query: string) { - const normalizedQuery = query.trim().toLocaleLowerCase(); - if (!normalizedQuery) return true; - - return option.label.toLocaleLowerCase().includes(normalizedQuery); -} - -function selectedFontFamilyOption(options: readonly EditorFontFamilySelectOption[], preferences: EditorPreferences) { - if (preferences.editorFontFamily.source === "theme") return options[0]!; - - return options.find((option) => option.fontFamily === preferences.editorFontFamily.family) ?? options[0]!; -} - -function editorFontFamilyOptionStyle(option: EditorFontFamilySelectOption): CSSProperties | undefined { - if (option.fontFamily === null) return undefined; - - return { - fontFamily: editorFontFamilyCssValue({ - family: option.fontFamily, - source: "system" - }) ?? undefined - }; -} - -function SearchableEditorFontFamilySelect({ - label, - onChange, - options, - value -}: { - label: string; - onChange: (option: EditorFontFamilySelectOption) => unknown; - options: EditorFontFamilySelectOption[]; - value: EditorFontFamilySelectOption; -}) { - const [activeIndex, setActiveIndex] = useState(0); - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const inputValue = open ? query : value.label; - const inputStyle = open ? undefined : editorFontFamilyOptionStyle(value); - const filteredOptions = useMemo( - () => options.filter((option) => optionMatchesQuery(option, query)), - [options, query] - ); - const visibleOptions = filteredOptions; - const activeOption = visibleOptions[activeIndex] ?? visibleOptions[0]; - - useEffect(() => { - setActiveIndex(0); - }, [open, query]); - - const selectOption = (option: EditorFontFamilySelectOption) => { - setQuery(""); - setOpen(false); - onChange(option); - }; - const handleKeyDown = (event: ReactKeyboardEvent) => { - if (event.key === "ArrowDown") { - event.preventDefault(); - setOpen(true); - if (visibleOptions.length === 0) return; - setActiveIndex((currentIndex) => Math.min(currentIndex + 1, visibleOptions.length - 1)); - return; - } - - if (event.key === "ArrowUp") { - event.preventDefault(); - setOpen(true); - if (visibleOptions.length === 0) return; - setActiveIndex((currentIndex) => Math.max(currentIndex - 1, 0)); - return; - } - - if (event.key === "Enter" && open && activeOption) { - event.preventDefault(); - selectOption(activeOption); - return; - } - - if (event.key === "Escape") { - setOpen(false); - setQuery(""); - } - }; - - return ( -
-
- ); -} - function SettingsContentWidthInput({ label, onUpdatePreferences, @@ -655,12 +462,9 @@ export function EditorSettings({ systemFontFamilies?: readonly AppSystemFontFamily[]; translate: SettingsTranslate; }) { - const editorFontFamilyOptions = editorFontFamilySelectOptions( - preferences, - systemFontFamilies, - translate("settings.editor.fontFamily.theme") - ); - const editorFontFamilyValue = selectedFontFamilyOption(editorFontFamilyOptions, preferences); + const editorFontFamily = preferences.editorFontFamily.source === "system" + ? preferences.editorFontFamily.family + : null; return ( <> @@ -669,16 +473,17 @@ export function EditorSettings({ title={translate("settings.editor.fontFamily")} description={translate("settings.editor.fontFamilyDescription")} action={ - + systemFontFamilies={systemFontFamilies} + onChange={(family) => onUpdatePreferences({ ...preferences, - editorFontFamily: option.fontFamily === null + editorFontFamily: family === null ? { family: null, source: "theme" } - : { family: option.fontFamily, source: "system" } + : { family, source: "system" } }) } /> diff --git a/packages/app/src/components/settings/ExportSettings.test.tsx b/packages/app/src/components/settings/ExportSettings.test.tsx new file mode 100644 index 00000000..c903fff4 --- /dev/null +++ b/packages/app/src/components/settings/ExportSettings.test.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { defaultExportSettings } from "../../lib/settings/app-settings"; +import { translate } from "../../test/settings-components"; +import { ExportSettings } from "./ExportSettings"; + +describe("ExportSettings", () => { + it("selects an installed font for HTML and PDF exports", () => { + const onUpdateSettings = vi.fn(); + const settings = { + ...defaultExportSettings, + fontFamily: null + }; + + render( + + ); + + const fontFamilySelect = screen.getByRole("combobox", { name: "Export font" }); + + expect(fontFamilySelect).toHaveValue("Default"); + fireEvent.focus(fontFamilySelect); + fireEvent.change(fontFamilySelect, { target: { value: "ser" } }); + fireEvent.click(screen.getByRole("option", { name: "Example Serif" })); + + expect(onUpdateSettings).toHaveBeenCalledWith({ + ...settings, + fontFamily: "Example Serif" + }); + }); +}); diff --git a/packages/app/src/components/settings/ExportSettings.tsx b/packages/app/src/components/settings/ExportSettings.tsx index f1941ba8..7837f876 100644 --- a/packages/app/src/components/settings/ExportSettings.tsx +++ b/packages/app/src/components/settings/ExportSettings.tsx @@ -6,6 +6,8 @@ import { type PdfPageSize } from "../../lib/settings/app-settings"; import type { I18nKey } from "@markra/shared"; +import type { AppSystemFontFamily } from "../../runtime"; +import { FontFamilySelect } from "./FontFamilySelect"; import { SettingsButton, SettingsCheckbox, @@ -83,6 +85,7 @@ export function ExportSettings({ onUpdateSettings, pandocEnabled = true, settings, + systemFontFamilies = [], translate }: { focusTarget?: "pandocPath" | null; @@ -91,6 +94,7 @@ export function ExportSettings({ onUpdateSettings: (settings: ExportSettingsValue) => unknown; pandocEnabled?: boolean; settings: ExportSettingsValue; + systemFontFamilies?: readonly AppSystemFontFamily[]; translate: SettingsTranslate; }) { const pandocPathTargetRef = useRef(null); @@ -112,6 +116,27 @@ export function ExportSettings({ return ( <> + + + onUpdateSettings({ + ...settings, + fontFamily + }) + } + /> + } + /> + + (); + for (const systemFontFamily of systemFontFamilies) { + const normalizedFamily = normalizeSystemFontFamilyName(systemFontFamily.family); + if (!normalizedFamily) continue; + + const normalizedLabel = normalizeSystemFontFamilyName(systemFontFamily.label) ?? normalizedFamily; + if (!options.has(normalizedFamily)) { + options.set(normalizedFamily, { + family: normalizedFamily, + label: normalizedLabel + }); + } + } + if (options.size === 0 && family !== null) { + options.set(family, { + family, + label: family + }); + } + + return [ + { family: null, label: defaultLabel }, + ...Array.from(options.values()) + .sort((first, second) => first.label.localeCompare(second.label) || first.family!.localeCompare(second.family!)) + ]; +} + +function optionMatchesQuery(option: FontFamilyOption, query: string) { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (!normalizedQuery) return true; + + return option.label.toLocaleLowerCase().includes(normalizedQuery); +} + +function optionStyle(option: FontFamilyOption): CSSProperties | undefined { + if (option.family === null) return undefined; + + return { + fontFamily: systemFontFamilyCssValue(option.family, "var(--font-ui)") + }; +} + +export function FontFamilySelect({ + defaultLabel, + family, + label, + onChange, + systemFontFamilies +}: { + defaultLabel: string; + family: string | null; + label: string; + onChange: (family: string | null) => unknown; + systemFontFamilies: readonly AppSystemFontFamily[]; +}) { + const listboxId = `settings-font-family-options-${useId()}`; + const [activeIndex, setActiveIndex] = useState(0); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const options = useMemo( + () => fontFamilyOptions(family, systemFontFamilies, defaultLabel), + [defaultLabel, family, systemFontFamilies] + ); + const value = family === null + ? options[0]! + : options.find((option) => option.family === family) ?? options[0]!; + const inputValue = open ? query : value.label; + const inputStyle = open ? undefined : optionStyle(value); + const visibleOptions = useMemo( + () => options.filter((option) => optionMatchesQuery(option, query)), + [options, query] + ); + const activeOption = visibleOptions[activeIndex] ?? visibleOptions[0]; + + useEffect(() => { + setActiveIndex(0); + }, [open, query]); + + const selectOption = (option: FontFamilyOption) => { + setQuery(""); + setOpen(false); + onChange(option.family); + }; + const handleKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setOpen(true); + if (visibleOptions.length === 0) return; + setActiveIndex((currentIndex) => Math.min(currentIndex + 1, visibleOptions.length - 1)); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setOpen(true); + if (visibleOptions.length === 0) return; + setActiveIndex((currentIndex) => Math.max(currentIndex - 1, 0)); + return; + } + + if (event.key === "Enter" && open && activeOption) { + event.preventDefault(); + selectOption(activeOption); + return; + } + + if (event.key === "Escape") { + setOpen(false); + setQuery(""); + } + }; + + return ( +
+
+ ); +} diff --git a/packages/app/src/lib/document-export.test.ts b/packages/app/src/lib/document-export.test.ts index 4d3b757b..fb0c114c 100644 --- a/packages/app/src/lib/document-export.test.ts +++ b/packages/app/src/lib/document-export.test.ts @@ -76,6 +76,18 @@ describe("document export helpers", () => { ); }); + it("uses a selected system font for HTML and PDF document styles", () => { + const html = buildMarkdownHtmlDocument({ + bodyHtml: "

Mock export

", + fontFamily: "Example Serif", + language: "zh-CN", + title: "Mock font export" + }); + + expect(html).toContain('font-family: "Example Serif", ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;'); + expect(html).not.toContain('font-family: "Noto Serif CJK SC"'); + }); + it("removes rendered pure LaTeX macro definition blocks from standalone documents", () => { const html = buildMarkdownHtmlDocument({ bodyHtml: [ diff --git a/packages/app/src/lib/document-export.ts b/packages/app/src/lib/document-export.ts index 8a05ff8f..5bd994ec 100644 --- a/packages/app/src/lib/document-export.ts +++ b/packages/app/src/lib/document-export.ts @@ -1,13 +1,19 @@ import katexStyles from "katex/dist/katex.css?raw"; import { isMarkraMathMacroDefinitionSource } from "@markra/editor"; +import { normalizeSystemFontFamilyName, systemFontFamilyCssValue } from "./editor-font"; export type ExportDocumentFormat = "html" | "pdf" | "docx" | "epub" | "latex"; const defaultPdfMarginMm = 18; const defaultPdfPageHeightMm = 297; const defaultPdfPageWidthMm = 210; +const defaultExportFontFamily = 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif'; +const simplifiedChineseExportFontFamily = + '"Noto Serif CJK SC", "Noto Serif SC", "Source Han Serif SC", "Source Han Serif CN", "Songti SC", STSong, SimSun, ' + + defaultExportFontFamily; export type MarkdownExportStyleOptions = { + fontFamily?: string | null; pdfFooter?: string; pdfHeader?: string; pdfHeightMm?: number; @@ -29,6 +35,7 @@ function normalizePdfPageDimensionMm(value: number | undefined, fallback: number } export function createMarkdownExportStyles({ + fontFamily, pdfFooter, pdfHeader, pdfHeightMm, @@ -41,6 +48,21 @@ export function createMarkdownExportStyles({ const pageMarginMm = normalizePdfMarginMm(pdfMarginMm); const pageHeightMm = normalizePdfPageDimensionMm(pdfHeightMm, defaultPdfPageHeightMm); const pageWidthMm = normalizePdfPageDimensionMm(pdfWidthMm, defaultPdfPageWidthMm); + const normalizedFontFamily = normalizeSystemFontFamilyName(fontFamily); + const exportFontFamily = normalizedFontFamily + ? systemFontFamilyCssValue(normalizedFontFamily, defaultExportFontFamily) + : defaultExportFontFamily; + // The language-specific stack must not override the user's explicit font choice. + const simplifiedChineseFontStyles = normalizedFontFamily + ? "" + : ` + +/* Keep Han text and Latin numerals on one CJK-capable family when possible. */ +:root:lang(zh-CN), +:root:lang(zh-SG), +:root:lang(zh-Hans) { + font-family: ${simplifiedChineseExportFontFamily}; +}`; const pageBreakStyles = pdfPageBreakOnH1 ? ` @@ -59,15 +81,8 @@ ${katexStyles} :root { color: #222; background: #fff; - font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; -} - -/* Keep Han text and Latin numerals on one CJK-capable family when possible. */ -:root:lang(zh-CN), -:root:lang(zh-SG), -:root:lang(zh-Hans) { - font-family: "Noto Serif CJK SC", "Noto Serif SC", "Source Han Serif SC", "Source Han Serif CN", "Songti SC", STSong, SimSun, ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; -} + font-family: ${exportFontFamily}; +}${simplifiedChineseFontStyles} body { margin: 0; @@ -368,6 +383,7 @@ export const markdownExportStyles = createMarkdownExportStyles(); type BuildMarkdownHtmlDocumentInput = { bodyHtml: string; + fontFamily?: string | null; language?: string; pdfAuthor?: string; pdfFooter?: string; @@ -469,6 +485,7 @@ export function localFileUrlFromPath(path: string) { export function buildMarkdownHtmlDocument({ bodyHtml, + fontFamily, language = "en", pdfAuthor, pdfFooter, @@ -487,6 +504,7 @@ export function buildMarkdownHtmlDocument({ const escapedHeader = escapeHtmlText(pdfHeader?.trim() ?? ""); const exportBodyHtml = removeRenderedMathMacroDefinitions(removeRenderedMathMacroDefinitionMarkers(bodyHtml)); const documentStyles = styles ?? createMarkdownExportStyles({ + fontFamily, pdfFooter, pdfHeader, pdfHeightMm, diff --git a/packages/app/src/lib/editor-font.ts b/packages/app/src/lib/editor-font.ts index e21212f3..06ce06a3 100644 --- a/packages/app/src/lib/editor-font.ts +++ b/packages/app/src/lib/editor-font.ts @@ -16,7 +16,12 @@ export const defaultEditorFontFamily: EditorFontFamilyPreference = { const visualEditorFontFallback = "var(--font-ui)"; function quoteCssFontFamilyName(family: string) { - return `"${family.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`; + // Angle brackets are escaped because export CSS is serialized inside a raw