Merge pull request #1774 from megabrain-agent/feat/font-register

feat(web): font_register control op for host-supplied font files
This commit is contained in:
Hakan Seven committed 2026-10-14 09:11:06 +03:00
commit 592289f4fd
3 files changed
+183 -5

No files matched your search

+16
View File
@@ -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
+97 -1
View File
@@ -204,6 +204,26 @@ fn open_bytes_request(req: &Value) -> Result<(String, Vec<u8>), 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<u8>), 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();
+70 -4
View File
@@ -22,8 +22,9 @@ struct SysFonts {
families: Mutex<Vec<String>>,
/// 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<String, String>,
/// makes such a style resolve — see [`family_for_reference`]. Files a host
/// registers at run time join it ([`register_font_file`]).
by_file: Mutex<HashMap<String, String>>,
}
static FONTS: OnceLock<SysFonts> = 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<String> {
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<fontdb::ID> = db.faces().map(|f| f.id).collect();
db.load_font_data(bytes.to_vec());
let added: Vec<String> = 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.