diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 9f2dfe1f5..fa708a1bd 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -423,6 +423,19 @@ fn pending_weight(style: &str) -> i32 { w.map_or(i32::from(figcraft_doc::style_weight(style)), |n| n * 100) } +/// How far a face named `have` is from `style`, judged by name before its file is read: 0 for the +/// same name, else 1 and the difference in weight, and 1000 more when one slants and the other +/// doesn't, as [`FontDb::best`] weighs the faces read (DejaVu Sans names its slanted faces +/// Oblique: a regular run's fallback is Book, not Oblique). +fn style_distance(style: &str) -> impl Fn(&str) -> i32 { + let slants = |s: &str| italic_name(s) || s.to_lowercase().contains("oblique"); + let (want, want_w, want_slant) = (norm_style(style), pending_weight(style), slants(style)); + move |have: &str| { + let slant = if slants(have) == want_slant { 0 } else { 1000 }; + if norm_style(have) == want { 0 } else { 1 + (pending_weight(have) - want_w).abs() + slant } + } +} + /// The axes (user-space ranges) and named instances of a variable font. fn variation_info(f: &skrifa::FontRef) -> (Vec, Vec) { let all = f.axes(); @@ -592,8 +605,24 @@ pub struct FontDb { generation: u64, /// Name lookups worked out as of a generation ([`key`](Self::key), [`resolve`](Self::resolve)). names: std::sync::Mutex, + /// [`fallbacks_for`](Self::fallbacks_for)'s answers as of a generation. + fallback_lists: FallbackLists, + /// Font files read, by path: the faces of one file that several families name (Noto Sans CJK SC + /// and KR are faces of the same collections) share its bytes. + files: HashMap>>, } +/// [`FontDb::fallbacks_for`]'s faces by the style asked for, for the families known in one +/// generation: every text shaped asks for them, and they change only when fonts do. +#[derive(Default)] +struct FallbackLists { + generation: u64, + by_style: HashMap>>, +} + +/// The most styles whose fallback faces are kept (a file naming more starts the list again). +const MAX_FALLBACK_STYLES: usize = 64; + /// [`FontDb::key`]'s and [`FontDb::resolve`]'s answers for the families known in one generation. #[derive(Default)] struct Names { @@ -870,50 +899,63 @@ impl FontDb { /// rest stay pending). fn load_family(&mut self, family: &str, only: Option<&str>) { let key = self.key(family); - // Faces load under the family name the scan gave them (a hidden family's public name). - let display = self.pending.get(&key).map(|(d, _)| d.clone()); - let entries = match only { - None => match self.pending.remove(&key) { - Some((_, entries)) => entries, - None => return, - }, - Some(style) => { - let Some((_, pending)) = self.pending.get_mut(&key) else { return }; - let (want, want_w) = (norm_style(style), pending_weight(style)); - let cost = |e: &Pending| { - let italic = if italic_name(&e.style) { 1000 } else { 0 }; - if norm_style(&e.style) == want { 0 } else { 1 + (pending_weight(&e.style) - want_w).abs() + italic } - }; - let Some(i) = pending.iter().enumerate().min_by_key(|(_, e)| cost(e)).map(|(i, _)| i) else { return }; - let one = pending.remove(i); - if pending.is_empty() { - self.pending.remove(&key); + match only { + None => { + if let Some((display, entries)) = self.pending.remove(&key) { + self.read_faces(&key, &display, entries, false); } - vec![one] } - }; + Some(style) => { + let cost = style_distance(style); + let nearest = self.pending.get(&key).and_then(|(_, v)| v.iter().enumerate().min_by_key(|(_, e)| cost(&e.style)).map(|(i, _)| i)); + if let Some(i) = nearest { + self.read_pending(&key, i); + } + } + } + } + + /// Read pending face `i` of family `key` (the rest stay pending; a browser font is asked for). + fn read_pending(&mut self, key: &str, i: usize) { + let Some((display, pending)) = self.pending.get_mut(key) else { return }; + if i >= pending.len() { + return; + } + let (display, one) = (display.clone(), pending.remove(i)); + if pending.is_empty() { + self.pending.remove(key); + } + self.read_faces(key, &display, vec![one], true); + } + + /// Read `entries`, taken from family `key`'s pending ones, under the family name the scan gave + /// them (`display`: a hidden family's public name). The browser's go back to wait, and with + /// `ask` are asked for ([`keep_local`](Self::keep_local)). + fn read_faces(&mut self, key: &str, display: &str, entries: Vec, ask: bool) { self.generation += 1; - let entries = self.keep_local(&key, display.as_deref().unwrap_or(family), entries, only.is_some()); - let mut by_path: BTreeMap>> = BTreeMap::new(); + let entries = self.keep_local(key, display, entries, ask); for e in entries { - let bytes = match by_path.get(&e.path) { - Some(b) => b.clone(), - None => match std::fs::read(&e.path) { - Ok(b) => { - let b = Arc::new(b); - by_path.insert(e.path.clone(), b.clone()); - b - } - Err(_) => continue, - }, - }; - if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, display.as_deref(), Some(&e.style)) { + let Some(bytes) = self.file_bytes(&e.path) else { continue }; + if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, Some(display), Some(&e.style)) { self.add(face); self.loaded_files.insert((e.path, e.index)); } } } + /// Font file `path`'s bytes: those its faces read already hold while the file keeps their + /// length, else read now (a CJK collection is 15–20 MB, and several families name its faces). + fn file_bytes(&mut self, path: &std::path::Path) -> Option>> { + if let Some(bytes) = self.files.get(path).and_then(std::sync::Weak::upgrade) + && std::fs::metadata(path).is_ok_and(|m| u64::try_from(bytes.len()).is_ok_and(|n| n == m.len())) + { + return Some(bytes); + } + let bytes = Arc::new(std::fs::read(path).ok()?); + self.files.insert(path.to_path_buf(), Arc::downgrade(&bytes)); + Some(bytes) + } + /// The best face for `family` + `style` (exact style name, else nearest weight/italic). pub fn find(&mut self, family: &str, style: &str) -> Option> { let (family, style) = self.resolve(family, style); @@ -981,15 +1023,54 @@ impl FontDb { } /// [`fallbacks`](Self::fallbacks), each family's face nearest `style`, so the characters a - /// bold run takes from a fallback font (Hangul in Inter Bold) come out bold too. + /// bold run takes from a fallback font (Hangul in Inter Bold) come out bold too. Worked out + /// once per style until fonts change: every text shaped asks. pub fn fallbacks_for(&mut self, style: &str) -> Vec> { - let mut out = Vec::new(); - for fam in FALLBACK_FAMILIES { - if let Some(f) = self.find(fam, style) { - out.push(f); - } + if self.fallback_lists.generation == self.generation + && let Some(faces) = self.fallback_lists.by_style.get(style) + { + return faces.clone(); } - out + let faces: Vec> = FALLBACK_FAMILIES.iter().filter_map(|fam| self.fallback_face(fam, style)).collect(); + // Reading a face starts a generation: the lists are kept as of the one after it. + let lists = &mut self.fallback_lists; + if lists.generation != self.generation || lists.by_style.len() >= MAX_FALLBACK_STYLES { + *lists = FallbackLists { generation: self.generation, by_style: HashMap::new() }; + } + lists.by_style.insert(style.to_string(), faces.clone()); + faces + } + + /// The face of fallback family `family` nearest `style`. A fallback draws the few characters a + /// run's own font lacks, so it reads one face per style asked for rather than every file of + /// the family (Noto Sans CJK is seven files of 16–20 MB, Hiragino Sans ten): a face not read + /// yet is read only when its style is nearer than every face read already. Used by name, the + /// family reads its other faces too ([`find`](Self::find)). + fn fallback_face(&mut self, family: &str, style: &str) -> Option> { + let (family, style) = self.resolve(family, style); + let key = self.key(&family); + // A file that can't be read leaves the family without that face: the next nearest is tried + // (each turn reads or asks for one face at most once). + let waiting = self.pending.get(&key).map_or(0, |(_, v)| v.len()); + for _ in 0..waiting { + let Some(i) = self.nearer_pending(&key, &style) else { break }; + self.read_pending(&key, i); + } + self.best(&family, &style) + } + + /// The face of family `key` not read yet whose style name is nearer `style` than those of the + /// faces read already (by name, as [`load_family`](Self::load_family) picks one). None while + /// the nearest is a browser font asked for already: it is on its way. + fn nearer_pending(&self, key: &str, style: &str) -> Option { + let cost = style_distance(style); + let read = self.families.get(key).map(|(_, idx)| idx.as_slice()).unwrap_or_default(); + let nearest_read = read.iter().filter_map(|i| self.faces.get(*i)).map(|f| if has_style(f, style) { 0 } else { cost(&f.style) }).min(); + let asked = |e: &Pending| e.local.is_some_and(|id| self.asked.contains(&id)); + let (_, pending) = self.pending.get(key)?; + // Of two as near, the one asked for already. + let (i, nearest, on_its_way) = pending.iter().enumerate().map(|(i, e)| (i, cost(&e.style), asked(e))).min_by_key(|(_, c, a)| (*c, !*a))?; + (!on_its_way && nearest_read.is_none_or(|r| nearest < r)).then_some(i) } /// Colour emoji faces, for characters no other fallback covers. Kept out of @@ -1565,3 +1646,7 @@ mod local_tests; #[cfg(test)] #[path = "fontdb_names_tests.rs"] mod names_tests; + +#[cfg(test)] +#[path = "fontdb_fallback_tests.rs"] +mod fallback_tests; diff --git a/crates/text/src/fontdb_fallback_tests.rs b/crates/text/src/fontdb_fallback_tests.rs new file mode 100644 index 000000000..36658ece1 --- /dev/null +++ b/crates/text/src/fontdb_fallback_tests.rs @@ -0,0 +1,255 @@ +//! The fallback fonts text draws missing characters with: each family reads the one face nearest +//! the run's style (Noto Sans CJK is seven files of 16–20 MB, read whole until now), families +//! naming the faces of one file share its bytes, and the faces are worked out once per style until +//! fonts change, not for every text shaped. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::{Face, FontBytes, FontDb, LocalFont, Pending}; + +/// A synthetic TrueType font: printable ASCII as outline-less glyphs 500 of 1000 units wide. +fn synthetic_font(family: &str) -> Vec { + let be16 = |v: &mut Vec, x: u16| v.extend_from_slice(&x.to_be_bytes()); + let glyphs: u16 = 1 + (0x7E - 0x20 + 1); + let mut head = Vec::new(); + for x in [0x0001_0000u32, 0x0001_0000, 0, 0x5F0F_3CF5] { + head.extend_from_slice(&x.to_be_bytes()); + } + be16(&mut head, 0); // flags + be16(&mut head, 1000); // unitsPerEm + head.extend_from_slice(&[0; 16]); // created, modified + for x in [0u16, (-200i16) as u16, 500, 800, 0, 8, 2, 0, 0] { + be16(&mut head, x); // bbox, macStyle, lowestRecPPEM, direction hint, short loca, glyph format + } + let mut hhea = 0x0001_0000u32.to_be_bytes().to_vec(); + for x in [800u16, (-200i16) as u16, 0, 500, 0, 0, 500, 1, 0, 0, 0, 0, 0, 0, 0, glyphs] { + be16(&mut hhea, x); + } + let mut maxp = 0x0001_0000u32.to_be_bytes().to_vec(); + be16(&mut maxp, glyphs); + for x in [0u16, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0] { + be16(&mut maxp, x); + } + let mut hmtx = Vec::new(); + for _ in 0..glyphs { + be16(&mut hmtx, 500); + be16(&mut hmtx, 0); + } + let loca = vec![0; 2 * (usize::from(glyphs) + 1)]; + let glyf = vec![0; 4]; + // cmap: one Windows Unicode BMP subtable, format 4, 0x20..=0x7E → glyphs 1.. plus the end segment. + let mut cmap = Vec::new(); + for x in [0u16, 1, 3, 1, 0, 12] { + be16(&mut cmap, x); + } + for x in [4u16, 32, 0, 4, 4, 1, 0, 0x7E, 0xFFFF, 0, 0x20, 0xFFFF, (1i16 - 0x20) as u16, 1, 0, 0] { + be16(&mut cmap, x); + } + let strings = [(1u16, family.to_string()), (2, "Regular".into()), (6, family.replace(' ', "") + "-Regular")]; + let mut name = Vec::new(); + be16(&mut name, 0); + be16(&mut name, strings.len() as u16); + be16(&mut name, 6 + 12 * strings.len() as u16); + let mut storage = Vec::new(); + for (id, s) in &strings { + let utf16: Vec = s.encode_utf16().flat_map(u16::to_be_bytes).collect(); + for x in [3u16, 1, 0x409, *id, utf16.len() as u16, storage.len() as u16] { + be16(&mut name, x); + } + storage.extend(utf16); + } + name.extend(storage); + let mut post = 0x0003_0000u32.to_be_bytes().to_vec(); + post.extend_from_slice(&[0; 28]); + let tables: [(&[u8; 4], Vec); 9] = [ + (b"cmap", cmap), + (b"glyf", glyf), + (b"head", head), + (b"hhea", hhea), + (b"hmtx", hmtx), + (b"loca", loca), + (b"maxp", maxp), + (b"name", name), + (b"post", post), + ]; + let mut font = 0x0001_0000u32.to_be_bytes().to_vec(); + for x in [tables.len() as u16, 128, 3, 16] { + be16(&mut font, x); + } + let mut offset = 12 + 16 * tables.len(); + for (tag, data) in &tables { + font.extend_from_slice(*tag); + font.extend_from_slice(&0u32.to_be_bytes()); + font.extend_from_slice(&(offset as u32).to_be_bytes()); + font.extend_from_slice(&(data.len() as u32).to_be_bytes()); + offset += data.len().next_multiple_of(4); + } + for (_, data) in &tables { + font.extend_from_slice(data); + font.resize(font.len().next_multiple_of(4), 0); + } + font +} + +static NEXT: AtomicU64 = AtomicU64::new(0); + +/// A folder of synthetic font files, removed afterwards. +struct Fonts(PathBuf); + +impl Fonts { + fn new() -> Self { + let dir = std::env::temp_dir().join(format!("fig-fallback-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + /// Write a font file named `file`. + fn file(&self, file: &str) -> PathBuf { + let path = self.0.join(file); + std::fs::write(&path, synthetic_font("Synthetic")).unwrap(); + path + } +} + +impl Drop for Fonts { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Offer `family`'s faces as the font scan would: by style, from their files. +fn scanned(db: &mut FontDb, family: &str, faces: &[(&str, &Path)]) { + let entries = + faces.iter().map(|(style, path)| Pending { path: path.to_path_buf(), index: 0, style: (*style).into(), postscript: None, local: None }); + db.merge_scan(BTreeMap::from([(family.to_lowercase(), (family.to_string(), entries.collect()))])); +} + +/// The styles of `family` not read yet. +fn waiting(db: &FontDb, family: &str) -> Vec { + db.pending.get(&family.to_lowercase()).map(|(_, v)| v.iter().map(|e| e.style.clone()).collect()).unwrap_or_default() +} + +fn named(faces: &[Arc]) -> Vec<(String, String)> { + faces.iter().map(|f| (f.family.clone(), f.style.clone())).collect() +} + +const CJK: &str = "Noto Sans CJK SC"; + +#[test] +fn a_fallback_family_reads_only_the_face_nearest_each_style() { + let fonts = Fonts::new(); + let styles = ["Thin", "Light", "DemiLight", "Regular", "Medium", "Bold", "Black"]; + let files: Vec = styles.iter().map(|s| fonts.file(&format!("{s}.ttf"))).collect(); + let faces: Vec<(&str, &Path)> = styles.iter().copied().zip(files.iter().map(PathBuf::as_path)).collect(); + let mut db = FontDb::default(); + scanned(&mut db, CJK, &faces); + let regular = db.fallbacks(); + assert_eq!(named(®ular), [(CJK.to_string(), "Regular".to_string())]); + assert_eq!(waiting(&db, CJK), ["Thin", "Light", "DemiLight", "Medium", "Bold", "Black"], "one file read, not seven"); + // A bold run reads the bold face, once. + let bold = db.fallbacks_for("Bold"); + assert_eq!(named(&bold), [(CJK.to_string(), "Bold".to_string())]); + db.fallbacks_for("Bold"); + assert_eq!(waiting(&db, CJK), ["Thin", "Light", "DemiLight", "Medium", "Black"]); + // A style no nearer than a face read already reads nothing: SemiBold is as far from Medium. + db.fallbacks_for("SemiBold"); + assert_eq!(waiting(&db, CJK).len(), 5); + // Used by name, the family reads the rest. + assert!(db.find(CJK, "Light").is_some()); + assert!(waiting(&db, CJK).is_empty()); +} + +#[test] +fn fallback_faces_are_worked_out_once_until_fonts_change() { + let fonts = Fonts::new(); + let regular = fonts.file("Regular.ttf"); + let mut db = FontDb::default(); + scanned(&mut db, CJK, &[("Regular", regular.as_path())]); + let first = db.fallbacks(); + let generation = db.generation; + // Every text shaped asks again: the same faces, with nothing looked up or read. + for _ in 0..3 { + let again = db.fallbacks(); + assert!(first.len() == again.len() && first.iter().zip(&again).all(|(a, b)| Arc::ptr_eq(a, b))); + } + assert_eq!(db.generation, generation, "nothing read or looked up again"); + // A fallback family installed since joins in its place. + scanned(&mut db, "Noto Sans Arabic", &[("Regular", fonts.file("Arabic.ttf").as_path())]); + let families: Vec = db.fallbacks().iter().map(|f| f.family.clone()).collect(); + assert_eq!(families, [CJK, "Noto Sans Arabic"]); +} + +#[test] +fn families_naming_the_faces_of_one_file_share_its_bytes() { + // Noto Sans CJK SC and KR are faces of the same collections. + let fonts = Fonts::new(); + let file = fonts.file("NotoSansCJK-Regular.ttc"); + let mut db = FontDb::default(); + scanned(&mut db, CJK, &[("Regular", file.as_path())]); + scanned(&mut db, "Noto Sans CJK KR", &[("Regular", file.as_path())]); + let faces = db.fallbacks(); + let shared = |a: &Arc, b: &Arc| matches!((&a.bytes, &b.bytes), (FontBytes::Shared(a), FontBytes::Shared(b)) if Arc::ptr_eq(a, b)); + let [sc, kr] = faces.as_slice() else { panic!("{:?}", named(&faces)) }; + assert_eq!((sc.family.as_str(), kr.family.as_str()), (CJK, "Noto Sans CJK KR")); + assert!(shared(sc, kr), "the file is read once"); + // A file that changed length since is read again. + let mut grown = std::fs::read(&file).unwrap(); + grown.extend_from_slice(&[0; 4]); + std::fs::write(&file, grown).unwrap(); + scanned(&mut db, "Noto Sans KR", &[("Regular", file.as_path())]); + let faces = db.fallbacks(); + let kr2 = faces.iter().find(|f| f.family == "Noto Sans KR").unwrap(); + assert!(!shared(sc, kr2)); +} + +#[test] +fn a_face_that_cant_be_read_gives_way_to_the_next_nearest() { + let fonts = Fonts::new(); + let missing = fonts.0.join("Regular.ttf"); + let medium = fonts.file("Medium.ttf"); + let mut db = FontDb::default(); + let bold = fonts.file("Bold.ttf"); + scanned(&mut db, "DejaVu Sans", &[("Regular", missing.as_path()), ("Bold", bold.as_path()), ("Medium", medium.as_path())]); + assert_eq!(named(&db.fallbacks()), [("DejaVu Sans".to_string(), "Medium".to_string())]); + assert_eq!(waiting(&db, "DejaVu Sans"), ["Bold"]); +} + +#[test] +fn a_run_reads_the_face_that_slants_as_it_does() { + // DejaVu Sans names its upright face Book and its slanted ones Oblique: a regular run reads + // Book, not Oblique, and a bold italic one Bold Oblique, not Bold. + let fonts = Fonts::new(); + let styles = ["Bold", "Bold Oblique", "ExtraLight", "Oblique", "Book"]; + let files: Vec = styles.iter().map(|s| fonts.file(&format!("DejaVuSans-{s}.ttf"))).collect(); + let faces: Vec<(&str, &Path)> = styles.iter().copied().zip(files.iter().map(PathBuf::as_path)).collect(); + let mut db = FontDb::default(); + scanned(&mut db, "DejaVu Sans", &faces); + assert_eq!(named(&db.fallbacks()), [("DejaVu Sans".to_string(), "Book".to_string())]); + db.fallbacks_for("Bold Italic"); + assert_eq!(waiting(&db, "DejaVu Sans"), ["Bold", "ExtraLight", "Oblique"]); +} + +#[test] +fn a_browser_fallback_font_is_asked_for_once_per_style() { + let local = + |id: usize, style: &str| LocalFont { id, family: "Noto Sans Thai".into(), style: style.into(), postscript: format!("NotoSansThai-{style}") }; + let mut db = FontDb::default(); + db.add_local(vec![local(1, "Bold"), local(2, "Regular")]); + // Nothing draws until the bytes come; the regular face is asked for, not the bold one. + assert!(db.fallbacks().is_empty()); + assert_eq!(std::mem::take(&mut db.wanted), [2]); + let generation = db.generation; + db.fallbacks(); + db.fallbacks(); + assert!(db.wanted.is_empty(), "asked for once"); + assert_eq!(db.generation, generation); + // A bold run asks for the bold face. + db.fallbacks_for("Bold"); + assert_eq!(std::mem::take(&mut db.wanted), [1]); + assert_eq!(db.load_local(2, synthetic_font("Noto Sans Thai")).as_deref(), Some("Noto Sans Thai")); + assert_eq!(named(&db.fallbacks()), [("Noto Sans Thai".to_string(), "Regular".to_string())]); + assert!(db.wanted.is_empty()); +} diff --git a/docs/gaps.md b/docs/gaps.md index b701b9854..2dbed5877 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -289,6 +289,17 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe the render once drawn (or it is drawn again, the first shown till then), a zoom drops it. Bands overlap by what background blurs read, as the desktop's parallel bands do, and the bands together take longer than one render (each repeats its overlap and some per-band work). +- **Landed (2026-10-30):** text reads only the fallback fonts it draws with. Each fallback family + (`FALLBACK_FAMILIES`, for the characters a run's own font lacks) reads the face nearest the + run's style, not every face it has; families naming the faces of one file (Noto Sans CJK SC and + KR) share its bytes; and the faces are worked out once per style until fonts change, not for + every text laid out. On a Linux machine with Noto Sans CJK, DejaVu Sans and Inter installed, the + first text laid out read 41 font files (265 MiB, kept in memory) and parsed 41 faces; it now + reads 3 files (20 MiB) and parses 4 (6 files and 8 faces with a bold run too): 150–190 ms less + file reading there. Looking up the fallbacks for each text laid out, under the font database's + write lock, went from about 45 µs to 0.2 µs (the lookup logic timed alone). The faces drawn are + those drawn before, except where two are equally near the run's style (the one read already is + kept) and for Hiragino's W3-style names (read as their weight). - **Landed (2026-10-28):** the browser refines a zoom's preview only once no newer request has come for 120 ms (`SETTLE_MS`), or for as long as its last full render took when that is shorter: its full renders run on the UI thread, and one between every two wheel steps held the page up @@ -575,6 +586,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Gap 7: text reads only the fallback fonts it draws with: a face per fallback family and style, a file's bytes shared between families, the faces worked out once per style (first text laid out: 41 font files and 265 MiB read down to 3 files and 20 MiB on a Linux machine with Noto Sans CJK). Numbers unchanged | | 2026-10-31 | minor | Gap 24: connector paths can be reshaped by hand (path handles and `board.reshape`), so that item leaves the missing list. Estimate unchanged. Tests: `board_reshape_tests`, `board_line_tests`. | | 2026-10-31 | minor | Gap 10: library analytics count insertions, and insertions and detachments week by week for a year (Show usage: the past 30 days, 90 days or year; `library.usage {days}`). Styles and variables aren't counted. Estimate unchanged. Tests: `library_actions_tests`, `inserting_library_instances_counts_them` | | 2026-10-30 | minor | Gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks get the widget and redraw it; the Counter example sets its count from a panel. The entry's duplicated Missing text is merged. Estimate unchanged | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 2497de9d9..6d38e4b2b 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8: text reads only the fallback fonts it draws with. Each fallback family reads the face nearest a run's style, not all of them (Noto Sans CJK is seven files of 16–20 MB); families naming the faces of one file share its bytes; and the faces are worked out once per style until fonts change, not for every text laid out. First text laid out on a Linux machine with Noto Sans CJK, DejaVu Sans and Inter: 41 files (265 MiB, kept in memory) and 41 faces parsed → 3 files (20 MiB) and 4 faces; fallback lookups per text laid out ~45 µs → 0.2 µs. Tests: `fontdb_fallback_tests`. Numbers unchanged | | 2026-10-30 | minor | M8, #1117: AI agents and plugins no longer learn where the open files are on disk from `doc.list`. Numbers unchanged. | | 2026-10-30 | minor | M8, #1099: long batches (scripts, plugins, MCP `batch`) no longer hold a document copy per step: one undo snapshot per batch (a 1,000-step batch: +165 MiB to +10 MiB peak). Numbers unchanged. | | 2026-10-30 | minor | M8, #1082: canvas rendering of the deepest documents can't overflow a render thread: one depth cap bounds a render, pattern sources included, and render threads get 8 MiB stacks (a level takes about 6.5 KB). Numbers unchanged. |