mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 15:25:16 +00:00
Merge main into fix/3d-face-budget-per-object
The i18n test that main added for 3D refusals calls validate_three_d_at with this branch's (Option<usize>, usize) position: effect 0 of appearance item 0 is (Some(0), 0).
This commit is contained in:
@@ -82,7 +82,7 @@ pub mod rasterfx;
|
||||
mod recolor;
|
||||
pub mod recovery;
|
||||
pub mod scripts;
|
||||
mod select;
|
||||
pub(crate) mod select;
|
||||
pub(crate) mod shaper;
|
||||
pub mod shortcutset;
|
||||
pub(crate) mod slices;
|
||||
|
||||
+446
-40
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Text by Font",
|
||||
[],
|
||||
None,
|
||||
"{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, the instances of the symbols whose art uses it, the objects painted with a pattern whose art uses it and the objects whose opacity mask uses it (in type that is not hidden or locked in that art), that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; a pattern swatch that nothing paints, or a layer's mask, selects nothing; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}",
|
||||
"{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, the instances of the symbols whose art uses it, the text whose inline graphics draw such a symbol, the objects painted with a pattern whose art uses it (by a visible fill or stroke whose opacity is above 0, a stroke wider than 0) and the objects whose opacity mask uses it (in type that is not hidden or locked in that art), that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; a pattern swatch that nothing paints, or a layer's mask, selects nothing; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}",
|
||||
has_doc,
|
||||
select_font
|
||||
),
|
||||
@@ -108,23 +108,85 @@ fn pattern_being_edited(d: &Document, name: &str) -> bool {
|
||||
d.pattern_edit.as_ref().is_some_and(|e| e.pattern == name)
|
||||
}
|
||||
|
||||
/// Whether fill or stroke `i` paints: it is visible, its opacity is above 0 and, a stroke, it is
|
||||
/// wider than 0.
|
||||
fn paints(i: &AppearanceItem) -> bool {
|
||||
i.visible() && i.opacity() > 0.0 && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)
|
||||
}
|
||||
|
||||
/// The name of pattern paint `p`.
|
||||
fn pattern_name(p: &Paint) -> Option<&str> {
|
||||
match p {
|
||||
Paint::Pattern { pattern, .. } => Some(pattern.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `r` is laid out as an inline graphic: it has one and holds only its character.
|
||||
fn draws_inline(r: &TextRun) -> bool {
|
||||
r.inline.is_some() && vectorcraft_text::edit::is_inline_text(&r.text)
|
||||
}
|
||||
|
||||
/// The fill and, when it is wider than 0, the stroke of the characters of `r`.
|
||||
fn run_paints(r: &TextRun) -> impl Iterator<Item = &Paint> {
|
||||
[Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)].into_iter().flatten()
|
||||
}
|
||||
|
||||
/// [`run_paints`] of run `r` of `t` when its characters draw with them. The characters of an
|
||||
/// inline graphic draw no glyph: their fill and stroke paint only their underline and
|
||||
/// strikethrough (not on a path or in vertical type, see `vectorcraft_text::decorations`) and the
|
||||
/// ruby readings that layout draws with their run (`ruby`, as [`ruby_runs`] finds them).
|
||||
fn drawn_run_paints<'a>(t: &TextObject, ruby: &HashSet<*const TextRun>, r: &'a TextRun) -> impl Iterator<Item = &'a Paint> {
|
||||
let drawn = !draws_inline(r) || inline_decorated(t, ruby, r);
|
||||
run_paints(r).filter(move |_| drawn)
|
||||
}
|
||||
|
||||
/// The inline graphic runs of `t` that layout draws a ruby reading with. A reading takes the run
|
||||
/// of the first base character on its line, and type on a path has no ruby. `t` is laid out only
|
||||
/// when it has ruby and an inline graphic painted with a pattern.
|
||||
fn ruby_runs(t: &TextObject) -> HashSet<*const TextRun> {
|
||||
let patterned = |r: &TextRun| draws_inline(r) && run_paints(r).any(|p| pattern_name(p).is_some());
|
||||
if t.ruby.is_empty() || !t.runs.iter().any(patterned) {
|
||||
return HashSet::new();
|
||||
}
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
lay.ruby.iter().filter_map(|g| t.runs.get(g.run)).filter(|r| draws_inline(r)).map(std::ptr::from_ref).collect()
|
||||
}
|
||||
|
||||
/// Whether inline graphic run `r` of `t` has an underline or strikethrough that draws, or a ruby
|
||||
/// reading that layout draws with it (it is in `ruby`).
|
||||
fn inline_decorated(t: &TextObject, ruby: &HashSet<*const TextRun>, r: &TextRun) -> bool {
|
||||
let bars = !t.vertical && !matches!(t.kind, TextKind::OnPath { .. });
|
||||
if bars && (r.style.underline || r.style.strikethrough) {
|
||||
return true;
|
||||
}
|
||||
ruby.contains(&std::ptr::from_ref(r))
|
||||
}
|
||||
|
||||
/// The patterns that the fills and strokes `n` draws name (of a text object, also those of its
|
||||
/// characters): visible ones, and strokes wider than 0.
|
||||
fn paint_patterns(n: &Node) -> impl Iterator<Item = &str> {
|
||||
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,
|
||||
})
|
||||
/// characters): those that [`paints`] holds for, and the character paints of the runs that draw
|
||||
/// (as [`drawn_runs`] finds them: not the overset runs of area type or type on a path) where they
|
||||
/// draw (as [`drawn_run_paints`] says).
|
||||
fn paint_patterns(n: &Node) -> Vec<&str> {
|
||||
let mut out: Vec<&str> = n.appearance.items.iter().filter(|i| paints(i)).filter_map(|i| pattern_name(i.paint())).collect();
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.runs.iter().flat_map(run_paints).any(|p| pattern_name(p).is_some())
|
||||
{
|
||||
let ruby = ruby_runs(t);
|
||||
out.extend(drawn_runs(t).0.into_iter().flat_map(|r| drawn_run_paints(t, &ruby, r)).filter_map(pattern_name));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The symbols that the inline graphics in the type of `n` draw (as [`drawn_runs`] finds them: not
|
||||
/// those in the overset runs of area type or type on a path).
|
||||
fn inline_symbols(n: &Node) -> Vec<&str> {
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) if t.runs.iter().any(draws_inline) => {
|
||||
drawn_runs(t).0.into_iter().filter(|r| draws_inline(r)).filter_map(|r| r.inline.as_ref().map(|art| art.symbol.as_str())).collect()
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbols and patterns, by name: those whose art is in scope.
|
||||
@@ -135,13 +197,15 @@ struct Resources {
|
||||
}
|
||||
|
||||
impl Resources {
|
||||
/// Adds what `n` itself shows: the symbol of an instance, the patterns its paints name, and
|
||||
/// what the art of its opacity mask shows (not hidden or locked objects of that art).
|
||||
/// Adds what `n` itself shows: the symbol of an instance, the symbols of the inline graphics in
|
||||
/// its type, the patterns its paints name, and what the art of its opacity mask shows (not
|
||||
/// hidden or locked objects of that art).
|
||||
fn add_own(&mut self, n: &Node) {
|
||||
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
|
||||
self.symbols.insert(symbol.clone());
|
||||
}
|
||||
self.patterns.extend(paint_patterns(n).map(str::to_string));
|
||||
self.symbols.extend(inline_symbols(n).into_iter().map(str::to_string));
|
||||
self.patterns.extend(paint_patterns(n).into_iter().map(str::to_string));
|
||||
if let Some(m) = n.extras().mask.as_deref() {
|
||||
self.add(&m.art);
|
||||
}
|
||||
@@ -486,17 +550,16 @@ impl Printed<'_> {
|
||||
NodeKind::Text(t) => drawn_runs(t),
|
||||
_ => (vec![], false),
|
||||
};
|
||||
let items: Vec<&AppearanceItem> =
|
||||
n.appearance.items.iter().filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)).collect();
|
||||
let patterns: Vec<&str> = items
|
||||
.iter()
|
||||
.map(|i| i.paint())
|
||||
.chain(runs.iter().flat_map(|r| [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)]).flatten())
|
||||
.filter_map(|p| match p {
|
||||
Paint::Pattern { pattern, .. } => Some(pattern.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let items: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| paints(i)).collect();
|
||||
let ruby = match &n.kind {
|
||||
NodeKind::Text(t) => ruby_runs(t),
|
||||
_ => HashSet::new(),
|
||||
};
|
||||
let run_patterns = runs.iter().flat_map(|r| match &n.kind {
|
||||
NodeKind::Text(t) => Some(drawn_run_paints(t, &ruby, r)),
|
||||
_ => None,
|
||||
});
|
||||
let patterns: Vec<&str> = items.iter().map(|i| i.paint()).chain(run_patterns.flatten()).filter_map(pattern_name).collect();
|
||||
let brushed = vectorcraft_brush::has_brush(n);
|
||||
if (mask.is_some() || !patterns.is_empty() || brushed) && areas.is_none_or(|a| !open || self.meets(n, a)) {
|
||||
if let Some(m) = mask {
|
||||
@@ -527,7 +590,7 @@ impl Printed<'_> {
|
||||
for r in runs {
|
||||
self.fonts.extend(run_fonts(r));
|
||||
// Inline graphics draw their symbols' art.
|
||||
if let Some(art) = &r.inline {
|
||||
if let Some(art) = r.inline.as_ref().filter(|_| draws_inline(r)) {
|
||||
self.symbol(&art.symbol);
|
||||
}
|
||||
}
|
||||
@@ -910,14 +973,16 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Whether `n` or a descendant that is not hidden or locked, nor in a hidden or locked group,
|
||||
/// shows type in a font `uses` matches: text set in it, an instance of a symbol in `found`, a paint
|
||||
/// naming a pattern in `found`, or such type in the art of its opacity mask.
|
||||
/// shows type in a font `uses` matches: text set in it, an instance of a symbol in `found`, an
|
||||
/// inline graphic of a symbol in `found`, a paint naming a pattern in `found`, or such type in the
|
||||
/// art of its opacity mask.
|
||||
fn reaches(n: &Node, uses: &impl Fn(&TextObject) -> bool, found: &Resources) -> bool {
|
||||
n.visible
|
||||
&& !n.locked
|
||||
&& (matches!(&n.kind, NodeKind::Text(t) if uses(t))
|
||||
|| matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol))
|
||||
|| paint_patterns(n).any(|p| found.patterns.contains(p))
|
||||
|| inline_symbols(n).into_iter().any(|sym| found.symbols.contains(sym))
|
||||
|| paint_patterns(n).into_iter().any(|p| found.patterns.contains(p))
|
||||
|| n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, uses, found))
|
||||
|| n.children().is_some_and(|ch| ch.iter().any(|c| reaches(c, uses, found))))
|
||||
}
|
||||
@@ -964,14 +1029,16 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
found.patterns.extend(patterns);
|
||||
}
|
||||
// The objects the Selection tool can reach that show such type: the instances of those
|
||||
// symbols, the objects that those patterns paint, and the objects whose opacity mask holds it
|
||||
// (not layers, which the Selection tool doesn't select).
|
||||
// symbols, the text whose inline graphics draw them, the objects that those patterns paint,
|
||||
// and the objects whose opacity mask holds it (not layers, which the Selection tool doesn't
|
||||
// select).
|
||||
let masked: HashSet<NodeId> = masked.into_iter().collect();
|
||||
if !found.symbols.is_empty() || !found.patterns.is_empty() || !masked.is_empty() {
|
||||
let texts: HashSet<NodeId> = hits.iter().copied().collect();
|
||||
d.walk(&mut |n: &Node| {
|
||||
let shows = matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol))
|
||||
|| paint_patterns(n).any(|p| found.patterns.contains(p))
|
||||
|| inline_symbols(n).into_iter().any(|sym| found.symbols.contains(sym))
|
||||
|| paint_patterns(n).into_iter().any(|p| found.patterns.contains(p))
|
||||
|| (masked.contains(&n.id) && n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, &uses, &found)));
|
||||
if shows && !n.is_layer() && !texts.contains(&n.id) && editable(n.id) {
|
||||
hits.push(n.id);
|
||||
@@ -1069,6 +1136,37 @@ fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives the inline graphics in the type of [`art_texts_of`] the current bounds of their symbols'
|
||||
/// art, and lays out again the type whose inline graphics changed. A symbol drawn inline in the
|
||||
/// art of another symbol takes one more round, so there is at most one round per symbol.
|
||||
fn resolve_art_inline(doc: &mut Document) {
|
||||
for _ in 0..=doc.symbols.len() {
|
||||
let names: BTreeSet<String> =
|
||||
art_texts_of(doc).iter().flat_map(|(_, t, _)| t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone()))).collect();
|
||||
let bounds: BTreeMap<String, Option<Rect>> = names
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let b = doc.symbol_art_bounds(&name);
|
||||
(name, b)
|
||||
})
|
||||
.collect();
|
||||
let stale = |t: &TextObject| t.runs.iter().any(|r| r.inline.as_ref().is_some_and(|a| bounds.get(&a.symbol).is_some_and(|b| *b != a.bounds)));
|
||||
let mut changed = false;
|
||||
edit_art_texts_of(doc, &stale, &mut |t| {
|
||||
for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) {
|
||||
if let Some(b) = bounds.get(&a.symbol) {
|
||||
a.bounds = *b;
|
||||
}
|
||||
}
|
||||
refresh_bounds(t);
|
||||
changed = true;
|
||||
});
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -1076,7 +1174,9 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let faces = 0;
|
||||
// The canvas redraws type in the fonts available now (renderers lay it out again when the
|
||||
// fonts change), and the bounds follow; that isn't an edit to save.
|
||||
// fonts change), and the bounds follow, also of the type in opacity masks and in the art of
|
||||
// symbols and patterns; that isn't an edit to save. The type in art is laid out first, and the
|
||||
// inline graphics of the type on the layers then take the new bounds of their symbols' art.
|
||||
for d in &mut s.docs {
|
||||
let mut texts = vec![];
|
||||
d.doc.walk(|n| {
|
||||
@@ -1084,9 +1184,12 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
texts.push(n.id);
|
||||
}
|
||||
});
|
||||
if !texts.is_empty() {
|
||||
if !texts.is_empty() || !art_texts_of(&d.doc).is_empty() {
|
||||
d.change_unsaved(|doc| {
|
||||
let doc = std::sync::Arc::make_mut(doc);
|
||||
edit_art_texts_of(doc, &|_| true, &mut refresh_bounds);
|
||||
resolve_art_inline(doc);
|
||||
doc.resolve_inline_art();
|
||||
for id in texts {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
refresh_bounds(t);
|
||||
@@ -1751,6 +1854,309 @@ mod tests {
|
||||
assert_eq!(symbol_texts(&s.doc().unwrap().doc, None)[0].0.runs[0].style.font_family, "Source Sans 3");
|
||||
}
|
||||
|
||||
/// A pattern in a fill whose opacity is 0 paints nothing: Find doesn't select the object for
|
||||
/// the pattern's type, a selection-only Change leaves that type in its font, and Print doesn't
|
||||
/// count its font.
|
||||
#[test]
|
||||
fn find_font_skips_patterns_at_an_opacity_of_0() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [t]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let painted = s.execute("shape.rectangle", &json!({"x": 0, "y": 20, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [painted]})).unwrap();
|
||||
let printed = |s: &Session| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
printed_type(d, &d.layers, &[Rect::new(-1000.0, -1000.0, 1000.0, 1000.0)]).0.into_iter().map(|(family, ..)| family).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1);
|
||||
assert_eq!(printed(&s), ["Source Serif 4"]);
|
||||
s.edit("Clear the fill", |d, _| {
|
||||
for it in d.node_mut(NodeId(painted)).unwrap().appearance.items.iter_mut() {
|
||||
if let AppearanceItem::Fill(f) = it {
|
||||
f.opacity = 0.0;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0);
|
||||
assert!(printed(&s).is_empty(), "{:?}", printed(&s));
|
||||
s.execute("select.set", &json!({"ids": [painted]})).unwrap();
|
||||
let r = s
|
||||
.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true}))
|
||||
.unwrap();
|
||||
assert_eq!(r, json!({"runs": 0}));
|
||||
assert_eq!(pattern_families(&s, "Dots"), ["Source Serif 4"]);
|
||||
}
|
||||
|
||||
/// Characters of area type that don't fit its frame paint nothing: their pattern's type isn't
|
||||
/// found through them until they are drawn.
|
||||
#[test]
|
||||
fn find_font_skips_patterns_of_overset_characters() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [t]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let area = s
|
||||
.execute(
|
||||
"text.create",
|
||||
&json!({"x": 10, "y": 60, "text": "A\nB", "font": "Source Sans 3", "size": 20, "area": {"width": 100, "height": 40}}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("text.setRangeStyle", &json!({"id": area, "start": 2, "end": 3, "font": "Inter"})).unwrap();
|
||||
let paint_run = |s: &mut Session, letter: &'static str| {
|
||||
s.edit("Paint a run with the pattern", |d, _| {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(NodeId(area)).map(|n| &mut n.kind) {
|
||||
for r in t.runs.iter_mut().filter(|r| r.text.contains(letter)) {
|
||||
r.style.fill = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() };
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
};
|
||||
paint_run(&mut s, "B");
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), text(&s.doc().unwrap().doc, NodeId(area)).unwrap());
|
||||
assert!(laid.overflow, "the second line doesn't fit the frame");
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0);
|
||||
paint_run(&mut s, "A");
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1);
|
||||
}
|
||||
|
||||
/// An inline graphic that doesn't fit its area type draws nothing, and neither does an inline
|
||||
/// graphic named on a run of other characters: Find doesn't select the text for its symbol.
|
||||
#[test]
|
||||
fn find_font_skips_undrawn_inline_graphics() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [t]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let mark = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Se", "font": "Inter"})).unwrap()["id"].clone();
|
||||
s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let area = s
|
||||
.execute(
|
||||
"text.create",
|
||||
&json!({"x": 10, "y": 60, "text": "A\nB", "font": "Source Sans 3", "size": 20, "area": {"width": 100, "height": 40}}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("text.insertInline", &json!({"id": area, "at": 3, "symbol": "Mark"})).unwrap();
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), text(&s.doc().unwrap().doc, NodeId(area)).unwrap());
|
||||
assert!(laid.overflow && laid.inlines.is_empty(), "the inline graphic doesn't fit the frame");
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 0);
|
||||
let point = s.execute("text.create", &json!({"x": 10, "y": 140, "text": "Hi", "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.insertInline", &json!({"id": point, "at": 0, "symbol": "Mark"})).unwrap();
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 1);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(point)]);
|
||||
// A run that names an inline graphic but holds other characters is laid out as those
|
||||
// characters: its fill paints them, and it draws no symbol.
|
||||
let plain = s.execute("text.create", &json!({"x": 10, "y": 180, "text": "Ok", "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap();
|
||||
let art = text(&s.doc().unwrap().doc, NodeId(point)).unwrap().runs.iter().find_map(|r| r.inline.clone()).unwrap();
|
||||
s.edit("Name an inline graphic on plain characters", |d, _| {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(NodeId(plain)).map(|n| &mut n.kind) {
|
||||
for r in &mut t.runs {
|
||||
r.inline = Some(art.clone());
|
||||
r.style.fill = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() };
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(plain)]);
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 1);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(point)]);
|
||||
}
|
||||
|
||||
/// The characters of an inline graphic draw no glyph: their pattern fill paints only their
|
||||
/// underline, strikethrough or ruby reading. Without one, Find doesn't select the text for the
|
||||
/// pattern's type and Print doesn't count its font.
|
||||
#[test]
|
||||
fn find_font_skips_patterns_of_undecorated_inline_graphics() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [t]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let mark = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let point = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Hi", "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.insertInline", &json!({"id": point, "at": 1, "symbol": "Mark"})).unwrap();
|
||||
let style = |s: &mut Session, f: &dyn Fn(&mut TextObject)| {
|
||||
s.edit("Style the inline graphic", |d, _| {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(NodeId(point)).map(|n| &mut n.kind) {
|
||||
f(t);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
};
|
||||
let found = |s: &mut Session| s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"].as_u64();
|
||||
let printed = |s: &Session| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
printed_type(d, &d.layers, &[Rect::new(-1000.0, -1000.0, 1000.0, 1000.0)]).0.into_iter().map(|(family, ..)| family).collect::<Vec<_>>()
|
||||
};
|
||||
style(&mut s, &|t| {
|
||||
for r in t.runs.iter_mut().filter(|r| r.inline.is_some()) {
|
||||
r.style.fill = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() };
|
||||
}
|
||||
});
|
||||
assert_eq!(found(&mut s), Some(0));
|
||||
assert_eq!(printed(&s), ["Source Sans 3"]);
|
||||
// An underline paints with the pattern.
|
||||
style(&mut s, &|t| t.runs.iter_mut().filter(|r| r.inline.is_some()).for_each(|r| r.style.underline = true));
|
||||
assert_eq!(found(&mut s), Some(1));
|
||||
assert_eq!(printed(&s), ["Source Sans 3", "Source Serif 4"]);
|
||||
// Vertical type draws no underline.
|
||||
style(&mut s, &|t| t.vertical = true);
|
||||
assert_eq!(found(&mut s), Some(0));
|
||||
// A ruby reading that starts over the character before the inline graphic draws with that
|
||||
// character's paint.
|
||||
style(&mut s, &|t| {
|
||||
assert!(t.runs[1].inline.is_some(), "the inline graphic is the second run");
|
||||
t.ruby.push(vectorcraft_doc::RubySpan {
|
||||
start: 0,
|
||||
end: t.runs[0].text.len() + t.runs[1].text.len(),
|
||||
text: "x".into(),
|
||||
position: Default::default(),
|
||||
align: Default::default(),
|
||||
size_percent: 50.0,
|
||||
overhang: true,
|
||||
});
|
||||
});
|
||||
assert_eq!(found(&mut s), Some(0));
|
||||
// A ruby reading that starts over the inline graphic paints with the pattern.
|
||||
style(&mut s, &|t| t.ruby[0].start = t.runs[0].text.len());
|
||||
assert_eq!(found(&mut s), Some(1));
|
||||
}
|
||||
|
||||
/// Text whose inline graphic draws a symbol shows the symbol's type: Find selects that text
|
||||
/// and the objects painted with a pattern whose art holds it, and a selection-only Change
|
||||
/// changes the symbol's type.
|
||||
#[test]
|
||||
fn find_font_reaches_symbols_through_inline_graphics() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let serif = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Se", "font": "Source Serif 4"})).unwrap()["id"].clone();
|
||||
s.execute("symbol.new", &json!({"name": "Mark", "ids": [serif]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let inline = |s: &mut Session, y: i32| {
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": y, "text": "In", "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "Mark"})).unwrap();
|
||||
id
|
||||
};
|
||||
let listed = inline(&mut s, 60);
|
||||
let tiled = inline(&mut s, 100);
|
||||
s.execute("object.pattern.make", &json!({"ids": [tiled], "name": "Loop", "edit": false})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [tiled]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let painted = s.execute("shape.rectangle", &json!({"x": 0, "y": 140, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"swatch": "Loop", "ids": [painted]})).unwrap();
|
||||
let mut instances = 0;
|
||||
s.doc().unwrap().doc.walk(|n| instances += usize::from(matches!(n.kind, NodeKind::SymbolInstance { .. })));
|
||||
assert_eq!(instances, 0, "the symbol is drawn only by inline graphics");
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 2);
|
||||
let mut selected = s.doc().unwrap().selection.objects.clone();
|
||||
selected.sort();
|
||||
assert_eq!(selected, [NodeId(listed), NodeId(painted)]);
|
||||
s.execute("select.set", &json!({"ids": [painted]})).unwrap();
|
||||
let rows = font_rows(&mut s, json!({"selectionOnly": true}));
|
||||
assert_eq!(rows, [("Inter".to_string(), 2, 1), ("Source Serif 4".to_string(), 1, 1)]);
|
||||
let r = s
|
||||
.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true}))
|
||||
.unwrap();
|
||||
assert_eq!(r, json!({"runs": 1}));
|
||||
assert_eq!(symbol_texts(&s.doc().unwrap().doc, None)[0].0.runs[0].style.font_family, "Source Sans 3");
|
||||
}
|
||||
|
||||
/// Refresh Font List lays out again the type in opacity masks and in the art of symbols and
|
||||
/// patterns, as it does the type on the layers, and the inline graphics of the type on the
|
||||
/// layers follow the symbols' type as laid out again.
|
||||
#[test]
|
||||
fn rescanning_fonts_refreshes_the_bounds_of_type_in_art() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let owner = s.execute("shape.rectangle", &json!({"x": 0, "y": 20, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
let masked = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Mask", "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
|
||||
mask_with(&mut s, owner, masked);
|
||||
let tiled = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "Tile", "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.pattern.make", &json!({"ids": [tiled], "name": "Dots", "edit": false})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [tiled]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let stale = Rect::new(0.0, 0.0, 1.0, 1.0);
|
||||
s.edit("Make the bounds stale", |d, _| {
|
||||
edit_art_texts_of(d, &|_| true, &mut |t| t.cached_bounds = Some(stale));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let bounds = |s: &Session| art_texts_of(&s.doc().unwrap().doc).iter().map(|(_, t, _)| t.cached_bounds).collect::<Vec<_>>();
|
||||
assert_eq!(bounds(&s), [Some(stale), Some(stale)]);
|
||||
s.execute("text.rescanFonts", &json!({})).unwrap();
|
||||
let after = bounds(&s);
|
||||
assert_eq!(after.len(), 2);
|
||||
assert!(after.iter().all(|b| b.is_some_and(|b| b != stale && b.width() > 1.0)), "{after:?}");
|
||||
let marked = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Se", "font": "Inter"})).unwrap()["id"].clone();
|
||||
s.execute("symbol.new", &json!({"name": "Mark", "ids": [marked]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let line = s.execute("text.create", &json!({"x": 10, "y": 140, "text": "In", "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.insertInline", &json!({"id": line, "at": 0, "symbol": "Mark"})).unwrap();
|
||||
s.edit("Make the bounds stale", |d, _| {
|
||||
edit_art_texts_of(d, &|_| true, &mut |t| t.cached_bounds = Some(stale));
|
||||
d.resolve_inline_art();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let resolved = |s: &Session| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
matches!(d.inline_resolved(text(d, NodeId(line)).unwrap()), std::borrow::Cow::Borrowed(_))
|
||||
};
|
||||
assert!(resolved(&s), "the inline graphic took the stale bounds");
|
||||
s.execute("text.rescanFonts", &json!({})).unwrap();
|
||||
assert!(resolved(&s), "the inline graphic follows the symbol's type laid out again");
|
||||
// So does an inline graphic in the art of another symbol.
|
||||
let held = s.execute("text.create", &json!({"x": 10, "y": 180, "text": "Out", "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.insertInline", &json!({"id": held, "at": 0, "symbol": "Mark"})).unwrap();
|
||||
s.execute("symbol.new", &json!({"name": "Outer", "ids": [held]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
s.edit("Make the bounds stale", |d, _| {
|
||||
edit_art_texts_of(d, &|_| true, &mut |t| t.cached_bounds = Some(stale));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let shrunk = s.doc().unwrap().doc.symbol_art_bounds("Mark");
|
||||
s.edit("Take the stale bounds", |d, _| {
|
||||
edit_art_texts_of(d, &|_| true, &mut |t| {
|
||||
for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) {
|
||||
a.bounds = shrunk;
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let nested = |s: &Session| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
art_texts_of(d).iter().flat_map(|(_, t, _)| t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.bounds))).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(nested(&s), [shrunk]);
|
||||
s.execute("text.rescanFonts", &json!({})).unwrap();
|
||||
let mark = s.doc().unwrap().doc.symbol_art_bounds("Mark");
|
||||
assert_ne!(mark, shrunk);
|
||||
assert_eq!(nested(&s), [mark], "the inline graphic in the art of Outer follows Mark's type laid out again");
|
||||
}
|
||||
|
||||
/// The font family of text object `id`'s first run.
|
||||
fn text_family(s: &Session, id: u64) -> String {
|
||||
text(&s.doc().unwrap().doc, NodeId(id)).unwrap().runs[0].style.font_family.clone()
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
@@ -690,7 +690,9 @@ fn placed_across(d: &Document, ids: &[NodeId], paint: Paint, p: &Value, fill: bo
|
||||
/// The images among `ids` that a fill of `paint` colorizes: grayscale and 1-bit images, for a solid
|
||||
/// color (or none on a colorized image, which takes the color off). The color may be black, white,
|
||||
/// a process color or a spot color; on an image with an alpha channel, only a spot color, black or
|
||||
/// white, and other colors are an error.
|
||||
/// white, and other colors are an error. A colorized image that shows a preview that isn't
|
||||
/// grayscale, such as a missing link's placeholder, takes a solid color as its new colorize paint;
|
||||
/// while its paint is one an alpha channel allows, a process color is an error there too.
|
||||
fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result::Result<Vec<NodeId>, String> {
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
@@ -701,6 +703,19 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result:
|
||||
out.push(*id);
|
||||
continue;
|
||||
}
|
||||
// The Fill proxy shows such an image's colorize paint. Its pixels are not known until the
|
||||
// file is relinked: a process color replaces only a process color, which the image's
|
||||
// pixels already accepted, so a relink can't bring back an alpha channel under one.
|
||||
if matches!(paint, Paint::Solid { .. })
|
||||
&& let Some(old) = n.extras().colorize.as_deref()
|
||||
&& d.image_blob(im).is_some_and(|b| !b.takes_colorize())
|
||||
{
|
||||
if !colorizes_transparency(d, paint) && colorizes_transparency(d, old) {
|
||||
return Err(PROCESS_ON_TRANSPARENCY.into());
|
||||
}
|
||||
out.push(*id);
|
||||
continue;
|
||||
}
|
||||
// A color-masked image is colorized as it shows: its masked pixels, which have an alpha
|
||||
// channel.
|
||||
let Some(gray) = d.image_blob(&d.shown_image(n, im)).and_then(|b| b.gray_source()) else { continue };
|
||||
|
||||
@@ -528,6 +528,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"select.same.textStrokeColor",
|
||||
"select.segments",
|
||||
"select.set",
|
||||
"select.storedPath",
|
||||
"select.toggle",
|
||||
"select.update",
|
||||
"shape.arc",
|
||||
|
||||
@@ -49,6 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
set
|
||||
),
|
||||
cmd!(
|
||||
"select.storedPath",
|
||||
"Select Path",
|
||||
[],
|
||||
None,
|
||||
"{id: object id, fingerprint: text (\"v1:\" and 16 lowercase hex digits)} the Actions panel's Select Path step: select only the path or compound path `id` when its fingerprint (a hash of its kind, fill rule and anchors, its members' for a compound path) is still `fingerprint` and it can be selected: not hidden or locked, nor in a hidden or locked layer or group, a template layer or on a locked artboard, and inside the isolated object in isolation mode. A missing object, another kind of object, a path whose kind, fill rule or anchors changed, or one that cannot be selected is an error, and the selection stays as it was → {count, ids} (the resulting selection)",
|
||||
has_doc,
|
||||
stored_path
|
||||
),
|
||||
cmd!(
|
||||
"select.add",
|
||||
"Add to Selection",
|
||||
@@ -449,6 +458,150 @@ fn selection_result(s: &Session) -> Result<Value> {
|
||||
Ok(json!({ "count": selection.len(), "ids": selection.objects }))
|
||||
}
|
||||
|
||||
/// The prefix of every fingerprint [`path_fingerprint`] gives: the version of its hash.
|
||||
const FINGERPRINT_PREFIX: &str = "v1:";
|
||||
|
||||
/// FNV-1a (64 bits) over the bytes written to it, starting from the offset basis.
|
||||
pub(crate) struct Fnv(pub(crate) u64);
|
||||
|
||||
impl Default for Fnv {
|
||||
fn default() -> Self {
|
||||
Self(0xcbf2_9ce4_8422_2325)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Write for Fnv {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0 = buf.iter().fold(self.0, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3));
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The fingerprint of the path or compound path `n` that `select.storedPath` compares: FNV-1a (64
|
||||
/// bits) over a kind byte, the fill rule and the anchors of the path, or of each member of the
|
||||
/// compound path in order, as JSON, written as [`FINGERPRINT_PREFIX`] and 16 lowercase hex digits.
|
||||
/// None for any object [`is_storable_kind`] does not take.
|
||||
pub fn path_fingerprint(n: &Node) -> Option<String> {
|
||||
fn feed(h: &mut Fnv, n: &Node) -> Option<()> {
|
||||
use std::io::Write;
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => {
|
||||
h.write_all(b"P").ok()?;
|
||||
serde_json::to_writer(&mut *h, rule).ok()?;
|
||||
serde_json::to_writer(&mut *h, path).ok()
|
||||
}
|
||||
NodeKind::Compound { children, rule } => {
|
||||
h.write_all(b"C").ok()?;
|
||||
serde_json::to_writer(&mut *h, rule).ok()?;
|
||||
h.write_all(&(children.len() as u64).to_le_bytes()).ok()?;
|
||||
children.iter().try_for_each(|c| feed(h, c))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
if !is_storable_kind(n) {
|
||||
return None;
|
||||
}
|
||||
let mut h = Fnv::default();
|
||||
feed(&mut h, n)?;
|
||||
Some(format!("{FINGERPRINT_PREFIX}{:016x}", h.0))
|
||||
}
|
||||
|
||||
/// Whether `n` is an object a Select Path step can store: a path that is not a guide, or a compound
|
||||
/// path whose members all are.
|
||||
fn is_storable_kind(n: &Node) -> bool {
|
||||
let path = |n: &Node| matches!(n.kind, NodeKind::Path { guide: false, .. });
|
||||
match &n.kind {
|
||||
NodeKind::Compound { children, .. } => children.iter().all(|c| path(c)),
|
||||
_ => path(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `id` and `fingerprint` of `select.storedPath`'s params `p`, or why they are not ones: an
|
||||
/// object with only a non-negative whole `id` and a `fingerprint` that [`path_fingerprint`] could give.
|
||||
pub fn stored_path_params(p: &Value) -> std::result::Result<(NodeId, &str), String> {
|
||||
let o = p.as_object().ok_or("`params` must be an object")?;
|
||||
if let Some(key) = o.keys().find(|k| !matches!(k.as_str(), "id" | "fingerprint")) {
|
||||
let keys = "id, fingerprint";
|
||||
return Err(format!("unknown parameter `{key}`; the command takes: {keys}"));
|
||||
}
|
||||
let value = o.get("id").unwrap_or(&Value::Null);
|
||||
let id = value.as_u64().map(NodeId).ok_or_else(|| format!("invalid object id {value}: expected a non-negative integer"))?;
|
||||
let fingerprint = o.get("fingerprint").and_then(Value::as_str).unwrap_or_default();
|
||||
let hex = fingerprint.strip_prefix(FINGERPRINT_PREFIX).unwrap_or_default();
|
||||
if hex.len() != 16 || !hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
|
||||
return Err("`fingerprint` must be \"v1:\" and 16 lowercase hex digits".into());
|
||||
}
|
||||
Ok((id, fingerprint))
|
||||
}
|
||||
|
||||
/// Why the object `id` of `st` cannot be the path of a Select Path step: it is not there, is not a
|
||||
/// path (not a guide) or compound path ([`is_storable_kind`]), is a member of a compound path, cannot
|
||||
/// be selected ([`is_selectable`]), or is on a locked artboard, in isolation mode too.
|
||||
fn storable_path(st: &DocState, id: NodeId) -> std::result::Result<(), String> {
|
||||
let value = id.0;
|
||||
let n = st.doc.node(id).ok_or_else(|| format!("no such object id {value}"))?;
|
||||
let in_compound = st.doc.parent_of(id).and_then(|p| st.doc.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. }));
|
||||
if !is_storable_kind(n) || in_compound {
|
||||
return Err(WHOLE_PATH.into());
|
||||
}
|
||||
// In isolation mode `is_selectable` does not look at the locked artboards, so they are checked here too.
|
||||
let locks = st.doc.locked_artboards();
|
||||
if !is_selectable(&st.doc, st.isolation, &locks, id) || locks.holds_id(id) {
|
||||
return Err("the path is hidden or locked, or cannot be selected here".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What [`storable_selection`] asks for when the selection is not a whole path or compound path.
|
||||
const WHOLE_PATH: &str = "select one whole path or compound path";
|
||||
|
||||
/// The one object selected in `st` when it can be the path of a Select Path step: a whole path or
|
||||
/// compound path that can be selected ([`storable_path`]). An error for no selection, several
|
||||
/// objects, a path with ruler guides, anchors or segments of a path alone, a group, type or any other object, a guide, or a
|
||||
/// member of a compound path. It does not hash the path, so menus can ask it every frame.
|
||||
pub fn storable_selection(st: &DocState) -> std::result::Result<NodeId, String> {
|
||||
let sel = &st.selection;
|
||||
let id = match sel.objects.as_slice() {
|
||||
[] => return Err("select a path".into()),
|
||||
[id] if sel.guides.is_empty() => *id,
|
||||
_ => return Err("select only one path".into()),
|
||||
};
|
||||
if sel.anchors.get(&id).is_some_and(|a| !a.is_empty()) || sel.segments.get(&id).is_some_and(|s| !s.is_empty()) {
|
||||
return Err(WHOLE_PATH.into());
|
||||
}
|
||||
storable_path(st, id)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The params of a `select.storedPath` step for the path selected in `st` ([`storable_selection`]):
|
||||
/// `{id, fingerprint}`.
|
||||
pub fn stored_path_step(st: &DocState) -> std::result::Result<Value, String> {
|
||||
let id = storable_selection(st)?;
|
||||
let fingerprint = st.doc.node(id).and_then(path_fingerprint).ok_or(WHOLE_PATH)?;
|
||||
Ok(json!({ "id": id.0, "fingerprint": fingerprint }))
|
||||
}
|
||||
|
||||
/// `select.storedPath {id, fingerprint}`: selects only the path `id` when it is still the path the
|
||||
/// step stored ([`path_fingerprint`]) and can be selected ([`storable_path`]); else an error, and
|
||||
/// the selection stays as it was.
|
||||
fn stored_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.storedPath";
|
||||
let (id, fingerprint) = stored_path_params(p).map_err(|e| bad(C, e))?;
|
||||
let st = s.doc()?;
|
||||
let value = id.0;
|
||||
let n = st.doc.node(id).ok_or_else(|| bad(C, format!("no such object id {value}")))?;
|
||||
if path_fingerprint(n).as_deref() != Some(fingerprint) {
|
||||
return Err(bad(C, format!("object {value} is not the stored path: it changed, or the document is another one")));
|
||||
}
|
||||
storable_path(st, id).map_err(|e| bad(C, e))?;
|
||||
s.select(|_, sel| sel.set([id]))?;
|
||||
selection_result(s)
|
||||
}
|
||||
|
||||
fn key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = p.get("id").map(|value| checked_id(s, value, "select.key")).transpose()?;
|
||||
let guide = match p.get("guide").filter(|v| !v.is_null()) {
|
||||
|
||||
@@ -32,6 +32,7 @@ pub use cmd::distortcmds::perspective_click;
|
||||
pub use cmd::params::{check_keys, check_params};
|
||||
pub use cmd::pathops::auto_simplify_level;
|
||||
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
|
||||
pub use cmd::select::{path_fingerprint, storable_selection, stored_path_params, stored_path_step};
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use cmd::{EyedropperOptions, PathfinderOptions};
|
||||
pub use journal::Journal;
|
||||
|
||||
@@ -176,6 +176,7 @@ mod smartguides;
|
||||
mod smooth_path;
|
||||
mod spelling;
|
||||
mod store;
|
||||
mod stored_path;
|
||||
mod strokegeom;
|
||||
mod strokegradient;
|
||||
mod strokereach;
|
||||
|
||||
@@ -462,6 +462,49 @@ fn a_colorized_missing_link_placeholder_is_not_flattened() {
|
||||
assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 0, 0))), "the image keeps its paint");
|
||||
}
|
||||
|
||||
/// A fill color on a colorized image that shows the missing-link placeholder recolors the image,
|
||||
/// as the Fill proxy shows its colorize paint, and adds no fill item.
|
||||
#[test]
|
||||
fn a_fill_color_recolors_a_colorized_missing_link_placeholder() {
|
||||
let mut s = session();
|
||||
let bytes = std::sync::Arc::new(vectorcraft_doc::links::missing_placeholder().unwrap());
|
||||
let id = image(&mut s, "pic", ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) });
|
||||
s.edit("Colorize", |d, _| {
|
||||
d.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0))));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let appearance = s.doc().unwrap().doc.node(id).unwrap().appearance.clone();
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id.0]})).unwrap();
|
||||
assert_eq!(colorize(&s, id).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#0000ff".into()));
|
||||
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance, appearance, "no fill item");
|
||||
assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#0000ff".into()));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 0, 0))));
|
||||
}
|
||||
|
||||
/// The pixels behind the placeholder may have an alpha channel, so a missing-link image colorized
|
||||
/// with a spot color takes another spot color, black or white, and refuses a process color.
|
||||
#[test]
|
||||
fn a_colorized_missing_link_placeholder_in_a_spot_color_refuses_a_process_color() {
|
||||
let mut s = session();
|
||||
let bytes = std::sync::Arc::new(vectorcraft_doc::links::missing_placeholder().unwrap());
|
||||
let id = image(&mut s, "pic", ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) });
|
||||
s.execute("swatch.new", &json!({"name": "Ink 2", "color": {"c": 100, "m": 0, "y": 0, "k": 0}, "spot": true})).unwrap();
|
||||
s.edit("Colorize", |d, _| {
|
||||
let ink = Paint::Solid { color: Color::rgb8(255, 0, 0), swatch: Some("Ink".into()), tint: 1.0 };
|
||||
d.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(ink));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = colorize(&s, id);
|
||||
let e = s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id.0]})).unwrap_err().to_string();
|
||||
assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}");
|
||||
assert_eq!(colorize(&s, id), before);
|
||||
s.execute("paint.setFill", &json!({"swatch": "Ink 2", "ids": [id.0]})).unwrap();
|
||||
assert!(matches!(colorize(&s, id), Some(Paint::Solid { swatch: Some(w), .. }) if w == "Ink 2"), "{:?}", colorize(&s, id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flattened_colorized_image_shows_the_art_under_white_pixels() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -256,6 +256,122 @@ fn an_images_stroke_with_outline_object_draws_exports_and_expands_along_its_fram
|
||||
assert!(kids[0].appearance.fill_paint().is_none());
|
||||
}
|
||||
|
||||
/// A 100×100 PNG, transparent but for an opaque red disc of radius 30 in its middle.
|
||||
fn disc_png() -> Vec<u8> {
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_fn(100, 100, |x, y| {
|
||||
let r = (f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 50.0);
|
||||
image::Rgba([255, 0, 0, if r <= 30.0 { 255 } else { 0 }])
|
||||
})
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
png
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_images_stroke_with_outline_object_follows_its_opaque_area() {
|
||||
let mut s = session();
|
||||
// The frame runs from 50 to 150, the disc from 70 to 130.
|
||||
let r = run(&mut s, "file.place", json!({"name": "disc.png", "dataBase64": vectorcraft_format::base64_encode(&disc_png()), "at": [100, 100]}));
|
||||
let im = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 8, "color": "#000000"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.outlineObject", "item": 0}));
|
||||
// Under the image, the stroke's outer 4 pt shows around the disc, from 66 to 70; nothing runs
|
||||
// along the frame.
|
||||
let shade = |s: &Session, x: u32, y: u32| match pixel(s, x, y) {
|
||||
p if p == RED => "red",
|
||||
[r, g, b, _] if r.max(g).max(b) < 40 => "black",
|
||||
[r, g, b, _] if r.min(g).min(b) > 215 => "white",
|
||||
p => panic!("({x}, {y}) is {p:?}"),
|
||||
};
|
||||
assert_eq!([shade(&s, 64, 100), shade(&s, 67, 100), shade(&s, 100, 100), shade(&s, 100, 132)], ["white", "black", "red", "black"]);
|
||||
assert_eq!([shade(&s, 49, 100), shade(&s, 52, 100)], ["white", "white"]);
|
||||
// SVG and PDF hold the disc's outline stroked 8 pt.
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let disc = |b: vectorcraft_geom::Rect| [b.x0 - 70.0, b.y0 - 70.0, b.x1 - 130.0, b.y1 - 130.0].iter().all(|d| d.abs() < 0.5);
|
||||
let svg = vectorcraft_svg::export(&doc, &Default::default());
|
||||
assert!(svg.contains("<image") && svg.contains("stroke-width=\"8\""), "{svg}");
|
||||
let back = vectorcraft_pdf::import(&vectorcraft_pdf::export(&doc, &Default::default()).unwrap()).unwrap();
|
||||
let mut outlines = 0;
|
||||
for l in &back.layers {
|
||||
l.walk(&mut |n| {
|
||||
let stroked = (n.appearance.stroke_width() - 8.0).abs() < 0.01;
|
||||
outlines += usize::from(matches!(n.kind, NodeKind::Path { .. }) && stroked && n.geometric_bounds().is_some_and(disc));
|
||||
});
|
||||
}
|
||||
assert_eq!(outlines, 1);
|
||||
// Expand Appearance leaves the disc's outline below the image.
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let n = node(&s, im);
|
||||
let kids = n.children().unwrap();
|
||||
assert!(kids.len() == 2 && matches!(kids[1].kind, NodeKind::Image(_)), "{n:?}");
|
||||
assert!(disc(kids[0].geometric_bounds().unwrap()), "{:?}", kids[0].geometric_bounds());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
// An Offset Path of 3 pt after Outline Object moves the stroke 3 pt out: it shows from 63 to 70.
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 3}, "item": 0}));
|
||||
assert_eq!([shade(&s, 61, 100), shade(&s, 64, 100)], ["white", "black"]);
|
||||
// Relinked to an opaque image, the stroke follows the frame 3 pt out: it shows from 43 to 50.
|
||||
let dir = vectorcraft_testkit::temp_dir("outline-object-relink");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("opaque.png");
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(100, 100, image::Rgba([255, 0, 0, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
std::fs::write(&path, png).unwrap();
|
||||
run(&mut s, "links.relink", json!({"ids": [im.0], "path": path}));
|
||||
assert_eq!([shade(&s, 41, 100), shade(&s, 45, 100), shade(&s, 64, 100)], ["white", "black", "red"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area() {
|
||||
let mut s = session();
|
||||
// The frame runs from 50 to 150, the disc from 70 to 130.
|
||||
let r = run(&mut s, "file.place", json!({"name": "disc.png", "dataBase64": vectorcraft_format::base64_encode(&disc_png()), "at": [100, 100]}));
|
||||
let im = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 20, "color": "#000000"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.outlineObject", "item": 0}));
|
||||
// EPS and the PDF-compatible .ai draw the disc's outline stroked 20 pt, 20 pt inside the
|
||||
// image's frame on each side (EPS moves the art to its bounding box).
|
||||
let disc_outlines = |d: &vectorcraft_doc::Document| {
|
||||
let mut frame = None;
|
||||
d.walk(|c| {
|
||||
if matches!(c.kind, NodeKind::Image(_)) {
|
||||
frame = c.geometric_bounds();
|
||||
}
|
||||
});
|
||||
let f = frame.unwrap();
|
||||
let mut n = 0;
|
||||
d.walk(|c| {
|
||||
let disc = |b: vectorcraft_geom::Rect| {
|
||||
[b.x0 - f.x0 - 20.0, b.y0 - f.y0 - 20.0, f.x1 - b.x1 - 20.0, f.y1 - b.y1 - 20.0].iter().all(|d| d.abs() < 0.6)
|
||||
};
|
||||
let stroked = (c.appearance.stroke_width() - 20.0).abs() < 0.01;
|
||||
n += usize::from(matches!(c.kind, NodeKind::Path { .. }) && stroked && c.geometric_bounds().is_some_and(disc));
|
||||
});
|
||||
n
|
||||
};
|
||||
let saved = |s: &mut Session, p: Value| vectorcraft_format::base64_decode(run(s, "file.saveCopy", p)["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let eps = saved(&mut s, json!({"format": "eps", "previewFormat": "none", "thumbnails": false}));
|
||||
assert_eq!(disc_outlines(&vectorcraft_eps::import_with(&eps, false).unwrap().document), 1);
|
||||
let ai = saved(&mut s, json!({"format": "ai"}));
|
||||
assert_eq!(disc_outlines(&crate::cmd::fileio::page_document(&ai, 0, &Default::default()).unwrap().0), 1);
|
||||
// An Offset Path of 30 pt after Outline Object: the stroke runs round a circle of radius 60,
|
||||
// from 30 to 50 on the left, where along the frame it would run from 10 to 30.
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 30}, "item": 0}));
|
||||
assert_eq!((pixel(&s, 35, 100), pixel(&s, 15, 100)), ([0, 0, 0, 255], [255; 4]));
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert_eq!(click(&mut s, 35.0, 100.0), [im]);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert!(click(&mut s, 15.0, 100.0).is_empty());
|
||||
// Use Preview Bounds measures the stroke round the circle.
|
||||
s.prefs.use_preview_bounds = true;
|
||||
let b = s.transform_bounds(&[im]).unwrap();
|
||||
assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 170.0, b.y1 - 170.0].iter().all(|d| d.abs() < 1.0), "{b:?}");
|
||||
}
|
||||
|
||||
/// Click with the Selection tool at (x, y) on a 100% view: what is selected afterwards.
|
||||
fn click(s: &mut Session, x: f64, y: f64) -> Vec<NodeId> {
|
||||
let v = ViewInfo::default();
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! `select.storedPath`, the Actions panel's Select Path step: the step stores a path's id and a
|
||||
//! fingerprint of it, and playback selects that path only while it is still there unchanged and
|
||||
//! can be selected.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// A document with two rectangles and an ellipse, nothing selected → the session and their ids.
|
||||
fn doc() -> (Session, [u64; 3]) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let mut add = |shape: &str, x: u32| s.execute(shape, &json!({"x": x, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap();
|
||||
let ids = [add("shape.rectangle", 10), add("shape.rectangle", 80), add("shape.ellipse", 150)];
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[u64]) {
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
}
|
||||
|
||||
fn selected(s: &Session) -> Vec<u64> {
|
||||
s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect()
|
||||
}
|
||||
|
||||
/// The Select Path step's params for `id`, selected alone.
|
||||
fn step_for(s: &mut Session, id: u64) -> Value {
|
||||
select(s, &[id]);
|
||||
stored_path_step(s.doc().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hash_is_fnv_1a_64() {
|
||||
// The published FNV-1a 64-bit test values for "", "a" and "foobar".
|
||||
for (bytes, want) in [(&b""[..], 0xcbf2_9ce4_8422_2325_u64), (b"a", 0xaf63_dc4c_8601_ec8c), (b"foobar", 0x8594_4171_f739_67e8)] {
|
||||
let mut h = crate::cmd::select::Fnv::default();
|
||||
h.write_all(bytes).unwrap();
|
||||
assert_eq!(h.0, want, "{bytes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_step_stores_the_id_and_a_fingerprint_and_plays_by_selecting_the_path() {
|
||||
let (mut s, [a, b, _]) = doc();
|
||||
let p = step_for(&mut s, a);
|
||||
assert_eq!(p["id"], json!(a));
|
||||
let fingerprint = p["fingerprint"].as_str().unwrap();
|
||||
assert!(fingerprint.starts_with("v1:") && fingerprint.len() == 19, "{fingerprint}");
|
||||
assert_eq!(step_for(&mut s, a), p, "the same path gives the same step");
|
||||
assert_ne!(step_for(&mut s, b)["fingerprint"], p["fingerprint"], "another path gives another fingerprint");
|
||||
select(&mut s, &[b]);
|
||||
let r = s.execute("select.storedPath", &p).unwrap();
|
||||
assert_eq!(r, json!({"count": 1, "ids": [a]}));
|
||||
assert_eq!(selected(&s), [a], "only the stored path is selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_compound_path_is_one_step_and_a_path_in_a_group_is_one_too() {
|
||||
let (mut s, [a, b, c]) = doc();
|
||||
select(&mut s, &[a, b]);
|
||||
let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let p = step_for(&mut s, compound);
|
||||
let member = s.doc().unwrap().doc.children(Some(vectorcraft_doc::NodeId(compound))).unwrap()[0].id.0;
|
||||
select(&mut s, &[member]);
|
||||
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select one whole path or compound path", "a member alone is not stored");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("select.storedPath", &p).unwrap();
|
||||
assert_eq!(selected(&s), [compound]);
|
||||
|
||||
select(&mut s, &[c, compound]);
|
||||
let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select one whole path or compound path", "a group is not stored");
|
||||
let inner = step_for(&mut s, c);
|
||||
select(&mut s, &[group]);
|
||||
s.execute("select.storedPath", &inner).unwrap();
|
||||
assert_eq!(selected(&s), [c], "the path inside the group is selected alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_one_whole_path_that_can_be_selected_is_stored() {
|
||||
let (mut s, [a, b, _]) = doc();
|
||||
let whole = "select one whole path or compound path";
|
||||
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select a path");
|
||||
select(&mut s, &[a, b]);
|
||||
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select only one path");
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
|
||||
select(&mut s, &[a]);
|
||||
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.guides.len(), 1, "the guide is selected with the path");
|
||||
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select only one path", "a path with a guide");
|
||||
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
|
||||
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap(), step_for(&mut s, a), "the path alone again");
|
||||
s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 0]], "mode": "set"})).unwrap();
|
||||
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), whole, "an anchor alone");
|
||||
let t = s.execute("text.create", &json!({"x": 10, "y": 200, "text": "Type"})).unwrap()["id"].as_u64().unwrap();
|
||||
select(&mut s, &[t]);
|
||||
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), whole, "type");
|
||||
select(&mut s, &[a]);
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
let mut st = s.doc().unwrap().clone();
|
||||
st.selection.objects = vec![vectorcraft_doc::NodeId(a)];
|
||||
assert_eq!(stored_path_step(&st).unwrap_err(), "the path is hidden or locked, or cannot be selected here");
|
||||
let guide = {
|
||||
let mut d = (*st.doc).clone();
|
||||
if let Some(vectorcraft_doc::NodeKind::Path { guide, .. }) = d.node_mut(vectorcraft_doc::NodeId(a)).map(|n| &mut n.kind) {
|
||||
*guide = true;
|
||||
}
|
||||
d
|
||||
};
|
||||
assert!(path_fingerprint(guide.node(vectorcraft_doc::NodeId(a)).unwrap()).is_none(), "a guide is not stored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_missing_hidden_or_other_path_is_an_error_and_keeps_the_selection() {
|
||||
let (mut s, [a, b, c]) = doc();
|
||||
let p = step_for(&mut s, a);
|
||||
let fails = |s: &mut Session, p: &Value, want: &str| {
|
||||
select(s, &[c]);
|
||||
let e = s.execute("select.storedPath", p).unwrap_err().to_string();
|
||||
assert!(e.contains(want), "{e}");
|
||||
assert_eq!(selected(s), [c], "the selection stays as it was");
|
||||
};
|
||||
select(&mut s, &[a]);
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
fails(&mut s, &p, "is not the stored path");
|
||||
let moved = step_for(&mut s, a);
|
||||
s.execute("object.hide", &json!({})).unwrap();
|
||||
fails(&mut s, &moved, "hidden or locked");
|
||||
s.execute("object.showAll", &json!({})).unwrap();
|
||||
let shown = step_for(&mut s, b);
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
fails(&mut s, &shown, "no such object id");
|
||||
// Another document: the id names another path there, or nothing.
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 7, "height": 9})).unwrap();
|
||||
let c2 = s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 9, "height": 7})).unwrap()["id"].as_u64().unwrap();
|
||||
select(&mut s, &[c2]);
|
||||
assert!(s.execute("select.storedPath", &moved).is_err());
|
||||
assert_eq!(selected(&s), [c2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_on_a_locked_artboard_is_refused_in_isolation_mode_too() {
|
||||
let (mut s, [a, b, _]) = doc();
|
||||
select(&mut s, &[a, b]);
|
||||
let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let p = step_for(&mut s, a);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("object.isolate", &json!({"id": group})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
|
||||
assert!(s.doc().unwrap().isolation.is_some(), "the group stays isolated");
|
||||
assert!(s.doc().unwrap().doc.on_locked_artboard(vectorcraft_doc::NodeId(a)), "the path is on the locked artboard");
|
||||
let mut st = s.doc().unwrap().clone();
|
||||
st.selection.objects = vec![vectorcraft_doc::NodeId(a)];
|
||||
assert_eq!(stored_path_step(&st).unwrap_err(), "the path is hidden or locked, or cannot be selected here", "capture");
|
||||
let before = selected(&s);
|
||||
let e = s.execute("select.storedPath", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("hidden or locked"), "{e}");
|
||||
assert_eq!(selected(&s), before, "the selection stays as it was");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_step_in_a_batch_undoes_the_steps_before_it() {
|
||||
let (mut s, [a, _, c]) = doc();
|
||||
let p = step_for(&mut s, a);
|
||||
select(&mut s, &[a]);
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
select(&mut s, &[c]);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let steps = [json!({"command": "object.move", "params": {"dx": 1, "dy": 0}}), json!({"command": "select.storedPath", "params": p})];
|
||||
assert!(s.execute("command.batch", &json!({"commands": steps})).is_err());
|
||||
assert!(s.doc().unwrap().doc == before, "the move before the step is undone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_are_an_id_and_a_v1_fingerprint() {
|
||||
let (mut s, [a, ..]) = doc();
|
||||
let p = step_for(&mut s, a);
|
||||
let f = p["fingerprint"].clone();
|
||||
for bad in [
|
||||
json!({"fingerprint": f}),
|
||||
json!({"id": -1, "fingerprint": f}),
|
||||
json!({"id": "1", "fingerprint": f}),
|
||||
json!({"id": a}),
|
||||
json!({"id": a, "fingerprint": 5}),
|
||||
json!({"id": a, "fingerprint": "v2:0123456789abcdef"}),
|
||||
json!({"id": a, "fingerprint": "v1:0123456789ABCDEF"}),
|
||||
json!({"id": a, "fingerprint": "v1:0123456789abcde"}),
|
||||
json!({"id": a, "fingerprint": f, "extra": 1}),
|
||||
json!([a]),
|
||||
] {
|
||||
assert!(stored_path_params(&bad).is_err(), "{bad}");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("select.storedPath", &bad).is_err(), "{bad}");
|
||||
assert!(selected(&s).is_empty(), "{bad}");
|
||||
}
|
||||
assert!(stored_path_params(&p).is_ok());
|
||||
}
|
||||
Reference in New Issue
Block a user