mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 03:39:51 -06:00
Finding 44: font.add stored the file in the user font folder and font.remove deleted stored files directly, bypassing the automation write root (#769). Both now check the user font folder against the write policy in force before touching the database or the disk; with no folder (headless) a font still lasts for the session only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
143 lines
6.5 KiB
Rust
143 lines
6.5 KiB
Rust
//! Fonts the user adds without rebuilding (#582): `font.add`, `font.list`, `font.remove`.
|
||
//!
|
||
//! An added font joins the process-wide font database on top of the bundled and system fonts
|
||
//! (never replacing a face already there), so it shows in the font lists, lays out, renders and
|
||
//! embeds in PDFs like any other. Where it is kept between runs is [`FontDb::load_user_fonts`]'s
|
||
//! folder (desktop); without one it lasts for the session (the web shell stores it itself).
|
||
|
||
use serde_json::{Value, json};
|
||
use wordcraft_fonts::{FontDb, UserFont};
|
||
|
||
use crate::cmd::insert::base64_decode;
|
||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
CommandSpec::new("font.add", "Add Font…", "Home › Font", add)
|
||
.params(r#"{"path"?: string, "data"?: base64, "name"?: string (file name for "data")} — a TTF, OTF, TTC, WOFF or WOFF2 file; none: pick a file"#)
|
||
.pure(),
|
||
CommandSpec::new("font.list", "Added Fonts", "Home › Font", list).params(r#"{"all"?: bool (also every available family)}"#).pure(),
|
||
CommandSpec::new("font.remove", "Remove Added Font", "Home › Font", remove).params(r#"{"family": string}"#).pure(),
|
||
CommandSpec::new("font.loadSystem", "Load System Installed Fonts…", "Home › Font", load_system).pure(),
|
||
]
|
||
}
|
||
|
||
fn load_system(s: &mut Session, _v: &Value) -> CmdResult {
|
||
s.push_ui_request(json!({"open": "loadSystemFonts"}));
|
||
Ok(json!({"pending": true}))
|
||
}
|
||
|
||
fn font_json(f: &UserFont) -> Value {
|
||
json!({
|
||
"file": f.file,
|
||
"families": f.families(),
|
||
"faces": f.faces.iter().map(|(family, style)| json!({"family": family, "style": style})).collect::<Vec<_>>(),
|
||
"path": f.path.as_ref().map(|p| p.to_string_lossy().to_string()),
|
||
})
|
||
}
|
||
|
||
/// Read a font file named by the user, refusing one larger than the cap before reading it.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn read_font_file(path: &str) -> Result<Vec<u8>, CmdError> {
|
||
// Through `io`, which keeps automation inside its read root (#769).
|
||
crate::io::read_file_capped(path, wordcraft_fonts::MAX_FONT_FILE_BYTES as u64, "font file").map_err(CmdError::Failed)
|
||
}
|
||
|
||
/// Adding or removing a font writes to the user font folder: automation confined to a write root
|
||
/// (#769) may do that only when the folder is inside it.
|
||
fn check_font_store(dir: Option<&std::path::Path>) -> Result<(), CmdError> {
|
||
match dir {
|
||
Some(dir) => crate::file_access::check_write(&dir.to_string_lossy()).map_err(CmdError::Failed),
|
||
None => Ok(()),
|
||
}
|
||
}
|
||
|
||
fn add(s: &mut Session, v: &Value) -> CmdResult {
|
||
if p::str(v, "url").is_some() {
|
||
return Err(CmdError::Failed(
|
||
"adding a font from a link isn't available yet: download the font file and add it with {\"path\"} or {\"data\"}".into(),
|
||
));
|
||
}
|
||
let (name, bytes) = if let Some(d) = p::str(v, "data") {
|
||
// Base64 is a third larger than the file: refuse before decoding an oversized one.
|
||
if d.len() / 4 * 3 > wordcraft_fonts::MAX_FONT_FILE_BYTES + 3 {
|
||
return Err(CmdError::Failed(format!("the font file is larger than {} MB", wordcraft_fonts::MAX_FONT_FILE_BYTES >> 20)));
|
||
}
|
||
let bytes = base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))?;
|
||
(p::str(v, "name").unwrap_or("font").to_string(), bytes)
|
||
} else if let Some(path) = p::str(v, "path") {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
(path.to_string(), read_font_file(path)?)
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
let _ = path;
|
||
return Err(CmdError::Failed("reading files by path isn't available on the web".into()));
|
||
}
|
||
} else {
|
||
// From the ribbon: the UI shows a file picker and runs this again with the file.
|
||
s.push_ui_request(json!({"open": "addFont"}));
|
||
return Ok(json!({"pending": true}));
|
||
};
|
||
check_font_store(FontDb::global().user_font_dir().as_deref())?;
|
||
let r = FontDb::global().install_font(&name, &bytes).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||
// Text already set in the family (shown in a fallback until now) takes the new font.
|
||
s.relayout();
|
||
let families = r.font.families().join(", ");
|
||
s.status = match &r.warning {
|
||
Some(w) => format!("Added {families} ({w})"),
|
||
None => format!("Added {families}"),
|
||
};
|
||
let mut out = font_json(&r.font);
|
||
if let Some(w) = r.warning {
|
||
out["warning"] = json!(w);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
fn list(_: &mut Session, v: &Value) -> CmdResult {
|
||
let db = FontDb::global();
|
||
let mut out = json!({
|
||
"fonts": db.user_fonts().iter().map(font_json).collect::<Vec<_>>(),
|
||
"folder": db.user_font_dir().map(|p| p.to_string_lossy().to_string()),
|
||
});
|
||
if p::bool(v, "all") == Some(true) {
|
||
out["families"] = json!(db.families());
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
fn remove(s: &mut Session, v: &Value) -> CmdResult {
|
||
let family = p::str(v, "family").filter(|f| !f.trim().is_empty()).ok_or_else(|| CmdError::Params("family is required".into()))?;
|
||
check_font_store(FontDb::global().user_font_dir().as_deref())?;
|
||
let gone = FontDb::global().remove_user_font(family.trim()).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||
s.relayout();
|
||
s.status = format!("Removed {}", family.trim());
|
||
Ok(json!({"removed": gone.iter().map(font_json).collect::<Vec<_>>()}))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn confined_automation_changes_only_a_font_store_inside_its_write_root() {
|
||
let root = std::env::temp_dir().join(format!("wordcraft-font-store-{}", std::process::id()));
|
||
let outside = std::env::temp_dir().join(format!("wordcraft-font-store-out-{}", std::process::id()));
|
||
std::fs::create_dir_all(&root).unwrap();
|
||
std::fs::create_dir_all(&outside).unwrap();
|
||
let write = crate::file_access::AutomationRoots::new(None, Some(&root)).unwrap();
|
||
let read_only = crate::file_access::AutomationRoots::new(Some(&root), None).unwrap();
|
||
crate::file_access::confine(write.as_ref(), || {
|
||
assert!(check_font_store(None).is_ok(), "no folder: the font lasts for the session");
|
||
assert!(check_font_store(Some(&root.join("fonts"))).is_ok());
|
||
assert!(check_font_store(Some(&outside)).is_err());
|
||
});
|
||
crate::file_access::confine(read_only.as_ref(), || assert!(check_font_store(Some(&root)).is_err()));
|
||
assert!(check_font_store(Some(&outside)).is_ok(), "unconfined: the person at the keyboard");
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
let _ = std::fs::remove_dir_all(&outside);
|
||
}
|
||
}
|