mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #1820 from dotCooCoo/feat/font-alias-lookup-memo
Fonts: a missing font's name is looked up once until fonts are added or rescanned
This commit is contained in:
3 files changed
+148
-1
No files matched your search
@@ -320,6 +320,10 @@ type Catalog = HashMap<String, CatalogFamily>;
|
||||
/// The installed faces' (family, style) by normalized PostScript name ([`norm`]).
|
||||
type PostScriptNames = HashMap<String, (String, String)>;
|
||||
|
||||
/// Maps the family and style [`FontDb::canonical`] was asked for to its answer, for names that
|
||||
/// are neither a loaded nor an installed family.
|
||||
type CanonicalNames = HashMap<(String, String), (String, String)>;
|
||||
|
||||
/// Process-wide font database.
|
||||
pub struct FontDb {
|
||||
faces: RwLock<Vec<Arc<FontFace>>>,
|
||||
@@ -332,6 +336,9 @@ pub struct FontDb {
|
||||
font_paths: FontPaths,
|
||||
/// The installed fonts' other names (localized, legacy, full, PostScript) by normalized name.
|
||||
aliases: RwLock<HashMap<String, Vec<Alias>>>,
|
||||
/// Keeps [`FontDb::canonical`]'s answers for names other than the loaded and installed
|
||||
/// families until fonts are added or rescanned.
|
||||
canonical_cache: Mutex<CanonicalNames>,
|
||||
/// Set once the font folders have been scanned. Lookups by family name wait for the first
|
||||
/// scan, so what they find never depends on what ran before them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -455,6 +462,9 @@ const SYSTEM_FALLBACKS: &[&str] = &[
|
||||
|
||||
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
const OUTLINE_CACHE_MAX: usize = 50_000;
|
||||
/// How many answers [`FontDb::canonical`] keeps at most. When it has kept that many, it clears
|
||||
/// them before it keeps the next one.
|
||||
const CANONICAL_CACHE_MAX: usize = 4_096;
|
||||
|
||||
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
||||
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
|
||||
@@ -717,6 +727,8 @@ struct Alias {
|
||||
impl Alias {
|
||||
/// The aliases of a face `family` / `style` with `keys`, by normalized name.
|
||||
fn of(family: &str, style: &str, keys: &FaceKeys) -> Vec<(String, Alias)> {
|
||||
#[cfg(test)]
|
||||
ALIAS_LISTS.with(|c| c.set(c.get() + 1));
|
||||
let mut v: Vec<(String, Alias)> =
|
||||
keys.families.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: None, paired: None })).collect();
|
||||
v.extend(
|
||||
@@ -734,6 +746,18 @@ impl Alias {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: counts the faces whose aliases ([`Alias::of`]) were listed on this thread.
|
||||
static ALIAS_LISTS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Tests: returns how many faces' aliases were listed on this thread.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn alias_lists() -> u64 {
|
||||
ALIAS_LISTS.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// How a requested family and style resolved ([`FontDb::resolve`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FontMatch {
|
||||
@@ -1467,6 +1491,7 @@ impl FontDb {
|
||||
postscript: RwLock::new(PostScriptNames::new()),
|
||||
font_paths,
|
||||
aliases: RwLock::new(HashMap::new()),
|
||||
canonical_cache: Mutex::new(CanonicalNames::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -1509,9 +1534,11 @@ impl FontDb {
|
||||
self.catalog.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Fonts were added or the catalog changed: cached family lists are stale.
|
||||
/// Fonts were added or the catalog changed: cached family lists and [`FontDb::canonical`]'s
|
||||
/// answers are stale.
|
||||
fn changed(&self) {
|
||||
*self.family_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
self.canonical_cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
self.generation.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -1764,6 +1791,24 @@ impl FontDb {
|
||||
if self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) {
|
||||
return (family.to_string(), style.to_string());
|
||||
}
|
||||
// The lock is held until the answer is kept, and [`FontDb::changed`] waits for it: an
|
||||
// answer found from the fonts before a change is cleared by that change.
|
||||
let mut cache = self.canonical_cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let key = (family.to_string(), style.to_string());
|
||||
if let Some(answer) = cache.get(&key) {
|
||||
return answer.clone();
|
||||
}
|
||||
let answer = self.canonical_alias(family, style);
|
||||
if cache.len() >= CANONICAL_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, answer.clone());
|
||||
answer
|
||||
}
|
||||
|
||||
/// Finds [`FontDb::canonical`]'s answer for a name that is neither a loaded nor an installed
|
||||
/// family among the loaded faces' and the installed fonts' other names.
|
||||
fn canonical_alias(&self, family: &str, style: &str) -> (String, String) {
|
||||
let key = norm(family);
|
||||
let mut found: Vec<Alias> =
|
||||
self.read_faces().iter().flat_map(|f| Alias::of(&f.family, &f.style, &f.keys)).filter(|(k, _)| *k == key).map(|(_, a)| a).collect();
|
||||
@@ -2230,6 +2275,19 @@ mod alias_tests {
|
||||
assert!(db.find_postscript("NoSuchFont-Regular").is_none());
|
||||
assert!(db.has_family("SourceSans3-Regular"));
|
||||
}
|
||||
|
||||
/// The database keeps at most [`CANONICAL_CACHE_MAX`] answers, and names asked for past that
|
||||
/// many still get the same answers.
|
||||
#[test]
|
||||
fn the_answers_kept_for_names_are_capped() {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
for i in 0..=CANONICAL_CACHE_MAX {
|
||||
let name = format!("No Such Family {i}");
|
||||
assert_eq!(db.canonical(&name, "Regular"), (name.clone(), "Regular".into()));
|
||||
}
|
||||
assert!(db.canonical_cache.lock().unwrap().len() <= CANONICAL_CACHE_MAX);
|
||||
assert_eq!(db.canonical("SourceSans3-Semibold", "Regular"), ("Source Sans 3".into(), "Semibold".into()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -663,3 +663,91 @@ fn a_full_name_does_not_override_an_installed_family() {
|
||||
let (face, matched) = db.resolve("Alias Script Italic", "Regular").unwrap();
|
||||
assert_eq!((face.family.as_str(), face.style.as_str(), matched), ("Alias Script Italic", "Regular", FontMatch::Exact));
|
||||
}
|
||||
|
||||
/// When type is set in a font that isn't installed, layout searches the loaded faces' names for the
|
||||
/// font's name once until fonts are added.
|
||||
#[test]
|
||||
fn a_missing_fonts_name_is_looked_up_once_until_fonts_are_added() {
|
||||
use crate::fontdb::alias_lists;
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
// One lookup lists the names of every loaded face.
|
||||
let before = alias_lists();
|
||||
assert_eq!(db.canonical("Gone Sans", "Bold"), ("Gone Sans".to_string(), "Bold".to_string()));
|
||||
let one_lookup = alias_lists() - before;
|
||||
let style = vectorcraft_doc::CharStyle { font_family: "Gone Sans".into(), ..Default::default() };
|
||||
let t = TextObject::point(kurbo::Point::ZERO, &vec!["Set in a font that is not installed."; 20].join("\n"), style);
|
||||
let before = alias_lists();
|
||||
for _ in 0..2 {
|
||||
assert_eq!(layout(&db, &t).lines.len(), 20);
|
||||
}
|
||||
assert_eq!(alias_lists() - before, one_lookup, "two layouts of 20 paragraphs list the faces' names once");
|
||||
// A font added since has the name as its full name.
|
||||
assert_eq!(db.add_font(named(&[(3, EN, 1, "Found Sans"), (3, EN, 2, "Bold"), (3, EN, 4, "Gone Sans"), (3, EN, 6, "FoundSans-Bold")])), 1);
|
||||
let l = layout(&db, &t);
|
||||
assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| db.face_by_id(g.font_id).is_some_and(|f| f.family == "Found Sans")));
|
||||
}
|
||||
|
||||
/// Until an installed font has a name, `canonical` returns the name and the style asked for each
|
||||
/// time; once a rescan finds the font, the name gives the font's family.
|
||||
#[test]
|
||||
fn a_name_asked_for_before_its_font_is_installed_finds_it_after_a_rescan() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-asked-before", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
let answer = |family: &str, style: &str| (family.to_string(), style.to_string());
|
||||
for _ in 0..2 {
|
||||
for style in ["Regular", "Bold"] {
|
||||
assert_eq!(db.canonical("Later Three W3", style), answer("Later Three W3", style));
|
||||
assert_eq!(db.canonical("後の三", style), answer("後の三", style));
|
||||
}
|
||||
}
|
||||
// The name table is laid out like Hiragino Sans W3's, with a legacy family paired with its
|
||||
// legacy style.
|
||||
let three = named(&[
|
||||
(1, 0, 1, "Later Three"),
|
||||
(1, 0, 2, "W3"),
|
||||
(3, EN, 1, "Later Three W3"),
|
||||
(3, EN, 2, "Regular"),
|
||||
(3, EN, 6, "LThree-W3"),
|
||||
(3, EN, 16, "Later Three"),
|
||||
(3, EN, 17, "W3"),
|
||||
(3, JA, 16, "後の三"),
|
||||
(3, JA, 17, "W3"),
|
||||
]);
|
||||
std::fs::write(dir.join("LaterThree.ttf"), three).unwrap();
|
||||
assert_eq!(db.load_system_fonts(), 1);
|
||||
for _ in 0..2 {
|
||||
assert_eq!(db.canonical("Later Three W3", "Regular"), answer("Later Three", "W3"));
|
||||
assert_eq!(db.canonical("Later Three W3", "Bold"), answer("Later Three", "Bold"));
|
||||
assert_eq!(db.canonical("後の三", "Bold"), answer("Later Three", "Bold"));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Times the layout of a story of 1,000 paragraphs set in a font that is not installed, with 0, 100
|
||||
/// and 400 fonts added to the bundled ones. Run with
|
||||
/// `cargo test --release -p vectorcraft-text --lib missing_font_story_layout_timing -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
|
||||
fn missing_font_story_layout_timing() {
|
||||
let text = vec!["Type set in a font that is not installed on this computer."; 1000].join("\n");
|
||||
let style = vectorcraft_doc::CharStyle { font_family: "No Such Font".into(), ..Default::default() };
|
||||
let t = TextObject::point(kurbo::Point::ZERO, &text, style);
|
||||
for extra in [0, 100, 400] {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
for i in 0..extra {
|
||||
let (family, full, postscript) = (format!("Loaded Sans {i}"), format!("Loaded Sans {i} Regular"), format!("LoadedSans{i}-Regular"));
|
||||
assert_eq!(db.add_font(named(&[(3, EN, 1, &family), (3, EN, 2, "Regular"), (3, EN, 4, &full), (3, EN, 6, &postscript)])), 1);
|
||||
}
|
||||
let mut ms = vec![];
|
||||
for _ in 0..7 {
|
||||
let start = std::time::Instant::now();
|
||||
let l = layout(&db, &t);
|
||||
ms.push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
assert_eq!(l.lines.len(), 1000);
|
||||
}
|
||||
ms.sort_by(f64::total_cmp);
|
||||
println!("{extra} fonts added, {} families loaded: median {:.1} ms; samples {ms:.1?}", db.family_list().len(), ms[3]);
|
||||
}
|
||||
}
|
||||
@@ -508,6 +508,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
All Artwork Above and Other Layers (Object › Lock and Object › Hide), Transform Each, Rearrange All Artboards, the width point and width profile commands, Liquify, Puppet Warp, Define Perspective Grid, Attach to Active Plane, Release with Perspective, moves and transforms in perspective, Move Plane and Move Plane to Match Object record no undo step when they change nothing.
|
||||
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; `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`).
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604).
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
|
||||
|
||||
Reference in new issue
Block a user