//! 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 ); } }