diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274e..0e4b8eb9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 55bf4231..b1577400 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -1,5 +1,6 @@ //! Type › Find/Replace Font: the fonts a document uses (missing ones flagged) and replacing one -//! font with another everywhere — local formatting and style definitions. +//! font with another everywhere — local formatting and style definitions. Type › Reload Fonts: +//! the fonts installed, edited or removed since they were read. use std::collections::BTreeMap; use std::sync::Arc; @@ -8,7 +9,7 @@ use designcraft_doc::{CharAttrs, Document, Story}; use designcraft_fonts::FontSource; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use super::{CommandSpec, always, bad, cmd, has_doc, str_param}; use crate::{Result, Session}; pub fn specs() -> Vec { @@ -25,9 +26,30 @@ pub fn specs() -> Vec { has_doc, replace ), + cmd!( + noundo "font.reload", + "Reload Fonts", + ["Type"], + None, + "{} — scans the font folders again and reads again the installed font files changed or removed since they were read, then recomposes the open documents → {cataloged, changed: [path]}", + always, + reload + ), ] } +/// Fonts installed, edited or removed while the app runs (#328, #547): the font database reads +/// them again, then every open document is composed again with what is installed now. +fn reload(s: &mut Session, _: &Value) -> Result { + let reloaded = designcraft_fonts::FontDb::global().reload_fonts(); + // A new cache: render jobs still holding the old one can't bring back the old fonts' layout. + s.cache = Arc::new(designcraft_compose::Cache::new()); + for st in &mut s.docs { + st.bump_revision(); + } + Ok(json!({"cataloged": reloaded.cataloged, "changed": reloaded.changed})) +} + /// Every story's text, including table cells and footnotes. pub(super) fn for_each_story(d: &Document, f: &mut dyn FnMut(&Story)) { let mut pending: Vec<&Story> = d.stories.values().map(AsRef::as_ref).collect(); @@ -233,6 +255,22 @@ fn replace(s: &mut Session, p: &Value) -> Result { mod tests { use super::*; + #[test] + fn reload_fonts_recomposes_open_documents_without_editing_them() { + let mut s = Session::new(); + let r = s.execute("font.reload", &json!({})).unwrap(); + assert!(r["cataloged"].is_u64() && r["changed"].is_array(), "{r}"); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Some text here."})).unwrap(); + let (revision, undo) = (s.doc().unwrap().revision, s.doc().unwrap().history.undo.len()); + let cache = Arc::clone(&s.cache); + s.execute("font.reload", &json!({})).unwrap(); + assert!(!Arc::ptr_eq(&cache, &s.cache), "the layout composed with the old fonts is let go"); + let st = s.doc().unwrap(); + assert_ne!(st.revision, revision, "the canvas draws the document again"); + assert_eq!(st.history.undo.len(), undo, "reloading fonts isn't an edit"); + } + #[test] fn replacement_resolves_inherited_family_and_style_and_rejects_missing_targets() { let mut s = Session::new(); diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index f65dd700..352e0952 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -370,6 +370,24 @@ impl FontFace { } } +/// A font file's length and modification time. +#[cfg(not(target_arch = "wasm32"))] +type FileStamp = (u64, Option); + +#[cfg(not(target_arch = "wasm32"))] +fn file_stamp(path: &std::path::Path) -> Option { + std::fs::metadata(path).ok().map(|m| (m.len(), m.modified().ok())) +} + +/// What [`FontDb::reload_fonts`] did: the faces cataloged by the rescan, and the installed font +/// files that changed or were removed since they were read (their faces are read again, from the +/// new file, the next time they are looked up). +#[derive(Debug, Default, PartialEq)] +pub struct ReloadedFonts { + pub cataloged: usize, + pub changed: Vec, +} + #[cfg(not(target_arch = "wasm32"))] #[derive(Clone, Debug)] struct CatalogEntry { @@ -412,6 +430,10 @@ pub struct FontDb { outlines: Mutex>>, #[cfg(not(target_arch = "wasm32"))] catalog: RwLock>, + /// Each installed font file read into the shared fonts, with its (length, modification time) + /// when it was read: [`FontDb::reload_fonts`] reads again the files that changed since. + #[cfg(not(target_arch = "wasm32"))] + installed_files: Mutex>, /// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] font_dirs: Vec, @@ -1067,6 +1089,8 @@ impl FontDb { outlines: Mutex::new(HashMap::new()), #[cfg(not(target_arch = "wasm32"))] catalog: RwLock::new(Vec::new()), + #[cfg(not(target_arch = "wasm32"))] + installed_files: Mutex::new(HashMap::new()), font_dirs, font_files: Vec::new, #[cfg(not(target_arch = "wasm32"))] @@ -1221,6 +1245,56 @@ impl FontDb { count } + /// Fonts installed, changed or removed since they were read (#328): scan the font folders + /// again (see [`FontDb::load_system_fonts`]), then let go of the shared faces whose installed + /// file changed (length or modification time) or is gone, so the next lookup reads the file as + /// it is now, or finds the font missing. What was composed with the old faces must be composed + /// again. Faces are never freed (see [`FaceRef`]): each reload of a changed file keeps the old + /// copy. Bundled, added and document fonts are left alone. Nothing to do on wasm. + pub fn reload_fonts(&self) -> ReloadedFonts { + #[cfg(not(target_arch = "wasm32"))] + { + // Rescan first: a lookup after the faces are dropped finds the new files. + let cataloged = self.load_system_fonts(); + let changed: Vec = { + let mut files = self.installed_files.lock().unwrap_or_else(|e| e.into_inner()); + let mut changed: Vec<_> = files.iter().filter(|(p, stamp)| file_stamp(p).as_ref() != Some(*stamp)).map(|(p, _)| p.clone()).collect(); + changed.sort(); + for p in &changed { + files.remove(p); + } + changed + }; + if !changed.is_empty() { + let stale = |f: &FontFace| matches!(&f.source, FontSource::Installed(p) if changed.contains(p)); + let mut dropped = std::collections::HashSet::new(); + self.faces.write().unwrap_or_else(|e| e.into_inner()).retain(|f| { + let keep = !stale(f); + if !keep { + dropped.insert(f.id); + } + keep + }); + self.instances.write().unwrap_or_else(|e| e.into_inner()).retain(|(base, _), f| !dropped.contains(base) && !stale(f)); + self.outlines.lock().unwrap_or_else(|e| e.into_inner()).retain(|(face, _), _| !dropped.contains(face)); + log::debug!("reloading {} changed font files ({} faces)", changed.len(), dropped.len()); + } + ReloadedFonts { cataloged, changed } + } + #[cfg(target_arch = "wasm32")] + ReloadedFonts::default() + } + + /// Read an installed font file, noting its length and modification time as they were before + /// the read (a change during it is then found by the next reload). + #[cfg(not(target_arch = "wasm32"))] + fn read_installed(&self, path: &std::path::Path) -> Option> { + let stamp = file_stamp(path)?; + let data = std::fs::read(path).ok()?; + self.installed_files.lock().unwrap_or_else(|e| e.into_inner()).insert(path.to_path_buf(), stamp); + Some(data) + } + #[cfg(not(target_arch = "wasm32"))] fn scan_font_dirs(&self) -> usize { let mut found = Vec::new(); @@ -1294,7 +1368,7 @@ impl FontDb { // The whole family arrives at once (see `insert_faces`). let mut faces = Vec::new(); for p in paths { - if let Ok(data) = std::fs::read(&p) { + if let Some(data) = self.read_installed(&p) { faces.extend(self.new_faces(data, FontSource::Installed(p))); } } @@ -1499,7 +1573,7 @@ impl FontDb { if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) { continue; } - let Ok(data) = std::fs::read(&p) else { continue }; + let Some(data) = self.read_installed(&p) else { continue }; let hit = enumerate_faces(&data).iter().any(|(i, _, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())); if hit && self.add_faces(data, FontSource::Installed(p)) > 0 && covered(self) { diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 861e846a..826beb0a 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -20,7 +20,7 @@ mod vertical; pub use features::{FontFeatures, font_features}; pub use fontdb::{ DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontDb, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES, - MAX_DOCUMENT_FONTS_TOTAL, ScopedFonts, base_style, bundled, same_style, system_font_dirs, + MAX_DOCUMENT_FONTS_TOTAL, ReloadedFonts, ScopedFonts, base_style, bundled, same_style, system_font_dirs, }; pub use group::{FamilyInfo, FontGroup, sort_for_menu}; pub use harfrust::Feature; diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index bf01ad79..39bf0767 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -389,3 +389,53 @@ fn installed_and_loaded_fonts_are_found_by_postscript_name() { assert_eq!(db.postscript_face("Source Serif 4"), None); assert_eq!(db.postscript_face("NoSuchFont-Regular"), None); } + +/// Write `font` to `path` with a modification time `secs` from now, so a rewrite of the same +/// length still reads as changed however coarse the file system's clock. +fn write_font(path: &Path, font: &[u8], secs: u64) { + std::fs::write(path, font).unwrap(); + let file = std::fs::File::options().write(true).open(path).unwrap(); + file.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(secs)).unwrap(); +} + +#[test] +fn reloading_reads_changed_font_files_again_and_keeps_the_others() { + use crate::testing::font_with_glyph; + let dir = font_dir("reload"); + let family = "DC Test Reloaded"; + let path = dir.join("Reloaded.ttf"); + write_font(&path, &font_with_glyph(family, &['a'], 'W').unwrap(), 10); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + let before = db.face(family, "Regular"); + let wide = before.advance(before.glyph_for('a')); + let kept = db.face(FAMILY, "Regular"); + // The font is edited and installed again under the same name (#328). + write_font(&path, &font_with_glyph(family, &['a'], 'i').unwrap(), 20); + assert_eq!(db.face(family, "Regular").id(), before.id(), "a lookup alone doesn't read the file again"); + let reloaded = db.reload_fonts(); + assert_eq!(reloaded.changed, std::slice::from_ref(&path)); + assert_eq!(reloaded.cataloged, 3); + let after = db.face(family, "Regular"); + assert_ne!(after.id(), before.id()); + let narrow = after.advance(after.glyph_for('a')); + assert!(narrow < wide, "the edited font sets 'a' as its i: {narrow} vs {wide}"); + assert_eq!(db.face(FAMILY, "Regular").id(), kept.id(), "unchanged files aren't read again"); + assert_eq!(db.reload_fonts().changed, Vec::::new(), "nothing changed since"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn reloading_lets_go_of_removed_fonts_and_finds_new_ones() { + let dir = font_dir("reload-removed"); + let removed = dir.join("Removed.ttf"); + write_font(&removed, &crate::testing::font_with("DC Test Removed", &['a']).unwrap(), 10); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + assert_eq!(db.face("DC Test Removed", "Regular").family, "DC Test Removed"); + std::fs::remove_file(&removed).unwrap(); + write_font(&dir.join("Added.ttf"), &crate::testing::font_with("DC Test Added Later", &['a']).unwrap(), 10); + assert_eq!(db.reload_fonts().changed, [removed]); + assert!(!db.has_family("DC Test Removed")); + assert_eq!(db.face("DC Test Removed", "Regular").family, FALLBACK_FAMILY); + assert!(db.has_family("DC Test Added Later")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 975da9ca..d938c2dc 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -580,6 +580,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Flattener Preview", "გაბრტყელების წინასწარი ნახვა"), ("Float Panel", "მცურავი პანელი"), ("Fonts in Document", "დოკუმენტის შრიფტები"), + ("Reload Fonts", "შრიფტების ხელახლა ჩატვირთვა"), ("Footnotes", "სქოლიოები"), ("Form Fields", "ფორმის ველები"), ("Frame Fitting Options…", "ჩარჩოს ზომაზე მორგების პარამეტრები…"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79eb2c44..d993e9ed 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -834,7 +834,7 @@ impl DesignApp { } } if r.is_ok() - && matches!(id, "font.replace" | "edit.undo" | "edit.redo") + && matches!(id, "font.replace" | "font.reload" | "edit.undo" | "edit.redo") && let Some(dialog) = self.ui.dialog.as_mut().filter(|d| d.id == "findFont") { dialog.fields.remove("_fonts"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index acb8e3e4..79b8b2c4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -461,6 +461,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "cmd:type.createOutlines", "ui:app.findFontDialog", + "cmd:font.reload", ">Change Case", "cmd:type.changeCase|UPPERCASE|{\"case\": \"upper\"}", "cmd:type.changeCase|lowercase|{\"case\": \"lower\"}", @@ -5004,6 +5005,7 @@ mod tests { "-", "Create Outlines", "Find/Replace Font…", + "Reload Fonts", "Change Case >", "-", "Type on a Path >", diff --git a/docs/gaps.md b/docs/gaps.md index 2e9c57b3..d328cd4c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -93,7 +93,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ### 6. Installed fonts not always found (F) - **Missing:** fonts in user font folders used for text (#161, #110, #107), fonts that font - services and managers activate on Windows, reload when fonts change on disk (#328). + services and managers activate on Windows, noticing by itself when fonts change on disk (#328: + Type › Reload Fonts, `font.reload`, reads them again on request since 2026-10-16). - **Evidence:** on macOS the scan reads the font files CoreText has available, so fonts that Adobe Fonts and font managers activate are listed (#261, #327, 2026-10-11); not yet confirmed by the reporters. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6c6991ed..dc04a00d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -286,7 +286,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. - Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. - Linked text files: `file.place {link: true}` or Preferences › File Handling › Create Links When Placing Text Files keeps a placed .docx/.rtf/.txt story linked to its file (path, size and hash, and the style map and conflict option used). The Links panel shows it OK / Modified / Missing; Update imports the file again into the same frames and thread with the saved style map, replacing edits made in the layout (the Links panel and menu ask first; the `links.update` command updates and returns a warning; undoable); Relink, Go To and Unlink. Saved in the native format; IDML exports the text unlinked. -- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. +- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. Type › Reload Fonts (`font.reload`) scans the font folders again and reads again the installed font files edited or removed since they were read, then recomposes the open documents: a font installed or edited while DesignCraft runs needs no restart (#328, #547). InDesign notices such changes by itself. - Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts. - Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample). - Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement through craft-libs/craft-atomic, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original, and already-open readers retain the old bytes. On Unix a new file gets the permissions ordinary file creation gives it (the umask applies) and a replaced file keeps its permissions. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. diff --git a/docs/typography-parity.md b/docs/typography-parity.md index b97ec651..68d7a5d9 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -1,6 +1,6 @@ # Typography and composition parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343); character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Type › Reload Fonts reads fonts installed, edited or removed since start-up (#328, #547); Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343); character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6) Typesetting is what InDesign is bought for. A feature can exist and still set text differently from InDesign: different line breaks, different page count, overset where InDesign fits. This @@ -67,7 +67,7 @@ Fidelity: **unmeasured** unless stated. | CJK composition | mojikumi, kinsoku, aki, tsume, ruby, kenten, warichu, frame grid | most; mojikumi tables kept but not applied; ruby group-only; no frame grid | ~ | cjk.md, cjk-typography.md | | Arabic / Hebrew | kashida, digits, diacritic positioning, RTL stories and tables | ✓ | ~ | vendor justification presets unsupported; list bullets and multi-column RTL (#100) | | Missing glyphs and fonts | pink highlight, substitution | ✓ | ✓ | | -| Installed fonts | every activated font | system font folders; on macOS also fonts that font managers and Adobe Fonts activate; user folders not always found | ~ | #161, #110, #107, #327, #261 | +| Installed fonts | every activated font, picked up as installed or changed | system font folders; on macOS also fonts that font managers and Adobe Fonts activate; user folders not always found; fonts installed, edited or removed while running found by Type › Reload Fonts, not automatically | ~ | #161, #110, #107, #327, #261, #328 | | Composition highlights | H&J, keeps, custom tracking/kerning, substituted fonts | ✓ | ✓ | | Measured from the table above (2026-10-10): 34 rows, 18 ✓, 16 ~, 0 ✗, scoring ✓ = 1 and ~ = 0.5: