From 6abeb8a1cad0219eccdf6fdc2482e183df60325a Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 3 Nov 2026 01:00:19 -0300 Subject: [PATCH] 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. --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/cmd/text/format.rs | 59 +++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d88e8fc36..ce2c0c25e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b50850553..89b9e35a1 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -206,7 +206,7 @@ pub fn specs() -> Vec { "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)) ), diff --git a/crates/engine/src/cmd/text/format.rs b/crates/engine/src/cmd/text/format.rs index 7e1833c0a..edec044b9 100644 --- a/crates/engine/src/cmd/text/format.rs +++ b/crates/engine/src/cmd/text/format.rs @@ -162,6 +162,8 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { } } 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 = 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 { })?; } 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 { + 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}"); + } +}