From 43afdaf4bf86216bfd6b7a83e80adbb24a5c7a9b Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Sun, 4 Oct 2026 13:58:06 +0530 Subject: [PATCH] Add font import for Now, Gotham and Open Sauce One overrides; package fonts for Void Font import: - Migration M6 adds user_fonts; files are stored content-addressed under the local data dir. New Rust commands inspect, import, list, read and remove fonts, refusing WOFF2, collections, variable fonts and restricted fsType, and unpacking WOFF1 to plain sfnt. - Imported fonts register in the render worker as user: families with an italic alias, and are pinned in the frozen render prefs so issued invoices can still re-render. Fonts referenced by issued invoices are hidden, never deleted. - Size and tracking compensations tuned for the substitutes apply only while the face is not overridden; default output and fingerprint goldens are unchanged. - Settings gets a Fonts section with a licence acknowledgement. Void packaging: - Install fonts to /usr/share/fonts/voiced with per-family OFL licences, set font_dirs, and add INSTALL/REMOVE scripts that run fc-cache. xbps-create carries both scripts (verified in a void-glibc container). - Fix xbps-create dependency specs in build-xbps.sh. --- README.md | 10 +- packaging/void/INSTALL | 15 + packaging/void/REMOVE | 14 + packaging/void/build-xbps.sh | 23 +- packaging/void/template | 24 +- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/fonts.rs | 1071 ++++++++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/db.rs | 33 +- src-tauri/src/lib.rs | 5 + src/components/FontOverrideTable.tsx | 282 +++++++ src/hooks/useUserFonts.ts | 23 + src/lib/api.ts | 17 + src/lib/fontImport.test.ts | 45 + src/lib/fontImport.ts | 125 +++ src/lib/renderClient.ts | 13 +- src/pdf/engine/measure.ts | 26 + src/pdf/engine/metrics.ts | 8 + src/pdf/fonts/register.ts | 117 ++- src/pdf/fonts/roles.ts | 58 +- src/pdf/fonts/userFontStore.ts | 128 +++ src/pdf/fonts/userFonts.test.tsx | 160 ++++ src/pdf/model/prefs.ts | 17 + src/pdf/render/client.test.ts | 93 +- src/pdf/render/client.ts | 66 +- src/pdf/render/invoice.ts | 9 +- src/pdf/render/jobs.ts | 6 + src/pdf/render/render.worker.ts | 2 + src/pdf/render/userFontJob.ts | 19 + src/pdf/templates/catalog.ts | 11 +- src/pdf/templates/marble/tokens.ts | 23 +- src/pdf/templates/northline/citrus.tsx | 22 +- src/pdf/templates/northline/purple.tsx | 20 +- src/pdf/templates/northline/variant.ts | 2 +- src/views/AppSettings.tsx | 8 + src/views/NewInvoice.tsx | 10 +- 37 files changed, 2436 insertions(+), 72 deletions(-) create mode 100755 packaging/void/INSTALL create mode 100755 packaging/void/REMOVE create mode 100644 src-tauri/src/commands/fonts.rs create mode 100644 src/components/FontOverrideTable.tsx create mode 100644 src/hooks/useUserFonts.ts create mode 100644 src/lib/fontImport.test.ts create mode 100644 src/lib/fontImport.ts create mode 100644 src/pdf/fonts/userFontStore.ts create mode 100644 src/pdf/fonts/userFonts.test.tsx create mode 100644 src/pdf/render/userFontJob.ts diff --git a/README.md b/README.md index 6831d81..23ca5bc 100644 --- a/README.md +++ b/README.md @@ -108,12 +108,20 @@ Launch **Voiced** from the application menu, or run `voiced`. Remove it with The package installs `/usr/bin/voiced`, a desktop entry and hicolor icons, and depends on `libwebkit2gtk41`. +**Fonts on Void.** The package also installs the bundled TTF fonts under +`/usr/share/fonts/voiced//` and each family's OFL licence under +`/usr/share/licenses/voiced/`. The `voiced/` subdirectory avoids file conflicts with Void's own +`font-ibm-plex-ttf` and `noto-fonts-ttf`. The `INSTALL` and `REMOVE` scripts in `packaging/void/` +run `fc-cache -f /usr/share/fonts/voiced` after install and removal. `xbps-create` packs them from +the destdir root (checked with xbps 0.60.7). + ### Building through xbps-src Copy `packaging/void/template` into a `void-packages` checkout and build it the standard way: ```bash -cp -r packaging/void/template void-packages/srcpkgs/voiced/template +mkdir -p void-packages/srcpkgs/voiced +cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE void-packages/srcpkgs/voiced/ cd void-packages && ./xbps-src pkg voiced ``` diff --git a/packaging/void/INSTALL b/packaging/void/INSTALL new file mode 100755 index 0000000..1f76d74 --- /dev/null +++ b/packaging/void/INSTALL @@ -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 diff --git a/packaging/void/REMOVE b/packaging/void/REMOVE new file mode 100755 index 0000000..dbf679e --- /dev/null +++ b/packaging/void/REMOVE @@ -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 diff --git a/packaging/void/build-xbps.sh b/packaging/void/build-xbps.sh index a9ea852..fd94bd9 100755 --- a/packaging/void/build-xbps.sh +++ b/packaging/void/build-xbps.sh @@ -54,6 +54,27 @@ install_icon src-tauri/icons/icon.png 512x512 install -Dm644 README.md "$STAGE/usr/share/doc/voiced/README.md" +# Bundled fonts: one subdirectory per family under /usr/share/fonts/voiced, plus +# each family's OFL text. Only *.ttf files are installed (not manifest.json, and +# not the legacy woff/woff2 files that sit directly in public/fonts). +for dir in public/fonts/*/; do + family="$(basename "$dir")" + for ttf in "$dir"*.ttf; do + install -Dm644 "$ttf" "$STAGE/usr/share/fonts/voiced/$family/$(basename "$ttf")" + done + install -Dm644 "${dir}OFL.txt" "$STAGE/usr/share/licenses/voiced/${family}-OFL.txt" +done + +# xbps action scripts. xbps-create packs INSTALL and REMOVE found at the root of +# the destdir into the archive (verified: they appear as ./INSTALL and ./REMOVE +# members and are excluded from files.plist), and xbps runs them with $1 set to +# the action. They refresh the fontconfig cache for /usr/share/fonts/voiced. +# Fallback if a future xbps-create stops carrying them: font cache refresh would +# then rely on fontconfig regenerating its cache on first use, or on the xbps-src +# template (which uses the same scripts). +install -Dm755 packaging/void/INSTALL "$STAGE/INSTALL" +install -Dm755 packaging/void/REMOVE "$STAGE/REMOVE" + echo "==> Creating $PKGVER.$ARCH.xbps" # Kept outside Vite's `dist/` output directory, which is wiped on every build. OUT="$ROOT/build/void" @@ -71,7 +92,7 @@ mkdir -p "$REPO" -l "proprietary" \ -H "https://git.bongbetic.com/xavierk/Voiced" \ -m "Xavier Karma " \ - -D "libwebkit2gtk41>=2.50_1 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils" \ + -D "libwebkit2gtk41>=2.50_1 fontconfig>=0 xdg-utils>=0 hicolor-icon-theme>=0 desktop-file-utils>=0" \ -t "office finance" \ --compression zstd \ "$STAGE" diff --git a/packaging/void/template b/packaging/void/template index 4274ae5..b0cbeaa 100644 --- a/packaging/void/template +++ b/packaging/void/template @@ -3,7 +3,9 @@ # Canonical Void Linux package definition, to be built with xbps-src from a # void-packages checkout: # -# cp -r packaging/void/template void-packages/srcpkgs/voiced/template +# mkdir -p void-packages/srcpkgs/voiced +# cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE \ +# void-packages/srcpkgs/voiced/ # cd void-packages && ./xbps-src pkg voiced # # Notes: @@ -12,6 +14,13 @@ # tarball, mirror it, or drop the sources into the xbps-src masterdir. # * `checksum=SKIP` avoids pinning a hash that cannot be computed here; replace # it with the real sha256 once the tarball is published. +# * INSTALL and REMOVE (next to this file) are xbps action scripts that run +# `fc-cache -f /usr/share/fonts/voiced` after install and removal. xbps-src +# picks them up automatically from srcpkgs/voiced/INSTALL and +# srcpkgs/voiced/REMOVE and packs them into the package; the template does +# not install them. Copy them next to the template (see the commands above). +# The stock x11-fonts trigger enabled by font_dirs does not run fc-cache +# unless mkfontdir and mkfontscale are installed, hence these scripts. # * If the build machine is offline, pre-populate the npm cache and Cargo # registry in the masterdir before building. # @@ -29,6 +38,9 @@ homepage="https://git.bongbetic.com/xavierk/Voiced" distfiles="https://git.bongbetic.com/xavierk/Voiced/archive/v${version}.tar.gz" checksum=SKIP compression="zstd" +# Fonts install under a voiced/ subdirectory so they never conflict with the +# files of Void's own font-ibm-plex-ttf and noto-fonts-ttf packages. +font_dirs="/usr/share/fonts/voiced" do_build() { npm install --include=dev --no-audit --no-fund @@ -51,4 +63,14 @@ do_install() { vinstall "${wrksrc}/src-tauri/icons/icon.png" 644 \ usr/share/icons/hicolor/512x512/apps voiced.png vdoc "${wrksrc}/README.md" + + # Bundled fonts: one subdirectory per family, plus its OFL text. + # manifest.json and any other non-font file are deliberately skipped. + for dir in "${wrksrc}"/public/fonts/*/; do + family="$(basename "${dir}")" + for ttf in "${dir}"*.ttf; do + vinstall "${ttf}" 644 "usr/share/fonts/voiced/${family}" + done + vlicense "${dir}OFL.txt" "${family}-OFL.txt" + done } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7c7a6ae..72f3d41 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4290,6 +4290,7 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "chrono", + "flate2", "image", "percent-encoding", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c0a5dc5..0bcf23b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ uuid = { version = "1", features = ["v4"] } thiserror = "2" base64 = "0.22" sha2 = "0.10" +flate2 = "1" percent-encoding = "2" image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } diff --git a/src-tauri/src/commands/fonts.rs b/src-tauri/src/commands/fonts.rs new file mode 100644 index 0000000..6af13a0 --- /dev/null +++ b/src-tauri/src/commands/fonts.rs @@ -0,0 +1,1071 @@ +//! Font import for the three licensed template faces (Now, Gotham, Open Sauce One). +//! +//! The user supplies a font file they are licensed to use. `inspect_font` reports what is in it, `import_font` +//! stores it content-addressed under `/fonts/.` (not as a database blob, so a +//! backup of the folder covers it) and records it in `user_fonts`. WOFF (v1) is unpacked to a plain sfnt first; +//! what is stored and hashed is always that sfnt, so the renderer never needs a decompressor. +//! +//! Refused: WOFF2 (embeds without outlines, i.e. invisible text), font collections (`ttcf`), variable fonts +//! (`fvar`, `CFF2`: only the default instance would embed), files without outlines, and fonts whose OS/2 `fsType` +//! forbids embedding. The sfnt is parsed by hand (no font crate is available offline); only the tables needed +//! to describe the font are read. +use super::raw::{decode_header_path, header_map, raw_body, required_header, write_atomic, Headers}; +use crate::AppState; +use flate2::read::ZlibDecoder; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use tauri::ipc::{Request, Response}; +use tauri::State; + +/// The only faces a user font can override; mirrors `USER_FACES` in src/pdf/fonts/userFontStore.ts. +pub const FACES: [&str; 3] = ["Now", "Gotham", "Open Sauce One"]; +pub const MAX_FONT_BYTES: usize = 16 * 1024 * 1024; + +// OS/2 fsType bits. +const FS_RESTRICTED: u16 = 0x0002; +const FS_PREVIEW_PRINT: u16 = 0x0004; +const FS_EDITABLE: u16 = 0x0008; +const FS_NO_SUBSETTING: u16 = 0x0100; +const FS_BITMAP_ONLY: u16 = 0x0200; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FontInspection { + /// What is stored: "ttf" (TrueType outlines) or "otf" (CFF outlines). + pub format: String, + /// What the file was: "ttf", "otf" or "woff". + pub source_format: String, + pub family: String, + pub subfamily: String, + pub full_name: String, + pub postscript_name: String, + pub weight: u16, + pub italic: bool, + pub fs_type: u16, + pub num_glyphs: u16, + pub has_rupee: bool, + pub has_basic_latin: bool, + /// Size and sha256 of the sfnt that would be stored. + pub size: usize, + pub sha256: String, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UserFont { + pub id: i64, + pub face: String, + pub family_name: String, + pub full_name: String, + pub style_name: String, + pub weight: i64, + /// "normal" or "italic". + pub style: String, + pub sha256: String, + pub file_name: String, + pub format: String, + pub size: i64, + pub fs_type: i64, + pub has_rupee: bool, + pub licence_ack_at: String, + pub imported_at: String, + /// Retired but kept because an issued invoice references it; it still serves `get_user_font_bytes`. + pub hidden: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RemoveOutcome { + /// The row and (when no other row shares it) the file are gone. + pub deleted: bool, + /// An issued invoice references the file, so it was only hidden. + pub hidden: bool, +} + +// --------------------------------------------------------------------------- +// sfnt reading + +fn be16(b: &[u8], at: usize) -> Option { + Some(u16::from_be_bytes(b.get(at..at + 2)?.try_into().ok()?)) +} +fn be32(b: &[u8], at: usize) -> Option { + Some(u32::from_be_bytes(b.get(at..at + 4)?.try_into().ok()?)) +} + +/// The table directory of an sfnt: tag -> (offset, length), bounds-checked against the file. +struct Sfnt<'a> { + data: &'a [u8], + tables: Vec<([u8; 4], usize, usize)>, +} + +impl<'a> Sfnt<'a> { + fn parse(data: &'a [u8]) -> Result { + let n = be16(data, 4).ok_or("The font file is truncated")? as usize; + if n == 0 || n > 128 { + return Err("The font file has an invalid table directory".into()); + } + let mut tables = Vec::with_capacity(n); + for i in 0..n { + let rec = 12 + i * 16; + let tag: [u8; 4] = data + .get(rec..rec + 4) + .ok_or("The font file is truncated")? + .try_into() + .unwrap(); + let off = be32(data, rec + 8).ok_or("The font file is truncated")? as usize; + let len = be32(data, rec + 12).ok_or("The font file is truncated")? as usize; + if off.checked_add(len).map_or(true, |end| end > data.len()) { + return Err(format!("Table '{}' lies outside the file", String::from_utf8_lossy(&tag))); + } + tables.push((tag, off, len)); + } + Ok(Self { data, tables }) + } + + fn table(&self, tag: &[u8; 4]) -> Option<&'a [u8]> { + self.tables + .iter() + .find(|(t, _, _)| t == tag) + .map(|&(_, off, len)| &self.data[off..off + len]) + } +} + +fn utf16be(b: &[u8]) -> String { + let units: Vec = b.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + String::from_utf16_lossy(&units) +} + +/// The best string for a name ID: Windows/Unicode (UTF-16BE) first, then Macintosh Roman (Latin-1 range). +fn name_string(name: &[u8], wanted: u16) -> Option { + let count = be16(name, 2)? as usize; + let strings = be16(name, 4)? as usize; + let mut best: Option<(u8, String)> = None; + for i in 0..count { + let rec = 6 + i * 12; + let platform = be16(name, rec)?; + let id = be16(name, rec + 6)?; + if id != wanted { + continue; + } + let len = be16(name, rec + 8)? as usize; + let off = strings + be16(name, rec + 10)? as usize; + let raw = name.get(off..off + len)?; + let (rank, text) = match platform { + 3 => (0, utf16be(raw)), + 0 => (1, utf16be(raw)), + 1 => (2, raw.iter().map(|&c| c as char).collect()), + _ => continue, + }; + let text = text.trim().to_string(); + if text.is_empty() { + continue; + } + if best.as_ref().map_or(true, |(r, _)| rank < *r) { + best = Some((rank, text)); + } + } + best.map(|(_, s)| s) +} + +/// Whether the cmap maps `cp` to a real glyph, through a format 4 or 12 subtable. +struct Cmap<'a> { + table: &'a [u8], + offset: usize, + format: u16, +} + +impl<'a> Cmap<'a> { + fn new(table: &'a [u8]) -> Option { + let n = be16(table, 2)? as usize; + // (rank, offset, format): prefer full-repertoire Unicode, then BMP. + let mut best: Option<(u8, usize, u16)> = None; + for i in 0..n { + let rec = 4 + i * 8; + let platform = be16(table, rec)?; + let encoding = be16(table, rec + 2)?; + let offset = be32(table, rec + 4)? as usize; + let format = be16(table, offset)?; + let rank = match (platform, encoding, format) { + (3, 10, 12) => 0, + (0, 4 | 6, 12) => 1, + (3, 1, 4) => 2, + (0, _, 4) => 3, + (0, _, 12) => 1, + _ => continue, + }; + if best.map_or(true, |(r, _, _)| rank < r) { + best = Some((rank, offset, format)); + } + } + best.map(|(_, offset, format)| Self { table, offset, format }) + } + + fn has(&self, cp: u32) -> bool { + self.glyph(cp).map_or(false, |g| g != 0) + } + + fn glyph(&self, cp: u32) -> Option { + let t = self.table; + let base = self.offset; + match self.format { + 12 => { + let groups = be32(t, base + 12)? as usize; + for g in 0..groups { + let at = base + 16 + g * 12; + let (start, end, gid) = (be32(t, at)?, be32(t, at + 4)?, be32(t, at + 8)?); + if cp >= start && cp <= end { + return Some(gid + (cp - start)); + } + } + None + } + 4 => { + if cp > 0xFFFF { + return None; + } + let segs = (be16(t, base + 6)? / 2) as usize; + let ends = base + 14; + let starts = ends + segs * 2 + 2; + let deltas = starts + segs * 2; + let ranges = deltas + segs * 2; + for s in 0..segs { + let end = be16(t, ends + s * 2)? as u32; + if cp > end { + continue; + } + let start = be16(t, starts + s * 2)? as u32; + if cp < start { + return None; + } + let delta = be16(t, deltas + s * 2)? as u32; + let range = be16(t, ranges + s * 2)? as usize; + if range == 0 { + return Some((cp + delta) & 0xFFFF); + } + let at = ranges + s * 2 + range + (cp - start) as usize * 2; + let gid = be16(t, at)? as u32; + return Some(if gid == 0 { 0 } else { (gid + delta) & 0xFFFF }); + } + None + } + _ => None, + } + } +} + +// --------------------------------------------------------------------------- +// WOFF 1 -> sfnt + +/// Unpacks a WOFF 1 file into a plain sfnt. Each table is zlib-compressed unless its compressed length equals its +/// original length. +fn woff_to_sfnt(data: &[u8]) -> Result, String> { + let bad = || "The WOFF file is damaged".to_string(); + let flavor = be32(data, 4).ok_or_else(bad)?; + let n = be16(data, 12).ok_or_else(bad)? as usize; + if n == 0 || n > 128 { + return Err(bad()); + } + struct Entry { + tag: [u8; 4], + checksum: u32, + bytes: Vec, + } + let mut entries = Vec::with_capacity(n); + let mut total = 12 + 16 * n; + for i in 0..n { + let rec = 44 + i * 20; + let tag: [u8; 4] = data.get(rec..rec + 4).ok_or_else(bad)?.try_into().unwrap(); + let off = be32(data, rec + 4).ok_or_else(bad)? as usize; + let comp = be32(data, rec + 8).ok_or_else(bad)? as usize; + let orig = be32(data, rec + 12).ok_or_else(bad)? as usize; + let checksum = be32(data, rec + 16).ok_or_else(bad)?; + let stored = data.get(off..off.checked_add(comp).ok_or_else(bad)?).ok_or_else(bad)?; + total += (orig + 3) & !3; + if orig > MAX_FONT_BYTES || total > MAX_FONT_BYTES { + return Err("The font expands to more than the 16 MB limit".into()); + } + let bytes = if comp == orig { + stored.to_vec() + } else if comp < orig { + let mut out = Vec::with_capacity(orig); + // One byte over the stated size detects a table that lies about it, without trusting it for allocation. + ZlibDecoder::new(stored).take(orig as u64 + 1).read_to_end(&mut out).map_err(|_| bad())?; + if out.len() != orig { + return Err(bad()); + } + out + } else { + return Err(bad()); + }; + entries.push(Entry { tag, checksum, bytes }); + } + entries.sort_by_key(|e| e.tag); + + let mut entry_selector = 0u16; + while (1usize << (entry_selector + 1)) <= n { + entry_selector += 1; + } + let search_range = (1u16 << entry_selector) * 16; + let mut out = Vec::with_capacity(total); + out.extend_from_slice(&flavor.to_be_bytes()); + out.extend_from_slice(&(n as u16).to_be_bytes()); + out.extend_from_slice(&search_range.to_be_bytes()); + out.extend_from_slice(&entry_selector.to_be_bytes()); + out.extend_from_slice(&((n as u16) * 16 - search_range).to_be_bytes()); + let mut offset = 12 + 16 * n; + for e in &entries { + out.extend_from_slice(&e.tag); + out.extend_from_slice(&e.checksum.to_be_bytes()); + out.extend_from_slice(&(offset as u32).to_be_bytes()); + out.extend_from_slice(&(e.bytes.len() as u32).to_be_bytes()); + offset += (e.bytes.len() + 3) & !3; + } + for e in &entries { + out.extend_from_slice(&e.bytes); + out.resize(out.len() + ((4 - e.bytes.len() % 4) % 4), 0); + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// inspection + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +/// Sniffs by magic bytes, unpacks WOFF, refuses what cannot be embedded, and describes the font. Returns the sfnt that +/// should be stored together with its description. +pub fn inspect_bytes(input: &[u8]) -> Result<(Vec, FontInspection), String> { + if input.len() > MAX_FONT_BYTES { + return Err("The font file is larger than the 16 MB limit".into()); + } + if input.len() < 12 { + return Err("This is not a font file (it is too small)".into()); + } + let (sfnt, source_format) = match &input[0..4] { + [0, 1, 0, 0] | b"true" => (input.to_vec(), "ttf"), + b"OTTO" => (input.to_vec(), "otf"), + b"wOFF" => (woff_to_sfnt(input)?, "woff"), + b"wOF2" => { + return Err( + "WOFF2 fonts cannot be used: they embed in a PDF without glyph outlines (invisible text). \ + Import the TTF, OTF or WOFF file instead." + .into(), + ) + } + b"ttcf" => return Err("Font collections (.ttc/.otc) are not supported. Import a single TTF or OTF file.".into()), + _ => return Err("This is not a TrueType, OpenType or WOFF font file.".into()), + }; + + let font = Sfnt::parse(&sfnt)?; + if font.table(b"fvar").is_some() || font.table(b"CFF2").is_some() { + return Err( + "Variable fonts are not supported: a PDF would embed only the default instance. \ + Import a static font file (one weight per file)." + .into(), + ); + } + let cff = font.table(b"CFF ").is_some(); + if !cff && (font.table(b"glyf").is_none() || font.table(b"loca").is_none()) { + return Err("The font has no glyph outlines (no glyf or CFF table), so it cannot be embedded.".into()); + } + let head = font.table(b"head").ok_or("The font has no head table")?; + let cmap_table = font.table(b"cmap").ok_or("The font has no cmap table")?; + let name = font.table(b"name").ok_or("The font has no name table")?; + let num_glyphs = font.table(b"maxp").and_then(|m| be16(m, 4)).ok_or("The font has no usable maxp table")?; + + let os2 = font.table(b"OS/2"); + let fs_type = os2.and_then(|t| be16(t, 8)).unwrap_or(0); + // Embedding permissions. Restricted licence (0x0002) forbids embedding unless a more permissive bit is also set + // (the historical least-restrictive-wins reading). Bitmap-only (0x0200) allows only bitmaps, never outlines, so it + // is refused. No-subsetting (0x0100) is only a warning: the app subsets, and the user states they hold a licence. + if fs_type & FS_BITMAP_ONLY != 0 { + return Err("This font only permits bitmap embedding (OS/2 fsType), so it cannot be embedded in a PDF.".into()); + } + if fs_type & FS_RESTRICTED != 0 && fs_type & (FS_PREVIEW_PRINT | FS_EDITABLE) == 0 { + return Err("This font's licence flags (OS/2 fsType: restricted licence) forbid embedding it in documents.".into()); + } + let mut warnings = Vec::new(); + if fs_type & FS_NO_SUBSETTING != 0 { + warnings.push( + "The font asks not to be subset when embedded; Voiced always embeds only the glyphs used. \ + Check that your licence allows this." + .to_string(), + ); + } + if fs_type & FS_PREVIEW_PRINT != 0 && fs_type & FS_EDITABLE == 0 { + warnings.push( + "The font allows embedding for preview and print only. Voiced's PDFs are read-only, which this permits." + .to_string(), + ); + } + + let weight = os2 + .and_then(|t| be16(t, 4)) + .filter(|w| (1..=1000).contains(w)) + .map(|w| w.clamp(100, 900)) + .unwrap_or(400); + let fs_selection = os2.and_then(|t| be16(t, 62)).unwrap_or(0); + let mac_style = be16(head, 44).unwrap_or(0); + // fsSelection bit 0 = italic, bit 9 = oblique; head.macStyle bit 1 = italic. + let italic = if os2.map_or(false, |t| t.len() >= 64) { fs_selection & 0x0201 != 0 } else { mac_style & 0x2 != 0 }; + + let cmap = Cmap::new(cmap_table).ok_or("The font has no usable Unicode character map")?; + let has_rupee = cmap.has(0x20B9); + let missing_latin = (0x20u32..=0x7E).filter(|&c| !cmap.has(c)).count(); + let has_basic_latin = missing_latin == 0; + if !has_basic_latin { + warnings.push(format!( + "The font lacks {missing_latin} of the 95 basic Latin characters; those fall back to IBM Plex Sans." + )); + } + if !has_rupee { + warnings.push("The font has no rupee sign (\u{20B9}); amounts fall back to IBM Plex Sans for that glyph.".to_string()); + } + + let family = name_string(name, 16).or_else(|| name_string(name, 1)).unwrap_or_default(); + let subfamily = name_string(name, 17).or_else(|| name_string(name, 2)).unwrap_or_default(); + let full_name = name_string(name, 4).unwrap_or_else(|| format!("{family} {subfamily}").trim().to_string()); + let postscript_name = name_string(name, 6).unwrap_or_default(); + if family.is_empty() && full_name.is_empty() { + return Err("The font has no readable name".into()); + } + + let info = FontInspection { + format: if cff { "otf" } else { "ttf" }.to_string(), + source_format: source_format.to_string(), + family, + subfamily, + full_name, + postscript_name, + weight, + italic, + fs_type, + num_glyphs, + has_rupee, + has_basic_latin, + size: sfnt.len(), + sha256: sha256_hex(&sfnt), + warnings, + }; + Ok((sfnt, info)) +} + +// --------------------------------------------------------------------------- +// storage + +fn font_path(local_dir: &Path, sha256: &str, format: &str) -> Result { + let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); + if !valid { + return Err("The stored font hash is malformed".to_string()); + } + let ext = if format == "otf" { "otf" } else { "ttf" }; + Ok(local_dir.join("fonts").join(format!("{sha256}.{ext}"))) +} + +const COLS: &str = "id, face, family_name, full_name, style_name, weight, style, sha256, file_name, format, size, fs_type, + has_rupee, licence_ack_at, imported_at, hidden"; + +fn map_row(r: &rusqlite::Row) -> rusqlite::Result { + Ok(UserFont { + id: r.get(0)?, + face: r.get(1)?, + family_name: r.get(2)?, + full_name: r.get(3)?, + style_name: r.get(4)?, + weight: r.get(5)?, + style: r.get(6)?, + sha256: r.get(7)?, + file_name: r.get(8)?, + format: r.get(9)?, + size: r.get(10)?, + fs_type: r.get(11)?, + has_rupee: r.get::<_, i64>(12)? != 0, + licence_ack_at: r.get(13)?, + imported_at: r.get(14)?, + hidden: r.get::<_, i64>(15)? != 0, + }) +} + +fn get_font(conn: &Connection, id: i64) -> Result, String> { + conn.query_row(&format!("SELECT {COLS} FROM user_fonts WHERE id = ?1"), params![id], map_row) + .optional() + .map_err(|e| e.to_string()) +} + +pub fn list_user_fonts_impl(conn: &Connection, include_hidden: bool) -> Result, String> { + let sql = format!( + "SELECT {COLS} FROM user_fonts {} ORDER BY face, weight, style, id", + if include_hidden { "" } else { "WHERE hidden = 0" } + ); + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; + let rows = stmt.query_map([], map_row).map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) +} + +/// Whether any invoice's frozen render prefs name this font file. +fn referenced_by_invoice(conn: &Connection, sha256: &str) -> Result { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM invoices WHERE render_prefs IS NOT NULL AND instr(render_prefs, ?1) > 0)", + params![sha256], + |r| r.get::<_, i64>(0), + ) + .map(|n| n != 0) + .map_err(|e| e.to_string()) +} + +pub struct ImportRequest<'a> { + pub face: &'a str, + pub file_name: &'a str, + pub weight: Option, + pub italic: Option, + pub licence_acknowledged: bool, +} + +/// The file name for display: the last path component, short and free of control characters. +fn clean_file_name(raw: &str) -> String { + let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw); + let cleaned: String = base.chars().filter(|c| !c.is_control()).take(120).collect(); + if cleaned.trim().is_empty() { "font".to_string() } else { cleaned } +} + +pub fn import_font_impl( + conn: &mut Connection, + local_dir: &Path, + bytes: &[u8], + req: &ImportRequest, +) -> Result { + if !FACES.contains(&req.face) { + return Err(format!("Fonts can be imported for {} only", FACES.join(", "))); + } + if !req.licence_acknowledged { + return Err("Confirm that you hold a licence for this font before importing it".into()); + } + let (sfnt, info) = inspect_bytes(bytes)?; + let weight = req.weight.unwrap_or(info.weight).clamp(100, 900); + let italic = req.italic.unwrap_or(info.italic); + let style = if italic { "italic" } else { "normal" }; + + // The same file for the same slot again changes nothing. + let same: Option = conn + .query_row( + "SELECT id FROM user_fonts WHERE face = ?1 AND weight = ?2 AND style = ?3 AND sha256 = ?4 AND hidden = 0", + params![req.face, weight, style, info.sha256], + |r| r.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + if let Some(id) = same { + return get_font(conn, id)?.ok_or_else(|| "Font not found".to_string()); + } + + let path = font_path(local_dir, &info.sha256, &info.format)?; + std::fs::create_dir_all(path.parent().expect("font path has a parent")) + .map_err(|e| format!("Could not create the fonts folder: {e}"))?; + let present = std::fs::metadata(&path).map(|m| m.len() == sfnt.len() as u64).unwrap_or(false); + if !present { + write_atomic(&path, &sfnt)?; + } + + let now = chrono::Utc::now().to_rfc3339(); + let tx = conn.transaction().map_err(|e| e.to_string())?; + // A different font in the same face/weight/style slot is replaced; the old one goes through the same + // delete-or-hide rule as an explicit removal. + let previous: Vec = { + let mut stmt = tx + .prepare("SELECT id FROM user_fonts WHERE face = ?1 AND weight = ?2 AND style = ?3 AND hidden = 0") + .map_err(|e| e.to_string())?; + let ids = stmt + .query_map(params![req.face, weight, style], |r| r.get(0)) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + ids + }; + let mut obsolete: Vec = Vec::new(); + for id in previous { + if let Some(path) = retire(&tx, local_dir, id)? { + obsolete.push(path); + } + } + tx.execute( + "INSERT INTO user_fonts (face, family_name, full_name, style_name, weight, style, sha256, file_name, format, + size, fs_type, has_rupee, licence_ack_at, imported_at, hidden) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?13, 0)", + params![ + req.face, + info.family, + info.full_name, + info.subfamily, + weight, + style, + info.sha256, + clean_file_name(req.file_name), + info.format, + info.size as i64, + info.fs_type as i64, + info.has_rupee as i64, + now + ], + ) + .map_err(|e| e.to_string())?; + let id = tx.last_insert_rowid(); + tx.commit().map_err(|e| e.to_string())?; + for path in obsolete { + // Best effort: a file left behind is harmless, the table no longer lists it. + let _ = std::fs::remove_file(path); + } + get_font(conn, id)?.ok_or_else(|| "Font not found".to_string()) +} + +/// Deletes the row, or hides it when an issued invoice references its file. Returns the file path when the file may +/// now be deleted (no other row, hidden or not, uses it); the caller removes it after the transaction commits. +fn retire(conn: &Connection, local_dir: &Path, id: i64) -> Result, String> { + let font = get_font(conn, id)?.ok_or_else(|| "Font not found".to_string())?; + if referenced_by_invoice(conn, &font.sha256)? { + conn.execute("UPDATE user_fonts SET hidden = 1 WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; + return Ok(None); + } + conn.execute("DELETE FROM user_fonts WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; + let shared: i64 = conn + .query_row("SELECT COUNT(*) FROM user_fonts WHERE sha256 = ?1", params![font.sha256], |r| r.get(0)) + .map_err(|e| e.to_string())?; + if shared > 0 { + return Ok(None); + } + font_path(local_dir, &font.sha256, &font.format).map(Some) +} + +pub fn remove_user_font_impl(conn: &mut Connection, local_dir: &Path, id: i64) -> Result { + let tx = conn.transaction().map_err(|e| e.to_string())?; + let file = retire(&tx, local_dir, id)?; + let still_there: bool = tx + .query_row("SELECT EXISTS(SELECT 1 FROM user_fonts WHERE id = ?1)", params![id], |r| r.get::<_, i64>(0)) + .map_err(|e| e.to_string())? + != 0; + tx.commit().map_err(|e| e.to_string())?; + if let Some(path) = file { + // Best effort, as in import: the row is already gone. + let _ = std::fs::remove_file(path); + } + Ok(RemoveOutcome { deleted: !still_there, hidden: still_there }) +} + +/// The stored sfnt for a font file, hidden or not (an issued invoice must still be able to render with it). +pub fn user_font_bytes_impl(conn: &Connection, local_dir: &Path, sha256: &str) -> Result, String> { + let format: Option = conn + .query_row("SELECT format FROM user_fonts WHERE sha256 = ?1 LIMIT 1", params![sha256], |r| r.get(0)) + .optional() + .map_err(|e| e.to_string())?; + let format = format.ok_or_else(|| "This font is not in the font library".to_string())?; + let path = font_path(local_dir, sha256, &format)?; + let bytes = std::fs::read(&path).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + "The font file is missing".to_string() + } else { + format!("Could not read the font file: {e}") + } + })?; + if sha256_hex(&bytes) != sha256 { + return Err("The font file is corrupted (its checksum no longer matches)".into()); + } + Ok(bytes) +} + +fn parse_import_headers<'a>(headers: &'a Headers, file_name: &'a str) -> Result, String> { + let face = required_header(headers, "x-face")?; + let weight = match headers.get("x-weight").map(|v| v.trim()).filter(|v| !v.is_empty()) { + None => None, + Some(v) => Some(v.parse::().map_err(|_| "x-weight must be a number".to_string())?), + }; + let italic = match headers.get("x-style").map(|v| v.trim()).filter(|v| !v.is_empty()) { + None => None, + Some("normal") => Some(false), + Some("italic") => Some(true), + Some(_) => return Err("x-style must be normal or italic".into()), + }; + let licence_acknowledged = headers.get("x-licence-ack").map(|v| v.trim() == "true").unwrap_or(false); + Ok(ImportRequest { face, file_name, weight, italic, licence_acknowledged }) +} + +// The commands are async so parsing, hashing and file I/O of a multi-MB font stay off the main thread. +#[tauri::command] +pub async fn inspect_font(request: Request<'_>) -> Result { + let bytes = raw_body(&request)?; + inspect_bytes(bytes).map(|(_, info)| info) +} + +#[tauri::command] +pub async fn import_font(request: Request<'_>, state: State<'_, AppState>) -> Result { + let bytes = raw_body(&request)?; + let headers = header_map(&request); + let file_name = decode_header_path(headers.get("x-file-name").map(String::as_str).unwrap_or("font"))?; + let face = decode_header_path(required_header(&headers, "x-face")?)?; + let mut headers = headers; + headers.insert("x-face".to_string(), face); + let req = parse_import_headers(&headers, &file_name)?; + let mut conn = state.db.lock().map_err(|e| e.to_string())?; + import_font_impl(&mut conn, &state.local_data_dir, bytes, &req) +} + +#[tauri::command] +pub async fn list_user_fonts(include_hidden: Option, state: State<'_, AppState>) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + list_user_fonts_impl(&conn, include_hidden.unwrap_or(false)) +} + +#[tauri::command] +pub async fn get_user_font_bytes(sha256: String, state: State<'_, AppState>) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + user_font_bytes_impl(&conn, &state.local_data_dir, &sha256).map(Response::new) +} + +#[tauri::command] +pub async fn remove_user_font(id: i64, state: State<'_, AppState>) -> Result { + let mut conn = state.db.lock().map_err(|e| e.to_string())?; + remove_user_font_impl(&mut conn, &state.local_data_dir, id) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::ZlibEncoder; + use flate2::Compression; + use std::io::Write; + use tempfile::tempdir; + + fn jost(name: &str) -> Vec { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/fonts/jost").join(name); + std::fs::read(path).unwrap() + } + fn regular() -> Vec { + jost("Jost-Regular.ttf") + } + fn bold() -> Vec { + jost("Jost-Bold.ttf") + } + + /// Overwrites the 4-byte tag of the table named `from` (the directory entry only). + fn rename_table(font: &mut [u8], from: &[u8; 4], to: &[u8; 4]) { + let n = u16::from_be_bytes([font[4], font[5]]) as usize; + for i in 0..n { + let rec = 12 + i * 16; + if &font[rec..rec + 4] == from { + font[rec..rec + 4].copy_from_slice(to); + return; + } + } + panic!("table not found"); + } + + fn set_fs_type(font: &mut [u8], value: u16) { + let sfnt = Sfnt::parse(font).unwrap(); + let (_, off, _) = *sfnt.tables.iter().find(|(t, _, _)| t == b"OS/2").unwrap(); + font[off + 8..off + 10].copy_from_slice(&value.to_be_bytes()); + } + + /// Wraps an sfnt as WOFF 1, compressing every table that gets smaller. + fn to_woff(sfnt: &[u8]) -> Vec { + let font = Sfnt::parse(sfnt).unwrap(); + let mut tables = font.tables.clone(); + tables.sort_by_key(|(t, _, _)| *t); + let n = tables.len(); + let mut blobs = Vec::new(); + for (tag, off, len) in &tables { + let raw = &sfnt[*off..*off + *len]; + let mut enc = ZlibEncoder::new(Vec::new(), Compression::default()); + enc.write_all(raw).unwrap(); + let z = enc.finish().unwrap(); + blobs.push(if z.len() < raw.len() { (*tag, z, raw.len()) } else { (*tag, raw.to_vec(), raw.len()) }); + } + let mut out = vec![0u8; 44 + 20 * n]; + out[0..4].copy_from_slice(b"wOFF"); + out[4..8].copy_from_slice(&sfnt[0..4]); + out[12..14].copy_from_slice(&(n as u16).to_be_bytes()); + for (i, (tag, data, orig)) in blobs.iter().enumerate() { + let offset = out.len(); + out.extend_from_slice(data); + out.resize(out.len() + (4 - data.len() % 4) % 4, 0); + let rec = 44 + i * 20; + out[rec..rec + 4].copy_from_slice(tag); + out[rec + 4..rec + 8].copy_from_slice(&(offset as u32).to_be_bytes()); + out[rec + 8..rec + 12].copy_from_slice(&(data.len() as u32).to_be_bytes()); + out[rec + 12..rec + 16].copy_from_slice(&(*orig as u32).to_be_bytes()); + } + let total = out.len() as u32; + out[8..12].copy_from_slice(&total.to_be_bytes()); + out + } + + fn request<'a>(face: &'a str, ack: bool) -> ImportRequest<'a> { + ImportRequest { face, file_name: "C:\\fonts\\Jost-Regular.ttf", weight: None, italic: None, licence_acknowledged: ack } + } + + #[test] + fn inspects_a_ttf() { + let (sfnt, info) = inspect_bytes(®ular()).unwrap(); + assert_eq!(sfnt, regular()); + assert_eq!(info.format, "ttf"); + assert_eq!(info.source_format, "ttf"); + assert_eq!(info.family, "Jost"); + assert_eq!(info.weight, 400); + assert!(!info.italic); + assert_eq!(info.fs_type, 0); + assert!(info.has_basic_latin); + assert!(!info.has_rupee, "Jost has no rupee sign"); + assert_eq!(info.sha256, sha256_hex(®ular())); + assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); + assert!(info.warnings[0].contains("rupee")); + let poppins = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/fonts/poppins/Poppins-Regular.ttf")).unwrap(); + let (_, with_rupee) = inspect_bytes(&poppins).unwrap(); + assert!(with_rupee.has_rupee && with_rupee.warnings.is_empty(), "{:?}", with_rupee.warnings); + assert_eq!(inspect_bytes(&bold()).unwrap().1.weight, 700); + } + + #[test] + fn refuses_woff2_ttc_and_garbage_with_a_reason() { + let mut woff2 = regular(); + woff2[0..4].copy_from_slice(b"wOF2"); + assert!(inspect_bytes(&woff2).unwrap_err().contains("WOFF2")); + let mut ttc = regular(); + ttc[0..4].copy_from_slice(b"ttcf"); + assert!(inspect_bytes(&ttc).unwrap_err().contains("collections")); + assert!(inspect_bytes(b"").unwrap_err().contains("not a TrueType")); + assert!(inspect_bytes(b"short").is_err()); + let mut big = regular(); + big.resize(MAX_FONT_BYTES + 1, 0); + assert!(inspect_bytes(&big).unwrap_err().contains("16 MB")); + } + + #[test] + fn refuses_variable_fonts() { + let mut font = regular(); + rename_table(&mut font, b"GSUB", b"fvar"); + assert!(inspect_bytes(&font).unwrap_err().contains("Variable")); + let mut font = regular(); + rename_table(&mut font, b"GSUB", b"CFF2"); + assert!(inspect_bytes(&font).unwrap_err().contains("Variable")); + } + + #[test] + fn fs_type_rules() { + let mut restricted = regular(); + set_fs_type(&mut restricted, FS_RESTRICTED); + assert!(inspect_bytes(&restricted).unwrap_err().contains("restricted")); + + // A more permissive bit alongside wins (least restrictive). + let mut both = regular(); + set_fs_type(&mut both, FS_RESTRICTED | FS_EDITABLE); + assert!(inspect_bytes(&both).is_ok()); + + let mut preview = regular(); + set_fs_type(&mut preview, FS_PREVIEW_PRINT); + assert!(inspect_bytes(&preview).unwrap().1.warnings.iter().any(|w| w.contains("preview and print"))); + + let mut no_subset = regular(); + set_fs_type(&mut no_subset, FS_NO_SUBSETTING); + assert!(inspect_bytes(&no_subset).unwrap().1.warnings.iter().any(|w| w.contains("subset"))); + + let mut bitmap = regular(); + set_fs_type(&mut bitmap, FS_BITMAP_ONLY); + assert!(inspect_bytes(&bitmap).unwrap_err().contains("bitmap")); + } + + #[test] + fn refuses_a_font_without_outlines() { + let mut font = regular(); + rename_table(&mut font, b"glyf", b"XXXX"); + assert!(inspect_bytes(&font).unwrap_err().contains("no glyph outlines")); + } + + #[test] + fn unpacks_woff_to_the_same_font() { + let woff = to_woff(®ular()); + assert!(woff.len() < regular().len(), "the fixture should actually compress"); + let (sfnt, info) = inspect_bytes(&woff).unwrap(); + assert_eq!(info.source_format, "woff"); + assert_eq!(info.family, "Jost"); + assert_eq!(info.sha256, sha256_hex(&sfnt)); + // Same tables, same bytes per table, as the original. + let original_bytes = regular(); + let original = Sfnt::parse(&original_bytes).unwrap(); + let unpacked = Sfnt::parse(&sfnt).unwrap(); + assert_eq!(original.tables.len(), unpacked.tables.len()); + for (tag, _, _) in &original.tables { + assert_eq!(original.table(tag), unpacked.table(tag), "table {}", String::from_utf8_lossy(tag)); + } + let mut damaged = woff.clone(); + let len = damaged.len(); + damaged.truncate(len - 400); + assert!(inspect_bytes(&damaged).is_err()); + } + + #[test] + fn import_stores_content_addressed_and_round_trips() { + let dir = tempdir().unwrap(); + let mut conn = crate::db::open_in_memory().unwrap(); + let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); + assert_eq!(row.face, "Gotham"); + assert_eq!((row.weight, row.style.as_str(), row.format.as_str()), (400, "normal", "ttf")); + assert_eq!(row.file_name, "Jost-Regular.ttf", "only the base name is kept"); + assert!(!row.hidden && row.licence_ack_at.len() > 10); + let path = dir.path().join("fonts").join(format!("{}.ttf", row.sha256)); + assert_eq!(std::fs::read(&path).unwrap(), regular()); + assert_eq!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(), regular()); + + // Importing the same file again is a no-op. + let again = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); + assert_eq!(again.id, row.id); + assert_eq!(list_user_fonts_impl(&conn, false).unwrap().len(), 1); + + // A bold file takes its own slot next to it. + let b = import_font_impl(&mut conn, dir.path(), &bold(), &request("Gotham", true)).unwrap(); + assert_eq!(b.weight, 700); + assert_eq!(list_user_fonts_impl(&conn, false).unwrap().len(), 2); + } + + #[test] + fn import_overrides_and_guards() { + let dir = tempdir().unwrap(); + let mut conn = crate::db::open_in_memory().unwrap(); + assert!(import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", false)) + .unwrap_err() + .contains("licence")); + assert!(import_font_impl(&mut conn, dir.path(), ®ular(), &request("Montserrat", true)) + .unwrap_err() + .contains("Now, Gotham, Open Sauce One")); + assert!(!dir.path().join("fonts").exists(), "a refused import writes nothing"); + let mut woff2 = regular(); + woff2[0..4].copy_from_slice(b"wOF2"); + assert!(import_font_impl(&mut conn, dir.path(), &woff2, &request("Now", true)).is_err()); + + let req = ImportRequest { weight: Some(300), italic: Some(true), ..request("Open Sauce One", true) }; + let row = import_font_impl(&mut conn, dir.path(), ®ular(), &req).unwrap(); + assert_eq!((row.weight, row.style.as_str()), (300, "italic")); + // The table itself refuses other faces. + assert!(conn + .execute( + "INSERT INTO user_fonts (face, family_name, full_name, style_name, weight, style, sha256, file_name, format, + size, fs_type, has_rupee, licence_ack_at, imported_at) + VALUES ('Poppins','','','',400,'normal','x','f','ttf',1,0,0,'n','n')", + [], + ) + .is_err()); + } + + #[test] + fn replacing_a_slot_removes_the_old_unreferenced_font() { + let dir = tempdir().unwrap(); + let mut conn = crate::db::open_in_memory().unwrap(); + let first = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); + // Same slot, different file (the bold file declared as 400). + let req = ImportRequest { weight: Some(400), ..request("Gotham", true) }; + let second = import_font_impl(&mut conn, dir.path(), &bold(), &req).unwrap(); + assert_ne!(first.sha256, second.sha256); + let rows = list_user_fonts_impl(&conn, true).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].sha256, second.sha256); + assert!(!dir.path().join("fonts").join(format!("{}.ttf", first.sha256)).exists()); + } + + fn issue_invoice_with_prefs(conn: &Connection, prefs: &str) { + conn.execute( + "INSERT INTO invoices (number, invoice_date, client_name, status, created_at, updated_at, render_prefs) + VALUES ('INV/2026-001', '2026-04-01', 'Client', 'issued', 'now', 'now', ?1)", + params![prefs], + ) + .unwrap(); + } + + #[test] + fn remove_deletes_when_unreferenced() { + let dir = tempdir().unwrap(); + let mut conn = crate::db::open_in_memory().unwrap(); + let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap(); + let out = remove_user_font_impl(&mut conn, dir.path(), row.id).unwrap(); + assert_eq!(out, RemoveOutcome { deleted: true, hidden: false }); + assert!(list_user_fonts_impl(&conn, true).unwrap().is_empty()); + assert!(!dir.path().join("fonts").join(format!("{}.ttf", row.sha256)).exists()); + assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).is_err()); + assert!(remove_user_font_impl(&mut conn, dir.path(), row.id).is_err()); + } + + #[test] + fn remove_hides_a_font_an_issued_invoice_references() { + let dir = tempdir().unwrap(); + let mut conn = crate::db::open_in_memory().unwrap(); + let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); + issue_invoice_with_prefs( + &conn, + &format!(r#"{{"version":1,"templateId":"purple-pop","fonts":{{"Gotham":["{}"]}}}}"#, row.sha256), + ); + let out = remove_user_font_impl(&mut conn, dir.path(), row.id).unwrap(); + assert_eq!(out, RemoveOutcome { deleted: false, hidden: true }); + // Gone from the visible list, but kept and still served so the invoice can re-render. + assert!(list_user_fonts_impl(&conn, false).unwrap().is_empty()); + assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 1); + assert_eq!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(), regular()); + + // The slot is free again: importing a new font for it works, and the hidden one stays. + let fresh = import_font_impl(&mut conn, dir.path(), &bold(), &ImportRequest { weight: Some(400), ..request("Gotham", true) }).unwrap(); + assert_ne!(fresh.sha256, row.sha256); + assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 2); + } + + #[test] + fn replacing_a_referenced_font_hides_it_instead_of_deleting() { + let dir = tempdir().unwrap(); + let mut conn = crate::db::open_in_memory().unwrap(); + let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); + issue_invoice_with_prefs(&conn, &format!(r#"{{"fonts":{{"Gotham":["{}"]}}}}"#, row.sha256)); + import_font_impl(&mut conn, dir.path(), &bold(), &ImportRequest { weight: Some(400), ..request("Gotham", true) }).unwrap(); + assert!(dir.path().join("fonts").join(format!("{}.ttf", row.sha256)).exists()); + let all = list_user_fonts_impl(&conn, true).unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all.iter().filter(|f| f.hidden).count(), 1); + } + + #[test] + fn a_file_shared_by_two_faces_survives_removing_one() { + let dir = tempdir().unwrap(); + let mut conn = crate::db::open_in_memory().unwrap(); + let a = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); + let b = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap(); + assert_eq!(a.sha256, b.sha256); + remove_user_font_impl(&mut conn, dir.path(), a.id).unwrap(); + assert!(dir.path().join("fonts").join(format!("{}.ttf", a.sha256)).exists()); + remove_user_font_impl(&mut conn, dir.path(), b.id).unwrap(); + assert!(!dir.path().join("fonts").join(format!("{}.ttf", a.sha256)).exists()); + } + + #[test] + fn bytes_are_verified_and_the_hash_shape_checked() { + let dir = tempdir().unwrap(); + let mut conn = crate::db::open_in_memory().unwrap(); + let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap(); + std::fs::write(dir.path().join("fonts").join(format!("{}.ttf", row.sha256)), b"tampered").unwrap(); + assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap_err().contains("corrupted")); + assert!(font_path(dir.path(), "../../etc/passwd", "ttf").is_err()); + } + + #[test] + fn import_headers_parse() { + let mut h = Headers::new(); + h.insert("x-face".into(), "Gotham".into()); + h.insert("x-licence-ack".into(), "true".into()); + h.insert("x-weight".into(), "700".into()); + h.insert("x-style".into(), "italic".into()); + let req = parse_import_headers(&h, "f.ttf").unwrap(); + assert_eq!((req.weight, req.italic, req.licence_acknowledged), (Some(700), Some(true), true)); + h.insert("x-style".into(), "oblique".into()); + assert!(parse_import_headers(&h, "f.ttf").is_err()); + h.remove("x-licence-ack"); + h.insert("x-style".into(), "".into()); + assert!(!parse_import_headers(&h, "f.ttf").unwrap().licence_acknowledged); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a976e93..c2322a4 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod archive; pub mod assets; pub mod clients; pub mod files; +pub mod fonts; pub mod invoice; pub mod logo; pub mod raw; diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 2e9b995..6070647 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10; const BACKUP_PREFIX: &str = "voiced-pre-v"; /// Highest schema version, i.e. the number of entries in `migrations()`. -const LATEST_VERSION: i64 = 5; +const LATEST_VERSION: i64 = 6; #[derive(Debug, thiserror::Error)] pub enum DbError { @@ -190,8 +190,35 @@ ALTER TABLE app_settings ADD COLUMN logo_meta TEXT NOT NULL DEFAULT ''; ALTER TABLE app_settings ADD COLUMN logo_includes_name TEXT NOT NULL DEFAULT 'auto'; "#; +/// Version 6: user-imported fonts for the three licensed template faces. The files live content-addressed under +/// `/fonts/.`; invoices freeze the sha256 of the fonts they used in render_prefs. +/// `hidden` retires a font that an issued invoice still references (never deleted while referenced). +const M6: &str = r#" +CREATE TABLE user_fonts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + face TEXT NOT NULL CHECK (face IN ('Now', 'Gotham', 'Open Sauce One')), + family_name TEXT NOT NULL, + full_name TEXT NOT NULL, + style_name TEXT NOT NULL DEFAULT '', + weight INTEGER NOT NULL CHECK (weight BETWEEN 100 AND 900), + style TEXT NOT NULL CHECK (style IN ('normal', 'italic')), + sha256 TEXT NOT NULL, + file_name TEXT NOT NULL, + format TEXT NOT NULL CHECK (format IN ('ttf', 'otf')), + size INTEGER NOT NULL, + fs_type INTEGER NOT NULL DEFAULT 0, + has_rupee INTEGER NOT NULL DEFAULT 0, + licence_ack_at TEXT NOT NULL, + imported_at TEXT NOT NULL, + hidden INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX idx_user_fonts_sha ON user_fonts(sha256); +-- One visible font per face, weight and style; a hidden one keeps its row but frees the slot. +CREATE UNIQUE INDEX idx_user_fonts_slot ON user_fonts(face, weight, style) WHERE hidden = 0; +"#; + fn migrations() -> Migrations<'static> { - Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5)]) + Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)]) } /// Open (creating if needed) the database at `path` and bring it to the latest schema. @@ -620,7 +647,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); assert_eq!(user_version(&old), 4); } let conn = open(&path, &dir.path().join("backups")).unwrap(); - assert_eq!(user_version(&conn), 5); + assert_eq!(user_version(&conn), LATEST_VERSION); let (logo, meta): (String, String) = conn .query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| { Ok((r.get(0)?, r.get(1)?)) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f88a43f..1246654 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -141,6 +141,11 @@ pub fn run() { commands::archive::archive_pdf, commands::archive::read_archive, commands::archive::archive_status, + commands::fonts::inspect_font, + commands::fonts::import_font, + commands::fonts::list_user_fonts, + commands::fonts::get_user_font_bytes, + commands::fonts::remove_user_font, commands::files::write_export_file, commands::files::get_last_export_dir, commands::files::reveal_in_folder, diff --git a/src/components/FontOverrideTable.tsx b/src/components/FontOverrideTable.tsx new file mode 100644 index 0000000..22e2190 --- /dev/null +++ b/src/components/FontOverrideTable.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [pending, setPending] = useState(null); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(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 ( +
+

+ 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. +

+ {error ? : null} + {notice ? setNotice(null)} title={notice} /> : null} + {loading ? ( + + ) : ( + + + + Face + Drawn with + Used by + + + + + {USER_FACES.map((face) => { + const files = fonts.filter((f) => f.face === face); + return ( + + {face} + + {files.length === 0 ? ( + {FACE_SUBSTITUTES[face]} (bundled substitute) + ) : ( +
+ {files.map((f) => ( +
+ + {f.fullName || f.familyName} ({weightName(f.weight, f.style === "italic")}, {f.fileName}) + + {!f.hasRupee ? no ₹ : null} +
+ ))} +
+ )} +
+ {templatesUsing(face)} + + ) => { + const file = e.target.files?.[0]; + e.target.value = ""; + void choose(face, file); + }} + /> + +
+ ); + })} +
+
+ )} + {busy && !pending ? : null} + {pending ? void doImport()} busy={busy !== null} /> : null} +
+ ); +} + +function PendingImport({ + pending, + onChange, + onImport, + busy, +}: { + pending: Pending; + onChange: (p: Pending | null) => void; + onImport: () => void; + busy: boolean; +}) { + const { inspection: i } = pending; + return ( + +
+

+ {i.fullName || i.family} for {pending.face} +

+

+ {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 ₹"} +

+ {i.warnings.map((w) => ( + + ))} +
+ + onChange({ ...pending, italic: checked })} /> +
+ onChange({ ...pending, acknowledged: checked })} + /> +
+ + +
+
+
+ ); +} diff --git a/src/hooks/useUserFonts.ts b/src/hooks/useUserFonts.ts new file mode 100644 index 0000000..57739af --- /dev/null +++ b/src/hooks/useUserFonts.ts @@ -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([]); + 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; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index b1e98b9..02f3692 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -10,6 +10,7 @@ import type { Settings, } from "./types"; import type { LogoAsset } from "./logo"; +import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport"; export interface ArchiveStatus { archived: boolean; @@ -70,6 +71,22 @@ export const api = { readArchive: async (invoiceId: number): Promise => new Uint8Array(await invoke("read_archive", { invoiceId })), archiveStatus: (invoiceId: number) => invoke("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("inspect_font", bytes), + importFont: (bytes: Uint8Array, meta: ImportMeta) => + invoke("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("list_user_fonts", { includeHidden }), + getUserFontBytes: async (sha256: string): Promise => + new Uint8Array(await invoke("get_user_font_bytes", { sha256 })), + removeUserFont: (id: number) => invoke("remove_user_font", { id }), writeExportFile: (path: string, bytes: Uint8Array) => invoke("write_export_file", bytes, { headers: { "x-path": encodeURIComponent(path) } }), getLastExportDir: () => invoke("get_last_export_dir"), diff --git a/src/lib/fontImport.test.ts b/src/lib/fontImport.test.ts new file mode 100644 index 0000000..202f3fe --- /dev/null +++ b/src/lib/fontImport.test.ts @@ -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 => ({ + 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(" { + 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(); + }); +}); diff --git a/src/lib/fontImport.ts b/src/lib/fontImport.ts new file mode 100644 index 0000000..35dd529 --- /dev/null +++ b/src/lib/fontImport.ts @@ -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 = { + 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 = { 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 { + const out: NonNullable = {}; + 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 = {}; + 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()); diff --git a/src/lib/renderClient.ts b/src/lib/renderClient.ts index 1250949..a1a3485 100644 --- a/src/lib/renderClient.ts +++ b/src/lib/renderClient.ts @@ -1,11 +1,22 @@ import { createAutoClient, type RenderClient } from "../pdf/render/client"; +import type { UserFace } from "../pdf/fonts/userFontStore"; +import { api } from "./api"; let client: RenderClient | null = null; let lastId = 0; /** The one render client (and so one worker) shared by the preview and export. */ export function getRenderClient(): RenderClient { - client ??= createAutoClient(); + client ??= createAutoClient({ + // Font metadata comes from the database row, the bytes over the binary IPC; both by the file's sha256. + loadUserFont: async (sha256) => { + const rows = await api.listUserFonts(true); + const row = rows.find((r) => r.sha256 === sha256); + if (!row) throw new Error("An imported font used by this invoice is no longer in the font library"); + const bytes = await api.getUserFontBytes(sha256); + return { face: row.face as UserFace, weight: row.weight, italic: row.style === "italic", sha256, bytes }; + }, + }); return client; } diff --git a/src/pdf/engine/measure.ts b/src/pdf/engine/measure.ts index 8ebe1a0..05438ec 100644 --- a/src/pdf/engine/measure.ts +++ b/src/pdf/engine/measure.ts @@ -2,6 +2,7 @@ import { create, type Font } from "fontkit"; import { FONT_MANIFEST } from "../fonts/manifest"; import { familySources, FONT_STACK, stack, type FamilySource } from "../fonts/stack"; import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles"; +import { userFamilySources } from "../fonts/userFontStore"; export interface MeasureInput { text: string; @@ -29,6 +30,29 @@ export interface Measurer { missing(text: string, face?: string): number[]; } +const userFonts = new Map(); + +/** + * The fontkit font of a user-imported family at a weight and style (the file react-pdf resolves for it), or + * undefined when `familyName` is not a user family. Parsed once per file and shared by every measurer. + */ +export function userFontkitFont(familyName: string, weight: number, italic: boolean): Font | null | undefined { + const sources = userFamilySources(familyName); + if (sources.length === 0) return undefined; + const src = resolveSource( + sources.map((s) => ({ file: s.id, fontWeight: s.fontWeight, fontStyle: s.fontStyle })), + weight, + italic ? "italic" : "normal", + ); + if (!src) return null; + let font = userFonts.get(src.file); + if (!font) { + font = create((sources.find((s) => s.id === src.file) as { bytes: Uint8Array }).bytes) as Font; + userFonts.set(src.file, font); + } + return font; +} + /** react-pdf's FontFamily.resolve, so the measurer reads the same file the renderer embeds. */ function resolveSource(sources: FamilySource[], weight: number, style: "normal" | "italic"): FamilySource | undefined { const styled = sources.filter((s) => s.fontStyle === style); @@ -57,6 +81,8 @@ export function createMeasurer(loadBytes: (file: string) => Uint8Array): Measure const fonts = new Map(); function load(familyName: string, weight: number, italic: boolean): Font | null { + const own = userFontkitFont(familyName, weight, italic); + if (own !== undefined) return own; const fam = FONT_MANIFEST.families.find((f) => f.family === familyName); if (!fam) return null; const src = resolveSource(familySources(fam), weight, italic ? "italic" : "normal"); diff --git a/src/pdf/engine/metrics.ts b/src/pdf/engine/metrics.ts index 6162c5a..3a6e146 100644 --- a/src/pdf/engine/metrics.ts +++ b/src/pdf/engine/metrics.ts @@ -1,4 +1,5 @@ import { FONT_MANIFEST, type FontFileEntry } from "../fonts/manifest"; +import { userFontkitFont } from "./measure"; import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles"; function metricsFile(family: string, weight: number, italic: boolean): FontFileEntry | undefined { @@ -23,6 +24,13 @@ function metricsFile(family: string, weight: number, italic: boolean): FontFileE export function chromeHeight(role: RoleName | TypeToken, opts: { weight?: number; italic?: boolean; faceOverrides?: Readonly> } = {}): number { const token = roleToken(role); const family = resolveFace(token.face, opts.faceOverrides); + // A user-imported font has no manifest entry; its metrics come from the font itself (hhea, as the manifest's do). + const own = userFontkitFont(family, opts.weight ?? token.weight, opts.italic ?? token.italic ?? false); + if (own) { + // fontkit's types omit the hhea metrics it exposes. + const { ascent, descent, lineGap } = own as unknown as { ascent: number; descent: number; lineGap: number }; + return Math.ceil(((ascent - descent + lineGap) / own.unitsPerEm) * token.size) + 1; + } const file = metricsFile(family, opts.weight ?? token.weight, opts.italic ?? token.italic ?? false); if (!file) throw new Error(`chromeHeight: no font metrics for "${family}"`); // The manifest stores descent as a negative number, as the font's hhea table does. diff --git a/src/pdf/fonts/register.ts b/src/pdf/fonts/register.ts index 65585aa..85d2dc0 100644 --- a/src/pdf/fonts/register.ts +++ b/src/pdf/fonts/register.ts @@ -1,6 +1,15 @@ import { Font } from "@react-pdf/renderer"; import { FONT_MANIFEST } from "./manifest"; import { familySources } from "./stack"; +import { + assertSfnt, + familyNameFor, + isUserFamilyKnown, + rememberUserFiles, + userFamilySources, + type UserFace, + type UserFontFile, +} from "./userFontStore"; export { FONT_STACK, familySources, stack, type FamilySource } from "./stack"; @@ -71,7 +80,9 @@ function primeComponents(font: FkFont): void { for (const cp of lowest.values()) font.glyphForCodePoint(cp); } -let primed: Promise | 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(); +let primeChain: Promise = Promise.resolve(); /** * Loads every registered face and gives the component glyphs of its composites their code points. fontkit @@ -79,30 +90,104 @@ let primed: Promise | null = null; * time with no code points: Poppins' colon contains the period, so a document that sets ':' before any '.' * would leave the period glyph with codePoints [] for every later document (amounts extracted as "1,200;00", * textkit's line breaks slipping). Creating those glyphs here first makes a render independent of the renders - * before it. Memoised; await it before the first render. + * before it. Covers the bundled families and any user family registered so far, each descriptor once, so a + * font registered later is not skipped. Await it before the first render. */ export function primeFontGlyphs(): Promise { - if (!registered) return Promise.resolve(); - if (!primed) { - primed = run().catch((err) => { - primed = null; - throw err; - }); - } - return primed; + if (!registered && primedKeys.size === 0 && userFamilies.length === 0) return Promise.resolve(); + const next = primeChain.then(run); + primeChain = next.catch(() => undefined); + return next; async function run(): Promise { - for (const fam of FONT_MANIFEST.families) { - for (const s of familySources(fam)) { - const descriptor = { fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle }; - await Font.load(descriptor); - const data = (Font as unknown as { getFont(d: object): { data: FkFont | null } }).getFont(descriptor).data; - if (data) primeComponents(data); + const descriptors: Array<{ fontFamily: string; fontWeight: number; fontStyle: "normal" | "italic" }> = []; + if (registered) { + for (const fam of FONT_MANIFEST.families) { + for (const s of familySources(fam)) descriptors.push({ fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle }); } } + for (const family of userFamilies) { + for (const s of userFamilySources(family)) descriptors.push({ fontFamily: family, fontWeight: s.fontWeight, fontStyle: s.fontStyle }); + } + for (const descriptor of descriptors) { + const key = `${descriptor.fontFamily}|${descriptor.fontWeight}|${descriptor.fontStyle}`; + if (primedKeys.has(key)) continue; + await Font.load(descriptor); + const data = (Font as unknown as { getFont(d: object): { data: FkFont | null } }).getFont(descriptor).data; + if (data) primeComponents(data); + primedKeys.add(key); + } } } +// ---- User-imported fonts (Now, Gotham, Open Sauce One) ---- + +const userFamilies: string[] = []; +const isNode = typeof process !== "undefined" && !!process.versions?.node; + +/** + * A src react-pdf can load the bytes from. In a browser or worker that is a blob: URL (react-pdf fetches it; + * a data URI would be decoded character by character, which is slow for a multi-MB font). Under Node it is a + * temporary file, because react-pdf's Node build opens anything that is not an http(s) URL as a path. + */ +async function sourceFor(bytes: Uint8Array, id: string): Promise { + 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::` 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 { + 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(); + for (const s of userFamilySources(family)) { + if (!srcs.has(s.id)) srcs.set(s.id, await sourceFor(s.bytes, s.id)); + } + Font.register({ + family, + fonts: userFamilySources(family).map((s) => ({ src: srcs.get(s.id) as string, fontWeight: s.fontWeight as never, fontStyle: s.fontStyle })), + }); + userFamilies.push(family); + await primeFontGlyphs(); + return family; +} + +/** Cheap non-cryptographic id (two FNV-1a passes, 16 hex digits) for callers that have no sha256, i.e. tests. */ +function fallbackId(bytes: Uint8Array): string { + let a = 0x811c9dc5; + let b = 0x01000193; + for (let i = 0; i < bytes.length; i++) { + a = Math.imul(a ^ bytes[i], 0x01000193) >>> 0; + b = Math.imul(b ^ bytes[i], 0x811c9dc5) >>> 0; + } + return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0"); +} + +/** One-file convenience for Node tests and single-weight imports: the font as the only member of the face's set. */ +export async function registerUserFont( + face: UserFace, + bytes: Uint8Array, + opts: { weight?: number; italic?: boolean; sha256?: string } = {}, +): Promise<{ family: string; sha256: string }> { + const sha256 = opts.sha256 ?? fallbackId(bytes); + const family = await registerUserFontSet(face, [{ face, weight: opts.weight ?? 400, italic: opts.italic ?? false, sha256, bytes }]); + return { family, sha256 }; +} + const MIN_FONT_BYTES = 10 * 1024; function hasTrueTypeMagic(b: Uint8Array): boolean { diff --git a/src/pdf/fonts/roles.ts b/src/pdf/fonts/roles.ts index ce73313..09cb996 100644 --- a/src/pdf/fonts/roles.ts +++ b/src/pdf/fonts/roles.ts @@ -35,6 +35,13 @@ export interface TypeToken { tnum?: boolean; /** Smallest size IdText/FitText may shrink to. */ minSize?: number; + /** + * Size and tracking to use instead of `size`/`tracking` when a user-imported font replaces this token's + * substitute face. The token's own values are tuned for the substitute (Gotham drawn in Montserrat, Now in + * Jost); the real font takes the design values. defineRoles turns a token that has this into one whose + * size/tracking follow the active overrides; without an override its values are exactly the literals. + */ + ownFont?: { size?: number; tracking?: number }; } const SANS: FaceKey = "IBM Plex Sans"; @@ -63,14 +70,37 @@ export type RoleTable = Record; /** Checks a role table against TypeToken and keeps its literal keys: `export const ROLES = defineRoles({ ... })`. */ export function defineRoles>(table: T): T { - return table; + const out: Record = {}; + 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; + const copy: TypeToken = { ...token }; + for (const key of ["size", "tracking"] as const) { + const design = own[key]; + if (design === undefined) continue; + const substituted = token[key]; + Object.defineProperty(copy, key, { + enumerable: true, + configurable: true, + get: () => (resolveFace(token.face).startsWith(USER_FAMILY_PREFIX) ? design : substituted), + }); + } + return copy; } /** * Licensed template faces (Now, Gotham, Open Sauce One, ...) map to the OFL look-alike that is * actually bundled. A user-imported font replaces the licensed name through `faceOverrides`. * Gotham runs about 4% narrower than Montserrat, so tokens that use it carry sizes already scaled by - * GOTHAM_SIZE (see templates/northline); a real Gotham import would need them back at 1. + * GOTHAM_SIZE (see templates/northline); a real Gotham import takes them back at 1 through TypeToken.ownFont. */ export const SUBSTITUTES: Readonly> = { Now: "Jost", @@ -78,8 +108,30 @@ export const SUBSTITUTES: Readonly> = { "Open Sauce One": "Poppins", }; +/** Family names of user-imported fonts start with this (`user:Gotham:`). */ +export const USER_FAMILY_PREFIX = "user:"; + +type FaceOverrides = Readonly>; + +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(overrides: FaceOverrides | undefined, fn: () => Promise): Promise { + const previous = activeOverrides; + activeOverrides = overrides && Object.keys(overrides).length > 0 ? overrides : undefined; + try { + return await fn(); + } finally { + activeOverrides = previous; + } +} + /** The family name to register/look up for a face: user override first, then substitute, then itself. */ -export function resolveFace(face: string, overrides?: Readonly>): string { +export function resolveFace(face: string, overrides: FaceOverrides | undefined = activeOverrides): string { return overrides?.[face] ?? SUBSTITUTES[face] ?? face; } diff --git a/src/pdf/fonts/userFontStore.ts b/src/pdf/fonts/userFontStore.ts new file mode 100644 index 0000000..329ecf9 --- /dev/null +++ b/src/pdf/fonts/userFontStore.ts @@ -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::`. 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> = { + 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(); +const familyFiles = new Map(); + +/** 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> | undefined): Record | undefined { + if (!fonts) return undefined; + const out: Record = {}; + 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; +} diff --git a/src/pdf/fonts/userFonts.test.tsx b/src/pdf/fonts/userFonts.test.tsx new file mode 100644 index 0000000..18e674c --- /dev/null +++ b/src/pdf/fonts/userFonts.test.tsx @@ -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(" { + 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(""))).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([]); + }); +}); diff --git a/src/pdf/model/prefs.ts b/src/pdf/model/prefs.ts index fab9e88..5bc7223 100644 --- a/src/pdf/model/prefs.ts +++ b/src/pdf/model/prefs.ts @@ -1,4 +1,5 @@ import type { MarginPreset, PageId } from "../engine/geometry"; +import { isUserFace, type UserFace } from "../fonts/userFontStore"; /** Page and template choices frozen with an invoice at issue (stored as JSON). */ export interface RenderPrefsV1 { @@ -12,6 +13,11 @@ export interface RenderPrefsV1 { /** Templates without a Canva logo placeholder (Linea, ...) show the logo only when this is true. */ show?: boolean; }; + /** + * User-imported fonts in use, per face (Now, Gotham, Open Sauce One): the sha256 of each file (one per weight/style). + * Frozen with the invoice, which is what keeps those font files from being deleted. Absent: the bundled substitutes. + */ + fonts?: Partial>; } export const DEFAULT_PREFS: RenderPrefsV1 = { @@ -22,6 +28,7 @@ export const DEFAULT_PREFS: RenderPrefsV1 = { }; const PAGE_SIZE_IDS: readonly PageId[] = ["A4", "LETTER"]; +const FONT_ID = /^[0-9a-f]{16,64}$/; const MARGIN_IDS: readonly MarginPreset[] = ["template", "narrow", "normal"]; /** Tolerant: anything unreadable or out of range falls back to the default for that field. */ @@ -49,6 +56,16 @@ export function parsePrefs(json: string | null | undefined): RenderPrefsV1 { if (typeof show === "boolean") out.show = show; if (Object.keys(out).length > 0) prefs.logo = out; } + const fonts = o.fonts; + if (typeof fonts === "object" && fonts !== null) { + const out: NonNullable = {}; + for (const [face, ids] of Object.entries(fonts as Record)) { + 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; } diff --git a/src/pdf/render/client.test.ts b/src/pdf/render/client.test.ts index 978eda9..653e6e9 100644 --- a/src/pdf/render/client.test.ts +++ b/src/pdf/render/client.test.ts @@ -16,7 +16,7 @@ class FakeWorker implements WorkerLike { onerror: WorkerLike["onerror"] = null; sent: WorkerRequest[] = []; terminated = false; - postMessage(message: WorkerRequest) { + postMessage(message: WorkerRequest, _transfer?: Transferable[]) { this.sent.push(message); } terminate() { @@ -378,3 +378,94 @@ describe("main-thread client and fallback", () => { expect((await client.render(job(8))).id).toBe(8); }); }); + +describe("user fonts in render jobs", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + const file = (sha: string, bytes: number[]) => ({ face: "Gotham" as const, weight: 400, italic: false, sha256: sha, bytes: new Uint8Array(bytes) }); + const withFonts = (id: number, files: ReturnType[]): 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)]); + }); +}); diff --git a/src/pdf/render/client.ts b/src/pdf/render/client.ts index 52311e0..b449d6f 100644 --- a/src/pdf/render/client.ts +++ b/src/pdf/render/client.ts @@ -1,3 +1,4 @@ +import type { UserFontFile } from "../fonts/userFontStore"; import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs"; /** A newer render call replaced this one before it finished (or before it started). */ @@ -35,7 +36,7 @@ export class RenderFailed extends Error { /** The part of Worker the client uses, so tests can supply a fake. */ export interface WorkerLike { - postMessage(message: WorkerRequest): void; + postMessage(message: WorkerRequest, transfer?: Transferable[]): void; terminate(): void; onmessage: ((event: { data: WorkerMessage }) => void) | null; onerror: ((event: { message?: string }) => void) | null; @@ -55,6 +56,11 @@ export interface RenderClient { } export interface RenderClientOptions { + /** + * Returns a user font file by its sha256 (the app reads it from the database). Needed only for jobs whose + * prefs name user fonts; the result is cached, so each file is read once per session. + */ + loadUserFont?: (sha256: string) => Promise; workerFactory?: () => WorkerLike; /** Per-job watchdog. react-pdf's paginate loop is synchronous, so a layout bug hangs the worker for good. */ timeoutMs?: number; @@ -85,8 +91,21 @@ type Running = Slot & { timer: ReturnType; }; -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): { request: WorkerRequest; transfer: Transferable[]; ids: string[] } { + if (slot.kind !== "render") return { request: { type: "assemble-images", job: slot.job }, transfer: [], ids: [] }; + const fresh = (slot.job.userFonts ?? []).filter((f) => !sent.has(f.sha256)); + const copies = fresh.map((f) => ({ ...f, bytes: f.bytes.slice() })); + return { + request: { type: "render", job: { ...slot.job, userFonts: copies } }, + transfer: copies.map((f) => f.bytes.buffer), + ids: fresh.map((f) => f.sha256), + }; +} /** * Renders in a module worker. Only the newest render matters: a new call rejects the render in flight @@ -105,11 +124,14 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient let running: Running | null = null; let pending: RenderSlot | null = null; const assembles: AssembleSlot[] = []; + /** Ids of the user font files the current worker has been sent; a new worker starts with none. */ + const sent = new Set(); const stopWorker = () => { const w = worker; worker = null; ready = false; + sent.clear(); if (w) { w.onmessage = null; w.onerror = null; @@ -147,7 +169,9 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient } if (!slot) return; running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) }; - worker.postMessage(requestFor(slot)); + const { request, transfer, ids } = requestFor(slot, sent); + worker.postMessage(request, transfer); + for (const id of ids) sent.add(id); }; const spawn = () => { @@ -239,8 +263,13 @@ export type MainExecutor = (job: RenderJob) => Promise; // Loaded on demand so react-pdf stays out of the main bundle unless the worker is unavailable. const runOnMainThread: MainExecutor = async (job) => { - const [{ initBrowserRendering }, { renderInvoicePdf }] = await Promise.all([import("./browserInit"), import("./invoice")]); + const [{ initBrowserRendering }, { renderInvoicePdf }, { registerJobFonts }] = await Promise.all([ + import("./browserInit"), + import("./invoice"), + import("./userFontJob"), + ]); await initBrowserRendering(); + await registerJobFonts(job.prefs, job.userFonts); // Let pending input and paint run before the long synchronous layout. await new Promise((r) => setTimeout(r, 0)); const t0 = performance.now(); @@ -337,6 +366,27 @@ export function createAutoClient( ): RenderClient { const primary = createRenderClient(opts); let main: RenderClient | null = null; + const fontCache = new Map>(); + 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 => { + const ids = Object.values(job.prefs.fonts ?? {}).flat(); + if (ids.length === 0) return job; + const load = opts.loadUserFont; + if (!load) throw new Error("This render needs user fonts but no font loader is configured"); + const files = await Promise.all( + ids.map((id) => { + let p = fontCache.get(id); + if (!p) { + p = load(id); + fontCache.set(id, p); + p.catch(() => fontCache.delete(id)); + } + return p; + }), + ); + return { ...job, userFonts: files }; + }; const fallback = () => { main ??= createMainThreadClient(opts.mainExecutor, opts.mainAssembleExecutor); primary.dispose(); @@ -346,7 +396,11 @@ export function createAutoClient( get mode() { return main ? "main" : "worker"; }, - render(job) { + async render(plain) { + const call = ++renderCalls; + const job = await withFonts(plain); + // Reading fonts took time; keep latest-wins: a render requested meanwhile replaces this one. + if (call !== renderCalls) throw new Superseded(); if (main) return main.render(job); return primary.render(job).catch((err) => { if (!(err instanceof WorkerUnavailable)) throw err; diff --git a/src/pdf/render/invoice.ts b/src/pdf/render/invoice.ts index 14f54d1..6aa7455 100644 --- a/src/pdf/render/invoice.ts +++ b/src/pdf/render/invoice.ts @@ -4,6 +4,8 @@ import { computeFrame, PAGE_SIZES, type PageFrame } from "../engine/geometry"; import type { AuditIssue } from "../engine/audit"; import type { RenderModel } from "../model/build"; import type { RenderPrefsV1 } from "../model/prefs"; +import { withFaceOverrides } from "../fonts/roles"; +import { faceOverridesFor } from "../fonts/userFontStore"; import { CLOSING_ID } from "../engine/ids"; import { marginsFor, type PlacedLogo } from "../templates/contract"; import { getTemplate } from "../templates/registry"; @@ -41,7 +43,12 @@ export function frameForPrefs(prefs: RenderPrefsV1): PageFrame { * model + prefs -> template, frame, logo placement and keep-outs -> InvoicePdf -> renderCore. Needs * fonts registered and a measurer installed (render/browserInit.ts in the app, testing/setup.ts in tests). */ -export async function renderInvoicePdf({ model, prefs }: RenderInvoiceInput, bytesOf?: RenderBytes): Promise { +export function renderInvoicePdf(input: RenderInvoiceInput, bytesOf?: RenderBytes): Promise { + // 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 { const template = getTemplate(prefs.templateId); const frame = frameForPrefs(prefs); diff --git a/src/pdf/render/jobs.ts b/src/pdf/render/jobs.ts index 0249ee2..4093502 100644 --- a/src/pdf/render/jobs.ts +++ b/src/pdf/render/jobs.ts @@ -1,5 +1,6 @@ import type { AuditIssue } from "../engine/audit"; import type { RenderModel } from "../model/build"; +import type { UserFontFile } from "../fonts/userFontStore"; import type { RenderPrefsV1 } from "../model/prefs"; // Everything here crosses postMessage, so it stays plain data: no functions, classes or Maps. @@ -8,6 +9,11 @@ export interface RenderJob { id: number; model: RenderModel; prefs: RenderPrefsV1; + /** + * Bytes of the user fonts `prefs.fonts` names. The caller attaches all of them; the worker client sends only the + * ones that worker does not hold yet (as transferred copies, so the caller's master copy stays usable). + */ + userFonts?: UserFontFile[]; } export interface RenderResult { diff --git a/src/pdf/render/render.worker.ts b/src/pdf/render/render.worker.ts index 1799d91..a92930e 100644 --- a/src/pdf/render/render.worker.ts +++ b/src/pdf/render/render.worker.ts @@ -3,6 +3,7 @@ import { installStreamAsyncIterator } from "../../lib/streamPolyfill"; import { initBrowserRendering } from "./browserInit"; import { runAssembleJob } from "./assembleJob"; import { renderInvoicePdf } from "./invoice"; +import { registerJobFonts } from "./userFontJob"; import type { WorkerMessage, WorkerRequest } from "./jobs"; interface WorkerScope { @@ -44,6 +45,7 @@ scope.onmessage = async (event) => { const { job } = request; try { await started.catch(() => initBrowserRendering()); + await registerJobFonts(job.prefs, job.userFonts); const t0 = performance.now(); const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs }); const ms = Math.round(performance.now() - t0); diff --git a/src/pdf/render/userFontJob.ts b/src/pdf/render/userFontJob.ts new file mode 100644 index 0000000..f174cc3 --- /dev/null +++ b/src/pdf/render/userFontJob.ts @@ -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 { + 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[]); + } +} diff --git a/src/pdf/templates/catalog.ts b/src/pdf/templates/catalog.ts index e2be57d..2194411 100644 --- a/src/pdf/templates/catalog.ts +++ b/src/pdf/templates/catalog.ts @@ -11,6 +11,11 @@ export interface TemplateCatalogEntry { badge?: "Matches your original format"; /** No Canva placeholder: the logo shows only when the user turns it on. */ logoOptIn?: boolean; + /** + * The licensed faces this template draws (a user-imported font may replace them). Mirrors the roles of the + * template; a test keeps the two in step. + */ + faces?: readonly ("Now" | "Gotham" | "Open Sauce One")[]; } export const FAMILY_LABELS: Record = { @@ -32,11 +37,11 @@ export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [ { id: "tangerine-ledger", name: "Tangerine Ledger", family: "ledger", thumbnail: "/templates/tangerine-ledger.png" }, { id: "slate-band", name: "Slate Band", family: "band", thumbnail: "/templates/slate-band.png" }, { id: "teal-swoosh", name: "Teal Swoosh", family: "band", thumbnail: "/templates/teal-swoosh.png" }, - { id: "purple-pop", name: "Purple Pop", family: "northline", thumbnail: "/templates/purple-pop.png", logoOptIn: true }, - { id: "citrus-split", name: "Citrus Split", family: "northline", thumbnail: "/templates/citrus-split.png" }, + { id: "purple-pop", name: "Purple Pop", family: "northline", thumbnail: "/templates/purple-pop.png", logoOptIn: true, faces: ["Gotham", "Open Sauce One"] }, + { id: "citrus-split", name: "Citrus Split", family: "northline", thumbnail: "/templates/citrus-split.png", faces: ["Now", "Gotham"] }, { id: "monolith", name: "Monolith", family: "standalone", thumbnail: "/templates/monolith.png" }, { id: "cobalt-stripe", name: "Cobalt Stripe", family: "standalone", thumbnail: "/templates/cobalt-stripe.png" }, - { id: "marble", name: "Marble", family: "standalone", thumbnail: "/templates/marble.png", logoOptIn: true }, + { id: "marble", name: "Marble", family: "standalone", thumbnail: "/templates/marble.png", logoOptIn: true, faces: ["Now"] }, { id: "highlighter", name: "Highlighter", family: "standalone", thumbnail: "/templates/highlighter.png", logoOptIn: true }, ]; diff --git a/src/pdf/templates/marble/tokens.ts b/src/pdf/templates/marble/tokens.ts index b3f344e..3662040 100644 --- a/src/pdf/templates/marble/tokens.ts +++ b/src/pdf/templates/marble/tokens.ts @@ -18,31 +18,32 @@ export const BAND_TO = "#ECEAEE"; * Marble's type (docs/templates/01-marble.md): Lustria in tracked caps for the title, headings, table heads and the * amount due; "Now" (drawn in Jost, the bundled look-alike) for everything else. Jost sets about 12% narrower than Now, * so every Now role carries the tracking that restores the Canva widths (0.10 to 0.13 em on top of the design's - * 0.04 em). Lustria ascent 0.955, descent 0.327; Jost 1.07 / 0.375. + * 0.04 em). A real Now imported by the user replaces Jost, so those roles take the design's 0.04 em back through + * ownFont. Lustria ascent 0.955, descent 0.327; Jost 1.07 / 0.375. */ export const MARBLE_ROLES = defineRoles({ /** "INVOICE" / "TAX INVOICE": 25.18 pt, tracked 0.192 em, shrunk to the room the stack has. */ title: { face: L, weight: 400, size: 25.18, leading: 25.2, tracking: 0.192, caps: true, minSize: 13 }, /** The vendor's name at the header's left (the design leaves it empty). */ brand: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true, minSize: 9 }, - meta: { face: N, weight: 400, size: 11.55, leading: 17.5, tracking: 0.105, minSize: 8 }, + meta: { face: N, weight: 400, size: 11.55, leading: 17.5, tracking: 0.105, ownFont: { tracking: 0.04 }, minSize: 8 }, /** BILL TO:, PAYABLE TO:, SUPPLIER:. */ heading: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true }, - body: { face: N, weight: 400, size: 11.55, leading: 18.2, tracking: 0.105, minSize: 8 }, + body: { face: N, weight: 400, size: 11.55, leading: 18.2, tracking: 0.105, ownFont: { tracking: 0.04 }, minSize: 8 }, tableHead: { face: L, weight: 400, size: 11.68, leading: 16, tracking: 0.192, caps: true }, - tableBody: { face: N, weight: 400, size: 10.57, leading: 17.4, tracking: 0.102 }, - tableSub: { face: N, weight: 400, size: 8.5, leading: 11.5, tracking: 0.1 }, + tableBody: { face: N, weight: 400, size: 10.57, leading: 17.4, tracking: 0.102, ownFont: { tracking: 0.04 } }, + tableSub: { face: N, weight: 400, size: 8.5, leading: 11.5, tracking: 0.1, ownFont: { tracking: 0.04 } }, totalLabel: { face: L, weight: 400, size: 9, leading: 16, tracking: 0.192, caps: true }, - totalValue: { face: N, weight: 400, size: 10.57, leading: 16, tracking: 0.102 }, + totalValue: { face: N, weight: 400, size: 10.57, leading: 16, tracking: 0.102, ownFont: { tracking: 0.04 } }, /** AMOUNT DUE: 14.6 pt, tracked 0.192 em. */ due: { face: L, weight: 400, size: 14.6, leading: 20, tracking: 0.192, caps: true, minSize: 10 }, - small: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13 }, + small: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, ownFont: { tracking: 0.04 } }, smallLabel: { face: L, weight: 400, size: 9, leading: 15.3, tracking: 0.192, caps: true }, - signName: { face: N, weight: 400, size: 10.57, leading: 15, tracking: 0.102 }, - signDesignation: { face: N, weight: 400, size: 9.6, leading: 14, tracking: 0.13 }, - fine: { face: N, weight: 400, size: 8, leading: 11, tracking: 0.1 }, + signName: { face: N, weight: 400, size: 10.57, leading: 15, tracking: 0.102, ownFont: { tracking: 0.04 } }, + signDesignation: { face: N, weight: 400, size: 9.6, leading: 14, tracking: 0.13, ownFont: { tracking: 0.04 } }, + fine: { face: N, weight: 400, size: 8, leading: 11, tracking: 0.1, ownFont: { tracking: 0.04 } }, /** The footer band's contact lines. */ - contact: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, minSize: 7 }, + contact: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, ownFont: { tracking: 0.04 }, minSize: 7 }, footer: { face: N, weight: 400, size: 7 }, }); diff --git a/src/pdf/templates/northline/citrus.tsx b/src/pdf/templates/northline/citrus.tsx index c194045..fc7fe27 100644 --- a/src/pdf/templates/northline/citrus.tsx +++ b/src/pdf/templates/northline/citrus.tsx @@ -28,21 +28,21 @@ export const CITRUS_ROLES = defineRoles({ metaStrong: { face: DM, weight: 700, size: 12.36, leading: 16, minSize: 8 }, billedLabel: { face: DM, weight: 700, size: 12.4, leading: 17, caps: true }, clientName: { face: DM, weight: 700, size: 14.87, leading: 19, caps: true }, - clientLine: { face: G, weight: 400, size: gotham(10.5), leading: 15.5 }, - headService: { face: G, weight: 700, size: gotham(15.17), leading: 18, tracking: 0.15, caps: true }, - headNum: { face: G, weight: 700, size: gotham(14.17), leading: 18, caps: true }, - tableBody: { face: G, weight: 400, size: gotham(12), leading: 15 }, + clientLine: { face: G, weight: 400, size: gotham(10.5), ownFont: { size: 10.5 }, leading: 15.5 }, + headService: { face: G, weight: 700, size: gotham(15.17), ownFont: { size: 15.17 }, leading: 18, tracking: 0.15, caps: true }, + headNum: { face: G, weight: 700, size: gotham(14.17), ownFont: { size: 14.17 }, leading: 18, caps: true }, + tableBody: { face: G, weight: 400, size: gotham(12), ownFont: { size: 12 }, leading: 15 }, tableSub: { face: G, weight: 400, size: 9, leading: 11.5 }, - totalLabel: { face: G, weight: 700, size: gotham(12.5), leading: 17, caps: true }, - totalValue: { face: G, weight: 400, size: gotham(12.5), leading: 17 }, - grand: { face: G, weight: 700, size: gotham(14.7), leading: 18, caps: true }, - grandValue: { face: G, weight: 700, size: gotham(14.7), leading: 18 }, + totalLabel: { face: G, weight: 700, size: gotham(12.5), ownFont: { size: 12.5 }, leading: 17, caps: true }, + totalValue: { face: G, weight: 400, size: gotham(12.5), ownFont: { size: 12.5 }, leading: 17 }, + grand: { face: G, weight: 700, size: gotham(14.7), ownFont: { size: 14.7 }, leading: 18, caps: true }, + grandValue: { face: G, weight: 700, size: gotham(14.7), ownFont: { size: 14.7 }, leading: 18 }, words: { face: DM, weight: 700, size: 11, leading: 14 }, wordsLabel: { face: DM, weight: 700, size: 11, leading: 14 }, sectionLabel: { face: DM, weight: 700, size: 12, leading: 16, caps: true }, - bank: { face: G, weight: 400, size: gotham(11), leading: 15 }, - bullet: { face: G, weight: 400, size: gotham(12), leading: 16.5 }, - contact: { face: G, weight: 400, size: gotham(12.77), leading: 18, minSize: 7 }, + bank: { face: G, weight: 400, size: gotham(11), ownFont: { size: 11 }, leading: 15 }, + bullet: { face: G, weight: 400, size: gotham(12), ownFont: { size: 12 }, leading: 16.5 }, + contact: { face: G, weight: 400, size: gotham(12.77), ownFont: { size: 12.77 }, leading: 18, minSize: 7 }, small: { face: DM, weight: 400, size: 9, leading: 12 }, fine: { face: DM, weight: 400, size: 7, leading: 10 }, signName: { face: DM, weight: 700, size: 9.5, leading: 13 }, diff --git a/src/pdf/templates/northline/purple.tsx b/src/pdf/templates/northline/purple.tsx index aadd400..7c71b90 100644 --- a/src/pdf/templates/northline/purple.tsx +++ b/src/pdf/templates/northline/purple.tsx @@ -22,25 +22,25 @@ export const PURPLE_ROLES = defineRoles({ wordmark: { face: "Open Sauce One", weight: 800, size: 94.07, leading: 84, tracking: -0.08, minSize: 48 }, taxCaption: { face: DM, weight: 700, size: 14, leading: 18, tracking: 0.15, caps: true }, brand: { face: DM, weight: 700, size: 17.77, leading: 21, minSize: 12 }, - pill: { face: G, weight: 700, size: gotham(13.39), leading: 17, tracking: 0.13, caps: true, minSize: 8.5 }, - label: { face: G, weight: 700, size: gotham(12), leading: 16, caps: true }, - clientName: { face: G, weight: 700, size: gotham(14.39), leading: 18, caps: true }, - clientLine: { face: G, weight: 400, size: gotham(10.16), leading: 15.5 }, + pill: { face: G, weight: 700, size: gotham(13.39), ownFont: { size: 13.39 }, leading: 17, tracking: 0.13, caps: true, minSize: 8.5 }, + label: { face: G, weight: 700, size: gotham(12), ownFont: { size: 12 }, leading: 16, caps: true }, + clientName: { face: G, weight: 700, size: gotham(14.39), ownFont: { size: 14.39 }, leading: 18, caps: true }, + clientLine: { face: G, weight: 400, size: gotham(10.16), ownFont: { size: 10.16 }, leading: 15.5 }, amount: { face: DM, weight: 700, size: 32.74, leading: 36, minSize: 20, tnum: true }, date: { face: DM, weight: 700, size: 14.05, leading: 19.5, caps: true, minSize: 10 }, tableHead: { face: DM, weight: 700, size: 14, leading: 18, caps: true }, tableBody: { face: DM, weight: 400, size: 11.83, leading: 15 }, tableSub: { face: DM, weight: 400, size: 9, leading: 11.5 }, - totalLabel: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true }, - totalValue: { face: G, weight: 400, size: gotham(13), leading: 17 }, - grand: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true }, - grandValue: { face: G, weight: 700, size: gotham(13), leading: 17 }, + totalLabel: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17, caps: true }, + totalValue: { face: G, weight: 400, size: gotham(13), ownFont: { size: 13 }, leading: 17 }, + grand: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17, caps: true }, + grandValue: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17 }, words: { face: DM, weight: 400, size: 10, leading: 13 }, wordsLabel: { face: DM, weight: 700, size: 10, leading: 13 }, termsLabel: { face: DM, weight: 700, size: 13, leading: 17, caps: true }, bullet: { face: DM, weight: 400, size: 11, leading: 14.2 }, - bank: { face: G, weight: 400, size: gotham(11), leading: 12.5 }, - contact: { face: G, weight: 400, size: gotham(10.14), leading: 15, minSize: 7 }, + bank: { face: G, weight: 400, size: gotham(11), ownFont: { size: 11 }, leading: 12.5 }, + contact: { face: G, weight: 400, size: gotham(10.14), ownFont: { size: 10.14 }, leading: 15, minSize: 7 }, small: { face: DM, weight: 400, size: 9, leading: 12 }, fine: { face: DM, weight: 400, size: 7, leading: 10 }, signName: { face: DM, weight: 700, size: 9.5, leading: 13 }, diff --git a/src/pdf/templates/northline/variant.ts b/src/pdf/templates/northline/variant.ts index ee96a91..b31602e 100644 --- a/src/pdf/templates/northline/variant.ts +++ b/src/pdf/templates/northline/variant.ts @@ -5,7 +5,7 @@ import type { GapRange } from "../neutral/variant"; /** * Gotham (commercial) is drawn with Montserrat, which runs about 4% wider at the same size. Every Gotham-derived * size is multiplied by this when a token is defined, so the substitute takes the width the design has. A real - * Gotham imported over the licensed name would need these tokens at factor 1. + * Gotham imported over the licensed name takes the design size back: each such token carries it as `ownFont.size`. */ export const GOTHAM_SIZE = 0.96; const round2 = (n: number) => Math.round(n * 100) / 100; diff --git a/src/views/AppSettings.tsx b/src/views/AppSettings.tsx index 99eb9f8..b1b92aa 100644 --- a/src/views/AppSettings.tsx +++ b/src/views/AppSettings.tsx @@ -36,6 +36,7 @@ import { emailError, ifscError, panError, phoneError, vendorGstinError } from ". import { ImagePicker } from "../components/ImagePicker"; import { LogoBranding } from "../components/LogoBranding"; import DiagnosticsPanel from "../components/DiagnosticsPanel"; +import { FontOverrideTable } from "../components/FontOverrideTable"; const blankBank = (): BankAccount => ({ id: null, @@ -289,6 +290,13 @@ export default function AppSettings({ +
+

Fonts

+ + + +
+

Defaults

diff --git a/src/views/NewInvoice.tsx b/src/views/NewInvoice.tsx index 488de69..25d03fa 100644 --- a/src/views/NewInvoice.tsx +++ b/src/views/NewInvoice.tsx @@ -64,6 +64,8 @@ import { issueAndArchive, renderAndArchive } from "../lib/exportFlow"; import { loadExportMode } from "../lib/exportMode"; import type { ExportMode } from "../lib/filename"; import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs"; +import { useUserFonts } from "../hooks/useUserFonts"; +import { withUserFonts } from "../lib/fontImport"; import { ImagePicker } from "../components/ImagePicker"; import { loadLogoImages, type LogoImages } from "../lib/logo"; @@ -354,7 +356,11 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv // Page setup for the invoice being edited; an issued invoice shows what was frozen at issue. const [prefs, setPrefs] = useState(() => parsePrefs(settings.renderPrefs)); useEffect(() => setPrefs(parsePrefs(settings.renderPrefs)), [settings.renderPrefs]); - const activePrefs = useMemo(() => (issued ? prefsForInvoice(issued, settings) : prefs), [issued, prefs, settings]); + // setupPrefs is the page setup as edited (and saved as the default); activePrefs adds the imported fonts the template + // draws, which is what renders and what is frozen on the invoice at issue. An issued invoice keeps its frozen fonts. + const userFonts = useUserFonts(); + const setupPrefs = useMemo(() => (issued ? prefsForInvoice(issued, settings) : prefs), [issued, prefs, settings]); + const activePrefs = useMemo(() => (issued ? setupPrefs : withUserFonts(setupPrefs, userFonts)), [issued, setupPrefs, userFonts]); // The preview renders the real PDF from a draft-status Invoice built from the form. It is skipped // while this view is hidden (it stays mounted) so typing elsewhere never starts a render. @@ -1038,7 +1044,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv

Preview

- + }>