forked from 1b-agent/opencadstudio-upstream
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:
commit
592289f4fd
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