mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
Merge pull request #1721 from WiktorKluczny/perf/fallback-fonts-one-face
M8: text reads only the fallback fonts it draws with
This commit is contained in:
+126
-41
@@ -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();
|
||||
@@ -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<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 {
|
||||
@@ -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<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);
|
||||
@@ -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<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
|
||||
@@ -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;
|
||||
|
||||
@@ -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(®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<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
@@ -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 |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user