From 5f320422a7f497c95d7b80042ecf39b8546527d4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:11:22 -0800 Subject: [PATCH 1/4] Fonts: Find Font skips patterns at 0% opacity and follows inline graphics - A fill or stroke at an opacity of 0 no longer counts as painting its pattern, so Find, selection-only Change and Print leave that pattern's type out. - Text whose inline graphics draw a symbol shows that symbol's type: Find selects it, and selection-only Change and the font list reach the type through it, also through a pattern whose art holds such text. - Refresh Font List lays out again the type in opacity masks and in the art of symbols and patterns, and inline graphics, on the layers and in that art, take the new size of their symbols' art. --- crates/engine/src/cmd/fonts.rs | 234 +++++++++++++++++++++++++++++++-- docs/mcp.md | 3 +- docs/target-app-parity.md | 2 +- 3 files changed, 224 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 74cece5af3..7df3e57b2a 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Select Text by Font", [], None, - "{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, the instances of the symbols whose art uses it, the objects painted with a pattern whose art uses it and the objects whose opacity mask uses it (in type that is not hidden or locked in that art), that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; a pattern swatch that nothing paints, or a layer's mask, selects nothing; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}", + "{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, the instances of the symbols whose art uses it, the text whose inline graphics draw such a symbol, the objects painted with a pattern whose art uses it (by a visible fill or stroke whose opacity is above 0, a stroke wider than 0) and the objects whose opacity mask uses it (in type that is not hidden or locked in that art), that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; a pattern swatch that nothing paints, or a layer's mask, selects nothing; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}", has_doc, select_font ), @@ -108,8 +108,14 @@ fn pattern_being_edited(d: &Document, name: &str) -> bool { d.pattern_edit.as_ref().is_some_and(|e| e.pattern == name) } +/// Whether fill or stroke `i` paints: it is visible, its opacity is above 0 and, a stroke, it is +/// wider than 0. +fn paints(i: &AppearanceItem) -> bool { + i.visible() && i.opacity() > 0.0 && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0) +} + /// The patterns that the fills and strokes `n` draws name (of a text object, also those of its -/// characters): visible ones, and strokes wider than 0. +/// characters): those that [`paints`] holds for, and character strokes wider than 0. fn paint_patterns(n: &Node) -> impl Iterator { let runs = match &n.kind { NodeKind::Text(t) => t.runs.as_slice(), @@ -118,7 +124,7 @@ fn paint_patterns(n: &Node) -> impl Iterator { n.appearance .items .iter() - .filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)) + .filter(|i| paints(i)) .map(AppearanceItem::paint) .chain(runs.iter().flat_map(|r| [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)]).flatten()) .filter_map(|p| match p { @@ -127,6 +133,15 @@ fn paint_patterns(n: &Node) -> impl Iterator { }) } +/// The symbols that the inline graphics in the type of `n` draw. +fn inline_symbols(n: &Node) -> impl Iterator { + let runs = match &n.kind { + NodeKind::Text(t) => t.runs.as_slice(), + _ => &[], + }; + runs.iter().filter_map(|r| r.inline.as_ref().map(|art| art.symbol.as_str())) +} + /// Symbols and patterns, by name: those whose art is in scope. #[derive(Clone, Debug, Default)] struct Resources { @@ -135,12 +150,14 @@ struct Resources { } impl Resources { - /// Adds what `n` itself shows: the symbol of an instance, the patterns its paints name, and - /// what the art of its opacity mask shows (not hidden or locked objects of that art). + /// Adds what `n` itself shows: the symbol of an instance, the symbols of the inline graphics in + /// its type, the patterns its paints name, and what the art of its opacity mask shows (not + /// hidden or locked objects of that art). fn add_own(&mut self, n: &Node) { if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { self.symbols.insert(symbol.clone()); } + self.symbols.extend(inline_symbols(n).map(str::to_string)); self.patterns.extend(paint_patterns(n).map(str::to_string)); if let Some(m) = n.extras().mask.as_deref() { self.add(&m.art); @@ -486,8 +503,7 @@ impl Printed<'_> { NodeKind::Text(t) => drawn_runs(t), _ => (vec![], false), }; - let items: Vec<&AppearanceItem> = - n.appearance.items.iter().filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)).collect(); + let items: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| paints(i)).collect(); let patterns: Vec<&str> = items .iter() .map(|i| i.paint()) @@ -910,13 +926,15 @@ fn replace(s: &mut Session, p: &Value) -> Result { } /// Whether `n` or a descendant that is not hidden or locked, nor in a hidden or locked group, -/// shows type in a font `uses` matches: text set in it, an instance of a symbol in `found`, a paint -/// naming a pattern in `found`, or such type in the art of its opacity mask. +/// shows type in a font `uses` matches: text set in it, an instance of a symbol in `found`, an +/// inline graphic of a symbol in `found`, a paint naming a pattern in `found`, or such type in the +/// art of its opacity mask. fn reaches(n: &Node, uses: &impl Fn(&TextObject) -> bool, found: &Resources) -> bool { n.visible && !n.locked && (matches!(&n.kind, NodeKind::Text(t) if uses(t)) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) + || inline_symbols(n).any(|sym| found.symbols.contains(sym)) || paint_patterns(n).any(|p| found.patterns.contains(p)) || n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, uses, found)) || n.children().is_some_and(|ch| ch.iter().any(|c| reaches(c, uses, found)))) @@ -964,13 +982,15 @@ fn select_font(s: &mut Session, p: &Value) -> Result { found.patterns.extend(patterns); } // The objects the Selection tool can reach that show such type: the instances of those - // symbols, the objects that those patterns paint, and the objects whose opacity mask holds it - // (not layers, which the Selection tool doesn't select). + // symbols, the text whose inline graphics draw them, the objects that those patterns paint, + // and the objects whose opacity mask holds it (not layers, which the Selection tool doesn't + // select). let masked: HashSet = masked.into_iter().collect(); if !found.symbols.is_empty() || !found.patterns.is_empty() || !masked.is_empty() { let texts: HashSet = hits.iter().copied().collect(); d.walk(&mut |n: &Node| { let shows = matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) + || inline_symbols(n).any(|sym| found.symbols.contains(sym)) || paint_patterns(n).any(|p| found.patterns.contains(p)) || (masked.contains(&n.id) && n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, &uses, &found))); if shows && !n.is_layer() && !texts.contains(&n.id) && editable(n.id) { @@ -1069,6 +1089,37 @@ fn font_list(_: &mut Session, p: &Value) -> Result { } } +/// Gives the inline graphics in the type of [`art_texts_of`] the current bounds of their symbols' +/// art, and lays out again the type whose inline graphics changed. A symbol drawn inline in the +/// art of another symbol takes one more round, so there is at most one round per symbol. +fn resolve_art_inline(doc: &mut Document) { + for _ in 0..=doc.symbols.len() { + let names: BTreeSet = + art_texts_of(doc).iter().flat_map(|(_, t, _)| t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone()))).collect(); + let bounds: BTreeMap> = names + .into_iter() + .map(|name| { + let b = doc.symbol_art_bounds(&name); + (name, b) + }) + .collect(); + let stale = |t: &TextObject| t.runs.iter().any(|r| r.inline.as_ref().is_some_and(|a| bounds.get(&a.symbol).is_some_and(|b| *b != a.bounds))); + let mut changed = false; + edit_art_texts_of(doc, &stale, &mut |t| { + for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) { + if let Some(b) = bounds.get(&a.symbol) { + a.bounds = *b; + } + } + refresh_bounds(t); + changed = true; + }); + if !changed { + break; + } + } +} + pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { let db = vectorcraft_text::FontDb::global(); #[cfg(not(target_arch = "wasm32"))] @@ -1076,7 +1127,9 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { #[cfg(target_arch = "wasm32")] let faces = 0; // The canvas redraws type in the fonts available now (renderers lay it out again when the - // fonts change), and the bounds follow; that isn't an edit to save. + // fonts change), and the bounds follow, also of the type in opacity masks and in the art of + // symbols and patterns; that isn't an edit to save. The type in art is laid out first, and the + // inline graphics of the type on the layers then take the new bounds of their symbols' art. for d in &mut s.docs { let mut texts = vec![]; d.doc.walk(|n| { @@ -1084,9 +1137,12 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { texts.push(n.id); } }); - if !texts.is_empty() { + if !texts.is_empty() || !art_texts_of(&d.doc).is_empty() { d.change_unsaved(|doc| { let doc = std::sync::Arc::make_mut(doc); + edit_art_texts_of(doc, &|_| true, &mut refresh_bounds); + resolve_art_inline(doc); + doc.resolve_inline_art(); for id in texts { if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { refresh_bounds(t); @@ -1751,6 +1807,158 @@ mod tests { assert_eq!(symbol_texts(&s.doc().unwrap().doc, None)[0].0.runs[0].style.font_family, "Source Sans 3"); } + /// A pattern in a fill whose opacity is 0 paints nothing: Find doesn't select the object for + /// the pattern's type, a selection-only Change leaves that type in its font, and Print doesn't + /// count its font. + #[test] + fn find_font_skips_patterns_at_an_opacity_of_0() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let painted = s.execute("shape.rectangle", &json!({"x": 0, "y": 20, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [painted]})).unwrap(); + let printed = |s: &Session| { + let d = &s.doc().unwrap().doc; + printed_type(d, &d.layers, &[Rect::new(-1000.0, -1000.0, 1000.0, 1000.0)]).0.into_iter().map(|(family, ..)| family).collect::>() + }; + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1); + assert_eq!(printed(&s), ["Source Serif 4"]); + s.edit("Clear the fill", |d, _| { + for it in d.node_mut(NodeId(painted)).unwrap().appearance.items.iter_mut() { + if let AppearanceItem::Fill(f) = it { + f.opacity = 0.0; + } + } + Ok(()) + }) + .unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0); + assert!(printed(&s).is_empty(), "{:?}", printed(&s)); + s.execute("select.set", &json!({"ids": [painted]})).unwrap(); + let r = s + .execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true})) + .unwrap(); + assert_eq!(r, json!({"runs": 0})); + assert_eq!(pattern_families(&s, "Dots"), ["Source Serif 4"]); + } + + /// Text whose inline graphic draws a symbol shows the symbol's type: Find selects that text + /// and the objects painted with a pattern whose art holds it, and a selection-only Change + /// changes the symbol's type. + #[test] + fn find_font_reaches_symbols_through_inline_graphics() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let serif = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Se", "font": "Source Serif 4"})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [serif]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let inline = |s: &mut Session, y: i32| { + let id = s.execute("text.create", &json!({"x": 10, "y": y, "text": "In", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "Mark"})).unwrap(); + id + }; + let listed = inline(&mut s, 60); + let tiled = inline(&mut s, 100); + s.execute("object.pattern.make", &json!({"ids": [tiled], "name": "Loop", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [tiled]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let painted = s.execute("shape.rectangle", &json!({"x": 0, "y": 140, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Loop", "ids": [painted]})).unwrap(); + let mut instances = 0; + s.doc().unwrap().doc.walk(|n| instances += usize::from(matches!(n.kind, NodeKind::SymbolInstance { .. }))); + assert_eq!(instances, 0, "the symbol is drawn only by inline graphics"); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 2); + let mut selected = s.doc().unwrap().selection.objects.clone(); + selected.sort(); + assert_eq!(selected, [NodeId(listed), NodeId(painted)]); + s.execute("select.set", &json!({"ids": [painted]})).unwrap(); + let rows = font_rows(&mut s, json!({"selectionOnly": true})); + assert_eq!(rows, [("Inter".to_string(), 2, 1), ("Source Serif 4".to_string(), 1, 1)]); + let r = s + .execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true})) + .unwrap(); + assert_eq!(r, json!({"runs": 1})); + assert_eq!(symbol_texts(&s.doc().unwrap().doc, None)[0].0.runs[0].style.font_family, "Source Sans 3"); + } + + /// Refresh Font List lays out again the type in opacity masks and in the art of symbols and + /// patterns, as it does the type on the layers, and the inline graphics of the type on the + /// layers follow the symbols' type as laid out again. + #[test] + fn rescanning_fonts_refreshes_the_bounds_of_type_in_art() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let owner = s.execute("shape.rectangle", &json!({"x": 0, "y": 20, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let masked = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Mask", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + mask_with(&mut s, owner, masked); + let tiled = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "Tile", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [tiled], "name": "Dots", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [tiled]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let stale = Rect::new(0.0, 0.0, 1.0, 1.0); + s.edit("Make the bounds stale", |d, _| { + edit_art_texts_of(d, &|_| true, &mut |t| t.cached_bounds = Some(stale)); + Ok(()) + }) + .unwrap(); + let bounds = |s: &Session| art_texts_of(&s.doc().unwrap().doc).iter().map(|(_, t, _)| t.cached_bounds).collect::>(); + assert_eq!(bounds(&s), [Some(stale), Some(stale)]); + s.execute("text.rescanFonts", &json!({})).unwrap(); + let after = bounds(&s); + assert_eq!(after.len(), 2); + assert!(after.iter().all(|b| b.is_some_and(|b| b != stale && b.width() > 1.0)), "{after:?}"); + let marked = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Se", "font": "Inter"})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [marked]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let line = s.execute("text.create", &json!({"x": 10, "y": 140, "text": "In", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": line, "at": 0, "symbol": "Mark"})).unwrap(); + s.edit("Make the bounds stale", |d, _| { + edit_art_texts_of(d, &|_| true, &mut |t| t.cached_bounds = Some(stale)); + d.resolve_inline_art(); + Ok(()) + }) + .unwrap(); + let resolved = |s: &Session| { + let d = &s.doc().unwrap().doc; + matches!(d.inline_resolved(text(d, NodeId(line)).unwrap()), std::borrow::Cow::Borrowed(_)) + }; + assert!(resolved(&s), "the inline graphic took the stale bounds"); + s.execute("text.rescanFonts", &json!({})).unwrap(); + assert!(resolved(&s), "the inline graphic follows the symbol's type laid out again"); + // So does an inline graphic in the art of another symbol. + let held = s.execute("text.create", &json!({"x": 10, "y": 180, "text": "Out", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": held, "at": 0, "symbol": "Mark"})).unwrap(); + s.execute("symbol.new", &json!({"name": "Outer", "ids": [held]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + s.edit("Make the bounds stale", |d, _| { + edit_art_texts_of(d, &|_| true, &mut |t| t.cached_bounds = Some(stale)); + Ok(()) + }) + .unwrap(); + let shrunk = s.doc().unwrap().doc.symbol_art_bounds("Mark"); + s.edit("Take the stale bounds", |d, _| { + edit_art_texts_of(d, &|_| true, &mut |t| { + for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) { + a.bounds = shrunk; + } + }); + Ok(()) + }) + .unwrap(); + let nested = |s: &Session| { + let d = &s.doc().unwrap().doc; + art_texts_of(d).iter().flat_map(|(_, t, _)| t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.bounds))).collect::>() + }; + assert_eq!(nested(&s), [shrunk]); + s.execute("text.rescanFonts", &json!({})).unwrap(); + let mark = s.doc().unwrap().doc.symbol_art_bounds("Mark"); + assert_ne!(mark, shrunk); + assert_eq!(nested(&s), [mark], "the inline graphic in the art of Outer follows Mark's type laid out again"); + } + /// The font family of text object `id`'s first run. fn text_family(s: &Session, id: u64) -> String { text(&s.doc().unwrap().doc, NodeId(id)).unwrap().runs[0].style.font_family.clone() diff --git a/docs/mcp.md b/docs/mcp.md index 207ca19d57..4a664fd195 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9059,7 +9059,8 @@ font's variation axes (`tag`, `name`, `min`, `default`, `max`, `hidden`, and `va first style), empty for a static font. `text.rescanFonts` (Character panel menu › Refresh Font List) scans the font folders again, for fonts installed or removed since the app started: type set in a font that became available redraws in it, and type set in a family that was removed redraws in -the fallback font and is listed as missing, without editing the document. The app does so by +the fallback font and is listed as missing, without editing the document. The type in opacity masks and in the art of +symbols and pattern swatches is laid out again too, and inline graphics take the new size of their symbols' art. The app does so by itself when its window comes to the front and a font folder changed meanwhile. ```json diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e2f536bdeb..81d149fb58 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1203,7 +1203,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Recent font pickers (#3515):** The shared Character, Properties, Glyphs and Find Font family picker lists recently applied families first, once each, in most-recent order and limited by Preferences › Type › Number of Recent Fonts. Search and filters apply to both sections; unavailable families are omitted. Section headings cannot be selected or previewed. Confirmed character-range font edits update the same saved history; previews and failed edits do not. Requirement: [issue #3515](https://github.com/storytold/vectorcraft/issues/3515). - **Find Font: Replace With Font From:** a dropdown under Replace With Font chooses the list the replacement font comes from: Document lists the families the document uses, once each; Recent lists the recently applied families that are installed (fonts applied with Find Font's Change and Change All included), newest first, as many as Preferences › Type › Number of Recent Fonts allows; System, the default, lists the installed families available in the font menu. The dialog opens on System with Source Sans 3 chosen, or the font menu's first family when Source Sans 3 is not installed. Choosing a list keeps the chosen font when the list has it and it is installed; otherwise Document and Recent choose the list's first installed font, and System chooses Source Sans 3 or the font menu's first family. Change and Change All are unavailable when the list has no installed font (Recent before any font was applied) and when the chosen family is not installed or is not in the shown list. Behavior reference: [Find and replace fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) (public documentation). Tests: `crates/ui-egui/src/find_font.rs`. - **Select Text by Font: scope (a VectorCraft addition, #5529):** `select.font` takes `scope`: `"document"` (the default, as before), `{artboards: [index, …]}` or `{layers: [id, …]}` (sublayers included). On an artboard means what All on Active Artboard takes (the bounds overlap it or the center is on it; one shared test, `select::on_artboard`); hidden and locked text stays out as before; a symbol instance counts where the instance is. Type › Find Font…'s Find In dropdown passes the active artboard or the selected layers (else the current layer). Tests: `crates/engine/src/cmd/fonts.rs`, `crates/ui-egui/src/find_font.rs`, `crates/engine/tests/command_sweep.rs`. -- **Find Font: type in opacity masks and pattern swatches:** Type › Find Font…'s Document list (`text.fonts`), Find (`select.font`), Change and Change All (`text.replaceFont`), Replace Missing Fonts (`addon.cleanup.replaceFonts`), the Missing Fonts dialog (`text.missingFonts`), Document Info's Fonts and Font Details for the whole document (`document.info`), File › Package and the export warning about fonts that aren't available include the type in opacity masks (on objects and layers, disabled masks and masks inside mask, symbol or pattern art included) and in the art of pattern swatches, also a swatch that nothing paints with. A pattern's type is listed and changed once, however many objects the pattern paints. Find selects the objects whose mask holds the font and the objects painted with a pattern whose art uses it (VectorCraft's own rule; a layer's mask and a swatch that nothing paints with select nothing); Change and Change All leave the type in the mask of a hidden or locked object in its font and count it in `skipped`, and Change does the same with the type of a pattern that only hidden or locked selected objects paint with. The help page (https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) says the Document list holds all the fonts the document uses. Tests: `crates/engine/src/cmd/fonts.rs`, `crates/engine/src/cmd/addons/cleanup/text/tests.rs`, `crates/engine/src/cmd/docinfo.rs`. +- **Find Font: type in opacity masks and pattern swatches:** Type › Find Font…'s Document list (`text.fonts`), Find (`select.font`), Change and Change All (`text.replaceFont`), Replace Missing Fonts (`addon.cleanup.replaceFonts`), the Missing Fonts dialog (`text.missingFonts`), Document Info's Fonts and Font Details for the whole document (`document.info`), File › Package and the export warning about fonts that aren't available include the type in opacity masks (on objects and layers, disabled masks and masks inside mask, symbol or pattern art included) and in the art of pattern swatches, also a swatch that nothing paints with. A pattern's type is listed and changed once, however many objects the pattern paints. Find selects the objects whose mask holds the font, the text whose inline graphics draw a symbol whose art uses it, and the objects painted with a pattern whose art uses it by a visible fill or stroke above 0% opacity (VectorCraft's own rule; a layer's mask and a swatch that nothing paints with select nothing); Change and Change All leave the type in the mask of a hidden or locked object in its font and count it in `skipped`, and Change does the same with the type of a pattern that only hidden or locked selected objects paint with. The help page (https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) says the Document list holds all the fonts the document uses. Tests: `crates/engine/src/cmd/fonts.rs`, `crates/engine/src/cmd/addons/cleanup/text/tests.rs`, `crates/engine/src/cmd/docinfo.rs`. - **Mouse wheel over the font family field (#5253):** In the Character and Properties panels and the Control bar, turning the mouse wheel over the closed font family field applies the next family to the selected type (the previous one when turned up), keeping the style when the family has it, else its closest. Each notch is one undo step; leaving the field or Escape keeps the family, and Undo takes it back. The steps use the font menu's order at the first turn and keep it until the pointer leaves the field or the text selection changes, so recent-font updates do not reshuffle later turns. That order follows the menu's saved font filters but ignores its search text, puts recent families first, and stops at the first and last family. A turn with a modifier key is left to the panel and the canvas. Behavior reference: the [Illustrator CS6 reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf#page=10) describes editing hovered dropdown controls with the mouse wheel; [Find and apply fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-apply-fonts.html) names the family field's places. Applying it to the font field, the order, the ends and the undo steps are VectorCraft's own rules. Tests: `crates/ui-egui/src/tests/font_menu.rs`, `crates/ui-egui/src/font_menu/tests.rs`, `crates/ui-egui/tests/font_wheel.rs`. - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, Text Flow by rows or by columns (#4978), vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). From 5d71656ca7f106dd93b0d2f28a64ba4dc1230e6a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:35:19 -0800 Subject: [PATCH 2/4] Fonts: Find Font skips the patterns of overset characters Characters of area type or type on a path that don't fit draw nothing, so their character fill or stroke no longer counts as painting its pattern for Find and selection-only Change, as Print already leaves them out. --- crates/engine/src/cmd/fonts.rs | 83 ++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 7df3e57b2a..c793f56479 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -114,23 +114,30 @@ fn paints(i: &AppearanceItem) -> bool { i.visible() && i.opacity() > 0.0 && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0) } +/// The name of pattern paint `p`. +fn pattern_name(p: &Paint) -> Option<&str> { + match p { + Paint::Pattern { pattern, .. } => Some(pattern.as_str()), + _ => None, + } +} + +/// The fill and, when it is wider than 0, the stroke of the characters of `r`. +fn run_paints(r: &TextRun) -> impl Iterator { + [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)].into_iter().flatten() +} + /// The patterns that the fills and strokes `n` draws name (of a text object, also those of its -/// characters): those that [`paints`] holds for, and character strokes wider than 0. -fn paint_patterns(n: &Node) -> impl Iterator { - let runs = match &n.kind { - NodeKind::Text(t) => t.runs.as_slice(), - _ => &[], - }; - n.appearance - .items - .iter() - .filter(|i| paints(i)) - .map(AppearanceItem::paint) - .chain(runs.iter().flat_map(|r| [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)]).flatten()) - .filter_map(|p| match p { - Paint::Pattern { pattern, .. } => Some(pattern.as_str()), - _ => None, - }) +/// characters): those that [`paints`] holds for, and the character paints of the runs that draw +/// (as [`drawn_runs`] finds them: not the overset runs of area type or type on a path). +fn paint_patterns(n: &Node) -> Vec<&str> { + let mut out: Vec<&str> = n.appearance.items.iter().filter(|i| paints(i)).filter_map(|i| pattern_name(i.paint())).collect(); + if let NodeKind::Text(t) = &n.kind + && t.runs.iter().flat_map(run_paints).any(|p| pattern_name(p).is_some()) + { + out.extend(drawn_runs(t).0.into_iter().flat_map(run_paints).filter_map(pattern_name)); + } + out } /// The symbols that the inline graphics in the type of `n` draw. @@ -158,7 +165,7 @@ impl Resources { self.symbols.insert(symbol.clone()); } self.symbols.extend(inline_symbols(n).map(str::to_string)); - self.patterns.extend(paint_patterns(n).map(str::to_string)); + self.patterns.extend(paint_patterns(n).into_iter().map(str::to_string)); if let Some(m) = n.extras().mask.as_deref() { self.add(&m.art); } @@ -935,7 +942,7 @@ fn reaches(n: &Node, uses: &impl Fn(&TextObject) -> bool, found: &Resources) -> && (matches!(&n.kind, NodeKind::Text(t) if uses(t)) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) || inline_symbols(n).any(|sym| found.symbols.contains(sym)) - || paint_patterns(n).any(|p| found.patterns.contains(p)) + || paint_patterns(n).into_iter().any(|p| found.patterns.contains(p)) || n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, uses, found)) || n.children().is_some_and(|ch| ch.iter().any(|c| reaches(c, uses, found)))) } @@ -991,7 +998,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result { d.walk(&mut |n: &Node| { let shows = matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) || inline_symbols(n).any(|sym| found.symbols.contains(sym)) - || paint_patterns(n).any(|p| found.patterns.contains(p)) + || paint_patterns(n).into_iter().any(|p| found.patterns.contains(p)) || (masked.contains(&n.id) && n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, &uses, &found))); if shows && !n.is_layer() && !texts.contains(&n.id) && editable(n.id) { hits.push(n.id); @@ -1845,6 +1852,44 @@ mod tests { assert_eq!(pattern_families(&s, "Dots"), ["Source Serif 4"]); } + /// Characters of area type that don't fit its frame paint nothing: their pattern's type isn't + /// found through them until they are drawn. + #[test] + fn find_font_skips_patterns_of_overset_characters() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let area = s + .execute( + "text.create", + &json!({"x": 10, "y": 60, "text": "A\nB", "font": "Source Sans 3", "size": 20, "area": {"width": 100, "height": 40}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.setRangeStyle", &json!({"id": area, "start": 2, "end": 3, "font": "Inter"})).unwrap(); + let paint_run = |s: &mut Session, letter: &'static str| { + s.edit("Paint a run with the pattern", |d, _| { + if let Some(NodeKind::Text(t)) = d.node_mut(NodeId(area)).map(|n| &mut n.kind) { + for r in t.runs.iter_mut().filter(|r| r.text.contains(letter)) { + r.style.fill = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }; + } + } + Ok(()) + }) + .unwrap(); + }; + paint_run(&mut s, "B"); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), text(&s.doc().unwrap().doc, NodeId(area)).unwrap()); + assert!(laid.overflow, "the second line doesn't fit the frame"); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0); + paint_run(&mut s, "A"); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1); + } + /// Text whose inline graphic draws a symbol shows the symbol's type: Find selects that text /// and the objects painted with a pattern whose art holds it, and a selection-only Change /// changes the symbol's type. From 48e5f09371a581b00cc33deacd9df9fabd3538ff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 18:37:17 -0800 Subject: [PATCH 3/4] Fonts: Find Font skips inline graphics that draw nothing An inline graphic in overset characters, or one named on a run that holds other characters, draws no symbol, so Find, selection-only Change and Print no longer reach its symbol's type through it. --- crates/engine/src/cmd/fonts.rs | 91 ++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index c793f56479..3119887ad3 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -122,7 +122,13 @@ fn pattern_name(p: &Paint) -> Option<&str> { } } -/// The fill and, when it is wider than 0, the stroke of the characters of `r`. +/// Whether `r` is laid out as an inline graphic: it has one and holds only its character. +fn draws_inline(r: &TextRun) -> bool { + r.inline.is_some() && vectorcraft_text::edit::is_inline_text(&r.text) +} + +/// The fill and, when it is wider than 0, the stroke of the characters of `r` (of an inline +/// graphic too: they paint its underline, strikethrough and ruby). fn run_paints(r: &TextRun) -> impl Iterator { [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)].into_iter().flatten() } @@ -140,13 +146,15 @@ fn paint_patterns(n: &Node) -> Vec<&str> { out } -/// The symbols that the inline graphics in the type of `n` draw. -fn inline_symbols(n: &Node) -> impl Iterator { - let runs = match &n.kind { - NodeKind::Text(t) => t.runs.as_slice(), - _ => &[], - }; - runs.iter().filter_map(|r| r.inline.as_ref().map(|art| art.symbol.as_str())) +/// The symbols that the inline graphics in the type of `n` draw (as [`drawn_runs`] finds them: not +/// those in the overset runs of area type or type on a path). +fn inline_symbols(n: &Node) -> Vec<&str> { + match &n.kind { + NodeKind::Text(t) if t.runs.iter().any(draws_inline) => { + drawn_runs(t).0.into_iter().filter(|r| draws_inline(r)).filter_map(|r| r.inline.as_ref().map(|art| art.symbol.as_str())).collect() + } + _ => vec![], + } } /// Symbols and patterns, by name: those whose art is in scope. @@ -164,7 +172,7 @@ impl Resources { if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { self.symbols.insert(symbol.clone()); } - self.symbols.extend(inline_symbols(n).map(str::to_string)); + self.symbols.extend(inline_symbols(n).into_iter().map(str::to_string)); self.patterns.extend(paint_patterns(n).into_iter().map(str::to_string)); if let Some(m) = n.extras().mask.as_deref() { self.add(&m.art); @@ -511,15 +519,7 @@ impl Printed<'_> { _ => (vec![], false), }; let items: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| paints(i)).collect(); - let patterns: Vec<&str> = items - .iter() - .map(|i| i.paint()) - .chain(runs.iter().flat_map(|r| [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)]).flatten()) - .filter_map(|p| match p { - Paint::Pattern { pattern, .. } => Some(pattern.as_str()), - _ => None, - }) - .collect(); + let patterns: Vec<&str> = items.iter().map(|i| i.paint()).chain(runs.iter().flat_map(|r| run_paints(r))).filter_map(pattern_name).collect(); let brushed = vectorcraft_brush::has_brush(n); if (mask.is_some() || !patterns.is_empty() || brushed) && areas.is_none_or(|a| !open || self.meets(n, a)) { if let Some(m) = mask { @@ -550,7 +550,7 @@ impl Printed<'_> { for r in runs { self.fonts.extend(run_fonts(r)); // Inline graphics draw their symbols' art. - if let Some(art) = &r.inline { + if let Some(art) = r.inline.as_ref().filter(|_| draws_inline(r)) { self.symbol(&art.symbol); } } @@ -941,7 +941,7 @@ fn reaches(n: &Node, uses: &impl Fn(&TextObject) -> bool, found: &Resources) -> && !n.locked && (matches!(&n.kind, NodeKind::Text(t) if uses(t)) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) - || inline_symbols(n).any(|sym| found.symbols.contains(sym)) + || inline_symbols(n).into_iter().any(|sym| found.symbols.contains(sym)) || paint_patterns(n).into_iter().any(|p| found.patterns.contains(p)) || n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, uses, found)) || n.children().is_some_and(|ch| ch.iter().any(|c| reaches(c, uses, found)))) @@ -997,7 +997,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result { let texts: HashSet = hits.iter().copied().collect(); d.walk(&mut |n: &Node| { let shows = matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) - || inline_symbols(n).any(|sym| found.symbols.contains(sym)) + || inline_symbols(n).into_iter().any(|sym| found.symbols.contains(sym)) || paint_patterns(n).into_iter().any(|p| found.patterns.contains(p)) || (masked.contains(&n.id) && n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, &uses, &found))); if shows && !n.is_layer() && !texts.contains(&n.id) && editable(n.id) { @@ -1890,6 +1890,55 @@ mod tests { assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1); } + /// An inline graphic that doesn't fit its area type draws nothing, and neither does an inline + /// graphic named on a run of other characters: Find doesn't select the text for its symbol. + #[test] + fn find_font_skips_undrawn_inline_graphics() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let mark = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Se", "font": "Inter"})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let area = s + .execute( + "text.create", + &json!({"x": 10, "y": 60, "text": "A\nB", "font": "Source Sans 3", "size": 20, "area": {"width": 100, "height": 40}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.insertInline", &json!({"id": area, "at": 3, "symbol": "Mark"})).unwrap(); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), text(&s.doc().unwrap().doc, NodeId(area)).unwrap()); + assert!(laid.overflow && laid.inlines.is_empty(), "the inline graphic doesn't fit the frame"); + assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 0); + let point = s.execute("text.create", &json!({"x": 10, "y": 140, "text": "Hi", "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": point, "at": 0, "symbol": "Mark"})).unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(point)]); + // A run that names an inline graphic but holds other characters is laid out as those + // characters: its fill paints them, and it draws no symbol. + let plain = s.execute("text.create", &json!({"x": 10, "y": 180, "text": "Ok", "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap(); + let art = text(&s.doc().unwrap().doc, NodeId(point)).unwrap().runs.iter().find_map(|r| r.inline.clone()).unwrap(); + s.edit("Name an inline graphic on plain characters", |d, _| { + if let Some(NodeKind::Text(t)) = d.node_mut(NodeId(plain)).map(|n| &mut n.kind) { + for r in &mut t.runs { + r.inline = Some(art.clone()); + r.style.fill = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }; + } + } + Ok(()) + }) + .unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(plain)]); + assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(point)]); + } + /// Text whose inline graphic draws a symbol shows the symbol's type: Find selects that text /// and the objects painted with a pattern whose art holds it, and a selection-only Change /// changes the symbol's type. From b9b960d7170ef2167c6ff43a00421f28be1771f7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 19:52:05 -0800 Subject: [PATCH 4/4] Fonts: Find Font skips the patterns of inline graphics without decorations The characters of an inline graphic draw no glyph, so their fill and stroke paint only their underline and strikethrough (not on a path or in vertical type) and the ruby readings that layout draws with them (a reading takes the paint of the first base character on its line, and type on a path has no ruby). Without one, their pattern no longer counts as painted for Find, selection-only Change and Print. --- crates/engine/src/cmd/fonts.rs | 114 +++++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 3119887ad3..005427ded8 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -127,21 +127,53 @@ fn draws_inline(r: &TextRun) -> bool { r.inline.is_some() && vectorcraft_text::edit::is_inline_text(&r.text) } -/// The fill and, when it is wider than 0, the stroke of the characters of `r` (of an inline -/// graphic too: they paint its underline, strikethrough and ruby). +/// The fill and, when it is wider than 0, the stroke of the characters of `r`. fn run_paints(r: &TextRun) -> impl Iterator { [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)].into_iter().flatten() } +/// [`run_paints`] of run `r` of `t` when its characters draw with them. The characters of an +/// inline graphic draw no glyph: their fill and stroke paint only their underline and +/// strikethrough (not on a path or in vertical type, see `vectorcraft_text::decorations`) and the +/// ruby readings that layout draws with their run (`ruby`, as [`ruby_runs`] finds them). +fn drawn_run_paints<'a>(t: &TextObject, ruby: &HashSet<*const TextRun>, r: &'a TextRun) -> impl Iterator { + let drawn = !draws_inline(r) || inline_decorated(t, ruby, r); + run_paints(r).filter(move |_| drawn) +} + +/// The inline graphic runs of `t` that layout draws a ruby reading with. A reading takes the run +/// of the first base character on its line, and type on a path has no ruby. `t` is laid out only +/// when it has ruby and an inline graphic painted with a pattern. +fn ruby_runs(t: &TextObject) -> HashSet<*const TextRun> { + let patterned = |r: &TextRun| draws_inline(r) && run_paints(r).any(|p| pattern_name(p).is_some()); + if t.ruby.is_empty() || !t.runs.iter().any(patterned) { + return HashSet::new(); + } + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + lay.ruby.iter().filter_map(|g| t.runs.get(g.run)).filter(|r| draws_inline(r)).map(std::ptr::from_ref).collect() +} + +/// Whether inline graphic run `r` of `t` has an underline or strikethrough that draws, or a ruby +/// reading that layout draws with it (it is in `ruby`). +fn inline_decorated(t: &TextObject, ruby: &HashSet<*const TextRun>, r: &TextRun) -> bool { + let bars = !t.vertical && !matches!(t.kind, TextKind::OnPath { .. }); + if bars && (r.style.underline || r.style.strikethrough) { + return true; + } + ruby.contains(&std::ptr::from_ref(r)) +} + /// The patterns that the fills and strokes `n` draws name (of a text object, also those of its /// characters): those that [`paints`] holds for, and the character paints of the runs that draw -/// (as [`drawn_runs`] finds them: not the overset runs of area type or type on a path). +/// (as [`drawn_runs`] finds them: not the overset runs of area type or type on a path) where they +/// draw (as [`drawn_run_paints`] says). fn paint_patterns(n: &Node) -> Vec<&str> { let mut out: Vec<&str> = n.appearance.items.iter().filter(|i| paints(i)).filter_map(|i| pattern_name(i.paint())).collect(); if let NodeKind::Text(t) = &n.kind && t.runs.iter().flat_map(run_paints).any(|p| pattern_name(p).is_some()) { - out.extend(drawn_runs(t).0.into_iter().flat_map(run_paints).filter_map(pattern_name)); + let ruby = ruby_runs(t); + out.extend(drawn_runs(t).0.into_iter().flat_map(|r| drawn_run_paints(t, &ruby, r)).filter_map(pattern_name)); } out } @@ -519,7 +551,15 @@ impl Printed<'_> { _ => (vec![], false), }; let items: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| paints(i)).collect(); - let patterns: Vec<&str> = items.iter().map(|i| i.paint()).chain(runs.iter().flat_map(|r| run_paints(r))).filter_map(pattern_name).collect(); + let ruby = match &n.kind { + NodeKind::Text(t) => ruby_runs(t), + _ => HashSet::new(), + }; + let run_patterns = runs.iter().flat_map(|r| match &n.kind { + NodeKind::Text(t) => Some(drawn_run_paints(t, &ruby, r)), + _ => None, + }); + let patterns: Vec<&str> = items.iter().map(|i| i.paint()).chain(run_patterns.flatten()).filter_map(pattern_name).collect(); let brushed = vectorcraft_brush::has_brush(n); if (mask.is_some() || !patterns.is_empty() || brushed) && areas.is_none_or(|a| !open || self.meets(n, a)) { if let Some(m) = mask { @@ -1939,6 +1979,70 @@ mod tests { assert_eq!(s.doc().unwrap().selection.objects, [NodeId(point)]); } + /// The characters of an inline graphic draw no glyph: their pattern fill paints only their + /// underline, strikethrough or ruby reading. Without one, Find doesn't select the text for the + /// pattern's type and Print doesn't count its font. + #[test] + fn find_font_skips_patterns_of_undecorated_inline_graphics() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let mark = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let point = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Hi", "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": point, "at": 1, "symbol": "Mark"})).unwrap(); + let style = |s: &mut Session, f: &dyn Fn(&mut TextObject)| { + s.edit("Style the inline graphic", |d, _| { + if let Some(NodeKind::Text(t)) = d.node_mut(NodeId(point)).map(|n| &mut n.kind) { + f(t); + } + Ok(()) + }) + .unwrap(); + }; + let found = |s: &mut Session| s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"].as_u64(); + let printed = |s: &Session| { + let d = &s.doc().unwrap().doc; + printed_type(d, &d.layers, &[Rect::new(-1000.0, -1000.0, 1000.0, 1000.0)]).0.into_iter().map(|(family, ..)| family).collect::>() + }; + style(&mut s, &|t| { + for r in t.runs.iter_mut().filter(|r| r.inline.is_some()) { + r.style.fill = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }; + } + }); + assert_eq!(found(&mut s), Some(0)); + assert_eq!(printed(&s), ["Source Sans 3"]); + // An underline paints with the pattern. + style(&mut s, &|t| t.runs.iter_mut().filter(|r| r.inline.is_some()).for_each(|r| r.style.underline = true)); + assert_eq!(found(&mut s), Some(1)); + assert_eq!(printed(&s), ["Source Sans 3", "Source Serif 4"]); + // Vertical type draws no underline. + style(&mut s, &|t| t.vertical = true); + assert_eq!(found(&mut s), Some(0)); + // A ruby reading that starts over the character before the inline graphic draws with that + // character's paint. + style(&mut s, &|t| { + assert!(t.runs[1].inline.is_some(), "the inline graphic is the second run"); + t.ruby.push(vectorcraft_doc::RubySpan { + start: 0, + end: t.runs[0].text.len() + t.runs[1].text.len(), + text: "x".into(), + position: Default::default(), + align: Default::default(), + size_percent: 50.0, + overhang: true, + }); + }); + assert_eq!(found(&mut s), Some(0)); + // A ruby reading that starts over the inline graphic paints with the pattern. + style(&mut s, &|t| t.ruby[0].start = t.runs[0].text.len()); + assert_eq!(found(&mut s), Some(1)); + } + /// Text whose inline graphic draws a symbol shows the symbol's type: Find selects that text /// and the objects painted with a pattern whose art holds it, and a selection-only Change /// changes the symbol's type.