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
+9 -1
View File
@@ -108,12 +108,20 @@ Launch **Voiced** from the application menu, or run `voiced`. Remove it with
The package installs `/usr/bin/voiced`, a desktop entry and hicolor icons, and depends on The package installs `/usr/bin/voiced`, a desktop entry and hicolor icons, and depends on
`libwebkit2gtk41`. `libwebkit2gtk41`.
**Fonts on Void.** The package also installs the bundled TTF fonts under
`/usr/share/fonts/voiced/<family>/` and each family's OFL licence under
`/usr/share/licenses/voiced/`. The `voiced/` subdirectory avoids file conflicts with Void's own
`font-ibm-plex-ttf` and `noto-fonts-ttf`. The `INSTALL` and `REMOVE` scripts in `packaging/void/`
run `fc-cache -f /usr/share/fonts/voiced` after install and removal. `xbps-create` packs them from
the destdir root (checked with xbps 0.60.7).
### Building through xbps-src ### Building through xbps-src
Copy `packaging/void/template` into a `void-packages` checkout and build it the standard way: Copy `packaging/void/template` into a `void-packages` checkout and build it the standard way:
```bash ```bash
cp -r packaging/void/template void-packages/srcpkgs/voiced/template mkdir -p void-packages/srcpkgs/voiced
cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE void-packages/srcpkgs/voiced/
cd void-packages && ./xbps-src pkg voiced cd void-packages && ./xbps-src pkg voiced
``` ```
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# xbps action script for 'voiced': $1 = action, $2 = pkgname, $3 = version.
#
# Rebuilds the fontconfig cache for the bundled fonts after install and upgrade.
# The stock x11-fonts trigger (enabled by font_dirs) only runs fc-cache when
# mkfontdir and mkfontscale are installed, so it is not relied on here.
# Never fails the transaction: a missing fc-cache or a cache error is ignored.
case "$1" in
post)
if command -v fc-cache >/dev/null 2>&1; then
fc-cache -f /usr/share/fonts/voiced >/dev/null 2>&1 || :
fi
;;
esac
exit 0
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# xbps action script for 'voiced': $1 = action, $2 = pkgname, $3 = version.
#
# Refreshes the fontconfig cache after the bundled fonts are removed, so stale
# entries for /usr/share/fonts/voiced do not linger.
# Never fails the transaction: a missing fc-cache or a cache error is ignored.
case "$1" in
post)
if command -v fc-cache >/dev/null 2>&1; then
fc-cache -f /usr/share/fonts/voiced >/dev/null 2>&1 || :
fi
;;
esac
exit 0
+22 -1
View File
@@ -54,6 +54,27 @@ install_icon src-tauri/icons/icon.png 512x512
install -Dm644 README.md "$STAGE/usr/share/doc/voiced/README.md" install -Dm644 README.md "$STAGE/usr/share/doc/voiced/README.md"
# Bundled fonts: one subdirectory per family under /usr/share/fonts/voiced, plus
# each family's OFL text. Only *.ttf files are installed (not manifest.json, and
# not the legacy woff/woff2 files that sit directly in public/fonts).
for dir in public/fonts/*/; do
family="$(basename "$dir")"
for ttf in "$dir"*.ttf; do
install -Dm644 "$ttf" "$STAGE/usr/share/fonts/voiced/$family/$(basename "$ttf")"
done
install -Dm644 "${dir}OFL.txt" "$STAGE/usr/share/licenses/voiced/${family}-OFL.txt"
done
# xbps action scripts. xbps-create packs INSTALL and REMOVE found at the root of
# the destdir into the archive (verified: they appear as ./INSTALL and ./REMOVE
# members and are excluded from files.plist), and xbps runs them with $1 set to
# the action. They refresh the fontconfig cache for /usr/share/fonts/voiced.
# Fallback if a future xbps-create stops carrying them: font cache refresh would
# then rely on fontconfig regenerating its cache on first use, or on the xbps-src
# template (which uses the same scripts).
install -Dm755 packaging/void/INSTALL "$STAGE/INSTALL"
install -Dm755 packaging/void/REMOVE "$STAGE/REMOVE"
echo "==> Creating $PKGVER.$ARCH.xbps" echo "==> Creating $PKGVER.$ARCH.xbps"
# Kept outside Vite's `dist/` output directory, which is wiped on every build. # Kept outside Vite's `dist/` output directory, which is wiped on every build.
OUT="$ROOT/build/void" OUT="$ROOT/build/void"
@@ -71,7 +92,7 @@ mkdir -p "$REPO"
-l "proprietary" \ -l "proprietary" \
-H "https://git.bongbetic.com/xavierk/Voiced" \ -H "https://git.bongbetic.com/xavierk/Voiced" \
-m "Xavier Karma <xavier@bongbetic.com>" \ -m "Xavier Karma <xavier@bongbetic.com>" \
-D "libwebkit2gtk41>=2.50_1 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils" \ -D "libwebkit2gtk41>=2.50_1 fontconfig>=0 xdg-utils>=0 hicolor-icon-theme>=0 desktop-file-utils>=0" \
-t "office finance" \ -t "office finance" \
--compression zstd \ --compression zstd \
"$STAGE" "$STAGE"
+23 -1
View File
@@ -3,7 +3,9 @@
# Canonical Void Linux package definition, to be built with xbps-src from a # Canonical Void Linux package definition, to be built with xbps-src from a
# void-packages checkout: # void-packages checkout:
# #
# cp -r packaging/void/template void-packages/srcpkgs/voiced/template # mkdir -p void-packages/srcpkgs/voiced
# cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE \
# void-packages/srcpkgs/voiced/
# cd void-packages && ./xbps-src pkg voiced # cd void-packages && ./xbps-src pkg voiced
# #
# Notes: # Notes:
@@ -12,6 +14,13 @@
# tarball, mirror it, or drop the sources into the xbps-src masterdir. # tarball, mirror it, or drop the sources into the xbps-src masterdir.
# * `checksum=SKIP` avoids pinning a hash that cannot be computed here; replace # * `checksum=SKIP` avoids pinning a hash that cannot be computed here; replace
# it with the real sha256 once the tarball is published. # it with the real sha256 once the tarball is published.
# * INSTALL and REMOVE (next to this file) are xbps action scripts that run
# `fc-cache -f /usr/share/fonts/voiced` after install and removal. xbps-src
# picks them up automatically from srcpkgs/voiced/INSTALL and
# srcpkgs/voiced/REMOVE and packs them into the package; the template does
# not install them. Copy them next to the template (see the commands above).
# The stock x11-fonts trigger enabled by font_dirs does not run fc-cache
# unless mkfontdir and mkfontscale are installed, hence these scripts.
# * If the build machine is offline, pre-populate the npm cache and Cargo # * If the build machine is offline, pre-populate the npm cache and Cargo
# registry in the masterdir before building. # registry in the masterdir before building.
# #
@@ -29,6 +38,9 @@ homepage="https://git.bongbetic.com/xavierk/Voiced"
distfiles="https://git.bongbetic.com/xavierk/Voiced/archive/v${version}.tar.gz" distfiles="https://git.bongbetic.com/xavierk/Voiced/archive/v${version}.tar.gz"
checksum=SKIP checksum=SKIP
compression="zstd" compression="zstd"
# Fonts install under a voiced/ subdirectory so they never conflict with the
# files of Void's own font-ibm-plex-ttf and noto-fonts-ttf packages.
font_dirs="/usr/share/fonts/voiced"
do_build() { do_build() {
npm install --include=dev --no-audit --no-fund npm install --include=dev --no-audit --no-fund
@@ -51,4 +63,14 @@ do_install() {
vinstall "${wrksrc}/src-tauri/icons/icon.png" 644 \ vinstall "${wrksrc}/src-tauri/icons/icon.png" 644 \
usr/share/icons/hicolor/512x512/apps voiced.png usr/share/icons/hicolor/512x512/apps voiced.png
vdoc "${wrksrc}/README.md" vdoc "${wrksrc}/README.md"
# Bundled fonts: one subdirectory per family, plus its OFL text.
# manifest.json and any other non-font file are deliberately skipped.
for dir in "${wrksrc}"/public/fonts/*/; do
family="$(basename "${dir}")"
for ttf in "${dir}"*.ttf; do
vinstall "${ttf}" 644 "usr/share/fonts/voiced/${family}"
done
vlicense "${dir}OFL.txt" "${family}-OFL.txt"
done
} }
+1
View File
@@ -4290,6 +4290,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"flate2",
"image", "image",
"percent-encoding", "percent-encoding",
"rusqlite", "rusqlite",
+1
View File
@@ -27,6 +27,7 @@ uuid = { version = "1", features = ["v4"] }
thiserror = "2" thiserror = "2"
base64 = "0.22" base64 = "0.22"
sha2 = "0.10" sha2 = "0.10"
flate2 = "1"
percent-encoding = "2" percent-encoding = "2"
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,6 +2,7 @@ pub mod archive;
pub mod assets; pub mod assets;
pub mod clients; pub mod clients;
pub mod files; pub mod files;
pub mod fonts;
pub mod invoice; pub mod invoice;
pub mod logo; pub mod logo;
pub mod raw; pub mod raw;
+30 -3
View File
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
const BACKUP_PREFIX: &str = "voiced-pre-v"; const BACKUP_PREFIX: &str = "voiced-pre-v";
/// Highest schema version, i.e. the number of entries in `migrations()`. /// Highest schema version, i.e. the number of entries in `migrations()`.
const LATEST_VERSION: i64 = 5; const LATEST_VERSION: i64 = 6;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum DbError { pub enum DbError {
@@ -190,8 +190,35 @@ ALTER TABLE app_settings ADD COLUMN logo_meta TEXT NOT NULL DEFAULT '';
ALTER TABLE app_settings ADD COLUMN logo_includes_name TEXT NOT NULL DEFAULT 'auto'; ALTER TABLE app_settings ADD COLUMN logo_includes_name TEXT NOT NULL DEFAULT 'auto';
"#; "#;
/// Version 6: user-imported fonts for the three licensed template faces. The files live content-addressed under
/// `<local data dir>/fonts/<sha256>.<ttf|otf>`; invoices freeze the sha256 of the fonts they used in render_prefs.
/// `hidden` retires a font that an issued invoice still references (never deleted while referenced).
const M6: &str = r#"
CREATE TABLE user_fonts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
face TEXT NOT NULL CHECK (face IN ('Now', 'Gotham', 'Open Sauce One')),
family_name TEXT NOT NULL,
full_name TEXT NOT NULL,
style_name TEXT NOT NULL DEFAULT '',
weight INTEGER NOT NULL CHECK (weight BETWEEN 100 AND 900),
style TEXT NOT NULL CHECK (style IN ('normal', 'italic')),
sha256 TEXT NOT NULL,
file_name TEXT NOT NULL,
format TEXT NOT NULL CHECK (format IN ('ttf', 'otf')),
size INTEGER NOT NULL,
fs_type INTEGER NOT NULL DEFAULT 0,
has_rupee INTEGER NOT NULL DEFAULT 0,
licence_ack_at TEXT NOT NULL,
imported_at TEXT NOT NULL,
hidden INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_user_fonts_sha ON user_fonts(sha256);
-- One visible font per face, weight and style; a hidden one keeps its row but frees the slot.
CREATE UNIQUE INDEX idx_user_fonts_slot ON user_fonts(face, weight, style) WHERE hidden = 0;
"#;
fn migrations() -> Migrations<'static> { fn migrations() -> Migrations<'static> {
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5)]) Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)])
} }
/// Open (creating if needed) the database at `path` and bring it to the latest schema. /// Open (creating if needed) the database at `path` and bring it to the latest schema.
@@ -620,7 +647,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
assert_eq!(user_version(&old), 4); assert_eq!(user_version(&old), 4);
} }
let conn = open(&path, &dir.path().join("backups")).unwrap(); let conn = open(&path, &dir.path().join("backups")).unwrap();
assert_eq!(user_version(&conn), 5); assert_eq!(user_version(&conn), LATEST_VERSION);
let (logo, meta): (String, String) = conn let (logo, meta): (String, String) = conn
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| { .query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| {
Ok((r.get(0)?, r.get(1)?)) Ok((r.get(0)?, r.get(1)?))
+5
View File
@@ -141,6 +141,11 @@ pub fn run() {
commands::archive::archive_pdf, commands::archive::archive_pdf,
commands::archive::read_archive, commands::archive::read_archive,
commands::archive::archive_status, commands::archive::archive_status,
commands::fonts::inspect_font,
commands::fonts::import_font,
commands::fonts::list_user_fonts,
commands::fonts::get_user_font_bytes,
commands::fonts::remove_user_font,
commands::files::write_export_file, commands::files::write_export_file,
commands::files::get_last_export_dir, commands::files::get_last_export_dir,
commands::files::reveal_in_folder, commands::files::reveal_in_folder,
+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>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
import { api } from "../lib/api";
import { onUserFontsChanged, type UserFontRow } from "../lib/fontImport";
/** The visible user-imported fonts; reloads when the Settings font table changes them. Empty while loading or on error. */
export function useUserFonts(): UserFontRow[] {
const [fonts, setFonts] = useState<UserFontRow[]>([]);
useEffect(() => {
let active = true;
const load = () =>
api
.listUserFonts()
.then((rows) => active && setFonts(rows))
.catch(() => active && setFonts([]));
void load();
const off = onUserFontsChanged(() => void load());
return () => {
active = false;
off();
};
}, []);
return fonts;
}
+17
View File
@@ -10,6 +10,7 @@ import type {
Settings, Settings,
} from "./types"; } from "./types";
import type { LogoAsset } from "./logo"; import type { LogoAsset } from "./logo";
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
export interface ArchiveStatus { export interface ArchiveStatus {
archived: boolean; archived: boolean;
@@ -70,6 +71,22 @@ export const api = {
readArchive: async (invoiceId: number): Promise<Uint8Array> => readArchive: async (invoiceId: number): Promise<Uint8Array> =>
new Uint8Array(await invoke<ArrayBuffer>("read_archive", { invoiceId })), new Uint8Array(await invoke<ArrayBuffer>("read_archive", { invoiceId })),
archiveStatus: (invoiceId: number) => invoke<ArchiveStatus>("archive_status", { 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) => writeExportFile: (path: string, bytes: Uint8Array) =>
invoke<string>("write_export_file", bytes, { headers: { "x-path": encodeURIComponent(path) } }), invoke<string>("write_export_file", bytes, { headers: { "x-path": encodeURIComponent(path) } }),
getLastExportDir: () => invoke<string>("get_last_export_dir"), getLastExportDir: () => invoke<string>("get_last_export_dir"),
+45
View File
@@ -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();
});
});
+125
View File
@@ -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
View File
@@ -1,11 +1,22 @@
import { createAutoClient, type RenderClient } from "../pdf/render/client"; 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 client: RenderClient | null = null;
let lastId = 0; let lastId = 0;
/** The one render client (and so one worker) shared by the preview and export. */ /** The one render client (and so one worker) shared by the preview and export. */
export function getRenderClient(): RenderClient { 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; return client;
} }
+26
View File
@@ -2,6 +2,7 @@ import { create, type Font } from "fontkit";
import { FONT_MANIFEST } from "../fonts/manifest"; import { FONT_MANIFEST } from "../fonts/manifest";
import { familySources, FONT_STACK, stack, type FamilySource } from "../fonts/stack"; import { familySources, FONT_STACK, stack, type FamilySource } from "../fonts/stack";
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles"; import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
import { userFamilySources } from "../fonts/userFontStore";
export interface MeasureInput { export interface MeasureInput {
text: string; text: string;
@@ -29,6 +30,29 @@ export interface Measurer {
missing(text: string, face?: string): number[]; missing(text: string, face?: string): number[];
} }
const userFonts = new Map<string, Font>();
/**
* The fontkit font of a user-imported family at a weight and style (the file react-pdf resolves for it), or
* undefined when `familyName` is not a user family. Parsed once per file and shared by every measurer.
*/
export function userFontkitFont(familyName: string, weight: number, italic: boolean): Font | null | undefined {
const sources = userFamilySources(familyName);
if (sources.length === 0) return undefined;
const src = resolveSource(
sources.map((s) => ({ file: s.id, fontWeight: s.fontWeight, fontStyle: s.fontStyle })),
weight,
italic ? "italic" : "normal",
);
if (!src) return null;
let font = userFonts.get(src.file);
if (!font) {
font = create((sources.find((s) => s.id === src.file) as { bytes: Uint8Array }).bytes) as Font;
userFonts.set(src.file, font);
}
return font;
}
/** react-pdf's FontFamily.resolve, so the measurer reads the same file the renderer embeds. */ /** react-pdf's FontFamily.resolve, so the measurer reads the same file the renderer embeds. */
function resolveSource(sources: FamilySource[], weight: number, style: "normal" | "italic"): FamilySource | undefined { function resolveSource(sources: FamilySource[], weight: number, style: "normal" | "italic"): FamilySource | undefined {
const styled = sources.filter((s) => s.fontStyle === style); const styled = sources.filter((s) => s.fontStyle === style);
@@ -57,6 +81,8 @@ export function createMeasurer(loadBytes: (file: string) => Uint8Array): Measure
const fonts = new Map<string, Font>(); const fonts = new Map<string, Font>();
function load(familyName: string, weight: number, italic: boolean): Font | null { function load(familyName: string, weight: number, italic: boolean): Font | null {
const own = userFontkitFont(familyName, weight, italic);
if (own !== undefined) return own;
const fam = FONT_MANIFEST.families.find((f) => f.family === familyName); const fam = FONT_MANIFEST.families.find((f) => f.family === familyName);
if (!fam) return null; if (!fam) return null;
const src = resolveSource(familySources(fam), weight, italic ? "italic" : "normal"); const src = resolveSource(familySources(fam), weight, italic ? "italic" : "normal");
+8
View File
@@ -1,4 +1,5 @@
import { FONT_MANIFEST, type FontFileEntry } from "../fonts/manifest"; import { FONT_MANIFEST, type FontFileEntry } from "../fonts/manifest";
import { userFontkitFont } from "./measure";
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles"; import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
function metricsFile(family: string, weight: number, italic: boolean): FontFileEntry | undefined { function metricsFile(family: string, weight: number, italic: boolean): FontFileEntry | undefined {
@@ -23,6 +24,13 @@ function metricsFile(family: string, weight: number, italic: boolean): FontFileE
export function chromeHeight(role: RoleName | TypeToken, opts: { weight?: number; italic?: boolean; faceOverrides?: Readonly<Record<string, string>> } = {}): number { export function chromeHeight(role: RoleName | TypeToken, opts: { weight?: number; italic?: boolean; faceOverrides?: Readonly<Record<string, string>> } = {}): number {
const token = roleToken(role); const token = roleToken(role);
const family = resolveFace(token.face, opts.faceOverrides); const family = resolveFace(token.face, opts.faceOverrides);
// A user-imported font has no manifest entry; its metrics come from the font itself (hhea, as the manifest's do).
const own = userFontkitFont(family, opts.weight ?? token.weight, opts.italic ?? token.italic ?? false);
if (own) {
// fontkit's types omit the hhea metrics it exposes.
const { ascent, descent, lineGap } = own as unknown as { ascent: number; descent: number; lineGap: number };
return Math.ceil(((ascent - descent + lineGap) / own.unitsPerEm) * token.size) + 1;
}
const file = metricsFile(family, opts.weight ?? token.weight, opts.italic ?? token.italic ?? false); const file = metricsFile(family, opts.weight ?? token.weight, opts.italic ?? token.italic ?? false);
if (!file) throw new Error(`chromeHeight: no font metrics for "${family}"`); if (!file) throw new Error(`chromeHeight: no font metrics for "${family}"`);
// The manifest stores descent as a negative number, as the font's hhea table does. // The manifest stores descent as a negative number, as the font's hhea table does.
+97 -12
View File
@@ -1,6 +1,15 @@
import { Font } from "@react-pdf/renderer"; import { Font } from "@react-pdf/renderer";
import { FONT_MANIFEST } from "./manifest"; import { FONT_MANIFEST } from "./manifest";
import { familySources } from "./stack"; import { familySources } from "./stack";
import {
assertSfnt,
familyNameFor,
isUserFamilyKnown,
rememberUserFiles,
userFamilySources,
type UserFace,
type UserFontFile,
} from "./userFontStore";
export { FONT_STACK, familySources, stack, type FamilySource } from "./stack"; export { FONT_STACK, familySources, stack, type FamilySource } from "./stack";
@@ -71,7 +80,9 @@ function primeComponents(font: FkFont): void {
for (const cp of lowest.values()) font.glyphForCodePoint(cp); for (const cp of lowest.values()) font.glyphForCodePoint(cp);
} }
let primed: Promise<void> | null = null; /** Descriptors already primed; a font registered after the first render (a user font) is primed on the next call. */
const primedKeys = new Set<string>();
let primeChain: Promise<void> = Promise.resolve();
/** /**
* Loads every registered face and gives the component glyphs of its composites their code points. fontkit * Loads every registered face and gives the component glyphs of its composites their code points. fontkit
@@ -79,28 +90,102 @@ let primed: Promise<void> | null = null;
* time with no code points: Poppins' colon contains the period, so a document that sets ':' before any '.' * time with no code points: Poppins' colon contains the period, so a document that sets ':' before any '.'
* would leave the period glyph with codePoints [] for every later document (amounts extracted as "1,200;00", * would leave the period glyph with codePoints [] for every later document (amounts extracted as "1,200;00",
* textkit's line breaks slipping). Creating those glyphs here first makes a render independent of the renders * textkit's line breaks slipping). Creating those glyphs here first makes a render independent of the renders
* before it. Memoised; await it before the first render. * before it. Covers the bundled families and any user family registered so far, each descriptor once, so a
* font registered later is not skipped. Await it before the first render.
*/ */
export function primeFontGlyphs(): Promise<void> { export function primeFontGlyphs(): Promise<void> {
if (!registered) return Promise.resolve(); if (!registered && primedKeys.size === 0 && userFamilies.length === 0) return Promise.resolve();
if (!primed) { const next = primeChain.then(run);
primed = run().catch((err) => { primeChain = next.catch(() => undefined);
primed = null; return next;
throw err;
});
}
return primed;
async function run(): Promise<void> { async function run(): Promise<void> {
const descriptors: Array<{ fontFamily: string; fontWeight: number; fontStyle: "normal" | "italic" }> = [];
if (registered) {
for (const fam of FONT_MANIFEST.families) { for (const fam of FONT_MANIFEST.families) {
for (const s of familySources(fam)) { for (const s of familySources(fam)) descriptors.push({ fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle });
const descriptor = { fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle }; }
}
for (const family of userFamilies) {
for (const s of userFamilySources(family)) descriptors.push({ fontFamily: family, fontWeight: s.fontWeight, fontStyle: s.fontStyle });
}
for (const descriptor of descriptors) {
const key = `${descriptor.fontFamily}|${descriptor.fontWeight}|${descriptor.fontStyle}`;
if (primedKeys.has(key)) continue;
await Font.load(descriptor); await Font.load(descriptor);
const data = (Font as unknown as { getFont(d: object): { data: FkFont | null } }).getFont(descriptor).data; const data = (Font as unknown as { getFont(d: object): { data: FkFont | null } }).getFont(descriptor).data;
if (data) primeComponents(data); if (data) primeComponents(data);
primedKeys.add(key);
} }
} }
}
// ---- User-imported fonts (Now, Gotham, Open Sauce One) ----
const userFamilies: string[] = [];
const isNode = typeof process !== "undefined" && !!process.versions?.node;
/**
* A src react-pdf can load the bytes from. In a browser or worker that is a blob: URL (react-pdf fetches it;
* a data URI would be decoded character by character, which is slow for a multi-MB font). Under Node it is a
* temporary file, because react-pdf's Node build opens anything that is not an http(s) URL as a path.
*/
async function sourceFor(bytes: Uint8Array, id: string): Promise<string> {
if (!isNode) return URL.createObjectURL(new Blob([bytes as BlobPart], { type: "font/ttf" }));
const nodeFs = "node:fs";
const nodeOs = "node:os";
const nodePath = "node:path";
const [fs, os, path] = await Promise.all([import(/* @vite-ignore */ nodeFs), import(/* @vite-ignore */ nodeOs), import(/* @vite-ignore */ nodePath)]);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "voiced-userfont-"));
const file = path.join(dir, `${id.slice(0, 16)}.ttf`);
fs.writeFileSync(file, bytes);
return file;
}
/**
* Registers one face's files as the family `user:<Face>:<set id>` and returns that name. Every file's magic bytes
* are checked before fontkit sees it. Each weight without an italic gets its upright file as the italic. Idempotent:
* the same set registers once. Never resets the font store; a different set is a different family.
*/
export async function registerUserFontSet(face: UserFace, files: UserFontFile[]): Promise<string> {
if (files.length === 0) throw new Error(`No font files were given for ${face}`);
for (const f of files) assertSfnt(f.bytes);
const family = familyNameFor(face, files.map((f) => f.sha256));
if (isUserFamilyKnown(family)) return family;
rememberUserFiles(family, files);
const srcs = new Map<string, string>();
for (const s of userFamilySources(family)) {
if (!srcs.has(s.id)) srcs.set(s.id, await sourceFor(s.bytes, s.id));
} }
Font.register({
family,
fonts: userFamilySources(family).map((s) => ({ src: srcs.get(s.id) as string, fontWeight: s.fontWeight as never, fontStyle: s.fontStyle })),
});
userFamilies.push(family);
await primeFontGlyphs();
return family;
}
/** Cheap non-cryptographic id (two FNV-1a passes, 16 hex digits) for callers that have no sha256, i.e. tests. */
function fallbackId(bytes: Uint8Array): string {
let a = 0x811c9dc5;
let b = 0x01000193;
for (let i = 0; i < bytes.length; i++) {
a = Math.imul(a ^ bytes[i], 0x01000193) >>> 0;
b = Math.imul(b ^ bytes[i], 0x811c9dc5) >>> 0;
}
return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0");
}
/** One-file convenience for Node tests and single-weight imports: the font as the only member of the face's set. */
export async function registerUserFont(
face: UserFace,
bytes: Uint8Array,
opts: { weight?: number; italic?: boolean; sha256?: string } = {},
): Promise<{ family: string; sha256: string }> {
const sha256 = opts.sha256 ?? fallbackId(bytes);
const family = await registerUserFontSet(face, [{ face, weight: opts.weight ?? 400, italic: opts.italic ?? false, sha256, bytes }]);
return { family, sha256 };
} }
const MIN_FONT_BYTES = 10 * 1024; const MIN_FONT_BYTES = 10 * 1024;
+55 -3
View File
@@ -35,6 +35,13 @@ export interface TypeToken {
tnum?: boolean; tnum?: boolean;
/** Smallest size IdText/FitText may shrink to. */ /** Smallest size IdText/FitText may shrink to. */
minSize?: number; minSize?: number;
/**
* Size and tracking to use instead of `size`/`tracking` when a user-imported font replaces this token's
* substitute face. The token's own values are tuned for the substitute (Gotham drawn in Montserrat, Now in
* Jost); the real font takes the design values. defineRoles turns a token that has this into one whose
* size/tracking follow the active overrides; without an override its values are exactly the literals.
*/
ownFont?: { size?: number; tracking?: number };
} }
const SANS: FaceKey = "IBM Plex Sans"; const SANS: FaceKey = "IBM Plex Sans";
@@ -63,14 +70,37 @@ export type RoleTable<K extends string = string> = Record<K, TypeToken>;
/** Checks a role table against TypeToken and keeps its literal keys: `export const ROLES = defineRoles({ ... })`. */ /** Checks a role table against TypeToken and keeps its literal keys: `export const ROLES = defineRoles({ ... })`. */
export function defineRoles<const T extends Record<string, TypeToken>>(table: T): T { export function defineRoles<const T extends Record<string, TypeToken>>(table: T): T {
return table; const out: Record<string, TypeToken> = {};
for (const [name, token] of Object.entries(table)) out[name] = token.ownFont ? followOverrides(token) : token;
return out as T;
}
/**
* A copy of `token` whose size and tracking read as `ownFont.*` while a user font overrides its face, and as
* the original literals otherwise. Getters, so every plan and layout that reads `token.size` sees the right
* value without threading anything through; only the keys `ownFont` names become accessors.
*/
function followOverrides(token: TypeToken): TypeToken {
const own = token.ownFont as NonNullable<TypeToken["ownFont"]>;
const copy: TypeToken = { ...token };
for (const key of ["size", "tracking"] as const) {
const design = own[key];
if (design === undefined) continue;
const substituted = token[key];
Object.defineProperty(copy, key, {
enumerable: true,
configurable: true,
get: () => (resolveFace(token.face).startsWith(USER_FAMILY_PREFIX) ? design : substituted),
});
}
return copy;
} }
/** /**
* Licensed template faces (Now, Gotham, Open Sauce One, ...) map to the OFL look-alike that is * Licensed template faces (Now, Gotham, Open Sauce One, ...) map to the OFL look-alike that is
* actually bundled. A user-imported font replaces the licensed name through `faceOverrides`. * actually bundled. A user-imported font replaces the licensed name through `faceOverrides`.
* Gotham runs about 4% narrower than Montserrat, so tokens that use it carry sizes already scaled by * Gotham runs about 4% narrower than Montserrat, so tokens that use it carry sizes already scaled by
* GOTHAM_SIZE (see templates/northline); a real Gotham import would need them back at 1. * GOTHAM_SIZE (see templates/northline); a real Gotham import takes them back at 1 through TypeToken.ownFont.
*/ */
export const SUBSTITUTES: Readonly<Record<string, string>> = { export const SUBSTITUTES: Readonly<Record<string, string>> = {
Now: "Jost", Now: "Jost",
@@ -78,8 +108,30 @@ export const SUBSTITUTES: Readonly<Record<string, string>> = {
"Open Sauce One": "Poppins", "Open Sauce One": "Poppins",
}; };
/** Family names of user-imported fonts start with this (`user:Gotham:<set id>`). */
export const USER_FAMILY_PREFIX = "user:";
type FaceOverrides = Readonly<Record<string, string>>;
let activeOverrides: FaceOverrides | undefined;
/**
* Runs `fn` with `overrides` as the default for every resolveFace call that is not given its own map. A render
* sets this for its whole duration so templates, plans and the measurer agree on the faces without each of them
* being handed the map. Renders are serialised (the worker's job lane, the main-thread client), so one slot is enough.
*/
export async function withFaceOverrides<T>(overrides: FaceOverrides | undefined, fn: () => Promise<T>): Promise<T> {
const previous = activeOverrides;
activeOverrides = overrides && Object.keys(overrides).length > 0 ? overrides : undefined;
try {
return await fn();
} finally {
activeOverrides = previous;
}
}
/** The family name to register/look up for a face: user override first, then substitute, then itself. */ /** The family name to register/look up for a face: user override first, then substitute, then itself. */
export function resolveFace(face: string, overrides?: Readonly<Record<string, string>>): string { export function resolveFace(face: string, overrides: FaceOverrides | undefined = activeOverrides): string {
return overrides?.[face] ?? SUBSTITUTES[face] ?? face; return overrides?.[face] ?? SUBSTITUTES[face] ?? face;
} }
+128
View File
@@ -0,0 +1,128 @@
/**
* The user-imported fonts that rendering knows about: Now, Gotham and Open Sauce One only. This module holds
* plain data (no react-pdf), so the measurer can read it too. Registration with react-pdf is in register.ts.
*
* A face's fonts form a "set" (one file per weight and style) and each set is one react-pdf family named
* `user:<Face>:<set id>`. The id comes from the files' hashes, so replacing a font gives a new family instead of
* appending to the old one (react-pdf's font store only appends, and Font.reset() must not be called).
*/
import { USER_FAMILY_PREFIX } from "./roles";
/** The commercial template faces a user may import. Overrides for any other family are out of scope. */
export const USER_FACES = ["Now", "Gotham", "Open Sauce One"] as const;
export type UserFace = (typeof USER_FACES)[number];
export const isUserFace = (face: unknown): face is UserFace => USER_FACES.includes(face as UserFace);
/** What a font file looks like by its first four bytes. */
export type FontFormat = "ttf" | "otf" | "woff" | "woff2" | "ttc" | "unknown";
export function sniffFontFormat(b: Uint8Array): FontFormat {
if (b.length < 4) return "unknown";
if (b[0] === 0 && b[1] === 1 && b[2] === 0 && b[3] === 0) return "ttf";
const tag = String.fromCharCode(b[0], b[1], b[2], b[3]);
switch (tag) {
case "true":
return "ttf";
case "OTTO":
return "otf";
case "wOFF":
return "woff";
case "wOF2":
return "woff2";
case "ttcf":
return "ttc";
default:
return "unknown";
}
}
const REFUSALS: Partial<Record<FontFormat, string>> = {
woff2: "WOFF2 fonts embed without glyph outlines (invisible text in the PDF). Use the TTF or OTF file.",
woff: "A WOFF file must be converted to TTF/OTF first (the importer does this).",
ttc: "Font collections (.ttc) are not supported. Use a single TTF or OTF file.",
unknown: "This is not a TrueType or OpenType font file.",
};
/** Throws unless the bytes are a plain TrueType or OpenType (sfnt) file; fontkit must never see anything else. */
export function assertSfnt(b: Uint8Array): "ttf" | "otf" {
const format = sniffFontFormat(b);
if (format === "ttf" || format === "otf") return format;
throw new Error(REFUSALS[format] ?? REFUSALS.unknown);
}
/** One imported font file, as the renderer needs it. `sha256` identifies it (hex). */
export interface UserFontFile {
face: UserFace;
weight: number;
italic: boolean;
sha256: string;
bytes: Uint8Array;
}
const filesById = new Map<string, UserFontFile>();
const familyFiles = new Map<string, UserFontFile[]>();
/** The family name of a face's set of files. Order does not matter. */
export function familyNameFor(face: UserFace, ids: readonly string[]): string {
return `${USER_FAMILY_PREFIX}${face}:${[...ids].sort().map((id) => id.slice(0, 8)).join("-")}`;
}
export const hasUserFont = (id: string): boolean => filesById.has(id);
export const getUserFontFile = (id: string): UserFontFile | undefined => filesById.get(id);
export function rememberUserFile(file: UserFontFile): void {
filesById.set(file.sha256, file);
}
export function rememberUserFiles(family: string, files: UserFontFile[]): void {
for (const f of files) filesById.set(f.sha256, f);
familyFiles.set(family, files);
}
export const isUserFamilyKnown = (family: string): boolean => familyFiles.has(family);
export interface UserFamilySource {
id: string;
fontWeight: number;
fontStyle: "normal" | "italic";
bytes: Uint8Array;
}
/**
* The sources of a user family: its files, plus the upright file of each weight reused as italic when that weight
* has no italic of its own (every registered family needs an italic, layout rule 8). Same rule as familySources.
*/
export function userFamilySources(family: string): UserFamilySource[] {
const files = familyFiles.get(family);
if (!files) return [];
const sources: UserFamilySource[] = files.map((f) => ({
id: f.sha256,
fontWeight: f.weight,
fontStyle: f.italic ? "italic" : "normal",
bytes: f.bytes,
}));
for (const weight of new Set(sources.map((s) => s.fontWeight))) {
if (sources.some((s) => s.fontWeight === weight && s.fontStyle === "italic")) continue;
const upright = sources.find((s) => s.fontWeight === weight && s.fontStyle === "normal");
if (upright) sources.push({ ...upright, fontStyle: "italic" });
}
return sources;
}
/** `prefs.fonts` (face -> ids of the files in use) as the face -> family map resolveFace takes. */
export function faceOverridesFor(fonts: Partial<Record<UserFace, string[]>> | undefined): Record<string, string> | undefined {
if (!fonts) return undefined;
const out: Record<string, string> = {};
for (const face of USER_FACES) {
const ids = fonts[face];
if (!ids || ids.length === 0) continue;
const missing = ids.filter((id) => !filesById.has(id));
if (missing.length > 0) {
throw new Error(`The font imported for ${face} is not loaded in the renderer (${missing.length} file(s) missing).`);
}
const family = familyNameFor(face, ids);
if (!familyFiles.has(family)) throw new Error(`The font set for ${face} was not registered as a family.`);
out[face] = family;
}
return Object.keys(out).length > 0 ? out : undefined;
}
+160
View File
@@ -0,0 +1,160 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { createElement as h } from "react";
import { Document, Page, renderToBuffer, Text } from "@react-pdf/renderer";
import { describe, expect, it } from "vitest";
import { createMeasurer } from "../engine/measure";
import { DEFAULT_PREFS, parsePrefs } from "../model/prefs";
import { registerUserFont, registerUserFontSet } from "./register";
import { resolveFace, withFaceOverrides } from "./roles";
import { assertSfnt, faceOverridesFor, sniffFontFormat } from "./userFontStore";
import { CITRUS_ROLES } from "../templates/northline/citrus";
import { PURPLE_ROLES } from "../templates/northline/purple";
import { MARBLE_ROLES } from "../templates/marble/tokens";
import { TEMPLATE_CATALOG } from "../templates/catalog";
import { TEMPLATES } from "../templates/registry";
import { ensureSetup, prefsFor, renderFixture } from "../templates/harness";
import { gotham } from "../templates/northline/variant";
import { FONTS_DIR } from "../testing/setup";
ensureSetup();
const font = (folder: string, file: string) => new Uint8Array(readFileSync(path.join(FONTS_DIR, folder, file)));
const latin1 = (b: Uint8Array) => Buffer.from(b).toString("latin1");
const SPEC = { name: "user-font", items: 3, logo: "none" as const };
describe("font sniffing and refusal", () => {
it("recognises sfnt, WOFF, WOFF2 and collections by their magic bytes", () => {
const tag = (s: string) => new Uint8Array([...s].map((c) => c.charCodeAt(0)).concat([0, 0, 0, 0]));
expect(sniffFontFormat(new Uint8Array([0, 1, 0, 0, 0, 0]))).toBe("ttf");
expect(sniffFontFormat(tag("true"))).toBe("ttf");
expect(sniffFontFormat(tag("OTTO"))).toBe("otf");
expect(sniffFontFormat(tag("wOFF"))).toBe("woff");
expect(sniffFontFormat(tag("wOF2"))).toBe("woff2");
expect(sniffFontFormat(tag("ttcf"))).toBe("ttc");
expect(sniffFontFormat(tag("<htm"))).toBe("unknown");
expect(sniffFontFormat(new Uint8Array([0, 1]))).toBe("unknown");
});
it("refuses WOFF2, WOFF, collections and non-fonts before fontkit sees them", async () => {
const jost = font("jost", "Jost-Regular.ttf");
expect(assertSfnt(jost)).toBe("ttf");
const patched = (magic: string) => {
const b = jost.slice();
b.set([...magic].map((c) => c.charCodeAt(0)));
return b;
};
expect(() => assertSfnt(patched("wOF2"))).toThrow(/WOFF2/);
expect(() => assertSfnt(patched("ttcf"))).toThrow(/collections/);
expect(() => assertSfnt(patched("wOFF"))).toThrow(/WOFF/);
expect(() => assertSfnt(new TextEncoder().encode("<!doctype html>"))).toThrow(/not a TrueType/);
await expect(registerUserFont("Gotham", patched("wOF2"))).rejects.toThrow(/WOFF2/);
});
});
describe("prefs carry user fonts", () => {
it("keeps valid face/hash lists and drops everything else", () => {
const sha = "a".repeat(64);
const prefs = parsePrefs(JSON.stringify({ version: 1, templateId: "marble", fonts: { Now: [sha, "zz", 5], Gotham: [], Poppins: [sha], "Open Sauce One": "nope" } }));
expect(prefs.fonts).toEqual({ Now: [sha] });
expect(parsePrefs(JSON.stringify({ version: 1 })).fonts).toBeUndefined();
expect(parsePrefs("{}")).toEqual(DEFAULT_PREFS);
});
});
describe("override compensation", () => {
it("leaves the tuned values alone without an override", () => {
expect(PURPLE_ROLES.label.size).toBe(gotham(12));
expect(CITRUS_ROLES.tableBody.size).toBe(gotham(12));
expect(MARBLE_ROLES.body.tracking).toBe(0.105);
expect(Object.keys(MARBLE_ROLES.body)).toContain("tracking");
});
it("takes the design size and tracking back only while a user font replaces the face", async () => {
await withFaceOverrides({ Gotham: "user:Gotham:x", Now: "user:Now:x" }, async () => {
expect(PURPLE_ROLES.label.size).toBe(12);
expect(CITRUS_ROLES.tableBody.size).toBe(12);
expect(MARBLE_ROLES.body.tracking).toBe(0.04);
expect({ ...MARBLE_ROLES.body }.tracking).toBe(0.04);
// A face that is not overridden keeps its tuned values (Open Sauce One stays Poppins).
expect(PURPLE_ROLES.wordmark.size).toBe(94.07);
});
await withFaceOverrides({ Now: "user:Now:x" }, async () => {
expect(PURPLE_ROLES.label.size).toBe(gotham(12));
expect(MARBLE_ROLES.body.tracking).toBe(0.04);
});
expect(PURPLE_ROLES.label.size).toBe(gotham(12));
expect(MARBLE_ROLES.body.tracking).toBe(0.105);
expect(resolveFace("Gotham")).toBe("Montserrat");
});
it("the catalog lists exactly the licensed faces each template's roles use", () => {
for (const entry of TEMPLATE_CATALOG) {
const used = new Set(Object.values(TEMPLATES[entry.id].roles).map((t) => t.face).filter((f) => f === "Now" || f === "Gotham" || f === "Open Sauce One"));
expect([...(entry.faces ?? [])].sort(), entry.id).toEqual([...used].sort());
}
});
});
describe("a user font replaces a template face", () => {
it("embeds the imported font in a Purple Pop invoice, and only then", async () => {
const lustria = font("lustria", "Lustria-Regular.ttf");
const plain = await renderFixture(SPEC, prefsFor("A4", { templateId: "purple-pop" }));
expect(latin1(plain.result.bytes)).not.toContain("Lustria");
expect(plain.result.bytes.length).toBeGreaterThan(1000);
const { family, sha256 } = await registerUserFont("Gotham", lustria);
expect(family).toMatch(/^user:Gotham:[0-9a-f]{8}$/);
const over = await renderFixture(SPEC, prefsFor("A4", { templateId: "purple-pop", fonts: { Gotham: [sha256] } }));
expect(latin1(over.result.bytes)).toContain("Lustria");
expect(over.result.fingerprint).not.toBe(plain.result.fingerprint);
expect(over.result.issues.filter((i) => i.severity === "error")).toEqual([]);
// The override ended with the render: the same prefs without fonts give the original fingerprint again.
const after = await renderFixture(SPEC, prefsFor("A4", { templateId: "purple-pop" }));
expect(after.result.fingerprint).toBe(plain.result.fingerprint);
}, 60_000);
it("a Marble invoice with Now replaced by a user font lays out and embeds it", async () => {
const { sha256 } = await registerUserFont("Now", font("dm-sans", "DMSans-Regular.ttf"));
const plain = await renderFixture(SPEC, prefsFor("A4", { templateId: "marble" }));
const over = await renderFixture(SPEC, prefsFor("A4", { templateId: "marble", fonts: { Now: [sha256] } }));
expect(latin1(over.result.bytes)).toContain("DMSans");
expect(latin1(plain.result.bytes)).not.toContain("DMSans");
expect(over.pdf.pages[0].items.map((i) => i.str).join(" ")).toContain("₹");
}, 60_000);
it("fails clearly when prefs name a font the renderer was never given", async () => {
const prefs = prefsFor("A4", { templateId: "purple-pop", fonts: { Gotham: ["f".repeat(64)] } });
await expect(renderFixture(SPEC, prefs)).rejects.toThrow(/not loaded/);
});
it("every weight gets an italic: a set registers its upright files as italics, and italic text does not throw", async () => {
const regular = font("montserrat", "Montserrat-Regular.ttf");
const bold = font("montserrat", "Montserrat-Bold.ttf");
const family = await registerUserFontSet("Open Sauce One", [
{ face: "Open Sauce One", weight: 400, italic: false, sha256: "1".repeat(16), bytes: regular },
{ face: "Open Sauce One", weight: 700, italic: false, sha256: "2".repeat(16), bytes: bold },
]);
const render = (fontWeight: number) =>
renderToBuffer(h(Document, null, h(Page, { size: "A4" }, h(Text, { style: { fontFamily: [family], fontStyle: "italic", fontWeight } }, "Total ₹1,200.00"))));
for (const w of [400, 700]) expect((await render(w)).subarray(0, 5).toString()).toBe("%PDF-");
// Registering the same set again is a no-op.
expect(await registerUserFontSet("Open Sauce One", [
{ face: "Open Sauce One", weight: 700, italic: false, sha256: "2".repeat(16), bytes: bold },
{ face: "Open Sauce One", weight: 400, italic: false, sha256: "1".repeat(16), bytes: regular },
])).toBe(family);
expect(faceOverridesFor({ "Open Sauce One": ["1".repeat(16), "2".repeat(16)] })).toEqual({ "Open Sauce One": family });
});
it("the measurer reads user families from memory, bold and italic included", async () => {
const m = createMeasurer((file) => new Uint8Array(readFileSync(path.join(FONTS_DIR, file))));
const poppins = font("poppins", "Poppins-Regular.ttf");
const { family } = await registerUserFont("Gotham", poppins, { weight: 400 });
const own = m.measureWidth({ text: "Consultation", face: family, weight: 400, size: 12 });
const plex = m.measureWidth({ text: "Consultation", face: "Poppins", weight: 400, size: 12 });
expect(own).toBeCloseTo(plex, 6);
expect(m.measureWidth({ text: "Consultation", face: family, weight: 700, italic: true, size: 12 })).toBeCloseTo(plex, 6);
expect(m.missing("₹", family)).toEqual([]);
});
});
+17
View File
@@ -1,4 +1,5 @@
import type { MarginPreset, PageId } from "../engine/geometry"; import type { MarginPreset, PageId } from "../engine/geometry";
import { isUserFace, type UserFace } from "../fonts/userFontStore";
/** Page and template choices frozen with an invoice at issue (stored as JSON). */ /** Page and template choices frozen with an invoice at issue (stored as JSON). */
export interface RenderPrefsV1 { export interface RenderPrefsV1 {
@@ -12,6 +13,11 @@ export interface RenderPrefsV1 {
/** Templates without a reference logo placeholder (Linea, ...) show the logo only when this is true. */ /** Templates without a reference logo placeholder (Linea, ...) show the logo only when this is true. */
show?: boolean; show?: boolean;
}; };
/**
* User-imported fonts in use, per face (Now, Gotham, Open Sauce One): the sha256 of each file (one per weight/style).
* Frozen with the invoice, which is what keeps those font files from being deleted. Absent: the bundled substitutes.
*/
fonts?: Partial<Record<UserFace, string[]>>;
} }
export const DEFAULT_PREFS: RenderPrefsV1 = { export const DEFAULT_PREFS: RenderPrefsV1 = {
@@ -22,6 +28,7 @@ export const DEFAULT_PREFS: RenderPrefsV1 = {
}; };
const PAGE_SIZE_IDS: readonly PageId[] = ["A4", "LETTER"]; const PAGE_SIZE_IDS: readonly PageId[] = ["A4", "LETTER"];
const FONT_ID = /^[0-9a-f]{16,64}$/;
const MARGIN_IDS: readonly MarginPreset[] = ["template", "narrow", "normal"]; const MARGIN_IDS: readonly MarginPreset[] = ["template", "narrow", "normal"];
/** Tolerant: anything unreadable or out of range falls back to the default for that field. */ /** Tolerant: anything unreadable or out of range falls back to the default for that field. */
@@ -49,6 +56,16 @@ export function parsePrefs(json: string | null | undefined): RenderPrefsV1 {
if (typeof show === "boolean") out.show = show; if (typeof show === "boolean") out.show = show;
if (Object.keys(out).length > 0) prefs.logo = out; if (Object.keys(out).length > 0) prefs.logo = out;
} }
const fonts = o.fonts;
if (typeof fonts === "object" && fonts !== null) {
const out: NonNullable<RenderPrefsV1["fonts"]> = {};
for (const [face, ids] of Object.entries(fonts as Record<string, unknown>)) {
if (!isUserFace(face) || !Array.isArray(ids)) continue;
const valid = ids.filter((id): id is string => typeof id === "string" && FONT_ID.test(id)).slice(0, 16);
if (valid.length > 0) out[face] = valid;
}
if (Object.keys(out).length > 0) prefs.fonts = out;
}
return prefs; return prefs;
} }
+92 -1
View File
@@ -16,7 +16,7 @@ class FakeWorker implements WorkerLike {
onerror: WorkerLike["onerror"] = null; onerror: WorkerLike["onerror"] = null;
sent: WorkerRequest[] = []; sent: WorkerRequest[] = [];
terminated = false; terminated = false;
postMessage(message: WorkerRequest) { postMessage(message: WorkerRequest, _transfer?: Transferable[]) {
this.sent.push(message); this.sent.push(message);
} }
terminate() { terminate() {
@@ -378,3 +378,94 @@ describe("main-thread client and fallback", () => {
expect((await client.render(job(8))).id).toBe(8); expect((await client.render(job(8))).id).toBe(8);
}); });
}); });
describe("user fonts in render jobs", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
const file = (sha: string, bytes: number[]) => ({ face: "Gotham" as const, weight: 400, italic: false, sha256: sha, bytes: new Uint8Array(bytes) });
const withFonts = (id: number, files: ReturnType<typeof file>[]): RenderJob => ({
id,
model: {} as RenderJob["model"],
prefs: { fonts: { Gotham: files.map((f) => f.sha256) } } as unknown as RenderJob["prefs"],
userFonts: files,
});
it("sends each font to a worker once, as transferred copies, and again to a replacement worker", async () => {
const posts: Array<{ job: RenderJob; transfer: Transferable[] | undefined }> = [];
const workers: FakeWorker[] = [];
const client = createRenderClient({
timeoutMs: 1000,
workerFactory: () => {
const w = new FakeWorker();
w.postMessage = (message: WorkerRequest, transfer?: Transferable[]) => {
posts.push({ job: (message as { job: RenderJob }).job, transfer });
w.sent.push(message);
};
workers.push(w);
return w;
},
});
const master = file("a".repeat(16), [1, 2, 3]);
const p1 = client.render(withFonts(1, [master]));
workers[0].ready();
expect(posts[0].job.userFonts?.map((f) => f.sha256)).toEqual([master.sha256]);
expect(posts[0].job.userFonts?.[0].bytes).not.toBe(master.bytes);
expect(posts[0].transfer).toHaveLength(1);
expect(Array.from(master.bytes)).toEqual([1, 2, 3]);
workers[0].reply(1);
await p1;
const extra = file("b".repeat(16), [9]);
const p2 = client.render(withFonts(2, [master, extra]));
expect(posts[1].job.userFonts?.map((f) => f.sha256)).toEqual([extra.sha256]);
workers[0].reply(2);
await p2;
// A worker replaced after a timeout starts with nothing, so every font goes again.
const p3 = client.render(withFonts(3, [master]));
workers[0].reply(3);
await p3;
const p4 = client.render(withFonts(4, [master, extra]));
const timedOut = settled(p4);
vi.advanceTimersByTime(1000);
expect(await timedOut).toBeInstanceOf(RenderTimeout);
expect(workers).toHaveLength(2);
const p5 = client.render(withFonts(5, [master, extra]));
workers[1].ready();
expect(posts[posts.length - 1].job.userFonts?.map((f) => f.sha256).sort()).toEqual([master.sha256, extra.sha256]);
workers[1].reply(5);
await p5;
});
it("the auto client attaches the bytes from the loader and reads each file once", async () => {
vi.useRealTimers();
const reads: string[] = [];
const workers: FakeWorker[] = [];
const client = createAutoClient({
workerFactory: () => {
const w = new FakeWorker();
workers.push(w);
return w;
},
loadUserFont: async (sha) => {
reads.push(sha);
return file(sha, [7]);
},
});
const prefs = { fonts: { Gotham: ["c".repeat(16)] } } as unknown as RenderJob["prefs"];
const p1 = client.render({ id: 1, model: {} as RenderJob["model"], prefs });
await vi.waitFor(() => expect(workers).toHaveLength(1));
workers[0].ready();
await vi.waitFor(() => expect(workers[0].sent).toHaveLength(1));
expect((workers[0].sent[0] as { job: RenderJob }).job.userFonts?.map((f) => f.sha256)).toEqual(["c".repeat(16)]);
workers[0].reply(1);
await p1;
const p2 = client.render({ id: 2, model: {} as RenderJob["model"], prefs });
await vi.waitFor(() => expect(workers[0].sent).toHaveLength(2));
expect((workers[0].sent[1] as { job: RenderJob }).job.userFonts).toEqual([]);
workers[0].reply(2);
await p2;
expect(reads).toEqual(["c".repeat(16)]);
});
});
+60 -6
View File
@@ -1,3 +1,4 @@
import type { UserFontFile } from "../fonts/userFontStore";
import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs"; import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
/** A newer render call replaced this one before it finished (or before it started). */ /** A newer render call replaced this one before it finished (or before it started). */
@@ -35,7 +36,7 @@ export class RenderFailed extends Error {
/** The part of Worker the client uses, so tests can supply a fake. */ /** The part of Worker the client uses, so tests can supply a fake. */
export interface WorkerLike { export interface WorkerLike {
postMessage(message: WorkerRequest): void; postMessage(message: WorkerRequest, transfer?: Transferable[]): void;
terminate(): void; terminate(): void;
onmessage: ((event: { data: WorkerMessage }) => void) | null; onmessage: ((event: { data: WorkerMessage }) => void) | null;
onerror: ((event: { message?: string }) => void) | null; onerror: ((event: { message?: string }) => void) | null;
@@ -55,6 +56,11 @@ export interface RenderClient {
} }
export interface RenderClientOptions { export interface RenderClientOptions {
/**
* Returns a user font file by its sha256 (the app reads it from the database). Needed only for jobs whose
* prefs name user fonts; the result is cached, so each file is read once per session.
*/
loadUserFont?: (sha256: string) => Promise<UserFontFile>;
workerFactory?: () => WorkerLike; workerFactory?: () => WorkerLike;
/** Per-job watchdog. react-pdf's paginate loop is synchronous, so a layout bug hangs the worker for good. */ /** Per-job watchdog. react-pdf's paginate loop is synchronous, so a layout bug hangs the worker for good. */
timeoutMs?: number; timeoutMs?: number;
@@ -85,8 +91,21 @@ type Running = Slot & {
timer: ReturnType<typeof setTimeout>; timer: ReturnType<typeof setTimeout>;
}; };
const requestFor = (slot: Slot): WorkerRequest => /**
slot.kind === "render" ? { type: "render", job: slot.job } : { type: "assemble-images", job: slot.job }; * The message for a slot plus what to transfer. A render's user fonts are cut down to those this worker has not
* been sent (`sent`), and each goes as a transferred copy: the caller's bytes are a master copy that must survive
* a worker restart, and a transferred buffer is detached on this side.
*/
function requestFor(slot: Slot, sent: Set<string>): { request: WorkerRequest; transfer: Transferable[]; ids: string[] } {
if (slot.kind !== "render") return { request: { type: "assemble-images", job: slot.job }, transfer: [], ids: [] };
const fresh = (slot.job.userFonts ?? []).filter((f) => !sent.has(f.sha256));
const copies = fresh.map((f) => ({ ...f, bytes: f.bytes.slice() }));
return {
request: { type: "render", job: { ...slot.job, userFonts: copies } },
transfer: copies.map((f) => f.bytes.buffer),
ids: fresh.map((f) => f.sha256),
};
}
/** /**
* Renders in a module worker. Only the newest render matters: a new call rejects the render in flight * Renders in a module worker. Only the newest render matters: a new call rejects the render in flight
@@ -105,11 +124,14 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
let running: Running | null = null; let running: Running | null = null;
let pending: RenderSlot | null = null; let pending: RenderSlot | null = null;
const assembles: AssembleSlot[] = []; const assembles: AssembleSlot[] = [];
/** Ids of the user font files the current worker has been sent; a new worker starts with none. */
const sent = new Set<string>();
const stopWorker = () => { const stopWorker = () => {
const w = worker; const w = worker;
worker = null; worker = null;
ready = false; ready = false;
sent.clear();
if (w) { if (w) {
w.onmessage = null; w.onmessage = null;
w.onerror = null; w.onerror = null;
@@ -147,7 +169,9 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
} }
if (!slot) return; if (!slot) return;
running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) }; running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) };
worker.postMessage(requestFor(slot)); const { request, transfer, ids } = requestFor(slot, sent);
worker.postMessage(request, transfer);
for (const id of ids) sent.add(id);
}; };
const spawn = () => { const spawn = () => {
@@ -239,8 +263,13 @@ export type MainExecutor = (job: RenderJob) => Promise<RenderResult>;
// Loaded on demand so react-pdf stays out of the main bundle unless the worker is unavailable. // Loaded on demand so react-pdf stays out of the main bundle unless the worker is unavailable.
const runOnMainThread: MainExecutor = async (job) => { const runOnMainThread: MainExecutor = async (job) => {
const [{ initBrowserRendering }, { renderInvoicePdf }] = await Promise.all([import("./browserInit"), import("./invoice")]); const [{ initBrowserRendering }, { renderInvoicePdf }, { registerJobFonts }] = await Promise.all([
import("./browserInit"),
import("./invoice"),
import("./userFontJob"),
]);
await initBrowserRendering(); await initBrowserRendering();
await registerJobFonts(job.prefs, job.userFonts);
// Let pending input and paint run before the long synchronous layout. // Let pending input and paint run before the long synchronous layout.
await new Promise<void>((r) => setTimeout(r, 0)); await new Promise<void>((r) => setTimeout(r, 0));
const t0 = performance.now(); const t0 = performance.now();
@@ -337,6 +366,27 @@ export function createAutoClient(
): RenderClient { ): RenderClient {
const primary = createRenderClient(opts); const primary = createRenderClient(opts);
let main: RenderClient | null = null; let main: RenderClient | null = null;
const fontCache = new Map<string, Promise<UserFontFile>>();
let renderCalls = 0;
/** Attaches the bytes of the user fonts the job's prefs name (read once, then cached). */
const withFonts = async (job: RenderJob): Promise<RenderJob> => {
const ids = Object.values(job.prefs.fonts ?? {}).flat();
if (ids.length === 0) return job;
const load = opts.loadUserFont;
if (!load) throw new Error("This render needs user fonts but no font loader is configured");
const files = await Promise.all(
ids.map((id) => {
let p = fontCache.get(id);
if (!p) {
p = load(id);
fontCache.set(id, p);
p.catch(() => fontCache.delete(id));
}
return p;
}),
);
return { ...job, userFonts: files };
};
const fallback = () => { const fallback = () => {
main ??= createMainThreadClient(opts.mainExecutor, opts.mainAssembleExecutor); main ??= createMainThreadClient(opts.mainExecutor, opts.mainAssembleExecutor);
primary.dispose(); primary.dispose();
@@ -346,7 +396,11 @@ export function createAutoClient(
get mode() { get mode() {
return main ? "main" : "worker"; return main ? "main" : "worker";
}, },
render(job) { async render(plain) {
const call = ++renderCalls;
const job = await withFonts(plain);
// Reading fonts took time; keep latest-wins: a render requested meanwhile replaces this one.
if (call !== renderCalls) throw new Superseded();
if (main) return main.render(job); if (main) return main.render(job);
return primary.render(job).catch((err) => { return primary.render(job).catch((err) => {
if (!(err instanceof WorkerUnavailable)) throw err; if (!(err instanceof WorkerUnavailable)) throw err;
+8 -1
View File
@@ -4,6 +4,8 @@ import { computeFrame, PAGE_SIZES, type PageFrame } from "../engine/geometry";
import type { AuditIssue } from "../engine/audit"; import type { AuditIssue } from "../engine/audit";
import type { RenderModel } from "../model/build"; import type { RenderModel } from "../model/build";
import type { RenderPrefsV1 } from "../model/prefs"; import type { RenderPrefsV1 } from "../model/prefs";
import { withFaceOverrides } from "../fonts/roles";
import { faceOverridesFor } from "../fonts/userFontStore";
import { CLOSING_ID } from "../engine/ids"; import { CLOSING_ID } from "../engine/ids";
import { marginsFor, type PlacedLogo } from "../templates/contract"; import { marginsFor, type PlacedLogo } from "../templates/contract";
import { getTemplate } from "../templates/registry"; import { getTemplate } from "../templates/registry";
@@ -41,7 +43,12 @@ export function frameForPrefs(prefs: RenderPrefsV1): PageFrame {
* model + prefs -> template, frame, logo placement and keep-outs -> InvoicePdf -> renderCore. Needs * model + prefs -> template, frame, logo placement and keep-outs -> InvoicePdf -> renderCore. Needs
* fonts registered and a measurer installed (render/browserInit.ts in the app, testing/setup.ts in tests). * fonts registered and a measurer installed (render/browserInit.ts in the app, testing/setup.ts in tests).
*/ */
export async function renderInvoicePdf({ model, prefs }: RenderInvoiceInput, bytesOf?: RenderBytes): Promise<RenderInvoiceResult> { export function renderInvoicePdf(input: RenderInvoiceInput, bytesOf?: RenderBytes): Promise<RenderInvoiceResult> {
// prefs.fonts names the user fonts (already registered by the caller) that replace the substitute faces.
return withFaceOverrides(faceOverridesFor(input.prefs.fonts), () => renderInvoicePdfWith(input, bytesOf));
}
async function renderInvoicePdfWith({ model, prefs }: RenderInvoiceInput, bytesOf?: RenderBytes): Promise<RenderInvoiceResult> {
const template = getTemplate(prefs.templateId); const template = getTemplate(prefs.templateId);
const frame = frameForPrefs(prefs); const frame = frameForPrefs(prefs);
+6
View File
@@ -1,5 +1,6 @@
import type { AuditIssue } from "../engine/audit"; import type { AuditIssue } from "../engine/audit";
import type { RenderModel } from "../model/build"; import type { RenderModel } from "../model/build";
import type { UserFontFile } from "../fonts/userFontStore";
import type { RenderPrefsV1 } from "../model/prefs"; import type { RenderPrefsV1 } from "../model/prefs";
// Everything here crosses postMessage, so it stays plain data: no functions, classes or Maps. // Everything here crosses postMessage, so it stays plain data: no functions, classes or Maps.
@@ -8,6 +9,11 @@ export interface RenderJob {
id: number; id: number;
model: RenderModel; model: RenderModel;
prefs: RenderPrefsV1; prefs: RenderPrefsV1;
/**
* Bytes of the user fonts `prefs.fonts` names. The caller attaches all of them; the worker client sends only the
* ones that worker does not hold yet (as transferred copies, so the caller's master copy stays usable).
*/
userFonts?: UserFontFile[];
} }
export interface RenderResult { export interface RenderResult {
+2
View File
@@ -3,6 +3,7 @@ import { installStreamAsyncIterator } from "../../lib/streamPolyfill";
import { initBrowserRendering } from "./browserInit"; import { initBrowserRendering } from "./browserInit";
import { runAssembleJob } from "./assembleJob"; import { runAssembleJob } from "./assembleJob";
import { renderInvoicePdf } from "./invoice"; import { renderInvoicePdf } from "./invoice";
import { registerJobFonts } from "./userFontJob";
import type { WorkerMessage, WorkerRequest } from "./jobs"; import type { WorkerMessage, WorkerRequest } from "./jobs";
interface WorkerScope { interface WorkerScope {
@@ -44,6 +45,7 @@ scope.onmessage = async (event) => {
const { job } = request; const { job } = request;
try { try {
await started.catch(() => initBrowserRendering()); await started.catch(() => initBrowserRendering());
await registerJobFonts(job.prefs, job.userFonts);
const t0 = performance.now(); const t0 = performance.now();
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs }); const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
const ms = Math.round(performance.now() - t0); const ms = Math.round(performance.now() - t0);
+19
View File
@@ -0,0 +1,19 @@
import { registerUserFontSet } from "../fonts/register";
import { getUserFontFile, rememberUserFile, USER_FACES, type UserFontFile } from "../fonts/userFontStore";
import type { RenderPrefsV1 } from "../model/prefs";
/**
* Makes the user fonts a render's prefs name available to react-pdf and the measurer, before the render runs.
* `incoming` are the files the client sent with this job (only those this renderer has not been sent yet); the
* others are already held from earlier jobs. Registering a set is idempotent.
*/
export async function registerJobFonts(prefs: RenderPrefsV1, incoming: readonly UserFontFile[] = []): Promise<void> {
for (const file of incoming) rememberUserFile(file);
for (const face of USER_FACES) {
const ids = prefs.fonts?.[face];
if (!ids || ids.length === 0) continue;
const files = ids.map((id) => getUserFontFile(id));
if (files.some((f) => !f)) throw new Error(`The font imported for ${face} was not delivered to the renderer`);
await registerUserFontSet(face, files as UserFontFile[]);
}
}
+8 -3
View File
@@ -11,6 +11,11 @@ export interface TemplateCatalogEntry {
badge?: "Matches your original format"; badge?: "Matches your original format";
/** No reference placeholder: the logo shows only when the user turns it on. */ /** No reference placeholder: the logo shows only when the user turns it on. */
logoOptIn?: boolean; logoOptIn?: boolean;
/**
* The licensed faces this template draws (a user-imported font may replace them). Mirrors the roles of the
* template; a test keeps the two in step.
*/
faces?: readonly ("Now" | "Gotham" | "Open Sauce One")[];
} }
export const FAMILY_LABELS: Record<TemplateCatalogEntry["family"], string> = { export const FAMILY_LABELS: Record<TemplateCatalogEntry["family"], string> = {
@@ -32,11 +37,11 @@ export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [
{ id: "tangerine-ledger", name: "Tangerine Ledger", family: "ledger", thumbnail: "/templates/tangerine-ledger.png" }, { id: "tangerine-ledger", name: "Tangerine Ledger", family: "ledger", thumbnail: "/templates/tangerine-ledger.png" },
{ id: "slate-band", name: "Slate Band", family: "band", thumbnail: "/templates/slate-band.png" }, { id: "slate-band", name: "Slate Band", family: "band", thumbnail: "/templates/slate-band.png" },
{ id: "teal-swoosh", name: "Teal Swoosh", family: "band", thumbnail: "/templates/teal-swoosh.png" }, { id: "teal-swoosh", name: "Teal Swoosh", family: "band", thumbnail: "/templates/teal-swoosh.png" },
{ id: "purple-pop", name: "Purple Pop", family: "northline", thumbnail: "/templates/purple-pop.png", logoOptIn: true }, { id: "purple-pop", name: "Purple Pop", family: "northline", thumbnail: "/templates/purple-pop.png", logoOptIn: true, faces: ["Gotham", "Open Sauce One"] },
{ id: "citrus-split", name: "Citrus Split", family: "northline", thumbnail: "/templates/citrus-split.png" }, { id: "citrus-split", name: "Citrus Split", family: "northline", thumbnail: "/templates/citrus-split.png", faces: ["Now", "Gotham"] },
{ id: "monolith", name: "Monolith", family: "standalone", thumbnail: "/templates/monolith.png" }, { id: "monolith", name: "Monolith", family: "standalone", thumbnail: "/templates/monolith.png" },
{ id: "cobalt-stripe", name: "Cobalt Stripe", family: "standalone", thumbnail: "/templates/cobalt-stripe.png" }, { id: "cobalt-stripe", name: "Cobalt Stripe", family: "standalone", thumbnail: "/templates/cobalt-stripe.png" },
{ id: "marble", name: "Marble", family: "standalone", thumbnail: "/templates/marble.png", logoOptIn: true }, { id: "marble", name: "Marble", family: "standalone", thumbnail: "/templates/marble.png", logoOptIn: true, faces: ["Now"] },
{ id: "highlighter", name: "Highlighter", family: "standalone", thumbnail: "/templates/highlighter.png", logoOptIn: true }, { id: "highlighter", name: "Highlighter", family: "standalone", thumbnail: "/templates/highlighter.png", logoOptIn: true },
]; ];
+12 -11
View File
@@ -18,31 +18,32 @@ export const BAND_TO = "#ECEAEE";
* Marble's type (docs/templates/01-marble.md): Lustria in tracked caps for the title, headings, table heads and the * Marble's type (docs/templates/01-marble.md): Lustria in tracked caps for the title, headings, table heads and the
* amount due; "Now" (drawn in Jost, the bundled look-alike) for everything else. Jost sets about 12% narrower than Now, * amount due; "Now" (drawn in Jost, the bundled look-alike) for everything else. Jost sets about 12% narrower than Now,
* so every Now role carries the tracking that restores the reference widths (0.10 to 0.13 em on top of the design's * so every Now role carries the tracking that restores the reference widths (0.10 to 0.13 em on top of the design's
* 0.04 em). Lustria ascent 0.955, descent 0.327; Jost 1.07 / 0.375. * 0.04 em). A real Now imported by the user replaces Jost, so those roles take the design's 0.04 em back through
* ownFont. Lustria ascent 0.955, descent 0.327; Jost 1.07 / 0.375.
*/ */
export const MARBLE_ROLES = defineRoles({ export const MARBLE_ROLES = defineRoles({
/** "INVOICE" / "TAX INVOICE": 25.18 pt, tracked 0.192 em, shrunk to the room the stack has. */ /** "INVOICE" / "TAX INVOICE": 25.18 pt, tracked 0.192 em, shrunk to the room the stack has. */
title: { face: L, weight: 400, size: 25.18, leading: 25.2, tracking: 0.192, caps: true, minSize: 13 }, title: { face: L, weight: 400, size: 25.18, leading: 25.2, tracking: 0.192, caps: true, minSize: 13 },
/** The vendor's name at the header's left (the design leaves it empty). */ /** The vendor's name at the header's left (the design leaves it empty). */
brand: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true, minSize: 9 }, brand: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true, minSize: 9 },
meta: { face: N, weight: 400, size: 11.55, leading: 17.5, tracking: 0.105, minSize: 8 }, meta: { face: N, weight: 400, size: 11.55, leading: 17.5, tracking: 0.105, ownFont: { tracking: 0.04 }, minSize: 8 },
/** BILL TO:, PAYABLE TO:, SUPPLIER:. */ /** BILL TO:, PAYABLE TO:, SUPPLIER:. */
heading: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true }, heading: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true },
body: { face: N, weight: 400, size: 11.55, leading: 18.2, tracking: 0.105, minSize: 8 }, body: { face: N, weight: 400, size: 11.55, leading: 18.2, tracking: 0.105, ownFont: { tracking: 0.04 }, minSize: 8 },
tableHead: { face: L, weight: 400, size: 11.68, leading: 16, tracking: 0.192, caps: true }, tableHead: { face: L, weight: 400, size: 11.68, leading: 16, tracking: 0.192, caps: true },
tableBody: { face: N, weight: 400, size: 10.57, leading: 17.4, tracking: 0.102 }, tableBody: { face: N, weight: 400, size: 10.57, leading: 17.4, tracking: 0.102, ownFont: { tracking: 0.04 } },
tableSub: { face: N, weight: 400, size: 8.5, leading: 11.5, tracking: 0.1 }, tableSub: { face: N, weight: 400, size: 8.5, leading: 11.5, tracking: 0.1, ownFont: { tracking: 0.04 } },
totalLabel: { face: L, weight: 400, size: 9, leading: 16, tracking: 0.192, caps: true }, totalLabel: { face: L, weight: 400, size: 9, leading: 16, tracking: 0.192, caps: true },
totalValue: { face: N, weight: 400, size: 10.57, leading: 16, tracking: 0.102 }, totalValue: { face: N, weight: 400, size: 10.57, leading: 16, tracking: 0.102, ownFont: { tracking: 0.04 } },
/** AMOUNT DUE: 14.6 pt, tracked 0.192 em. */ /** AMOUNT DUE: 14.6 pt, tracked 0.192 em. */
due: { face: L, weight: 400, size: 14.6, leading: 20, tracking: 0.192, caps: true, minSize: 10 }, due: { face: L, weight: 400, size: 14.6, leading: 20, tracking: 0.192, caps: true, minSize: 10 },
small: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13 }, small: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, ownFont: { tracking: 0.04 } },
smallLabel: { face: L, weight: 400, size: 9, leading: 15.3, tracking: 0.192, caps: true }, smallLabel: { face: L, weight: 400, size: 9, leading: 15.3, tracking: 0.192, caps: true },
signName: { face: N, weight: 400, size: 10.57, leading: 15, tracking: 0.102 }, signName: { face: N, weight: 400, size: 10.57, leading: 15, tracking: 0.102, ownFont: { tracking: 0.04 } },
signDesignation: { face: N, weight: 400, size: 9.6, leading: 14, tracking: 0.13 }, signDesignation: { face: N, weight: 400, size: 9.6, leading: 14, tracking: 0.13, ownFont: { tracking: 0.04 } },
fine: { face: N, weight: 400, size: 8, leading: 11, tracking: 0.1 }, fine: { face: N, weight: 400, size: 8, leading: 11, tracking: 0.1, ownFont: { tracking: 0.04 } },
/** The footer band's contact lines. */ /** The footer band's contact lines. */
contact: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, minSize: 7 }, contact: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, ownFont: { tracking: 0.04 }, minSize: 7 },
footer: { face: N, weight: 400, size: 7 }, footer: { face: N, weight: 400, size: 7 },
}); });
+11 -11
View File
@@ -28,21 +28,21 @@ export const CITRUS_ROLES = defineRoles({
metaStrong: { face: DM, weight: 700, size: 12.36, leading: 16, minSize: 8 }, metaStrong: { face: DM, weight: 700, size: 12.36, leading: 16, minSize: 8 },
billedLabel: { face: DM, weight: 700, size: 12.4, leading: 17, caps: true }, billedLabel: { face: DM, weight: 700, size: 12.4, leading: 17, caps: true },
clientName: { face: DM, weight: 700, size: 14.87, leading: 19, caps: true }, clientName: { face: DM, weight: 700, size: 14.87, leading: 19, caps: true },
clientLine: { face: G, weight: 400, size: gotham(10.5), leading: 15.5 }, clientLine: { face: G, weight: 400, size: gotham(10.5), ownFont: { size: 10.5 }, leading: 15.5 },
headService: { face: G, weight: 700, size: gotham(15.17), leading: 18, tracking: 0.15, caps: true }, headService: { face: G, weight: 700, size: gotham(15.17), ownFont: { size: 15.17 }, leading: 18, tracking: 0.15, caps: true },
headNum: { face: G, weight: 700, size: gotham(14.17), leading: 18, caps: true }, headNum: { face: G, weight: 700, size: gotham(14.17), ownFont: { size: 14.17 }, leading: 18, caps: true },
tableBody: { face: G, weight: 400, size: gotham(12), leading: 15 }, tableBody: { face: G, weight: 400, size: gotham(12), ownFont: { size: 12 }, leading: 15 },
tableSub: { face: G, weight: 400, size: 9, leading: 11.5 }, tableSub: { face: G, weight: 400, size: 9, leading: 11.5 },
totalLabel: { face: G, weight: 700, size: gotham(12.5), leading: 17, caps: true }, totalLabel: { face: G, weight: 700, size: gotham(12.5), ownFont: { size: 12.5 }, leading: 17, caps: true },
totalValue: { face: G, weight: 400, size: gotham(12.5), leading: 17 }, totalValue: { face: G, weight: 400, size: gotham(12.5), ownFont: { size: 12.5 }, leading: 17 },
grand: { face: G, weight: 700, size: gotham(14.7), leading: 18, caps: true }, grand: { face: G, weight: 700, size: gotham(14.7), ownFont: { size: 14.7 }, leading: 18, caps: true },
grandValue: { face: G, weight: 700, size: gotham(14.7), leading: 18 }, grandValue: { face: G, weight: 700, size: gotham(14.7), ownFont: { size: 14.7 }, leading: 18 },
words: { face: DM, weight: 700, size: 11, leading: 14 }, words: { face: DM, weight: 700, size: 11, leading: 14 },
wordsLabel: { face: DM, weight: 700, size: 11, leading: 14 }, wordsLabel: { face: DM, weight: 700, size: 11, leading: 14 },
sectionLabel: { face: DM, weight: 700, size: 12, leading: 16, caps: true }, sectionLabel: { face: DM, weight: 700, size: 12, leading: 16, caps: true },
bank: { face: G, weight: 400, size: gotham(11), leading: 15 }, bank: { face: G, weight: 400, size: gotham(11), ownFont: { size: 11 }, leading: 15 },
bullet: { face: G, weight: 400, size: gotham(12), leading: 16.5 }, bullet: { face: G, weight: 400, size: gotham(12), ownFont: { size: 12 }, leading: 16.5 },
contact: { face: G, weight: 400, size: gotham(12.77), leading: 18, minSize: 7 }, contact: { face: G, weight: 400, size: gotham(12.77), ownFont: { size: 12.77 }, leading: 18, minSize: 7 },
small: { face: DM, weight: 400, size: 9, leading: 12 }, small: { face: DM, weight: 400, size: 9, leading: 12 },
fine: { face: DM, weight: 400, size: 7, leading: 10 }, fine: { face: DM, weight: 400, size: 7, leading: 10 },
signName: { face: DM, weight: 700, size: 9.5, leading: 13 }, signName: { face: DM, weight: 700, size: 9.5, leading: 13 },
+10 -10
View File
@@ -22,25 +22,25 @@ export const PURPLE_ROLES = defineRoles({
wordmark: { face: "Open Sauce One", weight: 800, size: 94.07, leading: 84, tracking: -0.08, minSize: 48 }, wordmark: { face: "Open Sauce One", weight: 800, size: 94.07, leading: 84, tracking: -0.08, minSize: 48 },
taxCaption: { face: DM, weight: 700, size: 14, leading: 18, tracking: 0.15, caps: true }, taxCaption: { face: DM, weight: 700, size: 14, leading: 18, tracking: 0.15, caps: true },
brand: { face: DM, weight: 700, size: 17.77, leading: 21, minSize: 12 }, brand: { face: DM, weight: 700, size: 17.77, leading: 21, minSize: 12 },
pill: { face: G, weight: 700, size: gotham(13.39), leading: 17, tracking: 0.13, caps: true, minSize: 8.5 }, pill: { face: G, weight: 700, size: gotham(13.39), ownFont: { size: 13.39 }, leading: 17, tracking: 0.13, caps: true, minSize: 8.5 },
label: { face: G, weight: 700, size: gotham(12), leading: 16, caps: true }, label: { face: G, weight: 700, size: gotham(12), ownFont: { size: 12 }, leading: 16, caps: true },
clientName: { face: G, weight: 700, size: gotham(14.39), leading: 18, caps: true }, clientName: { face: G, weight: 700, size: gotham(14.39), ownFont: { size: 14.39 }, leading: 18, caps: true },
clientLine: { face: G, weight: 400, size: gotham(10.16), leading: 15.5 }, clientLine: { face: G, weight: 400, size: gotham(10.16), ownFont: { size: 10.16 }, leading: 15.5 },
amount: { face: DM, weight: 700, size: 32.74, leading: 36, minSize: 20, tnum: true }, amount: { face: DM, weight: 700, size: 32.74, leading: 36, minSize: 20, tnum: true },
date: { face: DM, weight: 700, size: 14.05, leading: 19.5, caps: true, minSize: 10 }, date: { face: DM, weight: 700, size: 14.05, leading: 19.5, caps: true, minSize: 10 },
tableHead: { face: DM, weight: 700, size: 14, leading: 18, caps: true }, tableHead: { face: DM, weight: 700, size: 14, leading: 18, caps: true },
tableBody: { face: DM, weight: 400, size: 11.83, leading: 15 }, tableBody: { face: DM, weight: 400, size: 11.83, leading: 15 },
tableSub: { face: DM, weight: 400, size: 9, leading: 11.5 }, tableSub: { face: DM, weight: 400, size: 9, leading: 11.5 },
totalLabel: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true }, totalLabel: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17, caps: true },
totalValue: { face: G, weight: 400, size: gotham(13), leading: 17 }, totalValue: { face: G, weight: 400, size: gotham(13), ownFont: { size: 13 }, leading: 17 },
grand: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true }, grand: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17, caps: true },
grandValue: { face: G, weight: 700, size: gotham(13), leading: 17 }, grandValue: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17 },
words: { face: DM, weight: 400, size: 10, leading: 13 }, words: { face: DM, weight: 400, size: 10, leading: 13 },
wordsLabel: { face: DM, weight: 700, size: 10, leading: 13 }, wordsLabel: { face: DM, weight: 700, size: 10, leading: 13 },
termsLabel: { face: DM, weight: 700, size: 13, leading: 17, caps: true }, termsLabel: { face: DM, weight: 700, size: 13, leading: 17, caps: true },
bullet: { face: DM, weight: 400, size: 11, leading: 14.2 }, bullet: { face: DM, weight: 400, size: 11, leading: 14.2 },
bank: { face: G, weight: 400, size: gotham(11), leading: 12.5 }, bank: { face: G, weight: 400, size: gotham(11), ownFont: { size: 11 }, leading: 12.5 },
contact: { face: G, weight: 400, size: gotham(10.14), leading: 15, minSize: 7 }, contact: { face: G, weight: 400, size: gotham(10.14), ownFont: { size: 10.14 }, leading: 15, minSize: 7 },
small: { face: DM, weight: 400, size: 9, leading: 12 }, small: { face: DM, weight: 400, size: 9, leading: 12 },
fine: { face: DM, weight: 400, size: 7, leading: 10 }, fine: { face: DM, weight: 400, size: 7, leading: 10 },
signName: { face: DM, weight: 700, size: 9.5, leading: 13 }, signName: { face: DM, weight: 700, size: 9.5, leading: 13 },
+1 -1
View File
@@ -5,7 +5,7 @@ import type { GapRange } from "../neutral/variant";
/** /**
* Gotham (commercial) is drawn with Montserrat, which runs about 4% wider at the same size. Every Gotham-derived * Gotham (commercial) is drawn with Montserrat, which runs about 4% wider at the same size. Every Gotham-derived
* size is multiplied by this when a token is defined, so the substitute takes the width the design has. A real * size is multiplied by this when a token is defined, so the substitute takes the width the design has. A real
* Gotham imported over the licensed name would need these tokens at factor 1. * Gotham imported over the licensed name takes the design size back: each such token carries it as `ownFont.size`.
*/ */
export const GOTHAM_SIZE = 0.96; export const GOTHAM_SIZE = 0.96;
const round2 = (n: number) => Math.round(n * 100) / 100; const round2 = (n: number) => Math.round(n * 100) / 100;
+8
View File
@@ -36,6 +36,7 @@ import { emailError, ifscError, panError, phoneError, vendorGstinError } from ".
import { ImagePicker } from "../components/ImagePicker"; import { ImagePicker } from "../components/ImagePicker";
import { LogoBranding } from "../components/LogoBranding"; import { LogoBranding } from "../components/LogoBranding";
import DiagnosticsPanel from "../components/DiagnosticsPanel"; import DiagnosticsPanel from "../components/DiagnosticsPanel";
import { FontOverrideTable } from "../components/FontOverrideTable";
const blankBank = (): BankAccount => ({ const blankBank = (): BankAccount => ({
id: null, id: null,
@@ -289,6 +290,13 @@ export default function AppSettings({
</Tile> </Tile>
</section> </section>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Fonts</h3>
<Tile>
<FontOverrideTable />
</Tile>
</section>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}> <section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Defaults</h3> <h3>Defaults</h3>
<Grid narrow> <Grid narrow>
+8 -2
View File
@@ -64,6 +64,8 @@ import { issueAndArchive, renderAndArchive } from "../lib/exportFlow";
import { loadExportMode } from "../lib/exportMode"; import { loadExportMode } from "../lib/exportMode";
import type { ExportMode } from "../lib/filename"; import type { ExportMode } from "../lib/filename";
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs"; import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
import { useUserFonts } from "../hooks/useUserFonts";
import { withUserFonts } from "../lib/fontImport";
import { ImagePicker } from "../components/ImagePicker"; import { ImagePicker } from "../components/ImagePicker";
import { loadLogoImages, type LogoImages } from "../lib/logo"; import { loadLogoImages, type LogoImages } from "../lib/logo";
@@ -354,7 +356,11 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
// Page setup for the invoice being edited; an issued invoice shows what was frozen at issue. // Page setup for the invoice being edited; an issued invoice shows what was frozen at issue.
const [prefs, setPrefs] = useState<RenderPrefsV1>(() => parsePrefs(settings.renderPrefs)); const [prefs, setPrefs] = useState<RenderPrefsV1>(() => parsePrefs(settings.renderPrefs));
useEffect(() => setPrefs(parsePrefs(settings.renderPrefs)), [settings.renderPrefs]); useEffect(() => setPrefs(parsePrefs(settings.renderPrefs)), [settings.renderPrefs]);
const activePrefs = useMemo(() => (issued ? prefsForInvoice(issued, settings) : prefs), [issued, prefs, settings]); // setupPrefs is the page setup as edited (and saved as the default); activePrefs adds the imported fonts the template
// draws, which is what renders and what is frozen on the invoice at issue. An issued invoice keeps its frozen fonts.
const userFonts = useUserFonts();
const setupPrefs = useMemo(() => (issued ? prefsForInvoice(issued, settings) : prefs), [issued, prefs, settings]);
const activePrefs = useMemo(() => (issued ? setupPrefs : withUserFonts(setupPrefs, userFonts)), [issued, setupPrefs, userFonts]);
// The preview renders the real PDF from a draft-status Invoice built from the form. It is skipped // The preview renders the real PDF from a draft-status Invoice built from the form. It is skipped
// while this view is hidden (it stays mounted) so typing elsewhere never starts a render. // while this view is hidden (it stays mounted) so typing elsewhere never starts a render.
@@ -1038,7 +1044,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
<Column sm={4} md={8} lg={7}> <Column sm={4} md={8} lg={7}>
<div style={{ position: "sticky", top: "3rem" }}> <div style={{ position: "sticky", top: "3rem" }}>
<h3 style={{ marginBottom: "0.75rem" }}>Preview</h3> <h3 style={{ marginBottom: "0.75rem" }}>Preview</h3>
<PageSetupControls prefs={activePrefs} onChange={setPrefs} settings={settings} onSaved={onSettingsChange} disabled={locked} /> <PageSetupControls prefs={setupPrefs} onChange={setPrefs} settings={settings} onSaved={onSettingsChange} disabled={locked} />
<Suspense fallback={<InlineLoading description="Loading preview…" />}> <Suspense fallback={<InlineLoading description="Loading preview…" />}>
<PdfPreview <PdfPreview
bytes={pdf.bytes} bytes={pdf.bytes}