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]]
|
[[package]]
|
||||||
name = "margin-app"
|
name = "margin-app"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"fontdb",
|
||||||
"harper-core",
|
"harper-core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
zip = "2"
|
zip = "2"
|
||||||
|
fontdb = "0.23"
|
||||||
typst = "0.14.2"
|
typst = "0.14.2"
|
||||||
typst-pdf = "0.14.2"
|
typst-pdf = "0.14.2"
|
||||||
typst-as-lib = "0.15.5"
|
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 epub;
|
||||||
|
mod fonts;
|
||||||
mod library;
|
mod library;
|
||||||
mod pdf;
|
mod pdf;
|
||||||
mod project;
|
mod project;
|
||||||
@@ -83,6 +84,7 @@ pub fn run() {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
epub::package_epub,
|
epub::package_epub,
|
||||||
epub::unzip_epub,
|
epub::unzip_epub,
|
||||||
|
fonts::list_system_fonts,
|
||||||
pdf::compile_pdf,
|
pdf::compile_pdf,
|
||||||
project::read_file,
|
project::read_file,
|
||||||
project::write_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 type { ReactNode } from "react";
|
||||||
import { useBook } from "../store/useBook";
|
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";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
@@ -12,6 +23,8 @@ const LANGUAGES = [
|
|||||||
{ value: "pt", label: "Portuguese" },
|
{ value: "pt", label: "Portuguese" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let systemFontCache: string[] | null = null;
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
@@ -19,6 +32,7 @@ interface Draft {
|
|||||||
isbn: string;
|
isbn: string;
|
||||||
language: string;
|
language: string;
|
||||||
bleed: boolean;
|
bleed: boolean;
|
||||||
|
fonts: BookFonts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
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 }) {
|
export function Settings({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
|
||||||
const book = useBook((s) => s.book);
|
const book = useBook((s) => s.book);
|
||||||
const setMetadata = useBook((s) => s.setMetadata);
|
const setMetadata = useBook((s) => s.setMetadata);
|
||||||
@@ -42,16 +80,31 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
|||||||
isbn: book?.metadata.isbn ?? "",
|
isbn: book?.metadata.isbn ?? "",
|
||||||
language: book?.metadata.language ?? "en",
|
language: book?.metadata.language ?? "en",
|
||||||
bleed: book?.settings.bleed ?? true,
|
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;
|
if (!book) return null;
|
||||||
|
|
||||||
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
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 save = () => {
|
||||||
const { bleed, ...metadata } = draft;
|
const { bleed, fonts, ...metadata } = draft;
|
||||||
setMetadata(metadata);
|
setMetadata(metadata);
|
||||||
setSettings({ bleed });
|
setSettings({ bleed, fonts });
|
||||||
onSave();
|
onSave();
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -87,6 +140,51 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</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">
|
<label className="check">
|
||||||
<input type="checkbox" checked={draft.bleed} onChange={(e) => set({ bleed: e.target.checked })} />
|
<input type="checkbox" checked={draft.bleed} onChange={(e) => set({ bleed: e.target.checked })} />
|
||||||
Add bleed for full-page images (print)
|
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 });
|
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images, emitWarnings });
|
||||||
return new Uint8Array(buffer);
|
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);
|
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 {
|
export function fontFamilyName(ref: FontRef): string {
|
||||||
if (ref.kind === "system") return ref.family;
|
if (ref.kind === "system") return ref.family;
|
||||||
return bundledFont(ref.id)?.family ?? "Literata";
|
return bundledFont(ref.id)?.family ?? "Literata";
|
||||||
|
|||||||
@@ -669,6 +669,87 @@ body.resizing {
|
|||||||
accent-color: var(--ink-soft);
|
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,
|
.editor-host,
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
Reference in new issue
Block a user