Files
Voiced/src-tauri/src/commands/fonts.rs
T
soubarna b6cf46b883 style(src-tauri): cargo fmt
Pure mechanical rustfmt reformat (default config, no rustfmt.toml).
2026-10-05 17:19:13 +05:30

1350 lines
48 KiB
Rust

//! 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 `<local data dir>/fonts/<sha256>.<ttf|otf>` (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<String>,
}
#[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<u16> {
Some(u16::from_be_bytes(b.get(at..at + 2)?.try_into().ok()?))
}
fn be32(b: &[u8], at: usize) -> Option<u32> {
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<Self, String> {
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<u16> = 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<String> {
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<Self> {
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<u32> {
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<Vec<u8>, 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<u8>,
}
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<u8>, 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<PathBuf, String> {
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<UserFont> {
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<Option<UserFont>, 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<Vec<UserFont>, 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::<Result<Vec<_>, _>>()
.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<bool, String> {
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<u16>,
pub italic: Option<bool>,
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<UserFont, String> {
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<i64> = 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<i64> = {
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::<Result<Vec<i64>, _>>()
.map_err(|e| e.to_string())?;
ids
};
let mut obsolete: Vec<PathBuf> = 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<Option<PathBuf>, 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<RemoveOutcome, String> {
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<Vec<u8>, String> {
let format: Option<String> = 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<ImportRequest<'a>, 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::<u16>()
.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<FontInspection, String> {
let bytes = raw_body(&request)?;
inspect_bytes(bytes).map(|(_, info)| info)
}
#[tauri::command]
pub async fn import_font(
request: Request<'_>,
state: State<'_, AppState>,
) -> Result<UserFont, String> {
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<bool>,
state: State<'_, AppState>,
) -> Result<Vec<UserFont>, 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<Response, String> {
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<RemoveOutcome, String> {
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<u8> {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../public/fonts/jost")
.join(name);
std::fs::read(path).unwrap()
}
fn regular() -> Vec<u8> {
jost("Jost-Regular.ttf")
}
fn bold() -> Vec<u8> {
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<u8> {
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(&regular()).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(&regular()));
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"<!doctype html><html></html>")
.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(&regular());
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(), &regular(), &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(), &regular(), &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(), &regular(), &request("Gotham", false))
.unwrap_err()
.contains("licence")
);
assert!(import_font_impl(
&mut conn,
dir.path(),
&regular(),
&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(), &regular(), &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(), &regular(), &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(), &regular(), &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(), &regular(), &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(), &regular(), &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(), &regular(), &request("Gotham", true)).unwrap();
let b = import_font_impl(&mut conn, dir.path(), &regular(), &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(), &regular(), &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
);
}
}