feat(web): font_register control op for host-supplied font files

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 <noreply@anthropic.com>
This commit is contained in:
megabrain-agentandClaude Opus 5.5 committed 2026-10-14 02:26:51 +02:00
1 parent acb8c5ea54
commit 0998cb9e88
3 files changed
+183 -5

No files matched your search

+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();