Add font import for Now, Gotham and Open Sauce One overrides; package fonts for Void

Font import:
- Migration M6 adds user_fonts; files are stored content-addressed under
  the local data dir. New Rust commands inspect, import, list, read and
  remove fonts, refusing WOFF2, collections, variable fonts and
  restricted fsType, and unpacking WOFF1 to plain sfnt.
- Imported fonts register in the render worker as user:<Face> families
  with an italic alias, and are pinned in the frozen render prefs so
  issued invoices can still re-render. Fonts referenced by issued
  invoices are hidden, never deleted.
- Size and tracking compensations tuned for the substitutes apply only
  while the face is not overridden; default output and fingerprint
  goldens are unchanged.
- Settings gets a Fonts section with a licence acknowledgement.

Void packaging:
- Install fonts to /usr/share/fonts/voiced with per-family OFL licences,
  set font_dirs, and add INSTALL/REMOVE scripts that run fc-cache.
  xbps-create carries both scripts (verified in a void-glibc container).
- Fix xbps-create dependency specs in build-xbps.sh.
This commit is contained in:
2026-10-04 13:58:06 +05:30
parent 4d18a50753
commit afbc0aa56f
37 changed files with 2436 additions and 72 deletions
+282
View File
@@ -0,0 +1,282 @@
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<UserFontRow[]>([]);
const [loading, setLoading] = useState(true);
const [pending, setPending] = useState<Pending | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(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 (
<div className="voiced-stack">
<p className="voiced-muted">
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.
</p>
{error ? <InlineNotification kind="error" lowContrast hideCloseButton title="Font not imported" subtitle={error} /> : null}
{notice ? <InlineNotification kind="info" lowContrast onCloseButtonClick={() => setNotice(null)} title={notice} /> : null}
{loading ? (
<InlineLoading description="Loading fonts…" />
) : (
<Table size="md" useZebraStyles={false} aria-label="Font overrides">
<TableHead>
<TableRow>
<TableHeader>Face</TableHeader>
<TableHeader>Drawn with</TableHeader>
<TableHeader>Used by</TableHeader>
<TableHeader />
</TableRow>
</TableHead>
<TableBody>
{USER_FACES.map((face) => {
const files = fonts.filter((f) => f.face === face);
return (
<TableRow key={face}>
<TableCell>{face}</TableCell>
<TableCell>
{files.length === 0 ? (
<span className="voiced-muted">{FACE_SUBSTITUTES[face]} (bundled substitute)</span>
) : (
<div className="voiced-stack">
{files.map((f) => (
<div key={f.id} style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap" }}>
<span>
{f.fullName || f.familyName} <span className="voiced-muted">({weightName(f.weight, f.style === "italic")}, {f.fileName})</span>
</span>
{!f.hasRupee ? <Tag size="sm" type="gray">no ₹</Tag> : null}
<Button
kind="ghost"
size="sm"
hasIconOnly
renderIcon={TrashCan}
iconDescription={`Remove ${f.fileName}`}
tooltipPosition="left"
disabled={busy !== null}
onClick={() => void remove(f)}
/>
</div>
))}
</div>
)}
</TableCell>
<TableCell>{templatesUsing(face)}</TableCell>
<TableCell>
<FileUploaderButton
labelText={files.length === 0 ? "Import font…" : "Add or replace…"}
buttonKind="tertiary"
size="sm"
accept={[".ttf", ".otf", ".woff"]}
disabled={busy !== null}
disableLabelChanges
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
void choose(face, file);
}}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
{busy && !pending ? <InlineLoading description="Reading the font…" /> : null}
{pending ? <PendingImport pending={pending} onChange={setPending} onImport={() => void doImport()} busy={busy !== null} /> : null}
</div>
);
}
function PendingImport({
pending,
onChange,
onImport,
busy,
}: {
pending: Pending;
onChange: (p: Pending | null) => void;
onImport: () => void;
busy: boolean;
}) {
const { inspection: i } = pending;
return (
<Tile>
<div className="voiced-stack">
<h4>
{i.fullName || i.family} for {pending.face}
</h4>
<p className="voiced-muted">
{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 ₹"}
</p>
{i.warnings.map((w) => (
<InlineNotification key={w} kind="warning" lowContrast hideCloseButton title={w} />
))}
<div style={{ display: "flex", gap: "1rem", alignItems: "flex-end", flexWrap: "wrap" }}>
<Select
id="font-import-weight"
labelText="Weight"
size="sm"
value={String(pending.weight)}
onChange={(e) => onChange({ ...pending, weight: Number(e.target.value) })}
>
{WEIGHTS.map((w) => (
<SelectItem key={w} value={String(w)} text={weightName(w, false)} />
))}
</Select>
<Checkbox id="font-import-italic" labelText="Italic" checked={pending.italic} onChange={(_, { checked }) => onChange({ ...pending, italic: checked })} />
</div>
<Checkbox
id="font-import-licence"
labelText={`I hold a licence that allows me to use this font in my invoices.`}
checked={pending.acknowledged}
onChange={(_, { checked }) => onChange({ ...pending, acknowledged: checked })}
/>
<div className="voiced-header-action">
<Button size="sm" disabled={!pending.acknowledged || busy} onClick={onImport}>
{busy ? "Importing…" : "Import font"}
</Button>
<Button kind="ghost" size="sm" onClick={() => onChange(null)} disabled={busy}>
Cancel
</Button>
</div>
</div>
</Tile>
);
}