Files
wordcraft/crates/engine/src/cmd/font.rs
T
Martin BallandClaude Opus 5.5 bcce02ab36 #3692: confined automation adds or removes fonts only when the font store is inside its write root
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>
2026-10-25 21:02:01 -04:00

143 lines
6.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}