From 52867e709688c7e8c88ccbd78171517c8aa56c2c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 15:29:22 -0800 Subject: [PATCH] Fonts: Find Font, Replace Missing Fonts and font lists include type in opacity masks and patterns Find Font and Replace Missing Fonts looked only at the type on the layers and in symbols, and the font list behind the export warning, Package and the Missing Fonts dialog skipped opacity masks and pattern swatches. - text.fonts lists the type in opacity masks (on objects and layers, disabled masks and masks inside other art included) and in pattern swatches, a swatch that nothing paints with included. A pattern's type counts once however many objects it paints. With selectionOnly, the masks of the selected objects and the symbols and patterns they show. - select.font also selects the objects whose mask holds the font and the objects painted with a pattern whose art uses it (visible fills and strokes wider than 0). A layer's mask and an unused swatch select nothing. - text.replaceFont changes that type in one undo step. Type in the mask of a hidden or locked object, and the type of a pattern that only hidden or locked selected objects paint with, keeps its font and is counted in skipped. - addon.cleanup.replaceFonts replaces missing fonts in masks and patterns, hidden or locked type included. - text.missingFonts, File > Package and the export warning read masks and patterns too. - document.info's fonts for the whole document (the Fonts and Font Details categories and the text report) include the same type. With selectionOnly the list stays the selected type's fonts. While a mask or pattern is edited, its type is taken from the editing layer only. --- crates/engine/src/cmd/addons/cleanup/text.rs | 11 +- .../src/cmd/addons/cleanup/text/tests.rs | 50 ++ crates/engine/src/cmd/docinfo.rs | 41 +- crates/engine/src/cmd/fontfiles.rs | 4 +- crates/engine/src/cmd/fonts.rs | 794 ++++++++++++++---- docs/mcp.md | 14 +- docs/target-app-parity.md | 1 + 7 files changed, 725 insertions(+), 190 deletions(-) diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 10fd432dfc..7d7dcbb197 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -12,7 +12,7 @@ use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeId, NodeKind, Para use vectorcraft_geom::{Affine, Point, Vec2}; use vectorcraft_text::{FontDb, FontMatch, TextLayout, edit}; -use crate::cmd::fonts::{edit_symbol_texts, symbol_texts}; +use crate::cmd::fonts::{art_texts_of, edit_art_texts_of}; use crate::cmd::textedit::{editing_range, editing_story}; use crate::cmd::typecmd::refresh_bounds; use crate::cmd::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, switch_or, text_param}; @@ -108,7 +108,7 @@ pub fn specs() -> Vec { "Replace Missing Fonts…", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{font?: family (default: the default type font, Source Sans 3; any of its names)} set every character of the document's type (the type in the art of its symbols included) whose font family isn't available in `font`, in the style it has closest to the one each had, as one undo step → {runs (style runs changed), font}", + "{font?: family (default: the default type font, Source Sans 3; any of its names)} set every character of the document's type (the type in its opacity masks and in the art of its symbols and patterns included, hidden or locked type and disabled masks too) whose font family isn't available in `font`, in the style it has closest to the one each had, as one undo step → {runs (style runs changed), font}", has_doc, replace_fonts ), @@ -982,8 +982,9 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { } }); } - // The type in the symbols' art too, which exports name when its font is missing. - if ids.is_empty() && !symbol_texts(d, None).into_iter().any(|(t, _)| lacks(t)) { + // The type in opacity masks and in the art of symbols and patterns too, hidden or locked type + // included, which exports name when its font is missing. + if ids.is_empty() && !art_texts_of(d).into_iter().any(|(_, t, _)| lacks(t)) { return Err(EngineError::Other("No fonts are missing".into())); } let runs = s.edit("Replace Missing Fonts", |d, _| { @@ -1008,7 +1009,7 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { replace(t); } } - edit_symbol_texts(d, None, &|t, _| lacks(t), &mut replace); + edit_art_texts_of(d, &lacks, &mut replace); Ok(runs) })?; Ok(json!({ "runs": runs, "font": family })) diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index 0af24be36a..3769cf3b42 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -415,3 +415,53 @@ fn replace_missing_fonts_reaches_the_type_in_symbols() { s.execute("symbol.exitEditing", &json!({})).unwrap(); assert_eq!(crate::cmd::fonts::substitution_warning(&s.doc().unwrap().doc), None); } + +/// Replace Missing Fonts reaches the type in opacity masks (of a hidden object, disabled, in a +/// symbol's art) and in a pattern swatch nothing paints with, and the export warning, the missing +/// fonts list and Find Font name that type's font until it is replaced. +#[test] +fn replace_missing_fonts_reaches_the_type_in_masks_and_patterns() { + let mut s = session(); + let mask = |s: &mut Session, owner: NodeId, art: NodeId| { + s.edit("Mask", |d, _| { + let art = d.remove(art)?; + d.node_mut(owner).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new((*art).clone(), true))); + Ok(()) + }) + .unwrap(); + }; + let lost = |s: &mut Session| text(s, json!({"x": 40, "y": 60, "text": "Lost", "size": 20, "font": "No Such Font Family"})); + let rect = + |s: &mut Session| NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap()); + let (hidden, disabled, in_symbol) = (rect(&mut s), rect(&mut s), rect(&mut s)); + for owner in [hidden, disabled, in_symbol] { + let t = lost(&mut s); + mask(&mut s, owner, t); + } + s.execute("select.set", &json!({"ids": [hidden.0]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [disabled.0]})).unwrap(); + s.execute("transparency.disableOpacityMask", &json!({})).unwrap(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [in_symbol.0]})).unwrap(); + let t = lost(&mut s); + s.execute("object.pattern.make", &json!({"ids": [t.0], "name": "Unused", "edit": false})).unwrap(); + select(&mut s, &[t]); + s.execute("edit.clear", &json!({})).unwrap(); + let mut on_layers = 0; + s.doc().unwrap().doc.walk(|n| on_layers += usize::from(matches!(n.kind, NodeKind::Text(_)))); + assert_eq!(on_layers, 0, "the type is only in masks, a symbol and a pattern"); + let missing = |s: &Session| crate::cmd::fontfiles::missing_fonts(&s.doc().unwrap().doc).into_iter().map(|f| f.family).collect::>(); + assert_eq!(missing(&s), ["No Such Font Family"]); + assert!(crate::cmd::fonts::substitution_warning(&s.doc().unwrap().doc).is_some_and(|w| w.contains("No Such Font Family"))); + let listed = s.execute("text.fonts", &json!({})).unwrap(); + assert_eq!((listed[0]["family"].as_str(), listed[0]["objects"].as_u64()), (Some("No Such Font Family"), Some(4)), "{listed}"); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + let r = s.execute("addon.cleanup.replaceFonts", &json!({})).unwrap(); + assert_eq!(r["runs"].as_u64(), Some(4), "{r}"); + assert_eq!(steps(&s), before + 1); + assert!(missing(&s).is_empty()); + assert_eq!(crate::cmd::fonts::substitution_warning(&s.doc().unwrap().doc), None); + assert_eq!(other(s.execute("addon.cleanup.replaceFonts", &json!({}))), "No fonts are missing"); + assert_eq!(steps(&s), before + 1); +} diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index ac79d0acc7..dfc036ff07 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", has_doc, info ), @@ -627,6 +627,11 @@ fn info(s: &mut Session, p: &Value) -> Result { } }); } + // The document's fonts include those of the type in opacity masks and in the art of its + // symbols and patterns, as Find Font lists them. + if !selection_only { + fonts.extend(super::fonts::art_texts_of(d).into_iter().flat_map(|(_, t, _)| t.runs.iter().map(|r| super::fonts::font_of(&r.style)))); + } let font_names: Vec = fonts.iter().map(super::fonts::font_label).collect(); // Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the last of two // brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. @@ -755,6 +760,40 @@ mod tests { assert!(i["objects"]["textObjects"].is_null() && i["objects"]["paths"] == 1); } + /// Document Info's fonts include the type in an opacity mask, in a symbol's art and in a + /// pattern swatch that nothing paints with; with `selectionOnly`, only the selected type's. + #[test] + fn info_fonts_include_masks_symbols_and_patterns() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let id = |v: Value| NodeId(v["id"].as_u64().unwrap()); + let text = |s: &mut Session, font: &str| id(s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": font})).unwrap()); + let rect = |s: &mut Session| id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let (masked, in_symbol) = (rect(&mut s), rect(&mut s)); + for (owner, font) in [(masked, "Mask Family"), (in_symbol, "Symbol Mask Family")] { + let t = text(&mut s, font); + s.edit("Mask", |d, _| { + let art = d.remove(t)?; + d.node_mut(owner).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new((*art).clone(), true))); + Ok(()) + }) + .unwrap(); + } + s.execute("symbol.new", &json!({"name": "Mark", "ids": [in_symbol.0]})).unwrap(); + let t = text(&mut s, "Pattern Family"); + s.execute("object.pattern.make", &json!({"ids": [t.0], "name": "Unused", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let plain = text(&mut s, "Source Sans 3"); + let fonts = |s: &mut Session, p: Value| s.execute("document.info", &p).unwrap()["fonts"].clone(); + assert_eq!( + fonts(&mut s, json!({})), + json!(["Mask Family Regular", "Pattern Family Regular", "Source Sans 3 Regular", "Symbol Mask Family Regular"]) + ); + s.execute("select.set", &json!({"ids": [plain.0]})).unwrap(); + assert_eq!(fonts(&mut s, json!({"selectionOnly": true})), json!(["Source Sans 3 Regular"])); + } + /// Document Info › Objects gives the paths' total length in the document's units (#1958): an /// open line, a closed rectangle's perimeter and a compound path's pieces count, a guide doesn't. #[test] diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs index 0ccf1ab94a..fde85dd92d 100644 --- a/crates/engine/src/cmd/fontfiles.rs +++ b/crates/engine/src/cmd/fontfiles.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Missing Fonts", [], None, - "{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols (for type in a composite font, the fonts of the parts its characters are drawn in), that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)", + "{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers, its symbols and its patterns, opacity masks included (for type in a composite font, the fonts of the parts its characters are drawn in), that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)", has_doc, missing ), @@ -103,7 +103,7 @@ pub struct MissingFont { pub resolved: Option<(String, String)>, } -/// The fonts the type of `d` uses, in its layers and its symbols (a composite font's parts, those +/// The fonts the type of `d` uses, in its layers, symbols and patterns, opacity masks included (a composite font's parts, those /// its characters are drawn in, in place of its name), that aren't available as named (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif` /// ([`GENERIC_FAMILIES`]) and names longer than 256 bytes are left out. pub fn missing_fonts(d: &Document) -> Vec { diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 2a6d1cb0df..74cece5af3 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fonts in Document", [], None, - "{selectionOnly?} (the type in the art of the symbols in scope included: all of the document's, or those the selected instances show) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font). For a composite font, status is the worst among the parts its characters are drawn in, and missingGlyphs counts the characters their part's font lacks", + "{selectionOnly?} (the type in the opacity masks of the objects in scope and in the art of the symbols and patterns in scope included: all of the document's, a pattern swatch that nothing paints included, or those the selected objects show through their instances and paints; disabled masks too) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font). For a composite font, status is the worst among the parts its characters are drawn in, and missingGlyphs counts the characters their part's font lacks", has_doc, fonts ), @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Replace Font", [], None, - "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name, a composite font's as its own name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the art of the symbols in scope changes too (all of the document's, or those the selected instances show, so every instance of them follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's art), or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}", + "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name, a composite font's as its own name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the opacity masks of the objects in scope (disabled masks too) and in the art of the symbols and patterns in scope changes too (all of the document's, or those the selected objects show through their instances and paints, so every instance of a symbol and every object a pattern paints follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's, pattern's or mask's art), in the mask of a hidden or locked object, or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}", has_doc, replace ), @@ -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, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; 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 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}", has_doc, select_font ), @@ -64,15 +64,23 @@ pub fn specs() -> Vec { ] } -/// Text objects in scope: the selection's (and their descendants') or the whole document's. -fn scope(s: &Session, selection_only: bool, selected: Option<&[NodeId]>) -> Result> { +/// Text objects in scope: the selection's (and their descendants') or the whole document's, and +/// the objects among them that have an opacity mask (not the object whose mask is being edited: +/// its art is on the editing layer). +fn scope(s: &Session, selection_only: bool, selected: Option<&[NodeId]>) -> Result<(Vec, Vec)> { let st = s.doc()?; - let mut v = vec![]; + let editing = st.doc.mask_edit.map(|m| m.object); + let (mut texts, mut masked) = (vec![], vec![]); let mut seen = HashSet::new(); let mut add = |n: &vectorcraft_doc::Node| { n.walk(&mut |c| { - if matches!(c.kind, NodeKind::Text(_)) && seen.insert(c.id) { - v.push(c.id); + if seen.insert(c.id) { + if matches!(c.kind, NodeKind::Text(_)) { + texts.push(c.id); + } + if c.extras().mask.is_some() && Some(c.id) != editing { + masked.push(c.id); + } } }) }; @@ -87,7 +95,7 @@ fn scope(s: &Session, selection_only: bool, selected: Option<&[NodeId]>) -> Resu add(l); } } - Ok(v) + Ok((texts, masked)) } /// Whether `name` is the symbol being edited, whose art is on the editing layer. @@ -95,106 +103,269 @@ fn being_edited(d: &Document, name: &str) -> bool { d.symbol_edit.as_ref().is_some_and(|e| e.symbol == name) } -/// The symbols that the selected objects of `s` show: those of the instances among them and their -/// descendants, and of the instances in those symbols' art that are not hidden or locked, nor in a -/// hidden or locked group of the art. Returned as (all of them, those shown by instances the -/// Selection tool can reach: not hidden or locked, as `Document::editable` says). -fn selected_symbols(s: &Session, selected: Option<&[NodeId]>) -> Result<(BTreeSet, BTreeSet)> { - fn open_instances(n: &Node, out: &mut BTreeSet) { +/// Whether `name` is the pattern being edited, whose tile art is on the editing layer. +fn pattern_being_edited(d: &Document, name: &str) -> bool { + d.pattern_edit.as_ref().is_some_and(|e| e.pattern == name) +} + +/// 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. +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| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)) + .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, + }) +} + +/// Symbols and patterns, by name: those whose art is in scope. +#[derive(Clone, Debug, Default)] +struct Resources { + symbols: BTreeSet, + patterns: BTreeSet, +} + +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). + fn add_own(&mut self, n: &Node) { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { + self.symbols.insert(symbol.clone()); + } + self.patterns.extend(paint_patterns(n).map(str::to_string)); + if let Some(m) = n.extras().mask.as_deref() { + self.add(&m.art); + } + } + + /// Adds what `n` and its descendants show, leaving out hidden or locked objects and what they + /// hold. + fn add(&mut self, n: &Node) { if !n.visible || n.locked { return; } - if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { - out.insert(symbol.clone()); - } + self.add_own(n); for c in n.children().map(Vec::as_slice).unwrap_or_default() { - open_instances(c, out); + self.add(c); } } - let expand = |names: &mut BTreeSet, d: &Document| loop { - let mut more = BTreeSet::new(); - for sym in d.symbols.iter().filter(|sym| names.contains(&sym.name)) { - open_instances(&sym.art, &mut more); + + /// Adds what the art of the symbols and patterns named shows, until nothing more is added; each + /// symbol and pattern is read once, so art that shows itself ends. + fn expand(&mut self, d: &Document) { + let mut symbols: Vec = self.symbols.iter().cloned().collect(); + let mut patterns: Vec = self.patterns.iter().cloned().collect(); + while !symbols.is_empty() || !patterns.is_empty() { + let mut more = Resources::default(); + for name in symbols.drain(..) { + if let Some(sym) = d.symbols.iter().find(|sym| sym.name == name) { + more.add(&sym.art); + } + } + for name in patterns.drain(..) { + for art in d.pattern(&name).map(|p| p.art.as_slice()).unwrap_or_default() { + more.add(art); + } + } + symbols.extend(more.symbols.into_iter().filter(|name| self.symbols.insert(name.clone()))); + patterns.extend(more.patterns.into_iter().filter(|name| self.patterns.insert(name.clone()))); } - let before = names.len(); - names.extend(more); - if names.len() == before { - break; + } + + /// The symbols and patterns of `self` that are not in `other`. + fn difference(&self, other: &Self) -> Self { + Self { + symbols: self.symbols.difference(&other.symbols).cloned().collect(), + patterns: self.patterns.difference(&other.patterns).cloned().collect(), } - }; + } +} + +/// The symbols and patterns that the selected objects of `s` show: those of the instances among +/// them and their descendants and the patterns their paints name (also in the art of their +/// opacity masks), and those that the art of these symbols and patterns shows through objects that +/// are not hidden or locked, nor in a hidden or locked group of the art. Returned as (all of them, +/// those shown by objects the Selection tool can reach: not hidden or locked, as +/// `Document::editable` says). +fn selected_art(s: &Session, selected: Option<&[NodeId]>) -> Result<(Resources, Resources)> { let st = s.doc()?; - let (mut all, mut open) = (BTreeSet::new(), BTreeSet::new()); + let (mut all, mut open) = (Resources::default(), Resources::default()); let editable = st.doc.editable(); for id in selected.unwrap_or(&st.selection.objects) { if let Some(n) = st.doc.node(*id) { n.walk(&mut |c| { - if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { - all.insert(symbol.clone()); - if editable(c.id) { - open.insert(symbol.clone()); - } + all.add_own(c); + if editable(c.id) { + open.add_own(c); } }); } } drop(editable); - expand(&mut all, &st.doc); - expand(&mut open, &st.doc); + all.expand(&st.doc); + open.expand(&st.doc); Ok((all, open)) } -/// The text objects in the art of `d`'s symbols (with `only`, of those it names), each with -/// whether it is open: neither it nor a group around it in the art is hidden or locked. The symbol -/// being edited is left out: its art is on the editing layer. -pub(super) fn symbol_texts<'a>(d: &'a Document, only: Option<&BTreeSet>) -> Vec<(&'a TextObject, bool)> { - fn visit<'a>(n: &'a Node, open: bool, out: &mut Vec<(&'a TextObject, bool)>) { - let open = open && n.visible && !n.locked; - if let NodeKind::Text(t) = &n.kind { - out.push((&**t, open)); - } - for c in n.children().map(Vec::as_slice).unwrap_or_default() { - visit(c, open, out); - } +/// A text object found in art: its id, the text and whether it is open (neither it nor a group +/// around it, nor an object whose opacity mask holds it, is hidden or locked). +pub(super) type ArtText<'a> = (NodeId, &'a TextObject, bool); + +/// Adds the text objects of `n` and its descendants, the art of their opacity masks included, to +/// `out`; each is open when `open` holds and [`ArtText`] says so. +fn art_texts<'a>(n: &'a Node, open: bool, out: &mut Vec>) { + let open = open && n.visible && !n.locked; + if let NodeKind::Text(t) = &n.kind { + out.push((n.id, &**t, open)); } + if let Some(m) = n.extras().mask.as_deref() { + art_texts(&m.art, open, out); + } + for c in n.children().map(Vec::as_slice).unwrap_or_default() { + art_texts(c, open, out); + } +} + +/// The text objects in the art of the opacity masks of the objects `masked` (each with whether +/// the object is open: when it isn't, none of its mask's text is). +fn mask_texts<'a>(d: &'a Document, masked: &[(NodeId, bool)]) -> Vec> { let mut out = vec![]; - for s in d.symbols.iter().filter(|s| !being_edited(d, &s.name) && only.is_none_or(|o| o.contains(&s.name))) { - visit(&s.art, true, &mut out); + for (id, open) in masked { + if let Some(m) = d.node(*id).and_then(|n| n.extras().mask.as_deref()) { + art_texts(&m.art, *open, &mut out); + } } out } -/// Calls `f` on each text object in the art of `d`'s symbols (with `only`, of those it names) for -/// which `wants(text, open)` holds (open as in [`symbol_texts`]), the symbol being edited left out. -/// Art without such text is left as it is. -pub(super) fn edit_symbol_texts( - d: &mut Document, - only: Option<&BTreeSet>, - wants: &impl Fn(&TextObject, bool) -> bool, - f: &mut impl FnMut(&mut TextObject), -) { - fn wanted(n: &Node, open: bool, wants: &impl Fn(&TextObject, bool) -> bool) -> bool { - let open = open && n.visible && !n.locked; - matches!(&n.kind, NodeKind::Text(t) if wants(t, open)) || n.children().is_some_and(|ch| ch.iter().any(|c| wanted(c, open, wants))) +/// The text objects in the art of `d`'s symbols and patterns (with `only`, of those it names, a +/// pattern's art read once however many objects it paints), opacity masks in that art included. +/// The symbol and the pattern being edited are left out: their art is on the editing layer. +fn resource_texts<'a>(d: &'a Document, only: Option<&Resources>) -> Vec> { + let mut out = vec![]; + for s in d.symbols.iter().filter(|s| !being_edited(d, &s.name) && only.is_none_or(|o| o.symbols.contains(&s.name))) { + art_texts(&s.art, true, &mut out); } - fn visit(n: &mut Node, open: bool, wants: &impl Fn(&TextObject, bool) -> bool, f: &mut impl FnMut(&mut TextObject)) { - let open = open && n.visible && !n.locked; - if let NodeKind::Text(t) = &mut n.kind - && wants(t, open) - { - f(t); + for p in d.patterns.iter().filter(|p| !pattern_being_edited(d, &p.name) && only.is_none_or(|o| o.patterns.contains(&p.name))) { + for art in &p.art { + art_texts(art, true, &mut out); } - for c in n.children_mut().into_iter().flatten() { - if wanted(c, open, wants) { - visit(Arc::make_mut(c), open, wants, f); + } + out +} + +/// The text objects in the art of `d`'s symbols (with `only`, of those it names), as +/// [`resource_texts`] finds them, each with whether it is open. +#[cfg(test)] +pub(super) fn symbol_texts<'a>(d: &'a Document, only: Option<&BTreeSet>) -> Vec<(&'a TextObject, bool)> { + let mut out = vec![]; + for s in d.symbols.iter().filter(|s| !being_edited(d, &s.name) && only.is_none_or(|o| o.contains(&s.name))) { + art_texts(&s.art, true, &mut out); + } + out.into_iter().map(|(_, t, open)| (t, open)).collect() +} + +/// Whether `n` or a descendant (the art of their opacity masks included) holds a text object for +/// which `wants(text, open)` holds, open as [`ArtText`] says. +fn wanted(n: &Node, open: bool, wants: &impl Fn(&TextObject, bool) -> bool) -> bool { + let open = open && n.visible && !n.locked; + matches!(&n.kind, NodeKind::Text(t) if wants(t, open)) + || n.extras().mask.as_deref().is_some_and(|m| wanted(&m.art, open, wants)) + || n.children().is_some_and(|ch| ch.iter().any(|c| wanted(c, open, wants))) +} + +/// Calls `f` on each text object of `n` and its descendants (the art of their opacity masks +/// included) for which `wants(text, open)` holds; only the nodes on the way to such text are +/// copied. +fn edit_art(n: &mut Node, open: bool, wants: &impl Fn(&TextObject, bool) -> bool, f: &mut impl FnMut(&mut TextObject)) { + let open = open && n.visible && !n.locked; + if let NodeKind::Text(t) = &mut n.kind + && wants(t, open) + { + f(t); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && wanted(&m.art, open, wants) + { + edit_art(Arc::make_mut(&mut m.art), open, wants, f); + } + for c in n.children_mut().into_iter().flatten() { + if wanted(c, open, wants) { + edit_art(Arc::make_mut(c), open, wants, f); + } + } +} + +/// Calls `f` on each text object in the opacity masks of the objects `masked` (as [`mask_texts`] +/// finds them) for which `wants(text, open)` holds. Masks without such text are left as they are. +fn edit_mask_texts(d: &mut Document, masked: &[(NodeId, bool)], wants: &impl Fn(&TextObject, bool) -> bool, f: &mut impl FnMut(&mut TextObject)) { + for (id, open) in masked { + if d.node(*id).and_then(|n| n.extras().mask.as_deref()).is_some_and(|m| wanted(&m.art, *open, wants)) + && let Some(m) = d.node_mut(*id).and_then(|n| n.extras_if_any()).and_then(|e| e.mask.as_mut()) + { + edit_art(Arc::make_mut(&mut m.art), *open, wants, f); + } + } +} + +/// Calls `f` on each text object in the art of `d`'s symbols and patterns (as [`resource_texts`] +/// finds them) for which `wants(text, open)` holds. Art without such text is left as it is. +fn edit_resource_texts(d: &mut Document, only: Option<&Resources>, wants: &impl Fn(&TextObject, bool) -> bool, f: &mut impl FnMut(&mut TextObject)) { + let symbol = d.symbol_edit.as_ref().map(|e| e.symbol.clone()); + let pattern = d.pattern_edit.as_ref().map(|e| e.pattern.clone()); + for s in &mut d.symbols { + if symbol.as_ref() != Some(&s.name) && only.is_none_or(|o| o.symbols.contains(&s.name)) && wanted(&s.art, true, wants) { + edit_art(Arc::make_mut(&mut s.art), true, wants, f); + } + } + for p in &mut d.patterns { + if pattern.as_ref() != Some(&p.name) && only.is_none_or(|o| o.patterns.contains(&p.name)) { + for art in &mut p.art { + if wanted(art, true, wants) { + edit_art(Arc::make_mut(art), true, wants, f); + } } } } - let edited = d.symbol_edit.as_ref().map(|e| e.symbol.clone()); - for s in &mut d.symbols { - if edited.as_ref() != Some(&s.name) && only.is_none_or(|o| o.contains(&s.name)) && wanted(&s.art, true, wants) { - visit(Arc::make_mut(&mut s.art), true, wants, f); +} + +/// Every text object of `d` outside the objects of its layers: in the opacity masks of the +/// objects on its layers, and in the art of its symbols and patterns (as [`mask_texts`] and +/// [`resource_texts`] find them, each mask's text open). +pub(super) fn art_texts_of(d: &Document) -> Vec> { + let masked = masked_objects(d); + let mut out = mask_texts(d, &masked); + out.extend(resource_texts(d, None)); + out +} + +/// Calls `f` on each text object of [`art_texts_of`] for which `wants(text)` holds. +pub(super) fn edit_art_texts_of(d: &mut Document, wants: &impl Fn(&TextObject) -> bool, f: &mut impl FnMut(&mut TextObject)) { + let masked = masked_objects(d); + edit_mask_texts(d, &masked, &|t, _| wants(t), f); + edit_resource_texts(d, None, &|t, _| wants(t), f); +} + +/// The objects on the layers of `d` that have an opacity mask (not the object whose mask is being +/// edited), each as open. +fn masked_objects(d: &Document) -> Vec<(NodeId, bool)> { + let editing = d.mask_edit.map(|m| m.object); + let mut out = vec![]; + d.walk(|n| { + if n.extras().mask.is_some() && Some(n.id) != editing { + out.push((n.id, true)); } - } + }); + out } /// A font as type names it: family, style and, when it names one, the installed version. @@ -218,14 +389,16 @@ pub(super) fn run_fonts(r: &TextRun) -> Vec { } } -/// The fonts (family, style, version) the type in the layers and symbols of `d` is drawn in, a -/// composite font's parts in place of its name ([`run_fonts`]). +/// The fonts (family, style, version) the type in the layers, symbols and patterns of `d` is drawn +/// in, the type in opacity masks included, a composite font's parts in place of its name +/// ([`run_fonts`]). pub(super) fn used_fonts(d: &Document) -> BTreeSet { art_fonts(d, &[]) } -/// The fonts (family, style, version) of the type in the layers and symbols of `d` and in `art` -/// (art read from a file, its resources in `d`). +/// The fonts (family, style, version) of the type in the layers, symbols and patterns of `d` and +/// in `art` (art read from a file, its resources in `d`), the type in opacity masks included. The +/// pattern being edited is read from the editing layer only. pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet { let mut fonts = BTreeSet::new(); let mut add = |n: &Node| { @@ -233,12 +406,9 @@ pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet { fonts.extend(t.runs.iter().flat_map(run_fonts)); } }; - d.walk(&mut add); - for s in &d.symbols { - s.art.walk(&mut add); - } - for n in art { - n.walk(&mut add); + let patterns = d.patterns.iter().filter(|p| !pattern_being_edited(d, &p.name)).flat_map(|p| p.art.iter().map(|a| &**a)); + for n in d.layers.iter().map(|l| &**l).chain(d.symbols.iter().map(|s| &*s.art)).chain(patterns).chain(art) { + vectorcraft_doc::swatches::walk_with_masks(n, &mut add); } fonts } @@ -513,50 +683,78 @@ impl Session { pub fn skipped_font_ids(&self, params: &Value, selected: Option<&[NodeId]>) -> Result> { let (family, style) = font_param(params, "from", "text.replaceFont")?; let selection_only = bool_or(params, "selectionOnly", false); - let selected_symbols = selection_only.then(|| selected_symbols(self, selected)).transpose()?; - let d = &self.doc()?.doc; - let other: Vec = { - let editable = d.editable(); - scope(self, selection_only, selected)?.into_iter().filter(|id| !editable(*id)).collect() - }; + let in_scope = ReplaceScope::read(self, selection_only, selected)?; let uses = |t: &TextObject| { t.runs.iter().any(|r| { r.style.font_family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|name| r.style.font_style.eq_ignore_ascii_case(name)) }) }; - let mut ids = other.into_iter().filter(|id| text(d, *id).is_some_and(uses)).collect::>(); - fn visit(n: &Node, open: bool, closed: bool, uses: &impl Fn(&TextObject) -> bool, ids: &mut Vec) { - let open = open && n.visible && !n.locked; - if let NodeKind::Text(t) = &n.kind - && (!open || closed) - && uses(t) - { - ids.push(n.id); - } - for child in n.children().map(Vec::as_slice).unwrap_or_default() { - visit(child, open, closed, uses, ids); - } + Ok(in_scope.skipped(&self.doc()?.doc, &uses)) + } +} + +/// What Replace Font looks at: the text objects in scope that the Selection tool can reach and +/// those it can't, the objects with an opacity mask (each with whether it can reach them), and the +/// symbols and patterns whose art is in scope (`None`: all of the document's) with those shown +/// only by hidden or locked objects of the selection. +struct ReplaceScope { + ids: Vec, + other: Vec, + masked: Vec<(NodeId, bool)>, + resources: Option, + closed: Resources, +} + +impl ReplaceScope { + fn read(s: &Session, selection_only: bool, selected: Option<&[NodeId]>) -> Result { + // With selectionOnly: the symbols and patterns the selection shows; those shown only by + // hidden or locked objects keep their type, counted in `skipped`. + let (resources, closed) = if selection_only { + let (all, open) = selected_art(s, selected)?; + let closed = all.difference(&open); + (Some(open), closed) + } else { + (None, Resources::default()) + }; + let d = &s.doc()?.doc; + let editable = d.editable(); + let (texts, masked) = scope(s, selection_only, selected)?; + let (ids, other): (Vec, Vec) = texts.into_iter().partition(|id| editable(*id)); + let masked = masked.into_iter().map(|id| (id, editable(id))).collect(); + Ok(Self { ids, other, masked, resources, closed }) + } + + /// The text objects in the art of opacity masks, symbols and patterns in scope. + fn art<'a>(&self, d: &'a Document) -> Vec> { + let mut texts = mask_texts(d, &self.masked); + texts.extend(resource_texts(d, self.resources.as_ref())); + texts + } + + /// The text objects in scope set in a font `uses` matches that Replace Font leaves as they are: + /// those the Selection tool can't reach, hidden or locked text in art, and the text of the + /// symbols and patterns shown only by hidden or locked objects of the selection. + fn skipped(&self, d: &Document, uses: &impl Fn(&TextObject) -> bool) -> Vec { + let mut ids: Vec = self.other.iter().copied().filter(|id| text(d, *id).is_some_and(uses)).collect(); + ids.extend(self.art(d).into_iter().filter(|(_, t, open)| !open && uses(t)).map(|(id, ..)| id)); + if !self.closed.symbols.is_empty() || !self.closed.patterns.is_empty() { + ids.extend(resource_texts(d, Some(&self.closed)).into_iter().filter(|(_, t, _)| uses(t)).map(|(id, ..)| id)); } - for symbol in d - .symbols - .iter() - .filter(|symbol| !being_edited(d, &symbol.name) && selected_symbols.as_ref().is_none_or(|(all, _)| all.contains(&symbol.name))) - { - let closed = selected_symbols.as_ref().is_some_and(|(_, open)| !open.contains(&symbol.name)); - visit(&symbol.art, true, closed, &uses, &mut ids); - } - Ok(ids) + ids } } fn font_inventory(s: &Session, selected: Option<&[NodeId]>) -> Result { - let ids = scope(s, selected.is_some(), selected)?; + let (ids, masked) = scope(s, selected.is_some(), selected)?; let d = &s.doc()?.doc; let db = vectorcraft_text::FontDb::global(); - // The type in the art of the symbols in scope too: all of them, or those the selection shows. - let symbols = if selected.is_some() { Some(selected_symbols(s, selected)?.0) } else { None }; + // The type in the art of the opacity masks of the objects in scope, and of the symbols and + // patterns in scope: all of them, or those the selection shows. + let resources = if selected.is_some() { Some(selected_art(s, selected)?.0) } else { None }; let mut texts: Vec<&TextObject> = ids.iter().filter_map(|id| text(d, *id)).collect(); - texts.extend(symbol_texts(d, symbols.as_ref()).into_iter().map(|(t, _)| t)); + let masked: Vec<(NodeId, bool)> = masked.into_iter().map(|id| (id, true)).collect(); + texts.extend(mask_texts(d, &masked).into_iter().map(|(_, t, _)| t)); + texts.extend(resource_texts(d, resources.as_ref()).into_iter().map(|(_, t, _)| t)); // Each font's runs, objects and the text of its runs. let mut found: BTreeMap)> = BTreeMap::new(); for t in texts { @@ -617,21 +815,6 @@ fn font_param(p: &Value, k: &str, c: &str) -> Result<(String, Option)> { Ok((family.to_string(), v.get("style").and_then(Value::as_str).map(str::to_string))) } -fn skipped_font_texts( - d: &Document, - other: &[NodeId], - symbols: &[(&TextObject, bool)], - closed: &BTreeSet, - uses: &impl Fn(&TextObject) -> bool, -) -> usize { - let mut skipped = other.iter().filter(|id| text(d, **id).is_some_and(uses)).count(); - skipped += symbols.iter().filter(|(t, open)| !open && uses(t)).count(); - if !closed.is_empty() { - skipped += symbol_texts(d, Some(closed)).into_iter().filter(|(t, _)| uses(t)).count(); - } - skipped -} - fn replace(s: &mut Session, p: &Value) -> Result { const C: &str = "text.replaceFont"; let (ff, fs) = font_param(p, "from", C)?; @@ -660,35 +843,25 @@ fn replace(s: &mut Session, p: &Value) -> Result { }; // Only text the Selection tool can reach changes: not hidden or locked, in a hidden or locked // group or layer, or on a locked artboard. The text left in the font is counted. The type in - // the art of the symbols in scope (all of them, or those the selection shows) changes too, - // unless it or a group around it in the art is hidden or locked. + // the opacity masks of the objects in scope, and in the art of the symbols and patterns in + // scope (all of them, or those the selection shows), changes too, unless it, a group around it + // in the art or the object whose mask holds it is hidden or locked. let selection_only = bool_or(p, "selectionOnly", false); - // With selectionOnly: the symbols the selection shows; those shown only by hidden or locked - // instances keep their type, counted in `skipped`. - let (symbols, closed) = if selection_only { - let (all, open) = selected_symbols(s, None)?; - let closed: BTreeSet = all.difference(&open).cloned().collect(); - (Some(open), closed) - } else { - (None, BTreeSet::new()) - }; let uses = |t: &TextObject| t.runs.iter().any(|r| matches(&r.style)); // Whether a run of `t` changes. let changes = |t: &TextObject| t.runs.iter().any(|r| new_style(&r.style).is_some()); - let (ids, skipped, in_symbols) = { + let in_scope = ReplaceScope::read(s, selection_only, None)?; + let (ids, skipped, in_art) = { let d = &s.doc()?.doc; - let editable = d.editable(); - let (ids, other): (Vec, Vec) = scope(s, selection_only, None)?.into_iter().partition(|id| editable(*id)); - let texts = symbol_texts(d, symbols.as_ref()); - let skipped = skipped_font_texts(d, &other, &texts, &closed, &uses); + let skipped = in_scope.skipped(d, &uses).len(); // The text that changes: when none does, the document and its undo history stay as they are. - let ids: Vec = ids.into_iter().filter(|id| text(d, *id).is_some_and(changes)).collect(); - let in_symbols = texts.iter().any(|(t, open)| *open && changes(t)); - (ids, skipped, in_symbols) + let ids: Vec = in_scope.ids.iter().copied().filter(|id| text(d, *id).is_some_and(changes)).collect(); + let in_art = in_scope.art(d).iter().any(|(_, t, open)| *open && changes(t)); + (ids, skipped, in_art) }; // The fonts the replaced runs are set in, for the warning. let mut set = BTreeSet::new(); - let n = if ids.is_empty() && !in_symbols { + let n = if ids.is_empty() && !in_art { 0 } else { s.edit("Replace Font", |d, _| { @@ -717,7 +890,9 @@ fn replace(s: &mut Session, p: &Value) -> Result { replace_in(t); } } - edit_symbol_texts(d, symbols.as_ref(), &|t, open| open && changes(t), &mut replace_in); + let wants = |t: &TextObject, open: bool| open && changes(t); + edit_mask_texts(d, &in_scope.masked, &wants, &mut replace_in); + edit_resource_texts(d, in_scope.resources.as_ref(), &wants, &mut replace_in); Ok(n) })? }; @@ -734,13 +909,26 @@ fn replace(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// 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. +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)) + || 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)))) +} + /// What Replace Font says when it left text in the font that it can't reach. const REPLACE_SKIPPED: &str = "some text is hidden or locked, so its font wasn't replaced"; fn select_font(s: &mut Session, p: &Value) -> Result { let family = str_param(p, "family").ok_or_else(|| bad("select.font", "missing `family`"))?.to_string(); let style = str_param(p, "style").map(str::to_string); - let ids = scope(s, false, None)?; + let (ids, masked) = scope(s, false, None)?; let d = &s.doc()?.doc; let within = FontScope::read(d, p)?; // Only text the Selection tool can reach: not hidden or locked, in a hidden or locked group @@ -752,37 +940,40 @@ fn select_font(s: &mut Session, p: &Value) -> Result { .any(|r| r.style.font_family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|x| r.style.font_style.eq_ignore_ascii_case(x))) }; let mut hits: Vec = ids.into_iter().filter(|id| editable(*id) && text(d, *id).is_some_and(uses)).collect(); - // The symbols whose art uses the font in type that is not hidden or locked, nor in a hidden or - // locked group of the art, also through the instances of other symbols it holds. - fn reaches(n: &Node, uses: &impl Fn(&TextObject) -> bool, symbols: &BTreeSet<&str>) -> bool { - n.visible - && !n.locked - && match &n.kind { - NodeKind::Text(t) => uses(t), - NodeKind::SymbolInstance { symbol, .. } => symbols.contains(symbol.as_str()), - _ => n.children().is_some_and(|ch| ch.iter().any(|c| reaches(c, uses, symbols))), - } - } - let mut symbols: BTreeSet<&str> = BTreeSet::new(); + // The symbols and patterns whose art uses the font in type that is not hidden or locked, nor in + // a hidden or locked group of the art, also through the instances, patterns and opacity masks + // it holds. The pattern being edited is left out: its art is on the editing layer. + let mut found = Resources::default(); loop { - let found: Vec<&str> = d + let symbols: Vec = d .symbols .iter() - .filter(|sym| !symbols.contains(sym.name.as_str()) && reaches(&sym.art, &uses, &symbols)) - .map(|sym| sym.name.as_str()) + .filter(|sym| !found.symbols.contains(&sym.name) && reaches(&sym.art, &uses, &found)) + .map(|sym| sym.name.clone()) .collect(); - if found.is_empty() { + let patterns: Vec = d + .patterns + .iter() + .filter(|p| !pattern_being_edited(d, &p.name) && !found.patterns.contains(&p.name) && p.art.iter().any(|a| reaches(a, &uses, &found))) + .map(|p| p.name.clone()) + .collect(); + if symbols.is_empty() && patterns.is_empty() { break; } - symbols.extend(found); + found.symbols.extend(symbols); + found.patterns.extend(patterns); } - // Their instances the Selection tool can reach. - if !symbols.is_empty() { + // 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). + 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| { - if let NodeKind::SymbolInstance { symbol, .. } = &n.kind - && symbols.contains(symbol.as_str()) - && editable(n.id) - { + let shows = matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) + || 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) { hits.push(n.id); } }); @@ -1311,6 +1502,255 @@ mod tests { assert_eq!(s.execute("text.replaceFont", &serif_to_sans).unwrap(), json!({"runs": 0})); } + /// Moves object `art` of the layers into the opacity mask of `owner` (a layer or an object). + fn mask_with(s: &mut Session, owner: u64, art: u64) { + s.edit("Mask", |d, _| { + let art = d.remove(NodeId(art))?; + d.node_mut(NodeId(owner)).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new((*art).clone(), true))); + Ok(()) + }) + .unwrap(); + } + + /// The font families of the first runs of the text in the opacity mask of `owner`. + fn mask_families(s: &Session, owner: u64) -> Vec { + let d = &s.doc().unwrap().doc; + mask_texts(d, &[(NodeId(owner), true)]).iter().map(|(_, t, _)| t.runs[0].style.font_family.clone()).collect() + } + + /// The font families of the first runs of the text in the art of pattern `name`. + fn pattern_families(s: &Session, name: &str) -> Vec { + let d = &s.doc().unwrap().doc; + let mut out = vec![]; + for art in &d.pattern(name).unwrap().art { + art_texts(art, true, &mut out); + } + out.iter().map(|(_, t, _)| t.runs[0].style.font_family.clone()).collect() + } + + /// The rows of `text.fonts` with `p`: family, runs and objects. + fn font_rows(s: &mut Session, p: Value) -> Vec<(String, u64, u64)> { + let fonts = s.execute("text.fonts", &p).unwrap(); + fonts + .as_array() + .unwrap() + .iter() + .map(|f| (f["family"].as_str().unwrap().to_string(), f["runs"].as_u64().unwrap(), f["objects"].as_u64().unwrap())) + .collect() + } + + /// Find Font lists, selects and changes the type in opacity masks: on objects, on a layer, + /// disabled and inside the art of another mask. The type in the mask of a locked object is + /// listed and left in its font; Find selects the objects whose mask holds the type. + #[test] + fn find_font_reaches_the_type_in_opacity_masks() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let text = |s: &mut Session, y: i32| { + s.execute("text.create", &json!({"x": 10, "y": y, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap() + }; + let rect = |s: &mut Session, y: i32| { + s.execute("shape.rectangle", &json!({"x": 0, "y": y, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let mut owners = vec![]; + for y in [20, 50, 80] { + let owner = rect(&mut s, y); + let t = text(&mut s, y); + mask_with(&mut s, owner, t); + owners.push(owner); + } + let (open, locked, disabled) = (owners[0], owners[1], owners[2]); + s.execute("select.set", &json!({"ids": [locked]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [disabled]})).unwrap(); + s.execute("transparency.disableOpacityMask", &json!({})).unwrap(); + // A mask whose art is an object with a mask of its own, and a layer's mask. + let nested = rect(&mut s, 110); + let inner = rect(&mut s, 110); + let t = text(&mut s, 110); + mask_with(&mut s, inner, t); + mask_with(&mut s, nested, inner); + let layer = s.doc().unwrap().doc.layers[0].id.0; + let t = text(&mut s, 140); + mask_with(&mut s, layer, t); + let mut on_layers = 0; + s.doc().unwrap().doc.walk(|n| on_layers += usize::from(matches!(n.kind, NodeKind::Text(_)))); + assert_eq!(on_layers, 0, "the type is only in masks"); + assert_eq!(font_rows(&mut s, json!({})), [("Source Serif 4".to_string(), 5, 5)]); + s.execute("select.set", &json!({"ids": [open]})).unwrap(); + assert_eq!(font_rows(&mut s, json!({"selectionOnly": true})), [("Source Serif 4".to_string(), 1, 1)]); + // Find selects the objects whose mask holds the type, not the locked one or the layer. + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 3); + let mut selected = s.doc().unwrap().selection.objects.clone(); + selected.sort(); + assert_eq!(selected, [NodeId(open), NodeId(disabled), NodeId(nested)]); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"artboards": [0]}})).unwrap()["count"], 3); + // Change All: one undo step, the locked object's mask left in its font and counted. + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + let serif_to_sans = json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}}); + let r = s.execute("text.replaceFont", &serif_to_sans).unwrap(); + assert_eq!((r["runs"].as_u64(), r["skipped"].as_u64()), (Some(4), Some(1)), "{r}"); + assert_eq!(steps(&s), before + 1); + for owner in [open, disabled, nested, layer] { + assert_eq!(mask_families(&s, owner), ["Source Sans 3"], "the mask of {owner}"); + } + assert_eq!(mask_families(&s, locked), ["Source Serif 4"]); + let r = s.execute("text.replaceFont", &serif_to_sans).unwrap(); + assert_eq!(r, json!({"runs": 0, "skipped": 1, "warning": REPLACE_SKIPPED})); + assert_eq!(steps(&s), before + 1); + s.execute("edit.undo", &json!({})).unwrap(); + for owner in [open, disabled, nested, layer] { + assert_eq!(mask_families(&s, owner), ["Source Serif 4"], "the mask of {owner} after Undo"); + } + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(mask_families(&s, nested), ["Source Sans 3"]); + // While a mask is edited, its type on the editing layer is counted and changed once. + s.execute("transparency.editOpacityMask", &json!({"id": open})).unwrap(); + assert_eq!(font_rows(&mut s, json!({})), [("Source Sans 3".to_string(), 4, 4), ("Source Serif 4".to_string(), 1, 1)]); + let r = s.execute("text.replaceFont", &json!({"from": {"family": "Source Sans 3"}, "to": {"family": "Inter"}})).unwrap(); + assert_eq!(r["runs"].as_u64(), Some(4), "{r}"); + let families: Vec = used_fonts(&s.doc().unwrap().doc).into_iter().map(|(family, ..)| family).collect(); + assert_eq!(families, ["Inter", "Source Serif 4"], "the edited mask is written back after each edit"); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + assert_eq!(mask_families(&s, open), ["Inter"]); + } + + /// Find Font lists the type in pattern swatches once, also when nothing paints with them; + /// Find selects the objects the pattern paints, and Change changes the pattern's art once for + /// every object it paints. While a pattern is edited, its type is taken from the editing layer. + #[test] + fn find_font_reaches_the_type_in_patterns() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let pattern = |s: &mut Session, name: &str, font: &str| { + let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": font})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [t], "name": name, "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + }; + pattern(&mut s, "Dots", "Source Serif 4"); + pattern(&mut s, "Unused", "Inter"); + let mut on_layers = 0; + s.doc().unwrap().doc.walk(|n| on_layers += usize::from(matches!(n.kind, NodeKind::Text(_)))); + assert_eq!(on_layers, 0, "the type is only in patterns"); + let all = [("Inter".to_string(), 1, 1), ("Source Serif 4".to_string(), 1, 1)]; + assert_eq!(font_rows(&mut s, json!({})), all); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0); + let mut painted = vec![]; + for y in [20, 60] { + painted.push(s.execute("shape.rectangle", &json!({"x": 0, "y": y, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap()); + } + s.execute("paint.setFill", &json!({"swatch": "Dots", "ids": painted})).unwrap(); + assert_eq!(font_rows(&mut s, json!({})), all, "a pattern's type counts once"); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 2); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(painted[0]), NodeId(painted[1])]); + assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 0); + // Only the selection: the patterns the selected objects paint with. + s.execute("select.set", &json!({"ids": [painted[0]]})).unwrap(); + assert_eq!(font_rows(&mut s, json!({"selectionOnly": true})), [("Source Serif 4".to_string(), 1, 1)]); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + 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!(steps(&s), before + 1); + assert_eq!(pattern_families(&s, "Dots"), ["Source Sans 3"]); + assert_eq!(pattern_families(&s, "Unused"), ["Inter"]); + // No run changes: no undo step. + let r = s.execute("text.replaceFont", &json!({"from": {"family": "Source Sans 3"}, "to": {"family": "Source Sans 3"}})).unwrap(); + assert_eq!(r, json!({"runs": 0})); + assert_eq!(steps(&s), before + 1); + // Change All reaches the swatch nothing paints with. + let r = s.execute("text.replaceFont", &json!({"from": {"family": "Inter"}, "to": {"family": "Source Serif 4"}})).unwrap(); + assert_eq!(r, json!({"runs": 1})); + assert_eq!(pattern_families(&s, "Unused"), ["Source Serif 4"]); + // A pattern in a hidden fill doesn't show: Find doesn't select the object for its type. + let hidden = s.execute("shape.rectangle", &json!({"x": 100, "y": 20, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Unused", "ids": [hidden]})).unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1); + s.edit("Hide the fill", |d, _| { + for it in d.node_mut(NodeId(hidden)).unwrap().appearance.items.iter_mut() { + if let AppearanceItem::Fill(f) = it { + f.visible = false; + } + } + Ok(()) + }) + .unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0); + // Nor does a pattern on a stroke of no width. + s.execute("paint.setStroke", &json!({"swatch": "Unused", "ids": [hidden]})).unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1); + s.edit("Thin the stroke", |d, _| { + for it in d.node_mut(NodeId(hidden)).unwrap().appearance.items.iter_mut() { + if let AppearanceItem::Stroke(st) = it { + st.width = 0.0; + } + } + Ok(()) + }) + .unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0); + // A locked painted object: its pattern's type is listed and left in its font. + s.execute("select.set", &json!({"ids": [painted[1]]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [painted[1]]})).unwrap(); + let r = + s.execute("text.replaceFont", &json!({"from": {"family": "Source Sans 3"}, "to": {"family": "Inter"}, "selectionOnly": true})).unwrap(); + assert_eq!((r["runs"].as_u64(), r["skipped"].as_u64()), (Some(0), Some(1)), "{r}"); + assert_eq!(s.execute("select.font", &json!({"family": "Source Sans 3"})).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(painted[0])]); + // While the pattern is edited, its type on the editing layer is counted and changed once. + s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap(); + let rows = font_rows(&mut s, json!({})); + assert_eq!(rows, [("Source Sans 3".to_string(), 1, 1), ("Source Serif 4".to_string(), 1, 1)]); + let r = s.execute("text.replaceFont", &json!({"from": {"family": "Source Sans 3"}, "to": {"family": "Inter"}})).unwrap(); + assert_eq!(r, json!({"runs": 1})); + let families: Vec = used_fonts(&s.doc().unwrap().doc).into_iter().map(|(family, ..)| family).collect(); + assert_eq!(families, ["Inter", "Source Serif 4"], "the pattern's stored art is left out while it is edited"); + s.execute("object.pattern.done", &json!({})).unwrap(); + assert_eq!(pattern_families(&s, "Dots"), ["Inter"]); + } + + /// A pattern whose art holds a symbol instance, painting objects inside and outside a symbol: + /// Find Font lists each pattern's and symbol's type once and reaches the symbol's type through + /// the pattern. + #[test] + fn find_font_reaches_symbols_through_patterns() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let inter = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "In", "font": "Inter"})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [inter]})).unwrap(); + let instance = s.doc().unwrap().selection.objects[0].0; + let serif = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "Se", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap(); + let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 80})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [tile, serif, instance], "name": "Loop", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [tile, serif, instance]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + // Another object paints with the pattern too: its type is still read once. + let other = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Loop", "ids": [other]})).unwrap(); + s.execute("symbol.new", &json!({"name": "Outer", "ids": [other]})).unwrap(); + let outer = s.doc().unwrap().selection.objects[0]; + let painted = s.execute("shape.rectangle", &json!({"x": 0, "y": 100, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Loop", "ids": [painted]})).unwrap(); + let both = [("Inter".to_string(), 1, 1), ("Source Serif 4".to_string(), 1, 1)]; + assert_eq!(font_rows(&mut s, json!({})), both); + s.execute("select.set", &json!({"ids": [painted]})).unwrap(); + assert_eq!(font_rows(&mut s, json!({"selectionOnly": true})), both); + // Find reaches the symbol's type through the pattern, and through a symbol whose art paints + // with the pattern. + assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 2); + assert_eq!(s.doc().unwrap().selection.objects, [outer, NodeId(painted)]); + s.execute("select.set", &json!({"ids": [painted]})).unwrap(); + let r = + s.execute("text.replaceFont", &json!({"from": {"family": "Inter"}, "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"); + } + /// 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 0a6d0cf4b9..ca982326e8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6593,7 +6593,9 @@ result carries the same files as a zip (`{name: ".zip", dataBase64}`, entr `document.info {selectionOnly?, category?, format?: "text"}` adds `sections` (`[{id, title, rows: [[label, value]]}]`: document, objects, graphicStyles, brushes, spotColors, patterns, gradients, symbols, fonts, fontDetails, linkedImages, -embeddedImages, repeats), `brushNames` (the brushes the art's strokes use, each once, hidden strokes, strokes at an opacity of 0, strokes with no +embeddedImages, repeats), `fonts` (the fonts the document's type uses, the type in opacity masks and in the art of +its symbols and pattern swatches included, which the fonts and fontDetails categories list; with `selectionOnly`, the +fonts of the selected type), `brushNames` (the brushes the art's strokes use, each once, hidden strokes, strokes at an opacity of 0, strokes with no paint or width, guides, paths with no segment, type that draws no characters, a group's or layer's own strokes, a compound path member's own strokes and an image's strokes without a shape left out; with `selectionOnly`, the brushes the selected objects' strokes use), `brushes` (`[{name, type}]`: those brushes with their types, `calligraphic`, `scatter`, `art`, @@ -8946,9 +8948,10 @@ itself when its window comes to the front and a font folder changed meanwhile. {"name":"run_command","arguments":{"command":"text.rescanFonts","params":{}}} ``` -`text.missingFonts` lists the fonts that the active document's type uses, in its layers and its symbols, and that aren't -available as named: `{fonts: [{family, style, status, resolved}], count, fontsNextToDocument?}`. `status` is `missing` or -`substitute`, as in `text.fonts`. CSS generic families such as `sans-serif`, which no font file provides, and names +`text.missingFonts` lists the fonts that the active document's type uses, in its layers, its symbols and its pattern +swatches, the type in opacity masks included, and that aren't available as named: +`{fonts: [{family, style, status, resolved}], count, fontsNextToDocument?}`. `status` is `missing` or `substitute`, as +in `text.fonts`. CSS generic families such as `sans-serif`, which no font file provides, and names longer than 256 bytes are left out. `fontsNextToDocument` is the `Fonts` folder next to the saved document (File › Package writes it), when there is one and a search may start there. `text.findFontFiles {folder}` looks for the fonts' files in an absolute folder and its subfolders on separate threads and returns at once with @@ -9759,7 +9762,8 @@ glyph of its first character lands where it was drawn. size and the centre of its text. With `scale: true` the horizontal scale goes too, the object's and each character's. - `addon.cleanup.replaceFonts {font?}` sets every character of the document's type whose font family isn't available in `font` (by default the default type font, Source Sans 3), in that family's style closest to the one it had. The - type in the art of the document's symbols is replaced too. + type in the art of the document's symbols and pattern swatches and in opacity masks is replaced too, including + hidden or locked type and the type in disabled masks. In the panel, the Text row (which the panel can hide) runs them: Shift-click Lines to break into paragraphs, and Alt-click Reset Transform to reset the horizontal scale too. Recovering live text from outlines, combining vertical diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 68b93ac4d8..3cc56e1ee1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1196,6 +1196,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`. - **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).