From 9a38976000e1c6447170849653c5b0170edd2499 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 22 Jun 2026 14:24:09 -0400 Subject: [PATCH] feat(fonts): add font picker to book setup Add a Typography section to book setup with one-click preset pairings, an advanced body/heading picker, and a live sample. Enumerate installed fonts via a new list_system_fonts Tauri command (fontdb) so they appear alongside the bundled set, with a note that system fonts may not embed in exports. --- src-tauri/Cargo.lock | 3 +- src-tauri/Cargo.toml | 1 + src-tauri/src/fonts.rs | 47 ++++++++++++++++ src-tauri/src/lib.rs | 2 + src/components/Settings.tsx | 104 ++++++++++++++++++++++++++++++++++-- src/ipc.ts | 9 ++++ src/model/fonts.ts | 8 +++ src/styles/app.css | 81 ++++++++++++++++++++++++++++ 8 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/fonts.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9e19f17..75c683b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4704,9 +4704,10 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "margin-app" -version = "0.1.3" +version = "0.1.4" dependencies = [ "base64 0.22.1", + "fontdb", "harper-core", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 67e524e..7a3fe6d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.22" zip = "2" +fontdb = "0.23" typst = "0.14.2" typst-pdf = "0.14.2" typst-as-lib = "0.15.5" diff --git a/src-tauri/src/fonts.rs b/src-tauri/src/fonts.rs new file mode 100644 index 0000000..82528ea --- /dev/null +++ b/src-tauri/src/fonts.rs @@ -0,0 +1,47 @@ +use fontdb::{Database, Family, Query}; + +fn system_db() -> Database { + let mut db = Database::new(); + db.load_system_fonts(); + db +} + +#[tauri::command(async)] +pub fn list_system_fonts() -> Vec { + let db = system_db(); + let mut names: Vec = db + .faces() + .filter_map(|face| face.families.first().map(|(name, _)| name.clone())) + .collect(); + names.sort(); + names.dedup(); + names +} + +pub fn system_font_bytes(family: &str) -> Vec> { + let db = system_db(); + let mut out = Vec::new(); + let styles = [ + (fontdb::Weight::NORMAL, fontdb::Style::Normal), + (fontdb::Weight::NORMAL, fontdb::Style::Italic), + (fontdb::Weight::BOLD, fontdb::Style::Normal), + (fontdb::Weight::BOLD, fontdb::Style::Italic), + ]; + let mut seen = std::collections::HashSet::new(); + for (weight, style) in styles { + let query = Query { + families: &[Family::Name(family)], + weight, + style, + ..Query::default() + }; + if let Some(id) = db.query(&query) { + if seen.insert(id) { + if let Some(bytes) = db.with_face_data(id, |data, _index| data.to_vec()) { + out.push(bytes); + } + } + } + } + out +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3a8f153..907ee4d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod epub; +mod fonts; mod library; mod pdf; mod project; @@ -83,6 +84,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ epub::package_epub, epub::unzip_epub, + fonts::list_system_fonts, pdf::compile_pdf, project::read_file, project::write_file, diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 049ba73..3379e0a 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,6 +1,17 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { ReactNode } from "react"; import { useBook } from "../store/useBook"; +import { listSystemFonts } from "../ipc"; +import { + type BookFonts, + type FontRef, + BUNDLED_FONTS, + FONT_PAIRINGS, + decodeRef, + encodeRef, + fontStack, + pairingFor, +} from "../model/fonts"; import { Icon } from "./Icon"; const LANGUAGES = [ @@ -12,6 +23,8 @@ const LANGUAGES = [ { value: "pt", label: "Portuguese" }, ]; +let systemFontCache: string[] | null = null; + interface Draft { title: string; subtitle: string; @@ -19,6 +32,7 @@ interface Draft { isbn: string; language: string; bleed: boolean; + fonts: BookFonts; } function Field({ label, children }: { label: string; children: ReactNode }) { @@ -30,6 +44,30 @@ function Field({ label, children }: { label: string; children: ReactNode }) { ); } +function FontSelect({ value, system, onChange }: { value: FontRef; system: string[]; onChange: (ref: FontRef) => void }) { + const extra = value.kind === "system" && !system.includes(value.family) ? [value.family] : []; + return ( + + ); +} + export function Settings({ onClose, onSave }: { onClose: () => void; onSave: () => void }) { const book = useBook((s) => s.book); const setMetadata = useBook((s) => s.setMetadata); @@ -42,16 +80,31 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: () isbn: book?.metadata.isbn ?? "", language: book?.metadata.language ?? "en", bleed: book?.settings.bleed ?? true, + fonts: book?.settings.fonts ?? FONT_PAIRINGS[0], })); + const [system, setSystem] = useState(() => systemFontCache ?? []); + const [advanced, setAdvanced] = useState(() => pairingFor(draft.fonts) === null); + + useEffect(() => { + if (systemFontCache) return; + listSystemFonts().then((fonts) => { + systemFontCache = fonts; + setSystem(fonts); + }); + }, []); if (!book) return null; const set = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); + const setFont = (slot: "body" | "heading", ref: FontRef) => set({ fonts: { ...draft.fonts, [slot]: ref } }); + + const activePreset = pairingFor(draft.fonts); + const usesSystem = draft.fonts.body.kind === "system" || draft.fonts.heading.kind === "system"; const save = () => { - const { bleed, ...metadata } = draft; + const { bleed, fonts, ...metadata } = draft; setMetadata(metadata); - setSettings({ bleed }); + setSettings({ bleed, fonts }); onSave(); onClose(); }; @@ -87,6 +140,51 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: () ))} + +
+ Typography +
+ {FONT_PAIRINGS.map((p) => ( + + ))} +
+ +
+ + {advanced && ( + <> + + setFont("body", ref)} /> + + + setFont("heading", ref)} /> + + {usesSystem && ( +

System fonts preview here but may not embed in exported PDF or EPUB, so they can look different on other devices.

+ )} + + )} + +
+ + Chapter One + + She read the first line twice, and the whole quiet house seemed to lean in to listen. +
+