type.char: warn when the font family or style isn't available (#2189)

A fontFamily the app doesn't have was applied with a null result, so a
script that asked for Courier Prime exported the PDF in the substitute
and exited 0. The font is still applied, since documents can carry
missing fonts and Find Font and preflight list them, but type.char now
returns {"warnings": ["Missing font: Courier Prime (using Source Sans 3)"]},
or "Missing font style: ..." for a style the family doesn't have, in
preflight's words. Other calls still return null.
This commit is contained in:
JonRC committed 2026-11-03 01:00:19 -03:00
1 parent e79d682e02
commit 6abeb8a1ca
3 files changed
+59 -3

No files matched your search

+1
View File
@@ -389,6 +389,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-11-03** · `type.char` says when the font it applies isn't available (#2189): it returns `{"warnings": ["Missing font: Courier Prime (using Source Sans 3)"]}`, or `Missing font style: …` for a style the family doesn't have, in preflight's words. The font is still applied, as before, so Find Font and preflight list it. A script that asked for a font the machine doesn't have exported in the substitute with no word about it. No readiness percentage change.
- **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. Inline parent margin editing targets only the selected parent page and preserves opposite-page margins and document overrides; document commands retain their first-page default before the canvas is initialized. No readiness percentage change.
- **2026-10-31** · `font.list` reports the embedding licence of the face that sets each font's text: its OS/2 `fsType` and what the bits mean (`restrictedLicence`, `bitmapOnly`, `noSubsetting`, `editable`, `previewPrint`; null when the font is missing or has no OS/2 table). A script or host that makes PDFs can check every face, bundled and installed ones included, without reading font files. Export behaviour is unchanged. No readiness percentage change.
- **2026-10-31** · A `.designcraft` file opened from bytes (`file.openBytes` with its file name, as a browser or host passes it) is titled without the extension, as an IDML file opened that way is. Saving it in the browser downloaded `Brochure.designcraft.designcraft`, and the File Name variable showed the doubled name. Browser Open and Drop pass the full file name, so only the engine removes the extension and a repeated suffix is kept in the title. No readiness percentage change.
+1 -1
View File
@@ -206,7 +206,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character Formatting",
[],
None,
"{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}}",
"{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}} → null, or {warnings} naming a fontFamily or fontStyle that isn't available (still applied; a substitute draws the text)",
has_text_or_frames,
|s, p| format_chars(s, p.get("attrs").unwrap_or(p))
),
+57 -2
View File
@@ -162,6 +162,8 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
}
}
validate_char_fill(&s.doc()?.doc, &a, "type.char")?;
let warnings = missing_font_warnings(&s.doc()?.doc, &a);
let result = if warnings.is_empty() { Value::Null } else { json!({ "warnings": warnings }) };
let cleared: Vec<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default();
let targets = format_targets(s);
// A caret in text: the format is for the next typing. On an empty paragraph's hidden mark it
@@ -185,14 +187,33 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
})?;
}
s.typing_format = Some(f);
return ok();
return Ok(result);
}
s.edit(|d, _| {
apply_char_attrs(d, &targets, &a, &cleared);
ok()
Ok(result)
})
}
/// A font family or style the app doesn't have is still applied, so the document keeps its name
/// and preflight and Find Font list it, but a substitute draws the text. Say so, in preflight's
/// words, instead of succeeding silently.
fn missing_font_warnings(document: &designcraft_doc::Document, attrs: &CharAttrs) -> Vec<String> {
let Some(family) = attrs.font_family.as_deref().filter(|f| !f.trim().is_empty()) else {
return vec![];
};
let db = designcraft_fonts::FontDb::global().scoped(document.font_scope);
let style = attrs.font_style.as_deref().unwrap_or("Regular");
let face = db.face(family, style);
if !db.has_family(family) {
vec![format!("Missing font: {family} (using {})", face.family)]
} else if attrs.font_style.is_some() && !db.has_style(family, style) {
vec![format!("Missing font style: {family} {style} (using {} {})", face.family, face.style)]
} else {
vec![]
}
}
/// Kerning spaces a character from the next one, so the pair at a caret is kerned through the
/// character before it. With no such pair (story or paragraph start, a selection) this is
/// `type.char`.
@@ -1073,3 +1094,37 @@ mod size_bounds_tests {
}
}
}
#[cfg(test)]
mod missing_font_tests {
use serde_json::json;
use crate::Session;
/// A font the app doesn't have was applied with no word about it, so a script that asked for
/// Courier Prime exported a PDF in the substitute and exited 0. It is still applied (preflight
/// and Find Font list it), and the command now says which font is missing.
#[test]
fn a_font_that_is_not_available_is_applied_with_a_warning() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap();
for (attrs, warning) in [
(json!({"fontFamily": "Source Serif 4", "fontStyle": "Bold"}), None),
(json!({"fontFamily": "Nonexistent Sans"}), Some("Missing font: Nonexistent Sans (using ")),
(json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"}), Some("Missing font style: Source Serif 4 Nope (using Source Serif 4 ")),
] {
s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap();
let r = s.execute("type.char", &json!({"attrs": attrs})).unwrap();
match warning {
None => assert_eq!(r, json!(null), "{attrs}"),
Some(w) => assert!(r["warnings"][0].as_str().is_some_and(|m| m.starts_with(w)), "{attrs}: {r}"),
}
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontFamily"], attrs["fontFamily"], "{attrs}");
}
// At a caret the font is for the next typing, and the warning is the same.
s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 5, "focus": 5})).unwrap();
let r = s.execute("type.char", &json!({"attrs": {"fontFamily": "Nonexistent Sans"}})).unwrap();
assert!(r["warnings"][0].as_str().is_some_and(|m| m.starts_with("Missing font: Nonexistent Sans")), "{r}");
}
}