From 9eadb37622025f9eaf7d9f1ccdcfbbd5992d76ff Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:14:53 +0300 Subject: [PATCH 1/8] perf(ui): look for CJK names in edits, not on every pan Each frame checks the active document's names for CJK scripts, to install faces for them. The check was keyed on the revision, which pans and zooms bump too, so every frame of a pan read every layer name. In a 900 000-layer file that was ~70 ms a frame: panning ran at ~14 fps whatever the canvas cost. It now keeps the document version it read: the same version (a pan, a zoom) is skipped, and a new one reads only the layers that changed since (`Document::changed_ids`, sized by the edit), or every name past 10 000 changes. On that file the UI frame while panning went from ~71 ms to ~3 ms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/ui-egui/src/idle_tests.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 31 ++++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 2d4e57579..3c71146de 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -288,3 +288,23 @@ fn an_animated_gif_wakes_only_the_presenter_and_only_at_its_frames() { h.app.ui.present = None; h.assert_idle("the presenter closed"); } + +#[test] +fn cjk_names_are_looked_for_in_edits_not_on_every_pan() { + let mut s = Session::new(); + let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("node.set", &json!({"id": a, "name": "中文"})).unwrap(); + let mut h = Harness::new(s); + let ctx = h.ctx.clone(); + assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HAN, "the first look reads every name"); + // A pan or zoom changes the revision but not the document: its hundreds of thousands of + // names aren't read again (that took 70 ms a frame in a 900 000-layer file). + let revision = h.app.session.ds().unwrap().revision; + h.run("view.set", json!({"zoom": 2, "cx": 300, "cy": 200})); + assert_ne!(h.app.session.ds().unwrap().revision, revision); + assert_eq!(h.app.cjk_needed(&ctx, 0), 0); + // An edit is read: the layers it changed. + h.run("node.set", json!({"id": b, "name": "한국어"})); + assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f98..059ca364a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -156,6 +156,7 @@ mod windows_tests; use std::collections::BTreeSet; use std::path::PathBuf; +use std::sync::Arc; use figcraft_doc::NodeId; use figcraft_engine::Session; @@ -241,6 +242,9 @@ pub enum TitleBar { /// How often open documents get an autosave checkpoint (seconds). pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0; +/// Most changed layers whose names are checked for CJK scripts one by one (beyond: all of them). +const CJK_CHANGES: usize = 10_000; + /// How long a toast shows (seconds). const TOAST_SECS: f64 = 3.0; @@ -483,8 +487,8 @@ pub struct FigApp { /// System faces for scripts the built-in fonts lack were added (after the font scan), with /// faces for these CJK scripts ([`cjk`]) in this UI language's order. script_fonts: Option<(u8, &'static str)>, - /// The tab (uid) and revision whose names were last checked for CJK scripts. - cjk_checked: Option<(u64, u64)>, + /// The tab (uid) and document version whose names were last checked for CJK scripts. + cjk_checked: Option<(u64, Arc)>, /// The language preference the UI strings follow (applied when it changes). language: Option, /// A field had the keyboard when the last frame ended. @@ -856,11 +860,24 @@ impl FigApp { }) }); let dragging = ctx.input(|i| i.pointer.any_down()); - if let Some(d) = self.session.docs.get(self.session.active).filter(|d| !dragging && self.cjk_checked != Some((d.uid, d.revision))) { - self.cjk_checked = Some((d.uid, d.revision)); - let doc = &d.doc; - let comments = doc.comments.iter().flat_map(|c| std::iter::once(&c.first).chain(&c.replies)).flat_map(|m| [&m.author, &m.text]); - need |= std::iter::once(&doc.name).chain(doc.nodes().map(|n| &n.name)).chain(comments).fold(0, |m, s| m | cjk::scripts(s)); + // Pans and zooms change the revision too: only a new version of the document is looked + // at, and only the layers it changed (a large file has hundreds of thousands of names). + let current = self.session.docs.get(self.session.active).filter(|_| !dragging).map(|d| (d.uid, d.doc.clone())); + if let Some((uid, doc)) = current { + let before = self.cjk_checked.take().filter(|(u, _)| *u == uid).map(|(_, b)| b); + if !before.as_ref().is_some_and(|b| Arc::ptr_eq(b, &doc)) { + let mut scan = |s: &str| need |= cjk::scripts(s); + scan(&doc.name); + match before.as_ref().and_then(|b| doc.changed_ids(b, CJK_CHANGES)) { + Some(ids) => ids.into_iter().filter_map(|id| doc.node(id)).for_each(|n| scan(&n.name)), + None => doc.nodes().for_each(|n| scan(&n.name)), + } + doc.comments.iter().flat_map(|c| std::iter::once(&c.first).chain(&c.replies)).for_each(|m| { + scan(&m.author); + scan(&m.text); + }); + } + self.cjk_checked = Some((uid, doc)); } need } From 4f9cbf702fa3035e496327f603c57d963fb04641 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:23:44 +0200 Subject: [PATCH 2/8] Merge text runs made identical by style updates Fixes #452 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++++++ crates/doc/src/text.rs | 10 +++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index ef2f37b93..96349f48f 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -371,3 +371,43 @@ fn custom_easings() { assert_eq!(back, Easing::CustomSpring { mass: 2.0, stiffness: 50.0, damping: 5.0 }); assert_eq!(serde_json::from_value::(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut); } + +#[test] +fn text_style_updates_merge_equal_runs() { + let def = TextStyle { font_size: 14.0, ..Default::default() }; + let mut t = TextData::new("abcd"); + t.link_text_style(0, 2, &def, "S"); + t.link_text_style(2, 4, &def, "S"); + t.restyle(2, 4, |s| s.font_size = 24.0); + assert_eq!(t.runs.len(), 2); + t.update_text_style("S", &TextStyle { font_size: 24.0, ..def.clone() }); + let spans = t.spans(); + assert_eq!(spans.len(), 1); + assert_eq!((spans[0].0, spans[0].1), (0, 4)); + assert_eq!(spans[0].2.font_size, 24.0); + assert_eq!(spans[0].2.style_id.as_deref(), Some("S")); + + // Remapping two links to one id merges the runs. + let mut t = TextData::new("ab"); + t.link_text_style(0, 1, &def, "A"); + t.link_text_style(1, 2, &def, "B"); + t.map_text_styles(|_| Some("C".into())); + assert_eq!(t.spans().len(), 1); + assert_eq!(t.style_at(1).style_id.as_deref(), Some("C")); + + // Different values stay separate. + let mut t = TextData::new("ab"); + t.link_text_style(0, 1, &def, "A"); + t.link_text_style(1, 2, &TextStyle { font_size: 30.0, ..Default::default() }, "B"); + t.map_text_styles(|_| Some("C".into())); + let spans = t.spans(); + assert_eq!(spans.len(), 2); + assert_eq!((spans[0].2.font_size, spans[1].2.font_size), (14.0, 30.0)); + + // Unlinking a run equal to the base drops it. + let mut t = TextData::new("ab"); + let base = t.style.clone(); + t.link_text_style(0, 1, &base, "S"); + t.map_text_styles(|_| None); + assert!(t.runs.is_empty()); +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 8018609dc..2daf27013 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -562,7 +562,7 @@ impl TextData { } } - /// Drop empty/out-of-range runs and runs equal to the base style. + /// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs. pub fn normalize(&mut self) { let n = self.char_count(); let base = self.style.clone(); @@ -571,6 +571,14 @@ impl TextData { r.end = r.end.min(n); } self.runs.sort_by_key(|r| r.start); + let mut merged: Vec = Vec::with_capacity(self.runs.len()); + for r in std::mem::take(&mut self.runs) { + match merged.last_mut() { + Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end, + _ => merged.push(r), + } + } + self.runs = merged; } /// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`). From 6ea957ec5dcc2b54420e22eb238e1199f1fbbd09 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:36:06 +0300 Subject: [PATCH 3/8] doc: runs of a file that reach past the text can't overflow a text edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StyleRun start/end are usize taken straight from a .figcraft document.json (nothing clamps them on load; only the REST and SVG importers normalize), so TextData::replace_range's p - removed + inserted panics in a debug or test build and wraps to zero in release, where normalize then drops the run and the layer silently loses its formatting. Shift the runs with saturating arithmetic; normalize drops whatever is left. Regression test: text_runs_past_the_text_never_overflow — panics at crates/doc/src/text.rs:605 on main, passes with the clamp. --- crates/doc/src/tests.rs | 21 +++++++++++++++++++++ crates/doc/src/text.rs | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index d54bb29b9..bb03a440e 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -168,6 +168,27 @@ fn text_runs_restyle_and_replace() { assert_eq!(t.characters, ""); } +/// A run that reaches past the text (a file can carry one: nothing clamps `runs` on load) +/// must not overflow the arithmetic that shifts the runs along, and must not leave the +/// text unwritable. +#[test] +fn text_runs_past_the_text_never_overflow() { + let style = || TextStyle { font_style: "Bold".into(), ..Default::default() }; + for end in [usize::MAX, usize::MAX - 1, 1_000_000] { + let mut t = TextData::new("hi"); + t.runs = vec![StyleRun { start: 0, end, style: style() }]; + // Typing at the caret, and replacing a selection: both shift `end`. + t.replace_range(0, 0, "x"); + assert_eq!(t.characters, "xhi", "end {end}"); + t.replace_range(0, 3, "yo"); + assert_eq!(t.characters, "yo", "end {end}"); + // The text is still writable and the runs are back inside it. + t.replace_range(0, 0, "!"); + assert_eq!(t.characters, "!yo", "end {end}"); + assert!(t.runs.iter().all(|r| r.start < r.end && r.end <= t.char_count()), "end {end}: {:?}", t.runs); + } +} + #[test] fn text_case() { assert_eq!(TextCase::Title.apply("hello big world"), "Hello Big World"); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 8018609dc..b20512f7b 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -602,7 +602,9 @@ impl TextData { if p <= start { p } else if p >= end { - p - removed + inserted + // Runs of a file that reach past the text (nothing clamps them on load) + // would otherwise overflow here; `normalize` below drops what is left. + p.saturating_sub(removed).saturating_add(inserted) } else { start + inserted } From 3c28f605d192d776984242b5c2a8fe5489a07367 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:55:48 +0000 Subject: [PATCH 4/8] Keep cleared range hyperlinks through FIG export and import Fixes #458 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/format/src/fig/export.rs | 4 ++ crates/format/src/fig/map.rs | 3 +- crates/format/src/rest/text.rs | 2 + .../tests/range_hyperlink_round_trip.rs | 38 +++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 crates/format/tests/range_hyperlink_round_trip.rs diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 222433554..9bcf36f07 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1551,6 +1551,10 @@ impl<'d> Exporter<'d> { .map(|(st, id)| { let mut ef: Fields = vec![("styleID", u(id))]; style_fields(st, &mut ef); + // An empty link clears the text's own (a missing one would inherit it). + if st.link.is_none() && td.style.link.is_some() { + ef.push(("hyperlink", o(vec![("url", s(""))]))); + } if let Some(p) = st.fills.as_deref().or(td.style.fills.as_ref().map(|_| fills)) { ef.push(("fillPaints", self.paints(p, true, &Bound::default()))); } diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 787ca6d4b..40d2f0bc5 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1576,7 +1576,8 @@ fn text_style(v: &View, size: Option) -> Obj { } } } - if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str).filter(|u| !u.is_empty()) { + // An empty URL is kept: on an override entry it clears the text's own link. + if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str) { s.insert("hyperlink".into(), json!({"type": "URL", "url": u})); } s diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 5fbb368de..e8e53b1f7 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -127,6 +127,8 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle { if let Some(h) = s.get("hyperlink") { match (text(h, "type"), text(h, "url")) { (Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(super::props::clip_str(url).to_string()), + // An empty URL clears the link the base style has. + (Some("URL") | None, Some(_)) => t.link = None, _ => {} } } diff --git a/crates/format/tests/range_hyperlink_round_trip.rs b/crates/format/tests/range_hyperlink_round_trip.rs new file mode 100644 index 000000000..f47e22ae8 --- /dev/null +++ b/crates/format/tests/range_hyperlink_round_trip.rs @@ -0,0 +1,38 @@ +//! A hyperlink cleared on part of a text layer stays cleared through FIG export and import. + +use figcraft_doc::{Document, NodeKind, TextData}; +use figcraft_format::fig; + +fn links(doc: &Document, name: &str) -> Vec> { + let Some(NodeKind::Text(text)) = doc.nodes().find(|n| n.name == name).map(|n| &n.kind) else { return Vec::new() }; + text.spans().into_iter().flat_map(|(start, end, style)| std::iter::repeat_n(style.link.clone(), end - start)).collect() +} + +#[test] +fn cleared_range_hyperlink_round_trip() { + let mut document = Document::new("File"); + let page = document.first_page().unwrap(); + let url = "https://example.com/".to_string(); + + let mut text = TextData { characters: "AB".into(), ..Default::default() }; + text.style.link = Some(url.clone()); + text.restyle(1, 2, |style| style.link = None); + document.create(page, NodeKind::Text(text), "Cleared", 0.0, 0.0, 100.0, 30.0).unwrap(); + + let mut control = TextData { characters: "AB".into(), ..Default::default() }; + control.restyle(1, 2, |style| style.link = Some(url.clone())); + document.create(page, NodeKind::Text(control), "Explicit", 0.0, 40.0, 100.0, 30.0).unwrap(); + + let expected = vec![Some(url.clone()), None]; + assert_eq!(links(&document, "Cleared"), expected); + let native_bytes = figcraft_format::save_bytes(&document, None).unwrap(); + let native = figcraft_format::load_bytes(&native_bytes).unwrap().0; + assert_eq!(links(&native, "Cleared"), expected); + + let (bytes, export_warnings) = fig::export(&document, None).unwrap(); + let (loaded, import_warnings) = fig::import(&bytes, "File").unwrap(); + assert!(export_warnings.is_empty(), "{export_warnings:?}"); + assert!(import_warnings.is_empty(), "{import_warnings:?}"); + assert_eq!(links(&loaded, "Explicit"), vec![None, Some(url)]); + assert_eq!(links(&loaded, "Cleared"), expected); +} From a4f4869443fea70a705a411c4706c01afa5662f3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:10:00 +0200 Subject: [PATCH 5/8] Point duplicated page navigation at the copied frames Fixes #419 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 17 +++++++ crates/engine/src/cmd/page.rs | 5 +- crates/engine/src/lib.rs | 2 + .../engine/src/page_copy_navigation_tests.rs | 48 +++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/page_copy_navigation_tests.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 513800e37..71c6da393 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -781,6 +781,23 @@ impl Document { self.paste_nodes(&src_nodes, &map, id, parent, index) } + /// Deep-copy each of `ids`' subtrees to the end of `parent`, in order, returning the new root + /// ids. One id map covers every copy, so references between the copied subtrees (a navigation + /// from one copied frame to another) point at the copies. + pub fn duplicate_all_into(&mut self, ids: &[NodeId], parent: NodeId) -> DocResult> { + let mut map = BTreeMap::new(); + let mut trees = Vec::with_capacity(ids.len()); + for &id in ids { + let src_nodes: Vec = self.subtree(id).into_iter().filter_map(|i| self.node(i).cloned()).collect(); + for n in &src_nodes { + let nid = self.alloc_id(); + map.insert(n.id, nid); + } + trees.push((id, src_nodes)); + } + trees.into_iter().map(|(id, src_nodes)| self.paste_nodes(&src_nodes, &map, id, parent, None)).collect() + } + /// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids /// remapped by `map`. Interactions, instance references and pattern sources pointing inside the /// subtree are remapped too. diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 8fccd6076..e6c1876f3 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -92,9 +92,8 @@ pub fn specs() -> Vec { { *b = background; } - for c in doc.get(src)?.children.clone() { - doc.duplicate_into(c, np, None)?; - } + let children = doc.get(src)?.children.clone(); + doc.duplicate_all_into(&children, np)?; // Keep the new page right after the source. if let Some(si) = doc.pages.iter().position(|x| *x == src) { doc.pages.retain(|x| *x != np); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 42423dbc4..c10dd04f1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1528,6 +1528,8 @@ mod open_issues_tests; #[cfg(test)] mod outline_options_tests; #[cfg(test)] +mod page_copy_navigation_tests; +#[cfg(test)] mod paint_reorder_tests; #[cfg(test)] mod paint_tests; diff --git a/crates/engine/src/page_copy_navigation_tests.rs b/crates/engine/src/page_copy_navigation_tests.rs new file mode 100644 index 000000000..ddbc956a4 --- /dev/null +++ b/crates/engine/src/page_copy_navigation_tests.rs @@ -0,0 +1,48 @@ +//! Duplicating a page copies its prototype connections: navigation between two frames that are both +//! on the page leads between the copies, while a destination outside the copied contents is kept. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn frame(s: &mut Session, x: f64) -> NodeId { + id_of(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap() +} + +fn dest(s: &Session, id: NodeId) -> NodeId { + s.doc().unwrap().node(id).unwrap().interactions[0].actions[0].dest().unwrap() +} + +#[test] +fn page_copy_navigation_uses_the_copied_destination() { + let mut s = Session::new(); + let first = frame(&mut s, 0.0); + let second = frame(&mut s, 200.0); + ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}})); + let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap(); + let children = s.doc().unwrap().node(copy).unwrap().children.clone(); + assert_eq!(children.len(), 2); + assert_eq!(dest(&s, children[0]), children[1]); + let visited = Default::default(); + assert_eq!(crate::proto_run::navigate_target(s.doc().unwrap(), dest(&s, children[0]), &visited), Some(children[1])); + assert_eq!(dest(&s, first), second, "the original keeps its destination"); +} + +#[test] +fn frame_copy_keeps_an_outside_destination() { + let mut s = Session::new(); + let first = frame(&mut s, 0.0); + let second = frame(&mut s, 200.0); + ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}})); + ex(&mut s, "select.set", json!({"ids": [first]})); + ex(&mut s, "edit.duplicate", json!({})); + let copy = s.selection()[0]; + assert_ne!(copy, first); + assert_eq!(dest(&s, copy), second); +} From 843b00b22f8df10a5d05d1c8c92ac5f055e5aae7 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:18:17 +0200 Subject: [PATCH 6/8] text: scripted text stores CRLF, CR, U+2028 and U+2029 as LF like the editor (#427) --- crates/engine/src/cmd/create.rs | 2 +- crates/engine/src/cmd/edit.rs | 1 + crates/engine/src/cmd/props.rs | 7 +++- crates/engine/src/components.rs | 4 +- crates/engine/src/lib.rs | 2 + crates/engine/src/line_endings_tests.rs | 56 +++++++++++++++++++++++++ crates/engine/src/textedit.rs | 7 +--- 7 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 crates/engine/src/line_endings_tests.rs diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 61b0f8b4c..2150701a9 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -216,7 +216,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { } } if let NodeKind::Text(td) = &mut kind { - td.characters = string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default(); + td.characters = super::props::normalize_line_endings(&string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default()); if w > 0.0 { td.auto_resize = TextAutoResize::Height; } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3a0342cdc..9687e06ab 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1016,6 +1016,7 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { // Replace occurrence by occurrence so styles shift correctly. // The search resumes after each inserted replacement, so a replacement that // contains `find` neither loops forever nor stops the other occurrences. + let r = &super::props::normalize_line_endings(r); let mut guard = 0; let mut cursor = 0; let len = find.chars().count(); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index dd2daa82a..598be752f 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -30,6 +30,11 @@ fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\ arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \ group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}"; +/// CRLF, bare CR, U+2028 and U+2029 become LF, the only line ending text layout understands (editor insertion does the same). +pub(crate) fn normalize_line_endings(s: &str) -> String { + s.replace("\r\n", "\n").replace(['\r', '\u{2028}', '\u{2029}'], "\n") +} + /// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values). pub fn snake(v: &Value) -> Value { match v { @@ -599,7 +604,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { - if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str()) + if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str().map(crate::cmd::props::normalize_line_endings)) && t.characters != sv { let len = t.char_count(); - t.replace_range(0, len, sv); + t.replace_range(0, len, &sv); } } "mainComponent" => { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 42423dbc4..a80cc4c93 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1502,6 +1502,8 @@ mod keymap_tests; #[cfg(test)] mod library_variables_tests; #[cfg(test)] +mod line_endings_tests; +#[cfg(test)] mod live_access_tests; #[cfg(test)] mod live_docs_tests; diff --git a/crates/engine/src/line_endings_tests.rs b/crates/engine/src/line_endings_tests.rs new file mode 100644 index 000000000..160707a6c --- /dev/null +++ b/crates/engine/src/line_endings_tests.rs @@ -0,0 +1,56 @@ +//! Text set by a command stores every line ending as LF, like editor insertion does, so layout +//! sees two lines and no stray glyph for CRLF, bare CR, U+2028 or U+2029. + +use serde_json::json; + +use crate::Session; +use crate::components::tests::{card, chars, copy_of, ex, idv}; +use crate::params::id_of; + +const INPUTS: [&str; 5] = ["a\nb", "a\r\nb", "a\rb", "a\u{2028}b", "a\u{2029}b"]; + +#[test] +fn text_set_normalizes_line_endings() { + let mut s = Session::new(); + let created = s.execute("text.create", &json!({"text": "", "x": 0, "y": 0})).unwrap(); + let id = id_of(&created["id"]).unwrap(); + for input in INPUTS { + s.execute("text.set", &json!({"id": id, "characters": input})).unwrap(); + let t = s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone(); + assert_eq!(t.characters, "a\nb", "text.set {input:?}"); + } +} + +#[test] +fn text_create_normalizes_line_endings() { + let mut s = Session::new(); + for input in INPUTS { + let created = s.execute("text.create", &json!({"text": input, "x": 0, "y": 0})).unwrap(); + let id = id_of(&created["id"]).unwrap(); + let t = s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone(); + assert_eq!(t.characters, "a\nb", "text.create {input:?}"); + } +} + +#[test] +fn find_replace_normalizes_line_endings() { + for input in INPUTS { + let mut s = Session::new(); + let id = idv(&ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0}))); + ex(&mut s, "edit.findReplace", json!({"find": "x", "replace": input})); + assert_eq!(chars(&s, id), "a\nb", "edit.findReplace {input:?}"); + } +} + +#[test] +fn text_property_values_normalize_line_endings() { + let mut s = Session::new(); + let (c, _bg, title, _dot) = card(&mut s); + ex(&mut s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + let copy = copy_of(&s, i, title); + for input in INPUTS { + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": input}})); + assert_eq!(chars(&s, copy), "a\nb", "instance.setProperties {input:?}"); + } +} diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 6ba94217d..af9cb52c9 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -82,12 +82,7 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result /// Type (or paste) `text` replacing the selection. Line endings are normalised and other control /// characters dropped, so pasted plain text from any platform lands cleanly. pub fn insert(s: &mut Session, text: &str) -> Result<()> { - let text: String = text - .replace("\r\n", "\n") - .replace(['\r', '\u{2028}', '\u{2029}'], "\n") - .chars() - .filter(|c| !c.is_control() || matches!(c, '\n' | '\t')) - .collect(); + let text: String = crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect(); if text.is_empty() { return Ok(()); } From 5454bdc2449b1fc8fa9fc826ebc8a4d3f758e2f7 Mon Sep 17 00:00:00 2001 From: IanHollow <72767437+IanHollow@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:29:11 -0700 Subject: [PATCH 7/8] M7: skip special files when scanning system fonts Require resolved font entries to be regular files before indexing them. Preserve normal font symlinks and cover named pipes in a bounded child-process regression. Assisted-by: OpenAI Codex (GPT-6) Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> --- ROADMAP.md | 3 +- crates/text/src/fontdb.rs | 5 +++ crates/text/src/fontdb_tests.rs | 73 ++++++++++++++++++++++++++++++++- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ace7702ea..b03bc8f8b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; 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; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; 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; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; 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; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; 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; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; 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; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; 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; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | | 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 2c584ebc6..8f91d83fd 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -695,6 +695,11 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap status, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skipping named-pipe fixture: POSIX mkfifo is unavailable"); + return; + } + Err(error) => panic!("cannot create named-pipe fixture: {error}"), + }; + assert!(status.success(), "mkfifo failed"); + std::os::unix::fs::symlink("pipe.ttf", dir.0.join("linked.otf")).unwrap(); + std::os::unix::fs::symlink("missing.ttf", dir.0.join("broken.ttf")).unwrap(); + + // A regression must fail without leaving a blocked test thread behind. Run only this + // test in a child, then kill and reap it if opening the pipe blocks the scanner. + struct Child(std::process::Child); + impl Drop for Child { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let module = module_path!().split_once("::").unwrap().1; + let mut child = Child( + Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &format!("{module}::the_scan_skips_named_pipes"), "--nocapture"]) + .env(INPUT, &dir.0) + .stdout(Stdio::null()) + .spawn() + .unwrap(), + ); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = child.0.try_wait().unwrap() { + assert!(status.success(), "font-scan child failed: {status}"); + break; + } + assert!(Instant::now() < deadline, "font scan blocked on a named pipe"); + // std::process has no portable timed wait; a bounded poll keeps the regression finite. + std::thread::sleep(Duration::from_millis(10)); + } + // An exact-filter typo otherwise makes libtest report success after running zero tests. + assert_eq!(std::fs::read(dir.0.join("complete")).unwrap(), b"scanned"); +} From e9e2d874051918c5c6207d882013a06b4c9eac8c Mon Sep 17 00:00:00 2001 From: bkim <165993880+perskim@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:23:06 +0900 Subject: [PATCH 8/8] M1: Hangul falls back to a Korean system font, at the run's weight Text in the default Inter (or any Latin font) drew the missing-glyph box for every Hangul syllable on macOS and Windows: no Korean family was on the fallback list, so the Korean fonts those systems ship were never tried, and there is no default-font setting to work around it. Apple SD Gothic Neo (macOS), Malgun Gothic (Windows) and Noto Sans CJK KR / Noto Sans KR (Linux) now come after the Chinese and Japanese entries, so Han characters resolve as before. Fallback faces also follow the run's style: each fallback family gives its face nearest the run's style instead of always its Regular, so Hangul (or kana) in a bold run comes out bold. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015MAPjgJo17KasfbzMTbV65 --- crates/text/src/behaviour_tests.rs | 28 +++++++++++++++++ crates/text/src/fontdb.rs | 49 +++++++++++++++++++----------- crates/text/src/fontdb_tests.rs | 15 +++++++++ crates/text/src/layout.rs | 3 ++ 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index a5400b62b..b3cb4884d 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -237,6 +237,34 @@ fn fallback_fonts_dont_move_the_baseline() { } } +#[test] +fn hangul_comes_from_a_korean_font_when_one_is_installed() { + // The default (Latin) face has no Hangul. With a Korean system font (Apple SD Gothic Neo on + // macOS, Malgun Gothic on Windows, Noto Sans CJK KR on Linux) the fallback draws it instead + // of the missing-glyph box, at the run's weight. Skips on a machine without one. + let Some(f) = face() else { return }; + if f.has_char('\u{D55C}') { + return; + } + let korean = ["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR"]; + if !korean.iter().any(|k| find_face(k, "Regular").is_some_and(|k| k.has_char('\u{D55C}'))) { + return; + } + for style in ["Regular", "Bold"] { + let mut t = text("Ab \u{D55C}\u{AD6D}\u{C5B4}", TextAutoResize::WidthAndHeight); + t.style.font_style = style.into(); + let l = layout(&t, None, None); + let hangul: Vec<_> = l.glyphs.iter().filter(|g| g.char_start >= 3).collect(); + assert!(!hangul.is_empty()); + for g in &hangul { + assert!(g.face.has_char('\u{D55C}'), "{style}: drawn by {} {}, which has no Hangul", g.face.family, g.face.style); + } + let used = &hangul[0].face; + let nearest = find_face(&used.family, style).map_or(used.weight, |n| n.weight); + assert_eq!(used.weight, nearest, "{style}: the fallback face is the one nearest the run's style"); + } +} + #[test] fn break_opportunities() { // (before, after, may break between, derivation) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 2c584ebc6..72192eb30 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -391,6 +391,29 @@ fn names(f: &skrifa::FontRef) -> (Option, Option) { (family, style) } +/// Families tried, in order, for characters a text's own font lacks. +const FALLBACK_FAMILIES: [&str; 19] = [ + "Inter", + "Helvetica Neue", + "Arial", + "Segoe UI", + "DejaVu Sans", + "Noto Sans", + "BIZ UDPGothic", + "Hiragino Sans", + "PingFang SC", + "Noto Sans CJK SC", + "Microsoft YaHei", + "Apple SD Gothic Neo", + "Malgun Gothic", + "Noto Sans CJK KR", + "Noto Sans KR", + "Apple Color Emoji", + "Noto Sans Arabic", + "Apple Symbols", + "Segoe UI Symbol", +]; + /// A system font file entry not yet loaded. #[derive(Clone, Debug)] struct Pending { @@ -600,25 +623,15 @@ impl FontDb { /// Faces to try for characters the primary face lacks. pub fn fallbacks(&mut self) -> Vec> { + self.fallbacks_for("Regular") + } + + /// [`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. + pub fn fallbacks_for(&mut self, style: &str) -> Vec> { let mut out = Vec::new(); - for fam in [ - "Inter", - "Helvetica Neue", - "Arial", - "Segoe UI", - "DejaVu Sans", - "Noto Sans", - "BIZ UDPGothic", - "Hiragino Sans", - "PingFang SC", - "Noto Sans CJK SC", - "Microsoft YaHei", - "Apple Color Emoji", - "Noto Sans Arabic", - "Apple Symbols", - "Segoe UI Symbol", - ] { - if let Some(f) = self.find(fam, "Regular") { + for fam in FALLBACK_FAMILIES { + if let Some(f) = self.find(fam, style) { out.push(f); } } diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index b9add9c2f..00bdd038b 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -67,3 +67,18 @@ fn the_scan_reads_names_like_a_whole_file_read() { assert_eq!(faces_in(&index_bytes(&p, whole.len()).unwrap()), faces_in(&whole), "{}", p.display()); } } + +#[test] +fn fallback_faces_follow_the_runs_style() { + // A bold run's missing characters come from each fallback family's face nearest Bold, not its + // Regular, so Hangul or kana in a bold Latin run come out bold too. Same families, same order. + scan_system_fonts(); + let mut w = db().write().unwrap(); + let regular = w.fallbacks(); + let bold = w.fallbacks_for("Bold"); + let families = |v: &[Arc]| v.iter().map(|f| f.family.clone()).collect::>(); + assert_eq!(families(®ular), families(&bold)); + for (r, b) in regular.iter().zip(&bold) { + assert!(b.weight >= r.weight, "{}: {} is lighter than {}", b.family, b.style, r.style); + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index b5b617d00..9bf1f49bc 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -301,8 +301,10 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let mut dbw = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); let fallbacks = dbw.fallbacks(); let mut faces: Vec>> = Vec::with_capacity(styles.len()); + let mut style_fallbacks: Vec>> = Vec::with_capacity(styles.len()); for s in &styles { faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face())); + style_fallbacks.push(dbw.fallbacks_for(&s.font_style)); } drop(dbw); @@ -334,6 +336,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) if c == '\n' || primary.has_char(c) { return primary.clone(); } + let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks); fallbacks.iter().find(|f| f.has_char(c)).cloned().unwrap_or_else(|| primary.clone()) }; let first = chars.get(seg_start).copied().unwrap_or(' ');