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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user