mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Fonts: a removed font that type used leaves the list at the next font scan
When type had used a font, removing the font while the app ran left it in the font menus, and the type kept drawing in it until the app restarted. The font list is the loaded faces plus the scanned catalog, and a scan replaced the catalog but kept every loaded face. A scan now drops each loaded face whose file it did not catalog, with the face's cached outlines. A family with no face left leaves the font lists, and type set in it lays out in the fallback font and is reported missing, as when a document opens without the font. When the font is installed again, the next scan lists it and the type draws in it again. Bundled fonts and fonts added as bytes have no file and stay. Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
@@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Refresh Font List",
|
||||
[],
|
||||
None,
|
||||
"{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it (no fonts are installed on the web) → {families, faces} (families listed, installed faces found)",
|
||||
"{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it, and text in a family that was removed redraws in the fallback font (no fonts are installed on the web) → {families, faces} (families listed, installed faces found)",
|
||||
always,
|
||||
rescan
|
||||
),
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! A font removed while the app runs: once the fonts are scanned again (`text.rescanFonts`), its
|
||||
//! family is no longer listed, and type set in it, which had loaded it, is laid out in the fallback
|
||||
//! font and listed as missing, until the font is installed again. One test in its own process, so
|
||||
//! the process-wide font database and font folders start fresh, as when the app starts.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_text::{FALLBACK_FAMILY, FontDb};
|
||||
|
||||
const FAMILY: &str = "Gonefont Sans";
|
||||
|
||||
/// `text.fonts`' status for [`FAMILY`] and the family that stands in for it.
|
||||
fn status(s: &mut Session) -> (String, String) {
|
||||
let listed = s.execute("text.fonts", &json!({})).unwrap();
|
||||
let row = listed.as_array().unwrap().iter().find(|f| f["family"] == FAMILY).cloned().unwrap();
|
||||
(row["status"].as_str().unwrap().into(), row["resolved"]["family"].as_str().unwrap().into())
|
||||
}
|
||||
|
||||
/// Whether `text.fontList` lists [`FAMILY`].
|
||||
fn listed(s: &mut Session) -> bool {
|
||||
let r = s.execute("text.fontList", &json!({})).unwrap();
|
||||
r["families"].as_array().unwrap().iter().any(|f| f == FAMILY)
|
||||
}
|
||||
|
||||
/// The families of the faces the type object `id` is laid out in.
|
||||
fn laid_out_in(s: &Session, id: u64) -> Vec<String> {
|
||||
let st = s.doc().unwrap();
|
||||
let Some(NodeKind::Text(t)) = st.doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("no type object {id}") };
|
||||
let db = FontDb::global();
|
||||
let mut families: Vec<String> = vectorcraft_text::layout(db, t).glyphs.iter().map(|g| db.face_by_id(g.font_id).unwrap().family.clone()).collect();
|
||||
families.dedup();
|
||||
families
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_in_a_removed_font_falls_back_after_a_rescan() {
|
||||
let root = vectorcraft_testkit::temp_dir("removed-font");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
vectorcraft_text::set_user_font_dirs(vec![root.clone()]);
|
||||
let file = root.join("Gonefont.otf");
|
||||
std::fs::write(&file, vectorcraft_testkit::fonts::renamed(FAMILY)).unwrap();
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Gone", "font": FAMILY})).unwrap()["id"].as_u64().unwrap();
|
||||
assert_eq!(status(&mut s), ("exact".into(), FAMILY.into()));
|
||||
assert!(listed(&mut s));
|
||||
assert_eq!(laid_out_in(&s, id), [FAMILY]);
|
||||
|
||||
// Removed: after a rescan the type is laid out in the fallback font, without editing the document.
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let revision = s.doc().unwrap().revision;
|
||||
let r: Value = s.execute("text.rescanFonts", &json!({})).unwrap();
|
||||
assert!(r["faces"].is_u64(), "{r}");
|
||||
assert_eq!(status(&mut s), ("missing".into(), FALLBACK_FAMILY.into()));
|
||||
assert!(!listed(&mut s));
|
||||
assert!(s.execute("text.fontList", &json!({"family": FAMILY})).is_err());
|
||||
assert_eq!(laid_out_in(&s, id), [FALLBACK_FAMILY]);
|
||||
let missing = s.execute("text.missingFonts", &json!({})).unwrap();
|
||||
assert_eq!(missing["fonts"][0]["family"], FAMILY, "{missing}");
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.revision > revision && !st.is_dirty());
|
||||
|
||||
// Installed again: the type is set in it again.
|
||||
std::fs::write(&file, vectorcraft_testkit::fonts::renamed(FAMILY)).unwrap();
|
||||
s.execute("text.rescanFonts", &json!({})).unwrap();
|
||||
assert_eq!(status(&mut s), ("exact".into(), FAMILY.into()));
|
||||
assert!(listed(&mut s));
|
||||
assert_eq!(laid_out_in(&s, id), [FAMILY]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -1704,8 +1704,9 @@ impl FontDb {
|
||||
}
|
||||
|
||||
/// Scan the font folders again (fonts installed or removed since), cataloging the faces
|
||||
/// found (native only). Font data is loaded when a cataloged family is first resolved.
|
||||
/// Returns the number of faces cataloged.
|
||||
/// found (native only). Font data is loaded when a cataloged family is first resolved. Faces
|
||||
/// loaded from files the scan no longer finds are dropped. Returns the number of faces
|
||||
/// cataloged.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_system_fonts(&self) -> usize {
|
||||
// The first scan, or a rescan once it is done (never both at once).
|
||||
@@ -1793,10 +1794,27 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
log::debug!("cataloged {n} system font faces");
|
||||
let installed: std::collections::HashSet<PathBuf> = catalog.values().flat_map(|c| c.faces.iter().map(|cf| cf.path.clone())).collect();
|
||||
*self.stamp.lock().unwrap_or_else(|e| e.into_inner()) = Some(ScanStamp { paths, modified: modified_at });
|
||||
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog;
|
||||
*self.aliases.write().unwrap_or_else(|e| e.into_inner()) = aliases;
|
||||
*self.postscript.write().unwrap_or_else(|e| e.into_inner()) = postscript;
|
||||
// Faces loaded from a file this scan did not catalog (the font was removed, or its folder
|
||||
// is no longer read) are dropped with their outlines. A family with no face left and no
|
||||
// cataloged file leaves the lists, and type set in it resolves to the fallback font. A face
|
||||
// without a file (bundled, or added as bytes) stays.
|
||||
let mut dropped = std::collections::HashSet::new();
|
||||
self.faces.write().unwrap_or_else(|e| e.into_inner()).retain(|f| {
|
||||
let keep = f.path.as_ref().is_none_or(|p| installed.contains(p));
|
||||
if !keep {
|
||||
dropped.insert(f.id);
|
||||
}
|
||||
keep
|
||||
});
|
||||
if !dropped.is_empty() {
|
||||
log::debug!("dropped {} font faces whose files are no longer installed", dropped.len());
|
||||
self.outlines.lock().unwrap_or_else(|e| e.into_inner()).retain(|(id, _), _| !dropped.contains(id));
|
||||
}
|
||||
// A font installed since may cover the characters no font did: they are looked for again.
|
||||
{
|
||||
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
@@ -162,6 +162,51 @@ fn rescanning_finds_fonts_installed_since() {
|
||||
assert_ne!(db.generation(), generation);
|
||||
}
|
||||
|
||||
/// A font removed since the last scan leaves the database at the next one even when type had
|
||||
/// loaded it: the family leaves the lists, its cached outlines are dropped, and its name resolves
|
||||
/// to the fallback font until the font is installed again. Fonts added as bytes stay.
|
||||
#[test]
|
||||
fn rescanning_drops_loaded_fonts_removed_since() {
|
||||
let dir = font_dir("removed");
|
||||
let (regular, bold) = (dir.join("Sysfont-Regular.ttf"), dir.join("Sub/Sysfont-Bold.TTF"));
|
||||
let saved = std::fs::read(®ular).unwrap();
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
assert_eq!(db.add_font(renamed_to("SourceSans3-Regular.ttf", "Other Sans 33")), 1);
|
||||
let (face, m) = db.resolve(FAMILY, "Regular").unwrap();
|
||||
assert_eq!((face.family.as_str(), m), (FAMILY, FontMatch::Exact));
|
||||
assert_eq!(db.face(FAMILY, "Bold").unwrap().style, "Bold");
|
||||
let glyph = face.glyph_for('A');
|
||||
let outline = db.outline(&face, glyph);
|
||||
assert!(!outline.elements().is_empty());
|
||||
|
||||
// One style removed: the family keeps the other.
|
||||
std::fs::remove_file(&bold).unwrap();
|
||||
assert_eq!(db.load_system_fonts(), 1);
|
||||
assert_eq!(db.styles(FAMILY), ["Regular"]);
|
||||
assert_eq!(db.face(FAMILY, "Bold").unwrap().style, "Regular", "the closest style left");
|
||||
|
||||
// The whole family removed.
|
||||
let generation = db.generation();
|
||||
std::fs::remove_file(®ular).unwrap();
|
||||
assert_eq!(db.load_system_fonts(), 0);
|
||||
assert!(!db.has_family(FAMILY) && !has(&db.families(), FAMILY) && !has(&db.menu_family_list(), FAMILY));
|
||||
assert!(db.styles(FAMILY).is_empty() && db.find_family(FAMILY).is_none());
|
||||
assert!(db.face_by_id(face.id()).is_none());
|
||||
assert!(!Arc::ptr_eq(&db.outline(&face, glyph), &outline), "its cached outlines are dropped");
|
||||
assert_ne!(db.generation(), generation, "lists built from the fonts are built again");
|
||||
let (fallback, m) = db.resolve(FAMILY, "Regular").unwrap();
|
||||
assert_eq!((fallback.family.as_str(), m), (FALLBACK_FAMILY, FontMatch::Missing));
|
||||
assert!(db.has_family("Other Sans 33"), "a font added as bytes has no file to remove");
|
||||
|
||||
// Installed again: it loads again.
|
||||
std::fs::write(®ular, saved).unwrap();
|
||||
assert_eq!(db.load_system_fonts(), 1);
|
||||
let (again, m) = db.resolve(FAMILY, "Regular").unwrap();
|
||||
assert_eq!((again.family.as_str(), m), (FAMILY, FontMatch::Exact));
|
||||
assert_ne!(again.id(), face.id());
|
||||
assert_eq!(db.outline(&again, glyph).elements(), db.outline(&face, glyph).elements());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_scan_serves_the_first_lookup() {
|
||||
let dir = font_dir("background");
|
||||
|
||||
+2
-1
@@ -3237,7 +3237,8 @@ lists every family available, the installed ones included, each by its English (
|
||||
Names in English (`fontNamesInEnglish`) only changes the UI menu labels, not this list. With `family` it gives that
|
||||
family's styles (upright by weight, then italics) and fails when the family isn't available. `text.rescanFonts`
|
||||
(Character panel menu › Refresh Font List) scans the font folders again, for fonts installed or removed since the app
|
||||
started: type set in a font that became available redraws in it, without editing the document. The app does so by
|
||||
started: type set in a font that became available redraws in it, and type set in a family that was removed redraws in
|
||||
the fallback font and is listed as missing, without editing the document. The app does so by
|
||||
itself when its window comes to the front and a font folder changed meanwhile.
|
||||
|
||||
```json
|
||||
|
||||
@@ -564,6 +564,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Change Case, Smart Punctuation, Find and Replace, Convert To Area Type, Convert To Point Type, Type on a Path Options, the Type on a Path effects and Fill with Placeholder Text record no undo step when they change nothing.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font, and Print and Print Preview warn about the type that prints in it; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). The font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux).
|
||||
The font database looks up a name that is neither a loaded nor an installed family among the other names of the loaded and installed fonts once for each style it is asked in, and keeps the answer until fonts are added or rescanned. Laying out a story of 1,000 paragraphs set in a font that is not installed, with 400 more fonts loaded, takes a median of 56 to 71 ms instead of 409 to 507 ms on Windows 11 (`CRAFT_FONTS_DIR=<craft-fonts checkout> cargo test --release -p vectorcraft-text --lib missing_font_story_layout_timing -- --ignored --nocapture`).
|
||||
A font family removed while the app runs leaves the font menus when the app scans the font folders again (when its window comes to the front after a font folder changed, or Refresh Font List), also when type had used it: that type then draws in the fallback font and Find Font lists the font as missing, until the font is installed again and the folders are scanned.
|
||||
- **Files (M4):**
|
||||
- PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source.
|
||||
A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests_import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them.
|
||||
|
||||
Reference in New Issue
Block a user