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.
283 lines
9.8 KiB
TypeScript
283 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
}
|