forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
acb8c5ea54
commit
0998cb9e88
3 files changed
+183
-5
No files matched your search
@@ -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
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user