import { useCallback, useEffect, useState } from "react"; import { Button, Checkbox, FileUploaderButton, InlineLoading, InlineNotification, Select, SelectItem, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Tag, Tile, } from "@carbon/react"; import { TrashCan } from "@carbon/icons-react"; import { confirm } from "@tauri-apps/plugin-dialog"; import { api } from "../lib/api"; import { clientRefusal, FACE_SUBSTITUTES, notifyUserFontsChanged, USER_FACES, weightName, type FontInspection, type UserFace, type UserFontRow, } from "../lib/fontImport"; import { TEMPLATE_CATALOG } from "../pdf/templates/catalog"; const MAX_FONT_BYTES = 16 * 1024 * 1024; const WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900]; interface Pending { face: UserFace; fileName: string; bytes: Uint8Array; inspection: FontInspection; weight: number; italic: boolean; acknowledged: boolean; } const templatesUsing = (face: UserFace): string => TEMPLATE_CATALOG.filter((t) => t.faces?.includes(face)) .map((t) => t.name) .join(", "); /** * Settings, Fonts: one row for each of the three licensed template faces (Now, Gotham, Open Sauce One), showing the * bundled look-alike in use and any imported files. Choosing a file shows what was found in it; the licence * acknowledgement is required before it is stored. */ export function FontOverrideTable() { const [fonts, setFonts] = useState([]); const [loading, setLoading] = useState(true); const [pending, setPending] = useState(null); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const reload = useCallback(async () => { try { setFonts(await api.listUserFonts()); } catch (e) { setError(String(e)); } finally { setLoading(false); } }, []); useEffect(() => { void reload(); }, [reload]); const choose = async (face: UserFace, file: File | undefined) => { if (!file) return; setError(null); setNotice(null); setPending(null); if (file.size > MAX_FONT_BYTES) return setError("The font file is larger than the 16 MB limit."); setBusy(face); try { const bytes = new Uint8Array(await file.arrayBuffer()); const refusal = clientRefusal(bytes); if (refusal) return setError(refusal); const inspection = await api.inspectFont(bytes); setPending({ face, fileName: file.name, bytes, inspection, weight: inspection.weight, italic: inspection.italic, acknowledged: false }); } catch (e) { setError(String(e)); } finally { setBusy(null); } }; const doImport = async () => { if (!pending || !pending.acknowledged) return; setBusy(pending.face); setError(null); try { await api.importFont(pending.bytes, { face: pending.face, fileName: pending.fileName, weight: pending.weight, italic: pending.italic, licenceAcknowledged: true, }); setPending(null); await reload(); notifyUserFontsChanged(); } catch (e) { setError(String(e)); } finally { setBusy(null); } }; const remove = async (font: UserFontRow) => { const ok = await confirm(`Remove ${font.fileName} from ${font.face}? Templates go back to ${FACE_SUBSTITUTES[font.face]} for that weight.`, { title: "Remove font", kind: "warning", }); if (!ok) return; setBusy(font.face); setError(null); try { const outcome = await api.removeUserFont(font.id); setNotice( outcome.hidden ? `${font.fileName} is used by an issued invoice, so it was kept for that invoice and will not be used for new ones.` : `${font.fileName} was removed.`, ); await reload(); notifyUserFontsChanged(); } catch (e) { setError(String(e)); } finally { setBusy(null); } }; return (

Now, Gotham and Open Sauce One are commercial fonts, so Voiced draws them with free look-alikes. If you hold a licence for the real fonts, import the files here (TTF, OTF or WOFF; one file per weight). They are stored on this computer only. Invoices you issue keep the fonts they used.

{error ? : null} {notice ? setNotice(null)} title={notice} /> : null} {loading ? ( ) : ( Face Drawn with Used by {USER_FACES.map((face) => { const files = fonts.filter((f) => f.face === face); return ( {face} {files.length === 0 ? ( {FACE_SUBSTITUTES[face]} (bundled substitute) ) : (
{files.map((f) => (
{f.fullName || f.familyName} ({weightName(f.weight, f.style === "italic")}, {f.fileName}) {!f.hasRupee ? no ₹ : null}
))}
)}
{templatesUsing(face)} ) => { const file = e.target.files?.[0]; e.target.value = ""; void choose(face, file); }} />
); })}
)} {busy && !pending ? : null} {pending ? void doImport()} busy={busy !== null} /> : null}
); } function PendingImport({ pending, onChange, onImport, busy, }: { pending: Pending; onChange: (p: Pending | null) => void; onImport: () => void; busy: boolean; }) { const { inspection: i } = pending; return (

{i.fullName || i.family} for {pending.face}

{pending.fileName} · {i.sourceFormat.toUpperCase()} {i.sourceFormat === "woff" ? " (unpacked)" : ""} · {(i.size / 1024).toFixed(0)} KB · {i.numGlyphs} glyphs · {i.hasBasicLatin ? "full basic Latin" : "incomplete basic Latin"} ·{" "} {i.hasRupee ? "has ₹" : "no ₹"}

{i.warnings.map((w) => ( ))}
onChange({ ...pending, italic: checked })} />
onChange({ ...pending, acknowledged: checked })} />
); }