M8: text reads only the fallback fonts it draws with

The first text laid out asked the font database for its fallback faces
(the fonts a run's missing characters are drawn with), and each of the 29
fallback families went through `find`, which reads every face of a family.
On Linux with Noto Sans CJK that is all seven 16–20 MB files, twice (SC and
KR name the faces of the same collections, and the bytes were shared only
within one family); Hiragino Sans is ten files on macOS. Each text laid out
afterwards repeated the 29 lookups under the font database's write lock.

- `fallbacks_for` reads, per family, the face nearest the run's style and
  only when it is nearer than the faces read already (`fallback_face`,
  `nearer_pending`); a face that can't be read gives way to the next one.
  Used by name, a family still reads all its faces (`find`).
- Faces of one file share its bytes across families (`file_bytes`, kept
  while the file keeps its length).
- The fallback faces are worked out once per style until fonts change
  (`FallbackLists`, by generation), not for every text laid out.
- Choosing a face by name before it is read counts a slant mismatch, italic
  or oblique, as `best` does: DejaVu Sans names its slanted faces Oblique,
  and a regular run's fallback stays Book.

Measured on Linux 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; now 3 files (20 MiB) and 4 faces, 6 and 8 with a bold run too,
150–190 ms less file reading. The fallback lookups per text laid out took
about 45 µs and now 0.2 µs (lookup logic timed alone). The faces drawn are
the same except between two equally near the run's style.

Tests: fontdb_fallback_tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmygZSTYRL3cVw7cJWMG3B
This commit is contained in:
Wiktor Kluczny
2026-10-30 12:24:57 +01:00
co-authored by Claude Opus 5.5
parent f0abe51a84
commit 6acbd91347
4 changed files with 396 additions and 43 deletions
+126 -41
View File
@@ -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<Axis>, Vec<NamedInstance>) {
let all = f.axes();
@@ -591,8 +604,24 @@ pub struct FontDb {
generation: u64,
/// Name lookups worked out as of a generation ([`key`](Self::key), [`resolve`](Self::resolve)).
names: std::sync::Mutex<Names>,
/// [`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<std::path::PathBuf, std::sync::Weak<Vec<u8>>>,
}
/// [`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<String, Vec<Arc<Face>>>,
}
/// 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 {
@@ -869,50 +898,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<Pending>, 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<std::path::PathBuf, Arc<Vec<u8>>> = 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<Arc<Vec<u8>>> {
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<Arc<Face>> {
let (family, style) = self.resolve(family, style);
@@ -980,15 +1022,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<Arc<Face>> {
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<Arc<Face>> = 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<Arc<Face>> {
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<usize> {
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
@@ -1561,3 +1642,7 @@ mod local_tests;
#[cfg(test)]
#[path = "fontdb_names_tests.rs"]
mod names_tests;
#[cfg(test)]
#[path = "fontdb_fallback_tests.rs"]
mod fallback_tests;
+255
View File
@@ -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<u8> {
let be16 = |v: &mut Vec<u8>, 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<u8> = 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<u8>); 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<String> {
db.pending.get(&family.to_lowercase()).map(|(_, v)| v.iter().map(|e| e.style.clone()).collect()).unwrap_or_default()
}
fn named(faces: &[Arc<Face>]) -> 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<PathBuf> = 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(&regular), [(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<String> = 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<Face>, b: &Arc<Face>| 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<PathBuf> = 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());
}
+13 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -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; 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) · **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; 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) · **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
@@ -51,6 +51,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. |