Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afbc0aa56f | ||
|
|
4d18a50753 |
@@ -30,3 +30,4 @@ xwin/
|
||||
# Local agent notes (kept on disk, not in the repo)
|
||||
CLAUDE.md
|
||||
handoff.md
|
||||
.commandcode/
|
||||
|
||||
@@ -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
|
||||
`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
|
||||
|
||||
Copy `packaging/void/template` into a `void-packages` checkout and build it the standard way:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
|
||||
Executable
+15
@@ -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
|
||||
Executable
+14
@@ -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
|
||||
@@ -54,6 +54,27 @@ install_icon src-tauri/icons/icon.png 512x512
|
||||
|
||||
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"
|
||||
# Kept outside Vite's `dist/` output directory, which is wiped on every build.
|
||||
OUT="$ROOT/build/void"
|
||||
@@ -71,7 +92,7 @@ mkdir -p "$REPO"
|
||||
-l "proprietary" \
|
||||
-H "https://git.bongbetic.com/xavierk/Voiced" \
|
||||
-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" \
|
||||
--compression zstd \
|
||||
"$STAGE"
|
||||
|
||||
+23
-1
@@ -3,7 +3,9 @@
|
||||
# Canonical Void Linux package definition, to be built with xbps-src from a
|
||||
# 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
|
||||
#
|
||||
# Notes:
|
||||
@@ -12,6 +14,13 @@
|
||||
# tarball, mirror it, or drop the sources into the xbps-src masterdir.
|
||||
# * `checksum=SKIP` avoids pinning a hash that cannot be computed here; replace
|
||||
# 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
|
||||
# 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"
|
||||
checksum=SKIP
|
||||
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() {
|
||||
npm install --include=dev --no-audit --no-fund
|
||||
@@ -51,4 +63,14 @@ do_install() {
|
||||
vinstall "${wrksrc}/src-tauri/icons/icon.png" 644 \
|
||||
usr/share/icons/hicolor/512x512/apps voiced.png
|
||||
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
|
||||
}
|
||||
|
||||
Generated
+1
@@ -4290,6 +4290,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"image",
|
||||
"percent-encoding",
|
||||
"rusqlite",
|
||||
|
||||
@@ -27,6 +27,7 @@ uuid = { version = "1", features = ["v4"] }
|
||||
thiserror = "2"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
flate2 = "1"
|
||||
percent-encoding = "2"
|
||||
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ pub mod archive;
|
||||
pub mod assets;
|
||||
pub mod clients;
|
||||
pub mod files;
|
||||
pub mod fonts;
|
||||
pub mod invoice;
|
||||
pub mod logo;
|
||||
pub mod raw;
|
||||
|
||||
+30
-3
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
|
||||
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||
|
||||
/// 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)]
|
||||
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';
|
||||
"#;
|
||||
|
||||
/// 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> {
|
||||
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.
|
||||
@@ -620,7 +647,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
assert_eq!(user_version(&old), 4);
|
||||
}
|
||||
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
|
||||
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
|
||||
@@ -141,6 +141,11 @@ pub fn run() {
|
||||
commands::archive::archive_pdf,
|
||||
commands::archive::read_archive,
|
||||
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::get_last_export_dir,
|
||||
commands::files::reveal_in_folder,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
Settings,
|
||||
} from "./types";
|
||||
import type { LogoAsset } from "./logo";
|
||||
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
|
||||
|
||||
export interface ArchiveStatus {
|
||||
archived: boolean;
|
||||
@@ -70,6 +71,22 @@ export const api = {
|
||||
readArchive: async (invoiceId: number): Promise<Uint8Array> =>
|
||||
new Uint8Array(await invoke<ArrayBuffer>("read_archive", { invoiceId })),
|
||||
archiveStatus: (invoiceId: number) => invoke<ArchiveStatus>("archive_status", { invoiceId }),
|
||||
// Imported fonts (Now, Gotham, Open Sauce One). The font bytes are the raw body; metadata goes in headers.
|
||||
inspectFont: (bytes: Uint8Array) => invoke<FontInspection>("inspect_font", bytes),
|
||||
importFont: (bytes: Uint8Array, meta: ImportMeta) =>
|
||||
invoke<UserFontRow>("import_font", bytes, {
|
||||
headers: {
|
||||
"x-face": encodeURIComponent(meta.face),
|
||||
"x-file-name": encodeURIComponent(meta.fileName),
|
||||
"x-licence-ack": String(meta.licenceAcknowledged),
|
||||
...(meta.weight !== undefined ? { "x-weight": String(meta.weight) } : {}),
|
||||
...(meta.italic !== undefined ? { "x-style": meta.italic ? "italic" : "normal" } : {}),
|
||||
},
|
||||
}),
|
||||
listUserFonts: (includeHidden = false) => invoke<UserFontRow[]>("list_user_fonts", { includeHidden }),
|
||||
getUserFontBytes: async (sha256: string): Promise<Uint8Array> =>
|
||||
new Uint8Array(await invoke<ArrayBuffer>("get_user_font_bytes", { sha256 })),
|
||||
removeUserFont: (id: number) => invoke<RemoveOutcome>("remove_user_font", { id }),
|
||||
writeExportFile: (path: string, bytes: Uint8Array) =>
|
||||
invoke<string>("write_export_file", bytes, { headers: { "x-path": encodeURIComponent(path) } }),
|
||||
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_PREFS } from "../pdf/model/prefs";
|
||||
import { clientRefusal, selectionFrom, withUserFonts, type UserFontRow } from "./fontImport";
|
||||
|
||||
const row = (id: number, face: UserFontRow["face"], sha: string, over: Partial<UserFontRow> = {}): UserFontRow => ({
|
||||
id, face, familyName: "F", fullName: "F", styleName: "", weight: 400, style: "normal", sha256: sha, fileName: "f.ttf",
|
||||
format: "ttf", size: 1, fsType: 0, hasRupee: true, licenceAckAt: "t", importedAt: "t", hidden: false, ...over,
|
||||
});
|
||||
const A = "a".repeat(64);
|
||||
const B = "b".repeat(64);
|
||||
const C = "c".repeat(64);
|
||||
|
||||
describe("clientRefusal", () => {
|
||||
const bytes = (magic: string) => new Uint8Array([...magic].map((c) => c.charCodeAt(0)).concat(new Array(20).fill(0)));
|
||||
it("passes TTF, OTF and WOFF and refuses the rest with a reason", () => {
|
||||
expect(clientRefusal(new Uint8Array([0, 1, 0, 0, 0, 0, 0, 0]))).toBeNull();
|
||||
expect(clientRefusal(bytes("OTTO"))).toBeNull();
|
||||
expect(clientRefusal(bytes("wOFF"))).toBeNull();
|
||||
expect(clientRefusal(bytes("wOF2"))).toMatch(/WOFF2/);
|
||||
expect(clientRefusal(bytes("ttcf"))).toMatch(/collections/);
|
||||
expect(clientRefusal(bytes("<htm"))).toMatch(/not a TrueType/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withUserFonts", () => {
|
||||
const fonts = [row(1, "Gotham", B), row(2, "Gotham", A, { weight: 700 }), row(3, "Now", C), row(4, "Now", "d".repeat(64), { hidden: true })];
|
||||
|
||||
it("lists visible fonts per face", () => {
|
||||
expect(selectionFrom(fonts)).toEqual({ Gotham: [B, A], Now: [C] });
|
||||
});
|
||||
|
||||
it("adds only the faces the template draws, sorted, and nothing for a template without them", () => {
|
||||
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "purple-pop" }, fonts).fonts).toEqual({ Gotham: [A, B] });
|
||||
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "citrus-split" }, fonts).fonts).toEqual({ Now: [C], Gotham: [A, B] });
|
||||
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "marble" }, fonts).fonts).toEqual({ Now: [C] });
|
||||
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "classic" }, fonts)).toEqual({ ...DEFAULT_PREFS, templateId: "classic" });
|
||||
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "marble" }, [])).toEqual({ ...DEFAULT_PREFS, templateId: "marble" });
|
||||
});
|
||||
|
||||
it("replaces fonts already in the prefs rather than merging", () => {
|
||||
const stale = { ...DEFAULT_PREFS, templateId: "marble", fonts: { Now: ["e".repeat(64)] } };
|
||||
expect(withUserFonts(stale, fonts).fonts).toEqual({ Now: [C] });
|
||||
expect(withUserFonts(stale, []).fonts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { FontFormat, UserFace } from "../pdf/fonts/userFontStore";
|
||||
import { sniffFontFormat, USER_FACES } from "../pdf/fonts/userFontStore";
|
||||
import type { RenderPrefsV1 } from "../pdf/model/prefs";
|
||||
import { catalogEntry } from "../pdf/templates/catalog";
|
||||
|
||||
export { USER_FACES, type UserFace };
|
||||
|
||||
/** The three licensed faces and the bundled look-alike each one is drawn with until a font is imported. */
|
||||
export const FACE_SUBSTITUTES: Record<UserFace, string> = {
|
||||
Now: "Jost",
|
||||
Gotham: "Montserrat",
|
||||
"Open Sauce One": "Poppins",
|
||||
};
|
||||
|
||||
/** What the backend found in a font file (src-tauri/src/commands/fonts.rs `FontInspection`). */
|
||||
export interface FontInspection {
|
||||
format: "ttf" | "otf";
|
||||
sourceFormat: "ttf" | "otf" | "woff";
|
||||
family: string;
|
||||
subfamily: string;
|
||||
fullName: string;
|
||||
postscriptName: string;
|
||||
weight: number;
|
||||
italic: boolean;
|
||||
fsType: number;
|
||||
numGlyphs: number;
|
||||
hasRupee: boolean;
|
||||
hasBasicLatin: boolean;
|
||||
size: number;
|
||||
sha256: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** One stored font file (`UserFont`). */
|
||||
export interface UserFontRow {
|
||||
id: number;
|
||||
face: UserFace;
|
||||
familyName: string;
|
||||
fullName: string;
|
||||
styleName: string;
|
||||
weight: number;
|
||||
style: "normal" | "italic";
|
||||
sha256: string;
|
||||
fileName: string;
|
||||
format: "ttf" | "otf";
|
||||
size: number;
|
||||
fsType: number;
|
||||
hasRupee: boolean;
|
||||
licenceAckAt: string;
|
||||
importedAt: string;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface RemoveOutcome {
|
||||
deleted: boolean;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface ImportMeta {
|
||||
face: UserFace;
|
||||
fileName: string;
|
||||
weight?: number;
|
||||
italic?: boolean;
|
||||
licenceAcknowledged: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same magic-byte check the backend makes, to refuse early with a plain message. TTF, OTF and WOFF pass (the
|
||||
* backend unpacks WOFF); the backend stays the authority and repeats every check.
|
||||
*/
|
||||
export function clientRefusal(bytes: Uint8Array): string | null {
|
||||
const format: FontFormat = sniffFontFormat(bytes);
|
||||
switch (format) {
|
||||
case "ttf":
|
||||
case "otf":
|
||||
case "woff":
|
||||
return null;
|
||||
case "woff2":
|
||||
return "WOFF2 cannot be used: it embeds in a PDF without glyph outlines (invisible text). Import the TTF, OTF or WOFF file instead.";
|
||||
case "ttc":
|
||||
return "Font collections (.ttc) are not supported. Import a single TTF or OTF file.";
|
||||
default:
|
||||
return "This is not a TrueType, OpenType or WOFF font file.";
|
||||
}
|
||||
}
|
||||
|
||||
export const weightName = (weight: number, italic: boolean): string => {
|
||||
const names: Record<number, string> = { 100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular", 500: "Medium", 600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black" };
|
||||
return `${names[weight] ?? weight}${italic ? " Italic" : ""}`;
|
||||
};
|
||||
|
||||
/** Visible fonts grouped by face, as the sha256 list `prefs.fonts` carries. */
|
||||
export function selectionFrom(fonts: readonly UserFontRow[]): NonNullable<RenderPrefsV1["fonts"]> {
|
||||
const out: NonNullable<RenderPrefsV1["fonts"]> = {};
|
||||
for (const f of fonts) {
|
||||
if (f.hidden) continue;
|
||||
(out[f.face] ??= []).push(f.sha256);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prefs to render and issue with: `prefs` plus the imported fonts for the faces this template draws. Fonts for
|
||||
* faces it does not use stay out, so an invoice only pins (and so only protects) the fonts it really uses. The
|
||||
* result is for rendering; keep the clean `prefs` for the page-setup controls and the saved default.
|
||||
*/
|
||||
export function withUserFonts(prefs: RenderPrefsV1, fonts: readonly UserFontRow[]): RenderPrefsV1 {
|
||||
const { fonts: _drop, ...rest } = prefs;
|
||||
const faces = catalogEntry(prefs.templateId).faces ?? [];
|
||||
const selection = selectionFrom(fonts);
|
||||
const used: NonNullable<RenderPrefsV1["fonts"]> = {};
|
||||
for (const face of faces) {
|
||||
const ids = selection[face];
|
||||
if (ids && ids.length > 0) used[face] = [...ids].sort();
|
||||
}
|
||||
return Object.keys(used).length > 0 ? { ...rest, fonts: used } : rest;
|
||||
}
|
||||
|
||||
// Settings and the invoice view are separate parts of the UI; this lets the list in one refresh the other.
|
||||
const listeners = new Set<() => void>();
|
||||
export const onUserFontsChanged = (cb: () => void): (() => void) => {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
};
|
||||
export const notifyUserFontsChanged = (): void => listeners.forEach((cb) => cb());
|
||||
+12
-1
@@ -1,11 +1,22 @@
|
||||
import { createAutoClient, type RenderClient } from "../pdf/render/client";
|
||||
import type { UserFace } from "../pdf/fonts/userFontStore";
|
||||
import { api } from "./api";
|
||||
|
||||
let client: RenderClient | null = null;
|
||||
let lastId = 0;
|
||||
|
||||
/** The one render client (and so one worker) shared by the preview and export. */
|
||||
export function getRenderClient(): RenderClient {
|
||||
client ??= createAutoClient();
|
||||
client ??= createAutoClient({
|
||||
// Font metadata comes from the database row, the bytes over the binary IPC; both by the file's sha256.
|
||||
loadUserFont: async (sha256) => {
|
||||
const rows = await api.listUserFonts(true);
|
||||
const row = rows.find((r) => r.sha256 === sha256);
|
||||
if (!row) throw new Error("An imported font used by this invoice is no longer in the font library");
|
||||
const bytes = await api.getUserFontBytes(sha256);
|
||||
return { face: row.face as UserFace, weight: row.weight, italic: row.style === "italic", sha256, bytes };
|
||||
},
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create, type Font } from "fontkit";
|
||||
import { FONT_MANIFEST } from "../fonts/manifest";
|
||||
import { familySources, FONT_STACK, stack, type FamilySource } from "../fonts/stack";
|
||||
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||
import { userFamilySources } from "../fonts/userFontStore";
|
||||
|
||||
export interface MeasureInput {
|
||||
text: string;
|
||||
@@ -29,6 +30,29 @@ export interface Measurer {
|
||||
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. */
|
||||
function resolveSource(sources: FamilySource[], weight: number, style: "normal" | "italic"): FamilySource | undefined {
|
||||
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>();
|
||||
|
||||
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);
|
||||
if (!fam) return null;
|
||||
const src = resolveSource(familySources(fam), weight, italic ? "italic" : "normal");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FONT_MANIFEST, type FontFileEntry } from "../fonts/manifest";
|
||||
import { userFontkitFont } from "./measure";
|
||||
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||
|
||||
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 {
|
||||
const token = roleToken(role);
|
||||
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);
|
||||
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.
|
||||
|
||||
+101
-16
@@ -1,6 +1,15 @@
|
||||
import { Font } from "@react-pdf/renderer";
|
||||
import { FONT_MANIFEST } from "./manifest";
|
||||
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";
|
||||
|
||||
@@ -71,7 +80,9 @@ function primeComponents(font: FkFont): void {
|
||||
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
|
||||
@@ -79,30 +90,104 @@ let primed: Promise<void> | null = null;
|
||||
* 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",
|
||||
* 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> {
|
||||
if (!registered) return Promise.resolve();
|
||||
if (!primed) {
|
||||
primed = run().catch((err) => {
|
||||
primed = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return primed;
|
||||
if (!registered && primedKeys.size === 0 && userFamilies.length === 0) return Promise.resolve();
|
||||
const next = primeChain.then(run);
|
||||
primeChain = next.catch(() => undefined);
|
||||
return next;
|
||||
|
||||
async function run(): Promise<void> {
|
||||
for (const fam of FONT_MANIFEST.families) {
|
||||
for (const s of familySources(fam)) {
|
||||
const descriptor = { fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle };
|
||||
await Font.load(descriptor);
|
||||
const data = (Font as unknown as { getFont(d: object): { data: FkFont | null } }).getFont(descriptor).data;
|
||||
if (data) primeComponents(data);
|
||||
const descriptors: Array<{ fontFamily: string; fontWeight: number; fontStyle: "normal" | "italic" }> = [];
|
||||
if (registered) {
|
||||
for (const fam of FONT_MANIFEST.families) {
|
||||
for (const s of familySources(fam)) descriptors.push({ 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);
|
||||
const data = (Font as unknown as { getFont(d: object): { data: FkFont | null } }).getFont(descriptor).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;
|
||||
|
||||
function hasTrueTypeMagic(b: Uint8Array): boolean {
|
||||
|
||||
+55
-3
@@ -35,6 +35,13 @@ export interface TypeToken {
|
||||
tnum?: boolean;
|
||||
/** Smallest size IdText/FitText may shrink to. */
|
||||
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";
|
||||
@@ -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({ ... })`. */
|
||||
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
|
||||
* 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_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>> = {
|
||||
Now: "Jost",
|
||||
@@ -78,8 +108,30 @@ export const SUBSTITUTES: Readonly<Record<string, string>> = {
|
||||
"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. */
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
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). */
|
||||
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. */
|
||||
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 = {
|
||||
@@ -22,6 +28,7 @@ export const DEFAULT_PREFS: RenderPrefsV1 = {
|
||||
};
|
||||
|
||||
const PAGE_SIZE_IDS: readonly PageId[] = ["A4", "LETTER"];
|
||||
const FONT_ID = /^[0-9a-f]{16,64}$/;
|
||||
const MARGIN_IDS: readonly MarginPreset[] = ["template", "narrow", "normal"];
|
||||
|
||||
/** 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 (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;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class FakeWorker implements WorkerLike {
|
||||
onerror: WorkerLike["onerror"] = null;
|
||||
sent: WorkerRequest[] = [];
|
||||
terminated = false;
|
||||
postMessage(message: WorkerRequest) {
|
||||
postMessage(message: WorkerRequest, _transfer?: Transferable[]) {
|
||||
this.sent.push(message);
|
||||
}
|
||||
terminate() {
|
||||
@@ -378,3 +378,94 @@ describe("main-thread client and fallback", () => {
|
||||
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)]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UserFontFile } from "../fonts/userFontStore";
|
||||
import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
|
||||
|
||||
/** 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. */
|
||||
export interface WorkerLike {
|
||||
postMessage(message: WorkerRequest): void;
|
||||
postMessage(message: WorkerRequest, transfer?: Transferable[]): void;
|
||||
terminate(): void;
|
||||
onmessage: ((event: { data: WorkerMessage }) => void) | null;
|
||||
onerror: ((event: { message?: string }) => void) | null;
|
||||
@@ -55,6 +56,11 @@ export interface RenderClient {
|
||||
}
|
||||
|
||||
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;
|
||||
/** Per-job watchdog. react-pdf's paginate loop is synchronous, so a layout bug hangs the worker for good. */
|
||||
timeoutMs?: number;
|
||||
@@ -85,8 +91,21 @@ type Running = Slot & {
|
||||
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
|
||||
@@ -105,11 +124,14 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
|
||||
let running: Running | null = null;
|
||||
let pending: RenderSlot | null = null;
|
||||
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 w = worker;
|
||||
worker = null;
|
||||
ready = false;
|
||||
sent.clear();
|
||||
if (w) {
|
||||
w.onmessage = null;
|
||||
w.onerror = null;
|
||||
@@ -147,7 +169,9 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
|
||||
}
|
||||
if (!slot) return;
|
||||
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 = () => {
|
||||
@@ -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.
|
||||
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 registerJobFonts(job.prefs, job.userFonts);
|
||||
// Let pending input and paint run before the long synchronous layout.
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
const t0 = performance.now();
|
||||
@@ -337,6 +366,27 @@ export function createAutoClient(
|
||||
): RenderClient {
|
||||
const primary = createRenderClient(opts);
|
||||
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 = () => {
|
||||
main ??= createMainThreadClient(opts.mainExecutor, opts.mainAssembleExecutor);
|
||||
primary.dispose();
|
||||
@@ -346,7 +396,11 @@ export function createAutoClient(
|
||||
get mode() {
|
||||
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);
|
||||
return primary.render(job).catch((err) => {
|
||||
if (!(err instanceof WorkerUnavailable)) throw err;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { computeFrame, PAGE_SIZES, type PageFrame } from "../engine/geometry";
|
||||
import type { AuditIssue } from "../engine/audit";
|
||||
import type { RenderModel } from "../model/build";
|
||||
import type { RenderPrefsV1 } from "../model/prefs";
|
||||
import { withFaceOverrides } from "../fonts/roles";
|
||||
import { faceOverridesFor } from "../fonts/userFontStore";
|
||||
import { CLOSING_ID } from "../engine/ids";
|
||||
import { marginsFor, type PlacedLogo } from "../templates/contract";
|
||||
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
|
||||
* 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 frame = frameForPrefs(prefs);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AuditIssue } from "../engine/audit";
|
||||
import type { RenderModel } from "../model/build";
|
||||
import type { UserFontFile } from "../fonts/userFontStore";
|
||||
import type { RenderPrefsV1 } from "../model/prefs";
|
||||
|
||||
// Everything here crosses postMessage, so it stays plain data: no functions, classes or Maps.
|
||||
@@ -8,6 +9,11 @@ export interface RenderJob {
|
||||
id: number;
|
||||
model: RenderModel;
|
||||
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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { installStreamAsyncIterator } from "../../lib/streamPolyfill";
|
||||
import { initBrowserRendering } from "./browserInit";
|
||||
import { runAssembleJob } from "./assembleJob";
|
||||
import { renderInvoicePdf } from "./invoice";
|
||||
import { registerJobFonts } from "./userFontJob";
|
||||
import type { WorkerMessage, WorkerRequest } from "./jobs";
|
||||
|
||||
interface WorkerScope {
|
||||
@@ -44,6 +45,7 @@ scope.onmessage = async (event) => {
|
||||
const { job } = request;
|
||||
try {
|
||||
await started.catch(() => initBrowserRendering());
|
||||
await registerJobFonts(job.prefs, job.userFonts);
|
||||
const t0 = performance.now();
|
||||
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
|
||||
@@ -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[]);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export interface TemplateCatalogEntry {
|
||||
badge?: "Matches your original format";
|
||||
/** No reference placeholder: the logo shows only when the user turns it on. */
|
||||
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> = {
|
||||
@@ -32,11 +37,11 @@ export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [
|
||||
{ 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: "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: "citrus-split", name: "Citrus Split", family: "northline", thumbnail: "/templates/citrus-split.png" },
|
||||
{ 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", faces: ["Now", "Gotham"] },
|
||||
{ id: "monolith", name: "Monolith", family: "standalone", thumbnail: "/templates/monolith.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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
* 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
|
||||
* 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({
|
||||
/** "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 },
|
||||
/** 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 },
|
||||
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:. */
|
||||
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 },
|
||||
tableBody: { face: N, weight: 400, size: 10.57, leading: 17.4, tracking: 0.102 },
|
||||
tableSub: { face: N, weight: 400, size: 8.5, leading: 11.5, tracking: 0.1 },
|
||||
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, ownFont: { tracking: 0.04 } },
|
||||
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. */
|
||||
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 },
|
||||
signName: { face: N, weight: 400, size: 10.57, leading: 15, tracking: 0.102 },
|
||||
signDesignation: { face: N, weight: 400, size: 9.6, leading: 14, tracking: 0.13 },
|
||||
fine: { face: N, weight: 400, size: 8, leading: 11, tracking: 0.1 },
|
||||
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, ownFont: { tracking: 0.04 } },
|
||||
fine: { face: N, weight: 400, size: 8, leading: 11, tracking: 0.1, ownFont: { tracking: 0.04 } },
|
||||
/** 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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -28,21 +28,21 @@ export const CITRUS_ROLES = defineRoles({
|
||||
metaStrong: { face: DM, weight: 700, size: 12.36, leading: 16, minSize: 8 },
|
||||
billedLabel: { face: DM, weight: 700, size: 12.4, leading: 17, 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 },
|
||||
headService: { face: G, weight: 700, size: gotham(15.17), leading: 18, tracking: 0.15, caps: true },
|
||||
headNum: { face: G, weight: 700, size: gotham(14.17), leading: 18, caps: true },
|
||||
tableBody: { face: G, weight: 400, size: gotham(12), leading: 15 },
|
||||
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), ownFont: { size: 15.17 }, leading: 18, tracking: 0.15, 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), ownFont: { size: 12 }, leading: 15 },
|
||||
tableSub: { face: G, weight: 400, size: 9, leading: 11.5 },
|
||||
totalLabel: { face: G, weight: 700, size: gotham(12.5), leading: 17, caps: true },
|
||||
totalValue: { face: G, weight: 400, size: gotham(12.5), leading: 17 },
|
||||
grand: { face: G, weight: 700, size: gotham(14.7), leading: 18, caps: true },
|
||||
grandValue: { face: G, weight: 700, size: gotham(14.7), leading: 18 },
|
||||
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), ownFont: { size: 12.5 }, leading: 17 },
|
||||
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), ownFont: { size: 14.7 }, leading: 18 },
|
||||
words: { 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 },
|
||||
bank: { face: G, weight: 400, size: gotham(11), leading: 15 },
|
||||
bullet: { face: G, weight: 400, size: gotham(12), leading: 16.5 },
|
||||
contact: { face: G, weight: 400, size: gotham(12.77), leading: 18, minSize: 7 },
|
||||
bank: { face: G, weight: 400, size: gotham(11), ownFont: { size: 11 }, leading: 15 },
|
||||
bullet: { face: G, weight: 400, size: gotham(12), ownFont: { size: 12 }, leading: 16.5 },
|
||||
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 },
|
||||
fine: { face: DM, weight: 400, size: 7, leading: 10 },
|
||||
signName: { face: DM, weight: 700, size: 9.5, leading: 13 },
|
||||
|
||||
@@ -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 },
|
||||
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 },
|
||||
pill: { face: G, weight: 700, size: gotham(13.39), leading: 17, tracking: 0.13, caps: true, minSize: 8.5 },
|
||||
label: { face: G, weight: 700, size: gotham(12), leading: 16, caps: true },
|
||||
clientName: { face: G, weight: 700, size: gotham(14.39), leading: 18, caps: true },
|
||||
clientLine: { face: G, weight: 400, size: gotham(10.16), leading: 15.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), ownFont: { size: 12 }, leading: 16, 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), ownFont: { size: 10.16 }, leading: 15.5 },
|
||||
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 },
|
||||
tableHead: { face: DM, weight: 700, size: 14, leading: 18, caps: true },
|
||||
tableBody: { face: DM, weight: 400, size: 11.83, leading: 15 },
|
||||
tableSub: { face: DM, weight: 400, size: 9, leading: 11.5 },
|
||||
totalLabel: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true },
|
||||
totalValue: { face: G, weight: 400, size: gotham(13), leading: 17 },
|
||||
grand: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true },
|
||||
grandValue: { face: G, weight: 700, size: gotham(13), leading: 17 },
|
||||
totalLabel: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17, caps: true },
|
||||
totalValue: { face: G, weight: 400, size: gotham(13), ownFont: { size: 13 }, leading: 17 },
|
||||
grand: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17, caps: true },
|
||||
grandValue: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17 },
|
||||
words: { face: DM, weight: 400, size: 10, leading: 13 },
|
||||
wordsLabel: { face: DM, weight: 700, size: 10, leading: 13 },
|
||||
termsLabel: { face: DM, weight: 700, size: 13, leading: 17, caps: true },
|
||||
bullet: { face: DM, weight: 400, size: 11, leading: 14.2 },
|
||||
bank: { face: G, weight: 400, size: gotham(11), leading: 12.5 },
|
||||
contact: { face: G, weight: 400, size: gotham(10.14), leading: 15, minSize: 7 },
|
||||
bank: { face: G, weight: 400, size: gotham(11), ownFont: { size: 11 }, leading: 12.5 },
|
||||
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 },
|
||||
fine: { face: DM, weight: 400, size: 7, leading: 10 },
|
||||
signName: { face: DM, weight: 700, size: 9.5, leading: 13 },
|
||||
|
||||
@@ -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
|
||||
* 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;
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
@@ -36,6 +36,7 @@ import { emailError, ifscError, panError, phoneError, vendorGstinError } from ".
|
||||
import { ImagePicker } from "../components/ImagePicker";
|
||||
import { LogoBranding } from "../components/LogoBranding";
|
||||
import DiagnosticsPanel from "../components/DiagnosticsPanel";
|
||||
import { FontOverrideTable } from "../components/FontOverrideTable";
|
||||
|
||||
const blankBank = (): BankAccount => ({
|
||||
id: null,
|
||||
@@ -289,6 +290,13 @@ export default function AppSettings({
|
||||
</Tile>
|
||||
</section>
|
||||
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Fonts</h3>
|
||||
<Tile>
|
||||
<FontOverrideTable />
|
||||
</Tile>
|
||||
</section>
|
||||
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Defaults</h3>
|
||||
<Grid narrow>
|
||||
|
||||
@@ -64,6 +64,8 @@ import { issueAndArchive, renderAndArchive } from "../lib/exportFlow";
|
||||
import { loadExportMode } from "../lib/exportMode";
|
||||
import type { ExportMode } from "../lib/filename";
|
||||
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
|
||||
import { useUserFonts } from "../hooks/useUserFonts";
|
||||
import { withUserFonts } from "../lib/fontImport";
|
||||
import { ImagePicker } from "../components/ImagePicker";
|
||||
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.
|
||||
const [prefs, setPrefs] = useState<RenderPrefsV1>(() => parsePrefs(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
|
||||
// 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}>
|
||||
<div style={{ position: "sticky", top: "3rem" }}>
|
||||
<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…" />}>
|
||||
<PdfPreview
|
||||
bytes={pdf.bytes}
|
||||
|
||||
Reference in New Issue
Block a user