mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 09:29:44 -06:00
Fonts: Find Font and Replace Missing Fonts reach the type in symbols
Exports warn about type in a symbol's art that is set in a missing font and point to Find Font, but Find Font and Replace Missing Fonts only looked at the type on the layers. - text.fonts lists the fonts of the type in the document's symbols too; with selectionOnly, of the symbols the selected instances show. - select.font (Find) also selects the instances of the symbols whose art uses the font in type that is not hidden or locked. - text.replaceFont changes that type in the symbols in scope, so every instance follows. Type hidden or locked in a symbol's art, and the type of symbols that only a selected hidden or locked instance shows, keeps its font and is counted in skipped. - addon.cleanup.replaceFonts replaces missing fonts in symbols' type. While a symbol is edited, its type is taken from the editing layer only, so it is counted and changed once.
This commit is contained in:
@@ -12,6 +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::textedit::editing_range;
|
||||
use crate::cmd::typecmd::refresh_bounds;
|
||||
use crate::cmd::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, switch_or, text_param};
|
||||
@@ -107,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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 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 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}",
|
||||
has_doc,
|
||||
replace_fonts
|
||||
),
|
||||
@@ -918,24 +919,25 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, "the replacement font is not available")),
|
||||
};
|
||||
let missing = |st: &CharStyle| db.resolve(&st.font_family, &st.font_style).is_none_or(|(_, m)| m == FontMatch::Missing);
|
||||
let lacks = |t: &TextObject| t.runs.iter().any(|r| missing(&r.style));
|
||||
let d = &s.doc()?.doc;
|
||||
let mut ids = vec![];
|
||||
for l in &d.layers {
|
||||
l.walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.runs.iter().any(|r| missing(&r.style))
|
||||
&& lacks(t)
|
||||
{
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
if ids.is_empty() {
|
||||
// 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)) {
|
||||
return Err(EngineError::Other("No fonts are missing".into()));
|
||||
}
|
||||
let runs = s.edit("Replace Missing Fonts", |d, _| {
|
||||
let mut runs = 0;
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let mut replace = |t: &mut TextObject| {
|
||||
for r in t.runs.iter_mut().filter(|r| missing(&r.style)) {
|
||||
// The style the font has closest to the one the character had.
|
||||
r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone());
|
||||
@@ -946,7 +948,14 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
edit::normalize(&mut t.runs);
|
||||
refresh_bounds(t);
|
||||
};
|
||||
for id in &ids {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
replace(t);
|
||||
}
|
||||
}
|
||||
edit_symbol_texts(d, None, &|t, _| lacks(t), &mut replace);
|
||||
drop(replace);
|
||||
Ok(runs)
|
||||
})?;
|
||||
Ok(json!({ "runs": runs, "font": family }))
|
||||
|
||||
@@ -339,3 +339,25 @@ fn replace_missing_fonts_with_the_default_or_a_chosen_font() {
|
||||
let r = s.execute("addon.cleanup.replaceFonts", &json!({"font": "No Such Font Family"}));
|
||||
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_missing_fonts_reaches_the_type_in_symbols() {
|
||||
let mut s = session();
|
||||
let gone = text(&mut s, json!({"x": 40, "y": 60, "text": "Lost", "size": 20, "font": "No Such Font Family"}));
|
||||
s.execute("symbol.new", &json!({"name": "Mark", "ids": [gone.0]})).unwrap();
|
||||
let family = |s: &Session| crate::cmd::fonts::symbol_texts(&s.doc().unwrap().doc, None)[0].0.runs[0].style.font_family.clone();
|
||||
assert_eq!(family(&s), "No Such Font Family");
|
||||
let r = s.execute("addon.cleanup.replaceFonts", &json!({})).unwrap();
|
||||
assert_eq!((r["runs"].as_u64(), r["font"].as_str()), (Some(1), Some("Source Sans 3")), "{r}");
|
||||
assert_eq!(family(&s), "Source Sans 3");
|
||||
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");
|
||||
// A symbol being edited: its type on the editing layer is replaced once.
|
||||
let gone = text(&mut s, json!({"x": 40, "y": 60, "text": "Lost", "size": 20, "font": "No Such Font Family"}));
|
||||
s.execute("symbol.new", &json!({"name": "Mark 2", "ids": [gone.0]})).unwrap();
|
||||
s.execute("symbol.edit", &json!({"name": "Mark 2"})).unwrap();
|
||||
let r = s.execute("addon.cleanup.replaceFonts", &json!({})).unwrap();
|
||||
assert_eq!(r["runs"].as_u64(), Some(1), "{r}");
|
||||
s.execute("symbol.exitEditing", &json!({})).unwrap();
|
||||
assert_eq!(crate::cmd::fonts::substitution_warning(&s.doc().unwrap().doc), None);
|
||||
}
|
||||
|
||||
+299
-28
@@ -7,7 +7,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind, TextRun};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::typecmd::refresh_bounds;
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fonts in Document",
|
||||
[],
|
||||
None,
|
||||
"{selectionOnly?} → [{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)",
|
||||
"{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)",
|
||||
has_doc,
|
||||
fonts
|
||||
),
|
||||
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Replace Font",
|
||||
[],
|
||||
None,
|
||||
"{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); text that is hidden or locked, in a hidden or locked group or layer, or on a locked artboard keeps its font → {runs, 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), 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 → {runs, 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
|
||||
),
|
||||
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Text by Font",
|
||||
[],
|
||||
None,
|
||||
"{family, style?} select the text objects using the font that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard → {count}",
|
||||
"{family, style?} 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 → {count}",
|
||||
has_doc,
|
||||
select_font
|
||||
),
|
||||
@@ -88,6 +88,113 @@ fn scope(s: &Session, selection_only: bool) -> Result<Vec<NodeId>> {
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Whether `name` is the symbol being edited, whose art is on the editing layer.
|
||||
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) -> Result<(BTreeSet<String>, BTreeSet<String>)> {
|
||||
fn open_instances(n: &Node, out: &mut BTreeSet<String>) {
|
||||
if !n.visible || n.locked {
|
||||
return;
|
||||
}
|
||||
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
|
||||
out.insert(symbol.clone());
|
||||
}
|
||||
for c in n.children().map(Vec::as_slice).unwrap_or_default() {
|
||||
open_instances(c, out);
|
||||
}
|
||||
}
|
||||
let expand = |names: &mut BTreeSet<String>, 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);
|
||||
}
|
||||
let before = names.len();
|
||||
names.extend(more);
|
||||
if names.len() == before {
|
||||
break;
|
||||
}
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let (mut all, mut open) = (BTreeSet::new(), BTreeSet::new());
|
||||
let editable = st.doc.editable();
|
||||
for id in &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());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
drop(editable);
|
||||
expand(&mut all, &st.doc);
|
||||
expand(&mut open, &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<String>>) -> 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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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<String>>,
|
||||
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)))
|
||||
}
|
||||
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 c in n.children_mut().into_iter().flatten() {
|
||||
if wanted(c, open, wants) {
|
||||
visit(Arc::make_mut(c), open, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A font as type names it: family, style and, when it names one, the installed version.
|
||||
pub(super) type UsedFont = (String, String, Option<String>);
|
||||
|
||||
@@ -375,17 +482,24 @@ fn text(d: &Document, id: NodeId) -> Option<&vectorcraft_doc::TextObject> {
|
||||
}
|
||||
|
||||
fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
||||
let selection_only = bool_or(p, "selectionOnly", false);
|
||||
let ids = scope(s, selection_only)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut found: BTreeMap<UsedFont, (usize, Vec<NodeId>, String)> = BTreeMap::new();
|
||||
for id in ids {
|
||||
for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() {
|
||||
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())).or_default();
|
||||
// The type in the art of the symbols in scope too: all of them, or those the selection shows.
|
||||
let symbols = if selection_only { Some(selected_symbols(s)?.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 mut found: BTreeMap<UsedFont, (usize, usize, String)> = BTreeMap::new();
|
||||
for t in texts {
|
||||
let mut seen = BTreeSet::new();
|
||||
for r in &t.runs {
|
||||
let font = (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone());
|
||||
let e = found.entry(font.clone()).or_default();
|
||||
e.0 += 1;
|
||||
e.2.push_str(&r.text);
|
||||
if !e.1.contains(&id) {
|
||||
e.1.push(id);
|
||||
if seen.insert(font) {
|
||||
e.1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,7 +514,7 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
lacking.dedup();
|
||||
lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c)));
|
||||
let mut row = json!({
|
||||
"family": family, "style": style, "runs": runs, "objects": objects.len(),
|
||||
"family": family, "style": style, "runs": runs, "objects": objects,
|
||||
"missing": status == vectorcraft_text::FontMatch::Missing,
|
||||
"status": status.as_str(),
|
||||
"resolved": resolved.as_ref().map(|(f, _)| json!({ "family": f.family, "style": f.style })),
|
||||
@@ -436,20 +550,36 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x))
|
||||
};
|
||||
// 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.
|
||||
// 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.
|
||||
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)?;
|
||||
let closed: BTreeSet<String> = all.difference(&open).cloned().collect();
|
||||
(Some(open), closed)
|
||||
} else {
|
||||
(None, BTreeSet::new())
|
||||
};
|
||||
let uses = |t: &TextObject| t.runs.iter().any(|r| matches(&r.style));
|
||||
let (ids, skipped) = {
|
||||
let d = &s.doc()?.doc;
|
||||
let editable = d.editable();
|
||||
let (ids, other): (Vec<NodeId>, Vec<NodeId>) = scope(s, bool_or(p, "selectionOnly", false))?.into_iter().partition(|id| editable(*id));
|
||||
let skipped = other.into_iter().filter(|id| text(d, *id).is_some_and(|t| t.runs.iter().any(|r| matches(&r.style)))).count();
|
||||
let (ids, other): (Vec<NodeId>, Vec<NodeId>) = scope(s, selection_only)?.into_iter().partition(|id| editable(*id));
|
||||
let mut skipped = other.into_iter().filter(|id| text(d, *id).is_some_and(uses)).count();
|
||||
skipped += symbol_texts(d, symbols.as_ref()).into_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();
|
||||
}
|
||||
(ids, skipped)
|
||||
};
|
||||
// The fonts the replaced runs are set in, for the warning.
|
||||
let mut set = BTreeSet::new();
|
||||
let n = s.edit("Replace Font", |d, _| {
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let mut replace_in = |t: &mut TextObject| {
|
||||
let mut changed = false;
|
||||
for r in &mut t.runs {
|
||||
if matches(&r.style) {
|
||||
@@ -472,7 +602,14 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
vectorcraft_text::edit::normalize(&mut t.runs);
|
||||
refresh_bounds(t);
|
||||
}
|
||||
};
|
||||
for id in &ids {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
replace_in(t);
|
||||
}
|
||||
}
|
||||
edit_symbol_texts(d, symbols.as_ref(), &|t, open| open && uses(t), &mut replace_in);
|
||||
drop(replace_in);
|
||||
Ok(n)
|
||||
})?;
|
||||
let mut out = json!({ "runs": n });
|
||||
@@ -499,17 +636,47 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Only text the Selection tool can reach: not hidden or locked, in a hidden or locked group
|
||||
// or layer, or on a locked artboard.
|
||||
let editable = d.editable();
|
||||
let hits: Vec<NodeId> = ids
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
editable(*id)
|
||||
&& text(d, *id).is_some_and(|t| {
|
||||
t.runs.iter().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))
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let uses = |t: &TextObject| {
|
||||
t.runs
|
||||
.iter()
|
||||
.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<NodeId> = 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();
|
||||
loop {
|
||||
let found: Vec<&str> = d
|
||||
.symbols
|
||||
.iter()
|
||||
.filter(|sym| !symbols.contains(sym.name.as_str()) && reaches(&sym.art, &uses, &symbols))
|
||||
.map(|sym| sym.name.as_str())
|
||||
.collect();
|
||||
if found.is_empty() {
|
||||
break;
|
||||
}
|
||||
symbols.extend(found);
|
||||
}
|
||||
// Their instances the Selection tool can reach.
|
||||
if !symbols.is_empty() {
|
||||
d.walk(&mut |n: &Node| {
|
||||
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind
|
||||
&& symbols.contains(symbol.as_str())
|
||||
&& editable(n.id)
|
||||
{
|
||||
hits.push(n.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
drop(editable);
|
||||
let n = hits.len();
|
||||
s.select(|_, sel| sel.set(hits))?;
|
||||
@@ -722,6 +889,110 @@ mod tests {
|
||||
assert_eq!(r, json!({"runs": 2}));
|
||||
}
|
||||
|
||||
/// The fonts of the symbols' art: Find Font lists them, and Change All replaces them, leaving
|
||||
/// hidden or locked type in the art as it is.
|
||||
#[test]
|
||||
fn find_font_reaches_the_type_in_symbols() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let mut ids = vec![];
|
||||
for y in [20, 40, 60] {
|
||||
ids.push(s.execute("text.create", &json!({"x": 10, "y": y, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].clone());
|
||||
}
|
||||
s.execute("symbol.new", &json!({"name": "Mark", "ids": ids})).unwrap();
|
||||
s.edit("Hide and lock", |d, _| {
|
||||
let art = Arc::make_mut(&mut d.symbols[0].art);
|
||||
let members = art.children_mut().unwrap();
|
||||
Arc::make_mut(&mut members[1]).visible = false;
|
||||
Arc::make_mut(&mut members[2]).locked = true;
|
||||
Ok(())
|
||||
})
|
||||
.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 the symbol");
|
||||
let fonts = s.execute("text.fonts", &json!({})).unwrap();
|
||||
assert_eq!(
|
||||
(fonts[0]["family"].as_str(), fonts[0]["runs"].as_u64(), fonts[0]["objects"].as_u64()),
|
||||
(Some("Source Serif 4"), Some(3), Some(3)),
|
||||
"{fonts}"
|
||||
);
|
||||
// New Symbol selects the instance, which shows the symbol's type.
|
||||
assert_eq!(s.execute("text.fonts", &json!({"selectionOnly": true})).unwrap(), fonts);
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
assert_eq!(s.execute("text.fonts", &json!({"selectionOnly": true})).unwrap(), json!([]));
|
||||
// Find selects the symbol's instance, also when it is inside another symbol's instance.
|
||||
let mut instances = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::SymbolInstance { .. }) {
|
||||
instances.push(n.id);
|
||||
}
|
||||
});
|
||||
assert_eq!(instances.len(), 1);
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, instances);
|
||||
s.execute("symbol.new", &json!({"name": "Outer", "ids": [instances[0].0]})).unwrap();
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1);
|
||||
assert_ne!(s.doc().unwrap().selection.objects, instances);
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 0);
|
||||
let r = s.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}})).unwrap();
|
||||
assert_eq!((r["runs"].as_u64(), r["skipped"].as_u64()), (Some(1), Some(2)), "{r}");
|
||||
let family = |s: &Session| symbol_texts(&s.doc().unwrap().doc, None)[0].0.runs[0].style.font_family.clone();
|
||||
let families: Vec<String> = symbol_texts(&s.doc().unwrap().doc, None).iter().map(|(t, _)| t.runs[0].style.font_family.clone()).collect();
|
||||
assert_eq!(families, ["Source Sans 3", "Source Serif 4", "Source Serif 4"]);
|
||||
// Find leaves out instances whose symbol uses the font only in hidden or locked type.
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0);
|
||||
// Only the selection: with nothing selected, the symbols' art stays as it is.
|
||||
let sans_to_inter = json!({"from": {"family": "Source Sans 3"}, "to": {"family": "Inter"}, "selectionOnly": true});
|
||||
assert_eq!(s.execute("text.replaceFont", &sans_to_inter).unwrap(), json!({"runs": 0}));
|
||||
assert_eq!(family(&s), "Source Sans 3");
|
||||
// With the instance that Find selects, the selection's fonts and Change reach its symbols' type.
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Source Sans 3"})).unwrap()["count"], 1);
|
||||
let fonts = s.execute("text.fonts", &json!({"selectionOnly": true})).unwrap();
|
||||
let rows: Vec<(&str, u64)> = fonts.as_array().unwrap().iter().map(|f| (f["family"].as_str().unwrap(), f["runs"].as_u64().unwrap())).collect();
|
||||
assert_eq!(rows, [("Source Sans 3", 1), ("Source Serif 4", 2)]);
|
||||
assert_eq!(s.execute("text.replaceFont", &sans_to_inter).unwrap()["runs"].as_u64(), Some(1));
|
||||
assert_eq!(family(&s), "Inter");
|
||||
// While the symbol is edited, its type on the editing layer is counted once.
|
||||
s.execute("symbol.edit", &json!({"name": "Mark"})).unwrap();
|
||||
let fonts = s.execute("text.fonts", &json!({})).unwrap();
|
||||
let serif = fonts.as_array().unwrap().iter().find(|f| f["family"] == "Source Serif 4").unwrap();
|
||||
assert_eq!((serif["runs"].as_u64(), serif["objects"].as_u64()), (Some(2), Some(2)), "{fonts}");
|
||||
let r = s.execute("text.replaceFont", &json!({"from": {"family": "Inter"}, "to": {"family": "Source Serif 4"}})).unwrap();
|
||||
assert_eq!(r["runs"].as_u64(), Some(1), "{r}");
|
||||
s.execute("symbol.exitEditing", &json!({})).unwrap();
|
||||
// A selected locked instance: its symbols' fonts are listed, and Change leaves them, counted.
|
||||
let mut outer = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Outer") {
|
||||
outer.push(n.id.0);
|
||||
}
|
||||
});
|
||||
assert_eq!(outer.len(), 1);
|
||||
s.execute("select.set", &json!({"ids": outer})).unwrap();
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": outer})).unwrap();
|
||||
assert_eq!(s.execute("text.fonts", &json!({"selectionOnly": true})).unwrap()[0]["runs"], 3);
|
||||
let serif_to_sans = json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true});
|
||||
let r = s.execute("text.replaceFont", &serif_to_sans).unwrap();
|
||||
assert_eq!((r["runs"].as_u64(), r["skipped"].as_u64()), (Some(0), Some(3)), "{r}");
|
||||
// A hidden instance in a symbol's art does not bring its symbol into the selection's scope.
|
||||
s.execute("object.unlockAll", &json!({})).unwrap();
|
||||
s.edit("Hide the inner instance", |d, _| {
|
||||
let i = d.symbols.iter().position(|sym| sym.name == "Outer").unwrap();
|
||||
let art = Arc::make_mut(&mut d.symbols[i].art);
|
||||
match art.children_mut() {
|
||||
Some(members) => Arc::make_mut(&mut members[0]).visible = false,
|
||||
None => art.visible = false,
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.set", &json!({"ids": outer})).unwrap();
|
||||
assert_eq!(s.execute("text.fonts", &json!({"selectionOnly": true})).unwrap(), json!([]));
|
||||
assert_eq!(s.execute("text.replaceFont", &serif_to_sans).unwrap(), json!({"runs": 0}));
|
||||
}
|
||||
|
||||
/// 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()
|
||||
|
||||
@@ -94,7 +94,7 @@ fn missing_fonts_are_listed_symbols_included() {
|
||||
assert_eq!((r["count"].as_u64(), r["fonts"][1]["resolved"]["family"].as_str()), (Some(3), Some("Source Sans 3")));
|
||||
assert!(r.get("fontsNextToDocument").is_none(), "an unsaved document has no folder");
|
||||
let listed = s.execute("text.fonts", &json!({})).unwrap();
|
||||
assert!(!listed.as_array().unwrap().iter().any(|f| f["family"] == "Symbol Only Family"), "Find Font lists the layers' type only");
|
||||
assert!(listed.as_array().unwrap().iter().any(|f| f["family"] == "Symbol Only Family" && f["status"] == "missing"), "{listed}");
|
||||
assert!(listed.as_array().unwrap().iter().any(|f| f["family"] == "sans-serif" && f["status"] == "missing"), "{listed}");
|
||||
assert!(matches!(Session::new().execute("text.missingFonts", &json!({})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
+2
-1
@@ -6087,7 +6087,8 @@ glyph of its first character lands where it was drawn.
|
||||
- `addon.cleanup.resetTextTransform {scale?, ids?}` takes the rotation, shear and reflection off point type, keeping its
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user