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
|
default to the drawing's own; a conversion that would drop unsupported records needs
|
||||||
`allow_lossy: true`, as on native.
|
`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
|
## Receiving saved files in the host page
|
||||||
|
|
||||||
A page that embeds the app can take every saved or exported file itself instead of a
|
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))
|
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> {
|
fn string<'a>(req: &'a Value, key: &str) -> Result<&'a str, Value> {
|
||||||
req[key]
|
req[key]
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -775,7 +795,12 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
// Interactive requests operate on the active tab by definition, so
|
// Interactive requests operate on the active tab by definition, so
|
||||||
// a client may omit document_id (the GUI-hosted REST channel does).
|
// 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 (
|
return (
|
||||||
failure("document_required", "Read state and supply document_id"),
|
failure("document_required", "Read state and supply document_id"),
|
||||||
Task::none(),
|
Task::none(),
|
||||||
@@ -979,6 +1004,24 @@ impl OpenCADStudio {
|
|||||||
"open" => self.update(Message::OpenExternal(std::path::PathBuf::from(string(
|
"open" => self.update(Message::OpenExternal(std::path::PathBuf::from(string(
|
||||||
req, "path",
|
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" => {
|
"activate" | "switch_document" => {
|
||||||
let i = self
|
let i = self
|
||||||
.tabs
|
.tabs
|
||||||
@@ -2306,6 +2349,59 @@ mod tests {
|
|||||||
let bad = open_bytes_request(&json!({"name":"a.dwg","data_base64":"not base64!"}));
|
let bad = open_bytes_request(&json!({"name":"a.dwg","data_base64":"not base64!"}));
|
||||||
assert_eq!(bad.unwrap_err()["code"], "invalid_request");
|
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]
|
#[test]
|
||||||
fn open_from_bytes_is_refused_natively_without_touching_documents() {
|
fn open_from_bytes_is_refused_natively_without_touching_documents() {
|
||||||
let mut app = OpenCADStudio::new_for_test();
|
let mut app = OpenCADStudio::new_for_test();
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ struct SysFonts {
|
|||||||
families: Mutex<Vec<String>>,
|
families: Mutex<Vec<String>>,
|
||||||
/// Installed face file name (lower-case) → the family that face holds.
|
/// Installed face file name (lower-case) → the family that face holds.
|
||||||
/// A drawing references a TrueType style by *file* name, so this is what
|
/// A drawing references a TrueType style by *file* name, so this is what
|
||||||
/// makes such a style resolve — see [`family_for_reference`].
|
/// makes such a style resolve — see [`family_for_reference`]. Files a host
|
||||||
by_file: HashMap<String, String>,
|
/// registers at run time join it ([`register_font_file`]).
|
||||||
|
by_file: Mutex<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
static FONTS: OnceLock<SysFonts> = OnceLock::new();
|
static FONTS: OnceLock<SysFonts> = OnceLock::new();
|
||||||
@@ -60,7 +61,7 @@ fn fonts() -> &'static SysFonts {
|
|||||||
SysFonts {
|
SysFonts {
|
||||||
db: Mutex::new(db),
|
db: Mutex::new(db),
|
||||||
families: Mutex::new(families),
|
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() {
|
if file.is_empty() {
|
||||||
return None;
|
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
|
/// 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();
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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
|
/// 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,
|
/// 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.
|
/// and whether it comes as a bare name or as a path.
|
||||||
|
|||||||
Reference in new issue
Block a user