1350 lines
48 KiB
Rust
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(®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"<!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(®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
|
|
);
|
|
}
|
|
}
|