mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
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.
This commit is contained in:
1 parent
28367b6670
commit
9a38976000
8 files changed
+251
-4
No files matched your search
Generated
+2
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String> {
|
||||
let db = system_db();
|
||||
let mut names: Vec<String> = 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<Vec<u8>> {
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+101
-3
@@ -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 (
|
||||
<select value={encodeRef(value)} style={{ fontFamily: fontStack(value) }} onChange={(e) => onChange(decodeRef(e.target.value))}>
|
||||
<optgroup label="Bundled">
|
||||
{BUNDLED_FONTS.map((f) => (
|
||||
<option key={f.id} value={`b:${f.id}`}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{(system.length > 0 || extra.length > 0) && (
|
||||
<optgroup label="System">
|
||||
{[...extra, ...system].map((name) => (
|
||||
<option key={name} value={`s:${name}`}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
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<string[]>(() => 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<Draft>) => 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: ()
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Typography</span>
|
||||
<div className="font-presets">
|
||||
{FONT_PAIRINGS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className="font-preset"
|
||||
data-on={activePreset === p.id}
|
||||
onClick={() => set({ fonts: { body: p.body, heading: p.heading } })}
|
||||
>
|
||||
<span className="font-preset-demo" style={{ fontFamily: fontStack(p.heading) }}>
|
||||
Ag
|
||||
</span>
|
||||
<span className="font-preset-name">{p.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="font-advanced" onClick={() => setAdvanced((v) => !v)}>
|
||||
{advanced ? "Hide custom fonts" : "Customize fonts"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{advanced && (
|
||||
<>
|
||||
<Field label="Body font">
|
||||
<FontSelect value={draft.fonts.body} system={system} onChange={(ref) => setFont("body", ref)} />
|
||||
</Field>
|
||||
<Field label="Heading font">
|
||||
<FontSelect value={draft.fonts.heading} system={system} onChange={(ref) => setFont("heading", ref)} />
|
||||
</Field>
|
||||
{usesSystem && (
|
||||
<p className="font-note">System fonts preview here but may not embed in exported PDF or EPUB, so they can look different on other devices.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="font-sample" style={{ fontFamily: fontStack(draft.fonts.body) }}>
|
||||
<span className="font-sample-title" style={{ fontFamily: fontStack(draft.fonts.heading) }}>
|
||||
Chapter One
|
||||
</span>
|
||||
She read the first line twice, and the whole quiet house seemed to lean in to listen.
|
||||
</div>
|
||||
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={draft.bleed} onChange={(e) => set({ bleed: e.target.checked })} />
|
||||
Add bleed for full-page images (print)
|
||||
|
||||
@@ -15,3 +15,12 @@ export async function compilePdf(
|
||||
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images, emitWarnings });
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
export async function listSystemFonts(): Promise<string[]> {
|
||||
if (!isDesktop) return [];
|
||||
try {
|
||||
return await invoke<string[]>("list_system_fonts");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,14 @@ export function bundledFont(id: string): BundledFont | undefined {
|
||||
return BUNDLED_FONTS.find((f) => f.id === id);
|
||||
}
|
||||
|
||||
export function encodeRef(ref: FontRef): string {
|
||||
return ref.kind === "bundled" ? `b:${ref.id}` : `s:${ref.family}`;
|
||||
}
|
||||
|
||||
export function decodeRef(value: string): FontRef {
|
||||
return value.startsWith("s:") ? { kind: "system", family: value.slice(2) } : { kind: "bundled", id: value.slice(2) };
|
||||
}
|
||||
|
||||
export function fontFamilyName(ref: FontRef): string {
|
||||
if (ref.kind === "system") return ref.family;
|
||||
return bundledFont(ref.id)?.family ?? "Literata";
|
||||
|
||||
@@ -669,6 +669,87 @@ body.resizing {
|
||||
accent-color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.font-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.font-preset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 9px 12px;
|
||||
min-width: 70px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--raised);
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms var(--ease), box-shadow 120ms var(--ease);
|
||||
}
|
||||
|
||||
.font-preset:hover {
|
||||
border-color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.font-preset[data-on="true"] {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.font-preset-demo {
|
||||
font-size: 23px;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.font-preset-name {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-1);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.font-advanced {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.font-note {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-1);
|
||||
line-height: 1.5;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.font-sample {
|
||||
padding: 15px 17px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.font-sample-title {
|
||||
display: block;
|
||||
font-size: 21px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.editor-host,
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
|
||||
Reference in new issue
Block a user