From 4bdfc44b26bae26fef21967c9437c9bc7694d56b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 13:24:38 -0800 Subject: [PATCH] Type: documents that define one composite font name differently each draw in their own A document's composite fonts were lent by writing them into the font database's shared map, and a second lend of another definition of the same name was skipped while the first was held. A document drawn in a tiled or floating window while the front document drew, a document placed in one that defines the name differently, and an export made while another document is in front could draw their type in the other document's definition. Registering the front document's fonts during a render could also change the definition mid-render. Lending now applies on the lending thread only: each guard pushes the document's definitions onto a per-thread stack that lookups read before the database's own fonts, latest loan first, and removes them when it drops, also when the thread unwinds. The guard is neither Send nor Sync. Lending no longer changes the database or its generation; while a thread lends definitions other than the database's, FontDb::generation gives a number of its own for them, so kept layouts and render caches made in one document's definitions are not used for another's. --- crates/render/src/tests/fontchange.rs | 54 ++++++ crates/text/src/fontdb.rs | 5 +- crates/text/src/fontdb/composite.rs | 226 +++++++++++++++----------- crates/text/src/fontdb/db.rs | 7 +- crates/text/src/tests/composite.rs | 109 ++++++++++--- docs/target-app-parity.md | 1 + 6 files changed, 284 insertions(+), 118 deletions(-) diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 0e7864b0b9..bedffb6b4b 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -210,6 +210,60 @@ fn a_documents_own_composite_font_draws_without_being_registered() { assert!(db.composite_font(NAME).is_none(), "given back after each render"); } +/// A placed document draws its type in its own definition of a composite font that the document +/// placing it defines differently. +#[test] +fn a_placed_document_draws_its_own_definition_of_a_name_the_page_defines() { + use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts, ImageBlob, LinkInfo, PlacedDocument}; + let _adding = adding_fonts(); + const NAME: &str = "CF Placed Twice"; + let definition = |roman: &str| { + let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); + parts.roman = CompositePart::new(roman, "Regular"); + CompositeFont { name: NAME.into(), parts } + }; + let typed = |family: &str| { + let mut d = Document::new(300.0, 150.0); + let style = CharStyle { size: 60.0, font_family: family.into(), ..Default::default() }; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 100.0), "Hamburg", style)))); + let l = d.layers[0].id; + let id = d.insert(Some(l), 0, n).unwrap(); + (d, id) + }; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + // A page placing `d` (drawn exactly), defining `fonts`. + let page = |d: &Document, id: NodeId, key: &str, fonts: Vec| { + let blob = ImageBlob::new("application/json", key.as_bytes().to_vec()); + let p = PlacedDocument { + link: LinkInfo::new("/art/type.vectorcraft"), + key: key.into(), + bounding: false, + page_box: Default::default(), + width: 300.0, + height: 150.0, + xf: Affine::IDENTITY, + placement: Default::default(), + }; + let art = d.node(id).unwrap().clone(); + vectorcraft_doc::placed_document::prime(&p, blob.bytes.clone(), (d.clone(), art, Rect::new(0.0, 0.0, 300.0, 150.0))); + let mut page = Document::new(300.0, 150.0); + page.composite_fonts.extend(fonts); + page.images.insert(key.into(), blob); + let n = Node::new(page.alloc_id(), NodeKind::PlacedDocument(Box::new(p))); + let l = page.layers[0].id; + page.insert(Some(l), 0, n).unwrap(); + Renderer::new().render(&page, 300, 150, Affine::IDENTITY, &opts).pixels + }; + let (mut placed, id) = typed(NAME); + placed.composite_fonts.push(definition("JetBrains Mono")); + let (plain, plain_id) = typed("JetBrains Mono"); + let (inter, inter_id) = typed("Inter"); + let own = page(&placed, id, "fontchange-placed-twice", vec![definition("Inter")]); + assert!(own == page(&plain, plain_id, "fontchange-placed-twice-plain", vec![]), "drawn in the placed document's definition"); + assert!(own != page(&inter, inter_id, "fontchange-placed-twice-inter", vec![]), "not in the page's"); + assert!(vectorcraft_text::FontDb::global().composite_font(NAME).is_none()); +} + /// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes /// available: the glyph geometry kept for it is laid out again. #[test] diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index ace69a4148..f315f2bb69 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -116,10 +116,9 @@ pub struct FontDb { /// Faces instanced at the axis settings of type's Variable Font sliders ([`FontDb::instance`]). instances: Mutex, /// Composite fonts (Type › Composite Fonts…) by lowercased name ([`FontDb::set_composite_font`]): - /// type naming one as its family is set in its parts' fonts. + /// type naming one as its family is set in its parts' fonts. A thread's guards can lend others + /// in their place ([`FontDb::lend_composite_fonts`]). composites: RwLock>>, - /// The composite fonts [`FontDb::lend_composite_fonts`] put in, by lowercased name. - composite_loans: Mutex>, } /// Where a database's system font scan looks: folders (read with their subfolders) and font files. diff --git a/crates/text/src/fontdb/composite.rs b/crates/text/src/fontdb/composite.rs index 0e1d9afa86..16e98038fa 100644 --- a/crates/text/src/fontdb/composite.rs +++ b/crates/text/src/fontdb/composite.rs @@ -2,6 +2,12 @@ //! font menus and resolves to its base (Kanji) font, and layout sets each character of type in one //! in the font of the character's class ([`FontDb::composite_split`]). +use std::cell::RefCell; +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::marker::PhantomData; +use std::sync::atomic::{AtomicU64, Ordering}; + use unicode_segmentation::UnicodeSegmentation; use vectorcraft_doc::{CharClass, CompositeFont, TextObject, TextRun}; @@ -11,6 +17,42 @@ use super::{Arc, FontDb, FontFace, FontMatch}; /// A face [`FontDb::resolve`] found and how it matched. type Resolved = Option<(Arc, FontMatch)>; +thread_local! { + /// The composite fonts the guards of this thread lend ([`FontDb::lend_composite_fonts`]), the + /// latest loan last. + static LENT: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// The number of the next loan. +static NEXT_LOAN: AtomicU64 = AtomicU64::new(1); + +/// The composite fonts one guard lends, on the thread that made it. +struct Loan { + /// The lending database's [`FontDb::id`]. + db: u64, + /// Tells this loan from the others. + number: u64, + /// The fonts, by lowercased name. + fonts: HashMap>, + /// The database's generation when they were lent, when each was then the font the database + /// holds by its name. + same_at: Option, + /// A number for the definitions lent: loans of the same definitions have the same one. + print: u64, +} + +/// Feed composite font `f`'s name and parts to `h`. +fn hash_font(f: &CompositeFont, h: &mut DefaultHasher) { + f.name.hash(h); + let p = &f.parts; + for part in [&p.kanji, &p.kana, &p.punctuation, &p.symbols, &p.roman, &p.numerals] { + (&part.font, &part.style, part.scale_at_center).hash(h); + for v in [part.size, part.baseline, part.h_scale, part.v_scale] { + v.to_bits().hash(h); + } + } +} + /// How a composite font draws some text ([`FontDb::composite_coverage`]). #[derive(Clone, Debug)] pub struct CompositeCoverage { @@ -25,19 +67,13 @@ pub struct CompositeCoverage { impl FontDb { /// Make `font` available by its name, in place of a composite font of that name (any case). /// The font menus and anything else that lists fonts change only when the definition does. - /// Whether it did. + /// Whether it did. A font of that name a guard lends ([`FontDb::lend_composite_fonts`]) stays + /// in use on the guard's thread. pub fn set_composite_font(&self, font: &CompositeFont) -> bool { let key = font.name.to_lowercase(); { let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner()); if map.get(&key).is_some_and(|f| **f == *font) { - // The same definition a guard lends: it stays when the guard drops. - let mut loans = self.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(loan) = loans.get_mut(&key) - && map.get(&key).is_some_and(|have| Arc::ptr_eq(have, &loan.font)) - { - loan.adopted = true; - } return false; } map.insert(key, Arc::new(font.clone())); @@ -55,8 +91,16 @@ impl FontDb { removed } - /// The composite font named `name` (any case). + /// The composite font named `name` (any case): the one the latest guard of this thread that + /// lends that name lends ([`FontDb::lend_composite_fonts`]), else the one the database holds. pub fn composite_font(&self, name: &str) -> Option> { + if self.lends() { + let key = name.to_lowercase(); + if let Some(f) = self.lent_font(&key) { + return Some(f); + } + return self.composites.read().unwrap_or_else(|e| e.into_inner()).get(&key).cloned(); + } let map = self.composites.read().unwrap_or_else(|e| e.into_inner()); if map.is_empty() { return None; @@ -64,6 +108,39 @@ impl FontDb { map.get(&name.to_lowercase()).cloned() } + /// Whether a guard of this thread lends composite fonts of this database. + fn lends(&self) -> bool { + LENT.try_with(|l| l.borrow().iter().any(|loan| loan.db == self.id)).unwrap_or(false) + } + + /// The composite font named `key` (lowercased) that the latest guard of this thread lending that + /// name lends. + fn lent_font(&self, key: &str) -> Option> { + LENT.try_with(|l| l.borrow().iter().rev().filter(|loan| loan.db == self.id).find_map(|loan| loan.fonts.get(key).cloned())).ok().flatten() + } + + /// [`FontDb::generation`] on this thread, from the database's own generation `g`: `g` while each + /// font the guards of this thread lend is the one the database held by its name when it was lent + /// and the database has not changed since, else a number made from `g` and the definitions + /// lent, so what was laid out in one document's definitions is not taken for another's. + pub(super) fn lent_generation(&self, g: u64) -> u64 { + LENT.try_with(|l| { + let l = l.borrow(); + let loans = l.iter().filter(|loan| loan.db == self.id); + if loans.clone().all(|loan| loan.same_at == Some(g)) { + return g; + } + let mut h = DefaultHasher::new(); + g.hash(&mut h); + for loan in loans { + loan.print.hash(&mut h); + } + // The database's generations count up from 0 and never reach the top bit. + h.finish() | 1 << 63 + }) + .unwrap_or(g) + } + /// The face composite font `name` sets character `c` in (its class's part's), and whether /// that part's font is missing. `None` when `name` is no composite font. pub fn composite_face(&self, name: &str, c: char) -> Option<(bool, u32)> { @@ -168,48 +245,48 @@ impl FontDb { } } - /// Make each of the composite fonts `fonts` available while the returned guard lives, in place - /// of a composite font of the same name that the database holds. An app's session registers - /// the composite fonts of its front document; with the guard, a document exported without a - /// session, or one that is not in front, draws its type in its own. Dropping the guard puts - /// back the font each one replaced, or forgets it, unless another guard still lends the same - /// definition or a font of that name has been set since. While one guard lends a font, another - /// guard's different definition of that name is not lent. + /// Make each of the composite fonts `fonts` (a document's) available on this thread while the + /// returned guard lives, in place of a composite font of the same name that the database holds + /// or that an earlier guard of this thread lends. An app's session registers the composite fonts + /// of its front document; with the guard, a document exported without a session, one that is not + /// in front, or one placed in another document draws its type in its own, also while another + /// thread draws another document that defines the same name differently, or the session + /// registers another document's. Of fonts that share a name (in any case), the first is lent. + /// Other threads, and the fonts the database holds and lists, are left as they are. The guard + /// stays on its thread and ends its loan when it drops, also when the thread unwinds. pub fn lend_composite_fonts(&self, fonts: &[CompositeFont]) -> LentCompositeFonts<'_> { - let mut lent = Vec::new(); - let mut changed = false; // A font a document can't use (its name an installed family's, or a value out of range) // is not lent: an app leaves it out when it opens the document. let usable: Vec<&CompositeFont> = fonts.iter().filter(|f| f.check().is_ok() && !self.has_installed_family(&f.name)).collect(); - if !usable.is_empty() { - let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner()); - let mut loans = self.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); + if usable.is_empty() { + return LentCompositeFonts { number: None, thread: PhantomData }; + } + let mut lent: HashMap> = HashMap::with_capacity(usable.len()); + let mut h = DefaultHasher::new(); + let mut same = true; + let generation = { + let map = self.composites.read().unwrap_or_else(|e| e.into_inner()); for f in usable { let key = f.name.to_lowercase(); - // A loan still in force: lend the same definition again, and leave another as it is. - if let Some(loan) = loans.get_mut(&key) - && map.get(&key).is_some_and(|have| Arc::ptr_eq(have, &loan.font)) - { - if *loan.font == *f { - loan.guards += 1; - lent.push((key, loan.font.clone())); + if lent.contains_key(&key) { + continue; + } + hash_font(f, &mut h); + let font = match map.get(&key) { + Some(have) if **have == *f => have.clone(), + _ => { + same = false; + Arc::new(f.clone()) } - continue; - } - if map.get(&key).is_some_and(|have| **have == *f) { - continue; - } - let font = Arc::new((*f).clone()); - let previous = map.insert(key.clone(), font.clone()); - loans.insert(key.clone(), CompositeLoan { font: font.clone(), previous, guards: 1, adopted: false }); - lent.push((key, font)); - changed = true; + }; + lent.insert(key, font); } - } - if changed { - self.changed(); - } - LentCompositeFonts { db: self, lent } + self.generation.load(Ordering::Relaxed) + }; + let number = NEXT_LOAN.fetch_add(1, Ordering::Relaxed); + let loan = Loan { db: self.id, number, fonts: lent, same_at: same.then_some(generation), print: h.finish() }; + let pushed = LENT.try_with(|l| l.borrow_mut().push(loan)).is_ok(); + LentCompositeFonts { number: pushed.then_some(number), thread: PhantomData } } /// The family and style composite font `f` sets characters of `class` in: its part's, or the @@ -219,7 +296,7 @@ impl FontDb { if self.composite_font(&p.font).is_some() { (FALLBACK_FAMILY.to_string(), "Regular".to_string()) } else { (p.font.clone(), p.style.clone()) } } - /// The names of the composite fonts, sorted. + /// The names of the composite fonts the database holds (not those guards lend), sorted. pub fn composite_font_names(&self) -> Vec { let mut v: Vec = self.composites.read().unwrap_or_else(|e| e.into_inner()).values().map(|f| f.name.clone()).collect(); v.sort_by_key(|n| n.to_lowercase()); @@ -241,7 +318,7 @@ impl FontDb { /// its first character. Also the index of the run of `t` each run came from. `None` when no run /// is set in a composite font. pub fn composite_split(&self, t: &TextObject) -> Option<(TextObject, Vec)> { - if self.composites.read().unwrap_or_else(|e| e.into_inner()).is_empty() { + if !self.lends() && self.composites.read().unwrap_or_else(|e| e.into_inner()).is_empty() { return None; } let fonts: Vec>> = @@ -299,59 +376,24 @@ impl FontDb { } } -/// A composite font [`FontDb::lend_composite_fonts`] put in the database. -pub(super) struct CompositeLoan { - /// The font lent. - font: Arc, - /// The font of that name it replaced, put back when the last guard lending it drops. - previous: Option>, - /// How many guards lend it. - guards: usize, - /// Whether [`FontDb::set_composite_font`] set the same definition while it was lent: the last - /// guard to drop then leaves it in place. - adopted: bool, -} - -/// The composite fonts [`FontDb::lend_composite_fonts`] made available, given back when it drops. +/// The composite fonts [`FontDb::lend_composite_fonts`] made available on its thread, until it +/// drops. It is neither `Send` nor `Sync`: the loan belongs to the thread that made it. #[must_use = "the fonts are given back when the guard drops"] pub struct LentCompositeFonts<'a> { - db: &'a FontDb, - /// The lowercased names this guard lends, each with the font lent. - lent: Vec<(String, Arc)>, + /// The loan's number, when it lends any font. + number: Option, + thread: PhantomData<(&'a FontDb, *const ())>, } impl Drop for LentCompositeFonts<'_> { fn drop(&mut self) { - if self.lent.is_empty() { - return; - } - let mut changed = false; - { - let mut map = self.db.composites.write().unwrap_or_else(|e| e.into_inner()); - let mut loans = self.db.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); - for (key, font) in &self.lent { - // A later loan of that name is another guard's. - let Some(loan) = loans.get_mut(key).filter(|l| Arc::ptr_eq(&l.font, font)) else { continue }; - loan.guards -= 1; - if loan.guards > 0 { - continue; - } - let previous = loan.previous.take(); - let adopted = loan.adopted; - loans.remove(key); - // Given back unless a font of that name has been set (the same definition included) - // or removed since. - if !adopted && map.get(key).is_some_and(|have| Arc::ptr_eq(have, font)) { - match previous { - Some(p) => map.insert(key.clone(), p), - None => map.remove(key), - }; - changed = true; - } + let Some(number) = self.number else { return }; + // Fails only while the thread's own storage is being destroyed, which drops the loans. + let _ = LENT.try_with(|l| { + let mut l = l.borrow_mut(); + if let Some(at) = l.iter().rposition(|loan| loan.number == number) { + l.remove(at); } - } - if changed { - self.db.changed(); - } + }); } } diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 0e28d57528..2fda0e79a1 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -74,7 +74,6 @@ impl FontDb { stamp: Mutex::new(None), instances: Mutex::new(Instances::default()), composites: RwLock::new(HashMap::new()), - composite_loans: Mutex::new(HashMap::new()), } } @@ -127,9 +126,11 @@ impl FontDb { } /// A number that changes whenever the available fonts do (fonts added, system fonts - /// rescanned): lists built from [`FontDb::family_list`] are current while it doesn't. + /// rescanned): lists built from [`FontDb::family_list`] are current while it doesn't. While a + /// guard of this thread lends composite fonts other than the ones the database holds + /// ([`FontDb::lend_composite_fonts`]), it is a number of its own for those definitions. pub fn generation(&self) -> u64 { - self.generation.load(Ordering::Relaxed) + self.lent_generation(self.generation.load(Ordering::Relaxed)) } /// A number no other database made in this process has: with [`FontDb::generation`], it tells diff --git a/crates/text/src/tests/composite.rs b/crates/text/src/tests/composite.rs index 729afa0d5a..4158d89330 100644 --- a/crates/text/src/tests/composite.rs +++ b/crates/text/src/tests/composite.rs @@ -101,9 +101,9 @@ fn ruby_over_composite_type_is_set_in_its_own_parts() { assert!(rise > 8.0 && rise < 15.0, "the reading is {rise} pt above the base's baseline"); } -/// Lent composite fonts are available while a guard lends them: a second guard lends one again, a -/// font of the same name the database holds is replaced and put back afterwards, and a font named -/// like an installed family is not lent. +/// Lent composite fonts are available while a guard lends them, in place of a font of the same name +/// the database holds, which the database keeps. A font named like an installed family is not +/// lent, and of two fonts of one name the first is. #[test] fn lent_composite_fonts_last_as_long_as_a_guard() { let db = FontDb::with_font_dirs(vec![]); @@ -115,6 +115,7 @@ fn lent_composite_fonts_last_as_long_as_a_guard() { assert!(db.composite_font("lent").is_some()); assert_eq!(db.composite_font("Held").unwrap().parts.roman.size, 75.0); assert!(db.composite_font("Inter").is_none()); + assert_eq!(db.composite_font_names(), ["Held"], "the database's own fonts stay"); let second = db.lend_composite_fonts(&fonts); drop(first); assert!(db.composite_font("Lent").is_some()); @@ -122,29 +123,97 @@ fn lent_composite_fonts_last_as_long_as_a_guard() { drop(second); assert!(db.composite_font("Lent").is_none()); assert_eq!(db.composite_font("Held").unwrap().parts.roman.size, 50.0); - // A font set in its place while lent stays when the guard drops. + // A font set in the database while one of that name is lent doesn't change the one in use, and + // is used once the guard drops. let lent = db.lend_composite_fonts(&fonts[..1]); let mut replaced = mixed("Lent"); replaced.parts.roman.size = 60.0; db.set_composite_font(&replaced); + assert_eq!(db.composite_font("Lent").unwrap().parts.roman.size, 50.0); drop(lent); assert_eq!(db.composite_font("Lent").unwrap().parts.roman.size, 60.0); - // So does the same definition set while lent, after every guard lending it drops. - assert!(db.remove_composite_font("Lent")); - let (one, two) = (db.lend_composite_fonts(&fonts[..1]), db.lend_composite_fonts(&fonts[..1])); - assert!(!db.set_composite_font(&fonts[0])); - drop(one); - drop(two); - assert!(db.composite_font("Lent").is_some()); - // A guard whose font was removed doesn't give back a later guard's loan of that name. - assert!(db.remove_composite_font("Lent")); - let old = db.lend_composite_fonts(&fonts[..1]); - assert!(db.remove_composite_font("Lent")); - let new = db.lend_composite_fonts(&fonts[..1]); - drop(old); - assert!(db.composite_font("Lent").is_some()); - drop(new); - assert!(db.composite_font("Lent").is_none()); + let mut other = mixed("LENT"); + other.parts.roman.size = 70.0; + let lent = db.lend_composite_fonts(&[mixed("Lent"), other]); + assert_eq!(db.composite_font("lent").unwrap().parts.roman.size, 50.0); + drop(lent); +} + +/// A composite font `name` whose Roman part is set at `size` percent. +fn roman_at(name: &str, size: f64) -> CompositeFont { + let mut f = mixed(name); + f.parts.roman.size = size; + f +} + +/// The advance of `A` set at `size` points in `family`. +fn advance(db: &FontDb, family: &str, size: f64) -> f64 { + layout(db, &TextObject::point(Point::ZERO, "A", style(family, size))).glyphs[0].advance +} + +/// Two documents that define one name differently each lay out in their own definition, also when +/// one's guard lends inside the other's (a document placed in another): the inner definition is +/// used until its guard drops, then the outer one again, also after a panic inside the inner one. +/// The generation tells the layouts apart, and lending changes nothing the database holds. +#[test] +fn nested_loans_of_one_name_use_each_documents_definition() { + let db = FontDb::with_font_dirs(vec![]); + let (a, b) = (roman_at("CF Shared", 50.0), roman_at("CF Shared", 75.0)); + db.set_composite_font(&a); + let (half, three_quarters) = (advance(&db, "JetBrains Mono", 10.0), advance(&db, "JetBrains Mono", 15.0)); + // Lending looks the name up among the installed families, which catalogs them the first time. + assert!(!db.has_installed_family("CF Shared")); + let plain = db.generation(); + let outer = db.lend_composite_fonts(std::slice::from_ref(&a)); + assert_eq!(db.generation(), plain, "the database's own definition"); + assert!((advance(&db, "CF Shared", 20.0) - half).abs() < 1e-9); + { + let _inner = db.lend_composite_fonts(std::slice::from_ref(&b)); + assert_eq!(db.composite_font("cf shared").unwrap().parts.roman.size, 75.0); + assert!((advance(&db, "CF Shared", 20.0) - three_quarters).abs() < 1e-9); + assert_ne!(db.generation(), plain, "another definition lays out under another generation"); + let elsewhere = + std::thread::scope(|s| s.spawn(|| (db.generation(), db.composite_font("CF Shared").unwrap().parts.roman.size)).join().unwrap()); + assert_eq!(elsewhere, (plain, 50.0), "other threads keep the database's"); + } + assert!((advance(&db, "CF Shared", 20.0) - half).abs() < 1e-9); + assert_eq!(db.generation(), plain); + let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _inner = db.lend_composite_fonts(std::slice::from_ref(&b)); + panic!("a render that fails"); + })); + assert!(caught.is_err()); + assert_eq!(db.composite_font("CF Shared").unwrap().parts.roman.size, 50.0); + drop(outer); + let next = db.lend_composite_fonts(std::slice::from_ref(&b)); + assert_eq!(db.composite_font("CF Shared").unwrap().parts.roman.size, 75.0); + drop(next); + assert_eq!(db.composite_font("CF Shared").unwrap().parts.roman.size, 50.0); + assert_eq!(db.generation(), plain); +} + +/// Two threads that lend different definitions of one name at once each lay out in their own. +#[test] +fn threads_lending_one_name_at_once_use_their_own_definitions() { + let db = FontDb::with_font_dirs(vec![]); + let (half, three_quarters) = (advance(&db, "JetBrains Mono", 10.0), advance(&db, "JetBrains Mono", 15.0)); + let (both_lent, both_read) = (std::sync::Barrier::new(2), std::sync::Barrier::new(2)); + let lend = |size: f64| { + let _lent = db.lend_composite_fonts(&[roman_at("CF Threads", size)]); + both_lent.wait(); + let read = (advance(&db, "CF Threads", 20.0), db.generation()); + both_read.wait(); + read + }; + let ((a, ga), (b, gb)) = std::thread::scope(|s| { + let a = s.spawn(|| lend(50.0)); + let b = s.spawn(|| lend(75.0)); + (a.join().unwrap(), b.join().unwrap()) + }); + assert!((a - half).abs() < 1e-9, "{a} vs {half}"); + assert!((b - three_quarters).abs() < 1e-9, "{b} vs {three_quarters}"); + assert_ne!(ga, gb); + assert!(db.composite_font("CF Threads").is_none()); } /// The fonts a composite font sets text in are those of the parts its characters fall in, and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..b85b26b389 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -662,6 +662,7 @@ since 2026-10-01. - **Colour emoji with bitmap glyphs:** `sbix` (Apple Color Emoji) and `CBDT` (Noto Color Emoji) glyphs draw as their largest strike's PNG, placed with the type and unpainted by its fill; PDF writes them in Type 3 glyphs or as images, SVG as images with outlined text, EPS and legacy `.ai` as images, and Create Outlines makes embedded images. Synthetic test fonts in `crates/text/src/test_fonts.rs`; tests `crates/text/src/tests/bitmaps.rs`, `crates/render/src/tests/bitmapglyphs.rs`, `crates/svg/tests/it/colour_glyphs.rs`, `pdf::tests::colour_bitmap_glyphs_export_as_images`, the `eps` `colour_bitmap_glyphs_*` tests and `tests_pathops::create_outlines_turns_bitmap_glyphs_into_images`. COLR and OpenType SVG fonts remain. - **COLR colour fonts:** v0 and v1 glyphs draw as vector layers (solid colours, linear and radial gradients, clips, blend modes, the foreground colour from the run's fill) on the canvas, as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as Type 3 text in PDF, and as editable groups with Create Outlines; sweep gradients and Porter-Duff compositing draw the glyph as an image. Measured by `tests_colr`, `render::tests_colrglyphs`, `pdf::tests::colr_glyphs_export_as_vector_art`, `svg/tests/it/colour_glyphs.rs`, `eps` `colr_glyphs_*`, `tests_pathops::create_outlines_turns_colr_glyphs_into_layer_groups`. Scores unchanged (estimated). - **OpenType SVG colour fonts:** each glyph's SVG document is read by the SVG importer (registered by the engine) into vector art in the run's colours, then drawn, exported and outlined as COLR glyphs are; PDF real text writes them as text that draws nothing and draws their art where they sit. Measured by `tests_svg_glyphs`, `svg/tests/it/svg_glyphs.rs`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`. Scores unchanged (estimated). +- **Composite fonts that two documents define differently:** when two open documents each define a composite font (Type › Composite Fonts…) by the same name with different parts, each document draws and exports its type in its own definition: in a tiled or floating window drawn while the front document draws, when it is placed in a document that defines the name differently, and in an export made while another document is in front. Evidence: `tests::composite::nested_loans_of_one_name_use_each_documents_definition` and `threads_lending_one_name_at_once_use_their_own_definitions` (`crates/text`), `tests::fontchange::a_placed_document_draws_its_own_definition_of_a_name_the_page_defines` (`crates/render`). Scores unchanged (estimated). - **Character-style Auto leading:** capture and explicit style attributes retain Auto as a reset, distinct from omitted leading. Redefine and later numeric updates preserve local leading overrides; the Character Styles marker recognizes Auto correctly. Regression assertions cover partial definitions, Undo/Redo and native save/load serialization. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Leading](https://helpx.adobe.com/illustrator/using/line-character-spacing.html). - **Character-style numeric bounds:** `charStyle.new`, `charStyle.edit`, and `charStyle.setAttrs` accept size from 0.1 to 1296 pt, explicit leading from 0.1 to 5000 pt, horizontal and vertical scale from 1% to 10,000%, and baseline shift and Tate-chu-yoko offsets from -1296 to 1296 pt. Out-of-range input leaves the style and Undo history unchanged; Auto leading remains available. These limits match the Character panel controls. - **Character Style Options, General and Basic Character Formats:** the dialog edits a style's name and stored character formats together. Empty fields remain unset, checkboxes show an indeterminate unset state, Basic's Reset Panel clears its explicit settings, and Preview can be canceled without changing history. OK saves one undo step; New Character Style offers the same fields from captured text or the interface language's defaults without a selection. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html).