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:
@@ -10,6 +10,7 @@ import type {
|
||||
Settings,
|
||||
} from "./types";
|
||||
import type { LogoAsset } from "./logo";
|
||||
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
|
||||
|
||||
export interface ArchiveStatus {
|
||||
archived: boolean;
|
||||
@@ -70,6 +71,22 @@ export const api = {
|
||||
readArchive: async (invoiceId: number): Promise<Uint8Array> =>
|
||||
new Uint8Array(await invoke<ArrayBuffer>("read_archive", { invoiceId })),
|
||||
archiveStatus: (invoiceId: number) => invoke<ArchiveStatus>("archive_status", { invoiceId }),
|
||||
// Imported fonts (Now, Gotham, Open Sauce One). The font bytes are the raw body; metadata goes in headers.
|
||||
inspectFont: (bytes: Uint8Array) => invoke<FontInspection>("inspect_font", bytes),
|
||||
importFont: (bytes: Uint8Array, meta: ImportMeta) =>
|
||||
invoke<UserFontRow>("import_font", bytes, {
|
||||
headers: {
|
||||
"x-face": encodeURIComponent(meta.face),
|
||||
"x-file-name": encodeURIComponent(meta.fileName),
|
||||
"x-licence-ack": String(meta.licenceAcknowledged),
|
||||
...(meta.weight !== undefined ? { "x-weight": String(meta.weight) } : {}),
|
||||
...(meta.italic !== undefined ? { "x-style": meta.italic ? "italic" : "normal" } : {}),
|
||||
},
|
||||
}),
|
||||
listUserFonts: (includeHidden = false) => invoke<UserFontRow[]>("list_user_fonts", { includeHidden }),
|
||||
getUserFontBytes: async (sha256: string): Promise<Uint8Array> =>
|
||||
new Uint8Array(await invoke<ArrayBuffer>("get_user_font_bytes", { sha256 })),
|
||||
removeUserFont: (id: number) => invoke<RemoveOutcome>("remove_user_font", { id }),
|
||||
writeExportFile: (path: string, bytes: Uint8Array) =>
|
||||
invoke<string>("write_export_file", bytes, { headers: { "x-path": encodeURIComponent(path) } }),
|
||||
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_PREFS } from "../pdf/model/prefs";
|
||||
import { clientRefusal, selectionFrom, withUserFonts, type UserFontRow } from "./fontImport";
|
||||
|
||||
const row = (id: number, face: UserFontRow["face"], sha: string, over: Partial<UserFontRow> = {}): UserFontRow => ({
|
||||
id, face, familyName: "F", fullName: "F", styleName: "", weight: 400, style: "normal", sha256: sha, fileName: "f.ttf",
|
||||
format: "ttf", size: 1, fsType: 0, hasRupee: true, licenceAckAt: "t", importedAt: "t", hidden: false, ...over,
|
||||
});
|
||||
const A = "a".repeat(64);
|
||||
const B = "b".repeat(64);
|
||||
const C = "c".repeat(64);
|
||||
|
||||
describe("clientRefusal", () => {
|
||||
const bytes = (magic: string) => new Uint8Array([...magic].map((c) => c.charCodeAt(0)).concat(new Array(20).fill(0)));
|
||||
it("passes TTF, OTF and WOFF and refuses the rest with a reason", () => {
|
||||
expect(clientRefusal(new Uint8Array([0, 1, 0, 0, 0, 0, 0, 0]))).toBeNull();
|
||||
expect(clientRefusal(bytes("OTTO"))).toBeNull();
|
||||
expect(clientRefusal(bytes("wOFF"))).toBeNull();
|
||||
expect(clientRefusal(bytes("wOF2"))).toMatch(/WOFF2/);
|
||||
expect(clientRefusal(bytes("ttcf"))).toMatch(/collections/);
|
||||
expect(clientRefusal(bytes("<htm"))).toMatch(/not a TrueType/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withUserFonts", () => {
|
||||
const fonts = [row(1, "Gotham", B), row(2, "Gotham", A, { weight: 700 }), row(3, "Now", C), row(4, "Now", "d".repeat(64), { hidden: true })];
|
||||
|
||||
it("lists visible fonts per face", () => {
|
||||
expect(selectionFrom(fonts)).toEqual({ Gotham: [B, A], Now: [C] });
|
||||
});
|
||||
|
||||
it("adds only the faces the template draws, sorted, and nothing for a template without them", () => {
|
||||
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "purple-pop" }, fonts).fonts).toEqual({ Gotham: [A, B] });
|
||||
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "citrus-split" }, fonts).fonts).toEqual({ Now: [C], Gotham: [A, B] });
|
||||
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "marble" }, fonts).fonts).toEqual({ Now: [C] });
|
||||
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "classic" }, fonts)).toEqual({ ...DEFAULT_PREFS, templateId: "classic" });
|
||||
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "marble" }, [])).toEqual({ ...DEFAULT_PREFS, templateId: "marble" });
|
||||
});
|
||||
|
||||
it("replaces fonts already in the prefs rather than merging", () => {
|
||||
const stale = { ...DEFAULT_PREFS, templateId: "marble", fonts: { Now: ["e".repeat(64)] } };
|
||||
expect(withUserFonts(stale, fonts).fonts).toEqual({ Now: [C] });
|
||||
expect(withUserFonts(stale, []).fonts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { FontFormat, UserFace } from "../pdf/fonts/userFontStore";
|
||||
import { sniffFontFormat, USER_FACES } from "../pdf/fonts/userFontStore";
|
||||
import type { RenderPrefsV1 } from "../pdf/model/prefs";
|
||||
import { catalogEntry } from "../pdf/templates/catalog";
|
||||
|
||||
export { USER_FACES, type UserFace };
|
||||
|
||||
/** The three licensed faces and the bundled look-alike each one is drawn with until a font is imported. */
|
||||
export const FACE_SUBSTITUTES: Record<UserFace, string> = {
|
||||
Now: "Jost",
|
||||
Gotham: "Montserrat",
|
||||
"Open Sauce One": "Poppins",
|
||||
};
|
||||
|
||||
/** What the backend found in a font file (src-tauri/src/commands/fonts.rs `FontInspection`). */
|
||||
export interface FontInspection {
|
||||
format: "ttf" | "otf";
|
||||
sourceFormat: "ttf" | "otf" | "woff";
|
||||
family: string;
|
||||
subfamily: string;
|
||||
fullName: string;
|
||||
postscriptName: string;
|
||||
weight: number;
|
||||
italic: boolean;
|
||||
fsType: number;
|
||||
numGlyphs: number;
|
||||
hasRupee: boolean;
|
||||
hasBasicLatin: boolean;
|
||||
size: number;
|
||||
sha256: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** One stored font file (`UserFont`). */
|
||||
export interface UserFontRow {
|
||||
id: number;
|
||||
face: UserFace;
|
||||
familyName: string;
|
||||
fullName: string;
|
||||
styleName: string;
|
||||
weight: number;
|
||||
style: "normal" | "italic";
|
||||
sha256: string;
|
||||
fileName: string;
|
||||
format: "ttf" | "otf";
|
||||
size: number;
|
||||
fsType: number;
|
||||
hasRupee: boolean;
|
||||
licenceAckAt: string;
|
||||
importedAt: string;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface RemoveOutcome {
|
||||
deleted: boolean;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface ImportMeta {
|
||||
face: UserFace;
|
||||
fileName: string;
|
||||
weight?: number;
|
||||
italic?: boolean;
|
||||
licenceAcknowledged: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same magic-byte check the backend makes, to refuse early with a plain message. TTF, OTF and WOFF pass (the
|
||||
* backend unpacks WOFF); the backend stays the authority and repeats every check.
|
||||
*/
|
||||
export function clientRefusal(bytes: Uint8Array): string | null {
|
||||
const format: FontFormat = sniffFontFormat(bytes);
|
||||
switch (format) {
|
||||
case "ttf":
|
||||
case "otf":
|
||||
case "woff":
|
||||
return null;
|
||||
case "woff2":
|
||||
return "WOFF2 cannot be used: it embeds in a PDF without glyph outlines (invisible text). Import the TTF, OTF or WOFF file instead.";
|
||||
case "ttc":
|
||||
return "Font collections (.ttc) are not supported. Import a single TTF or OTF file.";
|
||||
default:
|
||||
return "This is not a TrueType, OpenType or WOFF font file.";
|
||||
}
|
||||
}
|
||||
|
||||
export const weightName = (weight: number, italic: boolean): string => {
|
||||
const names: Record<number, string> = { 100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular", 500: "Medium", 600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black" };
|
||||
return `${names[weight] ?? weight}${italic ? " Italic" : ""}`;
|
||||
};
|
||||
|
||||
/** Visible fonts grouped by face, as the sha256 list `prefs.fonts` carries. */
|
||||
export function selectionFrom(fonts: readonly UserFontRow[]): NonNullable<RenderPrefsV1["fonts"]> {
|
||||
const out: NonNullable<RenderPrefsV1["fonts"]> = {};
|
||||
for (const f of fonts) {
|
||||
if (f.hidden) continue;
|
||||
(out[f.face] ??= []).push(f.sha256);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prefs to render and issue with: `prefs` plus the imported fonts for the faces this template draws. Fonts for
|
||||
* faces it does not use stay out, so an invoice only pins (and so only protects) the fonts it really uses. The
|
||||
* result is for rendering; keep the clean `prefs` for the page-setup controls and the saved default.
|
||||
*/
|
||||
export function withUserFonts(prefs: RenderPrefsV1, fonts: readonly UserFontRow[]): RenderPrefsV1 {
|
||||
const { fonts: _drop, ...rest } = prefs;
|
||||
const faces = catalogEntry(prefs.templateId).faces ?? [];
|
||||
const selection = selectionFrom(fonts);
|
||||
const used: NonNullable<RenderPrefsV1["fonts"]> = {};
|
||||
for (const face of faces) {
|
||||
const ids = selection[face];
|
||||
if (ids && ids.length > 0) used[face] = [...ids].sort();
|
||||
}
|
||||
return Object.keys(used).length > 0 ? { ...rest, fonts: used } : rest;
|
||||
}
|
||||
|
||||
// Settings and the invoice view are separate parts of the UI; this lets the list in one refresh the other.
|
||||
const listeners = new Set<() => void>();
|
||||
export const onUserFontsChanged = (cb: () => void): (() => void) => {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
};
|
||||
export const notifyUserFontsChanged = (): void => listeners.forEach((cb) => cb());
|
||||
+12
-1
@@ -1,11 +1,22 @@
|
||||
import { createAutoClient, type RenderClient } from "../pdf/render/client";
|
||||
import type { UserFace } from "../pdf/fonts/userFontStore";
|
||||
import { api } from "./api";
|
||||
|
||||
let client: RenderClient | null = null;
|
||||
let lastId = 0;
|
||||
|
||||
/** The one render client (and so one worker) shared by the preview and export. */
|
||||
export function getRenderClient(): RenderClient {
|
||||
client ??= createAutoClient();
|
||||
client ??= createAutoClient({
|
||||
// Font metadata comes from the database row, the bytes over the binary IPC; both by the file's sha256.
|
||||
loadUserFont: async (sha256) => {
|
||||
const rows = await api.listUserFonts(true);
|
||||
const row = rows.find((r) => r.sha256 === sha256);
|
||||
if (!row) throw new Error("An imported font used by this invoice is no longer in the font library");
|
||||
const bytes = await api.getUserFontBytes(sha256);
|
||||
return { face: row.face as UserFace, weight: row.weight, italic: row.style === "italic", sha256, bytes };
|
||||
},
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user