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.