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