From 0998cb9e88fa7130c27283686963bc1741ea1a1d Mon Sep 17 00:00:00 2001 From: megabrain-agent Date: Wed, 14 Oct 2026 02:26:31 +0200 Subject: [PATCH] feat(web): font_register control op for host-supplied font files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web build has no font folder, so a text style whose TrueType file is not a bundled Noto subset falls back to it. font_register takes the file from the host page: {"op":"font_register","file_name":"Brand-Regular.ttf","data_base64":"…"} The faces join the font database, file_name resolves to the family of the first face (case-insensitive, without directory, like installed fonts), and open drawings lay their text out again (fallback glyph cache cleared, text geometry invalidated per tab). The reply names the family and the number of faces. Bytes without a face fail with font_invalid. SysFonts::by_file becomes updatable for this. Proposed in #1771. Co-Authored-By: Claude Opus 5.5 --- docs/automation/web.md | 16 +++++++ src/app/control/mod.rs | 98 ++++++++++++++++++++++++++++++++++++++- src/scene/text/sysfont.rs | 74 +++++++++++++++++++++++++++-- 3 files changed, 183 insertions(+), 5 deletions(-) diff --git a/docs/automation/web.md b/docs/automation/web.md index 89b0b430a..0bebaf578 100644 --- a/docs/automation/web.md +++ b/docs/automation/web.md @@ -71,6 +71,22 @@ and `data_base64`, and the tab is marked clean. `target_format` and `target_vers default to the drawing's own; a conversion that would drop unsupported records needs `allow_lossy: true`, as on native. +## Supplying fonts + +The web build has no font folder. A text style whose TrueType or OpenType file is not one +of the bundled Noto subsets falls back to them. The host page can hand the file over: + +```json +{"op":"font_register","file_name":"Brand-Regular.ttf","data_base64":"…"} +``` + +`file_name` is the name the drawing's text styles use; it is matched case-insensitively and +without a directory, as for installed fonts. The reply carries the `family` inside the file +and the number of `faces` it added. Open drawings lay their text out again. The font stays +registered for the session and is never written into saved files. Bytes without a readable +face fail with `font_invalid`; a missing `file_name` or empty or invalid base64 with +`invalid_request`. + ## Receiving saved files in the host page A page that embeds the app can take every saved or exported file itself instead of a diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 895eacb15..b78eebea4 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -204,6 +204,26 @@ fn open_bytes_request(req: &Value) -> Result<(String, Vec), Value> { } Ok((name.to_string(), bytes)) } +/// `{"op":"font_register","file_name":"Brand-Regular.ttf","data_base64":"…"}`: +/// a host hands over a TrueType/OpenType file that text styles reference by +/// name. The web build has no font folder, so this is how such styles get +/// their real face instead of a fallback. +fn font_register_request(req: &Value) -> Result<(String, Vec), Value> { + use base64::Engine as _; + let name = string(req, "file_name")?; + let name = name.rsplit(['/', '\\']).next().unwrap_or(name).trim(); + if name.is_empty() { + return Err(failure("invalid_request", "Missing file_name")); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(req["data_base64"].as_str().unwrap_or_default()) + .map_err(|e| failure("invalid_request", format!("data_base64: {e}")))?; + if bytes.is_empty() { + return Err(failure("invalid_request", "data_base64 is empty")); + } + Ok((name.to_string(), bytes)) +} + fn string<'a>(req: &'a Value, key: &str) -> Result<&'a str, Value> { req[key] .as_str() @@ -775,7 +795,12 @@ impl OpenCADStudio { } // Interactive requests operate on the active tab by definition, so // a client may omit document_id (the GUI-hosted REST channel does). - } else if !query && !matches!(op, "new" | "open" | "stop" | "user_select" | "getpoint") { + } else if !query + && !matches!( + op, + "new" | "open" | "font_register" | "stop" | "user_select" | "getpoint" + ) + { return ( failure("document_required", "Read state and supply document_id"), Task::none(), @@ -979,6 +1004,24 @@ impl OpenCADStudio { "open" => self.update(Message::OpenExternal(std::path::PathBuf::from(string( req, "path", )?))), + "font_register" => { + let (name, bytes) = font_register_request(req)?; + let (family, faces) = + crate::scene::text::sysfont::register_font_file(&name, &bytes) + .ok_or_else(|| failure("font_invalid", "data_base64 holds no font face"))?; + // Same invalidation as a fetched web font: styles that fell + // back while the file was missing lay out again. + crate::scene::text::ttf_glyph::clear_fallback_cache(); + for tab in self.tabs.iter_mut() { + tab.scene.invalidate_text_geometry_dependencies(); + } + self.set_control_result(json!({ + "file_name": name, + "family": family, + "faces": faces, + })); + Task::none() + } "activate" | "switch_document" => { let i = self .tabs @@ -2306,6 +2349,59 @@ mod tests { let bad = open_bytes_request(&json!({"name":"a.dwg","data_base64":"not base64!"})); assert_eq!(bad.unwrap_err()["code"], "invalid_request"); } + #[test] + fn font_register_validates_the_request() { + let ok = font_register_request( + &json!({"file_name":"C:\\Fonts\\Brand.ttf","data_base64":"AAEC"}), + ); + assert_eq!(ok, Ok(("Brand.ttf".to_string(), vec![0, 1, 2]))); + for req in [ + json!({"data_base64":"AAEC"}), + json!({"file_name":"fonts/","data_base64":"AAEC"}), + json!({"file_name":"a.ttf","data_base64":""}), + json!({"file_name":"a.ttf","data_base64":"not base64!"}), + ] { + assert_eq!( + font_register_request(&req).unwrap_err()["code"], + "invalid_request", + "{req}" + ); + } + } + + #[test] + fn font_register_makes_a_style_file_resolve() { + use base64::Engine as _; + let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/web/fonts/greek.ttf")) + .expect("bundled web font"); + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + let reply = request( + &mut app, + json!({ + "op":"font_register", + "file_name":"Control-Registered.ttf", + "data_base64": base64::engine::general_purpose::STANDARD.encode(&bytes), + }), + ); + assert_eq!(reply["ok"], true, "{reply}"); + let family = reply["result"]["family"] + .as_str() + .expect("family") + .to_string(); + assert!(reply["result"]["faces"].as_u64().unwrap() >= 1, "{reply}"); + assert_eq!( + crate::scene::text::sysfont::family_for_reference("control-registered.ttf"), + Some(family) + ); + + let refused = request( + &mut app, + json!({"op":"font_register","file_name":"x.ttf","data_base64":"AAEC"}), + ); + assert_eq!(refused["code"], "font_invalid", "{refused}"); + } + #[test] fn open_from_bytes_is_refused_natively_without_touching_documents() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/scene/text/sysfont.rs b/src/scene/text/sysfont.rs index 60dfe96d4..e66304e5f 100644 --- a/src/scene/text/sysfont.rs +++ b/src/scene/text/sysfont.rs @@ -22,8 +22,9 @@ struct SysFonts { families: Mutex>, /// Installed face file name (lower-case) → the family that face holds. /// A drawing references a TrueType style by *file* name, so this is what - /// makes such a style resolve — see [`family_for_reference`]. - by_file: HashMap, + /// makes such a style resolve — see [`family_for_reference`]. Files a host + /// registers at run time join it ([`register_font_file`]). + by_file: Mutex>, } static FONTS: OnceLock = OnceLock::new(); @@ -60,7 +61,7 @@ fn fonts() -> &'static SysFonts { SysFonts { db: Mutex::new(db), families: Mutex::new(families), - by_file, + by_file: Mutex::new(by_file), } }) } @@ -320,7 +321,12 @@ fn family_for_file(name: &str) -> Option { if file.is_empty() { return None; } - fonts().by_file.get(&file.to_ascii_lowercase()).cloned() + fonts() + .by_file + .lock() + .unwrap() + .get(&file.to_ascii_lowercase()) + .cloned() } /// The installed family a drawing's font reference names, or `None` when @@ -371,10 +377,70 @@ pub fn register_web_font(bytes: &[u8]) { canonical_cache().lock().unwrap().clear(); } +/// Add a font file a host supplies at run time (the `font_register` control +/// op): its faces join the database and `file_name` resolves to the family of +/// the first face, so a text style naming that file finds it. Returns that +/// family and the number of faces the file added, or `None` when the bytes +/// hold no face fontdb can read. Like [`register_web_font`], the family list +/// is rebuilt and the canonical-name memo cleared; callers invalidate the glyph +/// caches and re-layout text. +pub fn register_font_file(file_name: &str, bytes: &[u8]) -> Option<(String, usize)> { + let file = file_name.rsplit(['/', '\\']).next()?.trim(); + if file.is_empty() { + return None; + } + let sys = fonts(); + let (family, faces, names) = { + let mut db = sys.db.lock().unwrap(); + let before: std::collections::HashSet = db.faces().map(|f| f.id).collect(); + db.load_font_data(bytes.to_vec()); + let added: Vec = db + .faces() + .filter(|face| !before.contains(&face.id)) + .filter_map(|face| face.families.first().map(|(name, _)| name.clone())) + .collect(); + let family = added.first()?.clone(); + (family, added.len(), family_names(&db)) + }; + sys.by_file + .lock() + .unwrap() + .insert(file.to_ascii_lowercase(), family.clone()); + *sys.families.lock().unwrap() = names; + canonical_cache().lock().unwrap().clear(); + Some((family, faces)) +} + #[cfg(test)] mod tests { use super::*; + /// A host-registered file resolves by its file name — any case, bare or + /// with a path — to the family inside it; bytes without a face are refused + /// and leave nothing behind. + #[test] + fn registered_font_file_resolves_by_file_name() { + let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/web/fonts/greek.ttf")) + .expect("bundled web font"); + let (family, faces) = + register_font_file("C:\\Fonts\\HostRegistered-Test.ttf", &bytes).expect("a face"); + assert!(faces >= 1); + assert!(!family.is_empty()); + assert_eq!( + family_for_reference("hostregistered-test.TTF").as_deref(), + Some(family.as_str()) + ); + assert_eq!( + family_for_reference("fonts/HostRegistered-Test.ttf").as_deref(), + Some(family.as_str()) + ); + assert!(has_family(&family)); + + assert_eq!(register_font_file("Broken-Test.ttf", b"not a font"), None); + assert_eq!(family_for_reference("Broken-Test.ttf"), None); + assert_eq!(register_font_file("", &bytes), None); + } + /// A DWG stores a style's TrueType font as a file name, so every installed /// face has to be reachable by the name of its file — case-insensitively, /// and whether it comes as a bare name or as a path.