mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 09:33:54 +00:00
Merge main into fix/shape-builder-merge-off-ui-thread
This commit is contained in:
@@ -9,6 +9,13 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="theme-color" content="#262626">
|
||||
<title>VectorCraft</title>
|
||||
<!-- Tab and home-screen icons: copied from assets/app-icon (one folder per size, the PNG names are equal). -->
|
||||
<link data-trunk rel="copy-file" href="../../assets/app-icon/hicolor/scalable/apps/ai.storyteller.vectorcraft.svg" data-target-path="icons" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/app-icon/hicolor/32x32/apps/ai.storyteller.vectorcraft.png" data-target-path="icons/32" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.vectorcraft.png" data-target-path="icons/256" />
|
||||
<link rel="icon" type="image/svg+xml" href="icons/ai.storyteller.vectorcraft.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/32/ai.storyteller.vectorcraft.png">
|
||||
<link rel="apple-touch-icon" href="icons/256/ai.storyteller.vectorcraft.png">
|
||||
<link data-trunk rel="rust" data-bin="vectorcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
/* iPad: no page scroll, bounce, pinch zoom, text selection or callout; the app takes the gestures. */
|
||||
|
||||
+68
-1
@@ -110,6 +110,66 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
|
||||
hit_test_skipping(doc, p, opt, &|_| false, false)
|
||||
}
|
||||
|
||||
/// The object of `selected` (topmost first) whose own art lies under `p` where a clipping mask
|
||||
/// hides it: a clip group (or a layer with a clipping mask) holding it doesn't clip to `p`. A
|
||||
/// click there hits nothing ([`hit_test`]), so the selection tools grab the selection by that
|
||||
/// art instead of starting a marquee: an image in a mask, picked in the Layers panel, can be
|
||||
/// dragged by its hidden part, whose frame shows while it is selected. An object that a click
|
||||
/// can't hit anyway doesn't count: hidden, locked, a template, a guide while guides are locked
|
||||
/// or hidden, in a Shaper group's part a click can't reach, outside the isolated container
|
||||
/// (`opt.scope`) or on a locked artboard. On a layer whose Preview is off it counts by its
|
||||
/// outline, as a click there hits it.
|
||||
pub fn selected_hidden_at(doc: &Document, p: Point, opt: HitOptions, selected: &[NodeId]) -> Option<NodeId> {
|
||||
let symbols = doc.symbol_boxes();
|
||||
let locks = doc.locked_artboards();
|
||||
selected.iter().rev().copied().find(|&id| {
|
||||
let Some(chain) = doc.ancestry(id) else { return false };
|
||||
let nodes: Vec<&Node> = chain.iter().filter_map(|a| doc.node(*a)).collect();
|
||||
if nodes.len() != chain.len()
|
||||
|| nodes.iter().any(|n| !n.visible || n.locked || n.is_template())
|
||||
|| opt.scope.is_some_and(|s| !chain.contains(&s))
|
||||
|| locks.holds_id(id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// How a click reaches it, if one does.
|
||||
let Some(opt) = path_options(&nodes, &chain, opt) else { return false };
|
||||
// Hidden by a mask: one of the containers around it clips, and not to `p`.
|
||||
let masked = nodes
|
||||
.iter()
|
||||
.rev()
|
||||
.skip(1)
|
||||
.any(|n| n.clips() && n.children().and_then(|c| c.first()).is_some_and(|clip| clip.id != id && !clip_contains(clip, p)));
|
||||
// A click may reach it there ([`may_hit`]: not a guide while guides are locked or
|
||||
// hidden, nor a Shaper group's hidden part), and its own art is what a click would hit
|
||||
// in it: a group's hidden, locked or masked contents don't count.
|
||||
let [.., parent, n] = nodes.as_slice() else { return false };
|
||||
let reachable =
|
||||
parent.children().and_then(|c| c.iter().position(|c| c.id == id)).is_some_and(|i| may_hit(parent, i, n, p, opt, &symbols, true));
|
||||
masked && reachable && object_hit(n, p, opt, &symbols, true)
|
||||
})
|
||||
}
|
||||
|
||||
/// The options a click goes down `nodes` (an object's ancestry, `chain`, the layer first) with, as
|
||||
/// [`hit_test`] goes: from the isolated container (`opt.scope`), else from the layer, in outline
|
||||
/// below a layer whose Preview is off. `None` when a Shaper group's part it can't reach lies on
|
||||
/// the way ([`shaper_hides`]).
|
||||
fn path_options(nodes: &[&Node], chain: &[NodeId], opt: HitOptions) -> Option<HitOptions> {
|
||||
let scope_at = opt.scope.and_then(|s| chain.iter().position(|a| *a == s));
|
||||
let child_index = |parent: &Node, child: &Node| parent.children().and_then(|c| c.iter().position(|c| c.id == child.id));
|
||||
let hidden = nodes.windows(2).skip(scope_at.unwrap_or(0)).any(|w| match w {
|
||||
[parent, child] => child_index(parent, child).is_some_and(|i| shaper_hides(parent, i, opt.scope)),
|
||||
_ => false,
|
||||
});
|
||||
if hidden {
|
||||
return None;
|
||||
}
|
||||
// The isolated container's own Preview doesn't count: a click starts inside it.
|
||||
let below = scope_at.map_or(0, |i| i + 1);
|
||||
let outline = nodes.iter().skip(below).any(|n| matches!(n.kind, NodeKind::Layer { preview: false, .. }));
|
||||
Some(if outline { HitOptions { outline: true, ..opt } } else { opt })
|
||||
}
|
||||
|
||||
/// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost
|
||||
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
|
||||
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
|
||||
@@ -277,7 +337,14 @@ fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBox
|
||||
|
||||
/// Would a click at `p` hit the locked object `n` if it were not locked?
|
||||
fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> bool {
|
||||
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), false).is_some();
|
||||
object_hit(n, p, opt, symbols, false)
|
||||
}
|
||||
|
||||
/// Would a click at `p` hit the object `n`, wherever it is? A container is hit by its contents as
|
||||
/// [`hit_test`] tries them (its own clipping mask, and with `editable_only` none of its hidden,
|
||||
/// locked or template contents).
|
||||
fn object_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> bool {
|
||||
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), editable_only).is_some();
|
||||
match &n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Intertwine { .. } | NodeKind::Envelope { editing: true, .. } => inside(),
|
||||
NodeKind::Blend { .. } => inside() || hit_leaf(n, p, opt, symbols).is_some(),
|
||||
|
||||
@@ -77,6 +77,148 @@ fn half_clipped(d: &mut Document) -> (Node, Node) {
|
||||
(mask, art)
|
||||
}
|
||||
|
||||
/// The art a mask hides counts only for a selected object: there a click hits nothing, and the
|
||||
/// selection tools grab the selection by that art. Not where the art shows (the click hits it),
|
||||
/// beyond the art, for the clip group selected as a whole, nor for art a click can't hit (locked,
|
||||
/// hidden, outside the isolated container). A layer's clipping mask hides art as a clip group's
|
||||
/// does.
|
||||
#[test]
|
||||
fn a_selected_objects_art_hidden_by_a_mask_is_found_under_the_pointer() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (mask, art) = half_clipped(&mut d);
|
||||
let (g, art_id) = (d.alloc_id(), art.id);
|
||||
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
|
||||
clip.set_clips(true);
|
||||
d.insert(Some(l), 0, clip).unwrap();
|
||||
let (hidden, shown, beyond) = (Point::new(150.0, 50.0), Point::new(50.0, 50.0), Point::new(250.0, 50.0));
|
||||
let opt = HitOptions::default();
|
||||
assert!(hit_test(&d, hidden, opt).is_none());
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), Some(art_id));
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[]), None);
|
||||
assert_eq!(selected_hidden_at(&d, shown, opt, &[art_id]), None);
|
||||
assert_eq!(selected_hidden_at(&d, beyond, opt, &[art_id]), None);
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None);
|
||||
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(g), ..opt }, &[art_id]), Some(art_id));
|
||||
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(art_id), ..opt }, &[art_id]), Some(art_id));
|
||||
let other = d.add_layer(Some("Other"));
|
||||
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(other), ..opt }, &[art_id]), None);
|
||||
for change in [|n: &mut Node| n.locked = true, |n: &mut Node| n.visible = false] {
|
||||
let mut d = d.clone();
|
||||
change(d.node_mut(art_id).unwrap());
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), None);
|
||||
}
|
||||
// A layer's clipping mask.
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (mask, art) = half_clipped(&mut d);
|
||||
let art_id = art.id;
|
||||
d.insert(Some(l), 0, mask).unwrap();
|
||||
d.insert(Some(l), 1, art).unwrap();
|
||||
d.node_mut(l).unwrap().set_clips(true);
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), Some(art_id));
|
||||
}
|
||||
|
||||
/// A selected group's own art is what a click would hit in it: not a hidden or locked child, nor
|
||||
/// art its own clipping mask hides, all under the pointer where an outer mask hides them.
|
||||
#[test]
|
||||
fn a_selected_groups_hidden_art_counts_only_where_a_click_would_hit_it() {
|
||||
// An outer clip group masking to the left half (0..100); the selected group inside it holds
|
||||
// `child` (100..200 × 0..100, under the pointer at (150, 50)) and `shown` (inside the mask).
|
||||
let build = |change: &dyn Fn(&mut Node, &mut Node)| {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let outer_mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
|
||||
let mut child = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 200.0, 100.0)), Appearance::default_art());
|
||||
let shown = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), Appearance::default_art());
|
||||
let (gid, cid) = (d.alloc_id(), d.alloc_id());
|
||||
let mut group = Node::group(gid, vec![Arc::new(shown)]);
|
||||
change(&mut group, &mut child);
|
||||
if let NodeKind::Group { children, .. } = &mut group.kind {
|
||||
children.push(Arc::new(child));
|
||||
}
|
||||
let mut clip = Node::group(cid, vec![Arc::new(outer_mask), Arc::new(group)]);
|
||||
clip.set_clips(true);
|
||||
d.insert(Some(l), 0, clip).unwrap();
|
||||
(d, gid)
|
||||
};
|
||||
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
|
||||
let (d, g) = build(&|_, _| {});
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), Some(g));
|
||||
let (d, g) = build(&|_, child| child.visible = false);
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None, "a hidden child");
|
||||
let (d, g) = build(&|_, child| child.locked = true);
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None, "a locked child");
|
||||
// The selected group is a clip group too, whose own mask (its first child, 10..40) leaves
|
||||
// `child` out.
|
||||
let (d, g) = build(&|group, _| group.set_clips(true));
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None, "its own mask");
|
||||
}
|
||||
|
||||
/// A selected guide path where a mask hides it counts only while guides can be clicked: not
|
||||
/// while they are locked or hidden (`skip_guides`), as a click on it then hits nothing.
|
||||
#[test]
|
||||
fn a_selected_guide_hidden_by_a_mask_counts_only_while_guides_can_be_clicked() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (mask, mut art) = half_clipped(&mut d);
|
||||
if let NodeKind::Path { guide, .. } = &mut art.kind {
|
||||
*guide = true;
|
||||
}
|
||||
let (g, guide_id) = (d.alloc_id(), art.id);
|
||||
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
|
||||
clip.set_clips(true);
|
||||
d.insert(Some(l), 0, clip).unwrap();
|
||||
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[guide_id]), Some(guide_id));
|
||||
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { skip_guides: true, ..opt }, &[guide_id]), None);
|
||||
}
|
||||
|
||||
/// A selected path in a Shaper group's original shapes, where an outer mask hides it, counts only
|
||||
/// while those shapes can be clicked (Construction mode, with them isolated), however deep it
|
||||
/// lies in them.
|
||||
#[test]
|
||||
fn a_selected_path_in_a_shaper_groups_hidden_shapes_counts_only_in_construction_mode() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
// The outer clip group masks to 0..100; the path, 100..200, lies in the Shaper group's
|
||||
// original shapes (its first child), under (150, 50).
|
||||
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
|
||||
let path = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 200.0, 100.0)), Appearance::default_art());
|
||||
let path_id = path.id;
|
||||
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(path)]);
|
||||
sources.name = Some(crate::shaper::SOURCES.into());
|
||||
let sources_id = sources.id;
|
||||
let face = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), Appearance::default_art());
|
||||
let mut shaper = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]);
|
||||
shaper.extras_mut().shaper = Some(Box::default());
|
||||
let mut clip = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(shaper)]);
|
||||
clip.set_clips(true);
|
||||
d.insert(Some(l), 0, clip).unwrap();
|
||||
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[path_id]), None);
|
||||
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(sources_id), ..opt }, &[path_id]), Some(path_id));
|
||||
}
|
||||
|
||||
/// On a layer whose Preview is off, a click hits art in outline: a selected object's hidden art
|
||||
/// counts on its outline, not inside its fill.
|
||||
#[test]
|
||||
fn a_selected_objects_hidden_art_on_a_preview_off_layer_counts_on_its_outline() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (mask, art) = half_clipped(&mut d);
|
||||
let (g, art_id) = (d.alloc_id(), art.id);
|
||||
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
|
||||
clip.set_clips(true);
|
||||
d.insert(Some(l), 0, clip).unwrap();
|
||||
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(l).unwrap().kind {
|
||||
*preview = false;
|
||||
}
|
||||
let opt = HitOptions::default();
|
||||
assert_eq!(selected_hidden_at(&d, Point::new(150.0, 50.0), opt, &[art_id]), None, "inside the fill");
|
||||
assert_eq!(selected_hidden_at(&d, Point::new(200.0, 50.0), opt, &[art_id]), Some(art_id), "on the outline");
|
||||
}
|
||||
|
||||
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
|
||||
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
|
||||
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
|
||||
|
||||
+65
-1
@@ -126,6 +126,40 @@ impl ImageBlob {
|
||||
self
|
||||
}
|
||||
|
||||
/// This blob after its pixels are decoded once, within [`crate::MAX_RASTER_ALLOC`], to check
|
||||
/// that they can be read; with `proxy`, it also gets the preview [`Self::with_proxy`] makes,
|
||||
/// made from that decode. `None` when the pixels can't be read, as with a file cut short that
|
||||
/// has a header but not all its pixels. An image whose pixels would take more than the
|
||||
/// allowance as RGBA is not decoded, and one whose decode needs more than the allowance stops
|
||||
/// decoding; either is returned as it is.
|
||||
pub fn checked_with_proxy(self, proxy: bool) -> Option<Self> {
|
||||
self.checked_with_pixels(proxy).map(|(blob, _)| blob)
|
||||
}
|
||||
|
||||
/// [`Self::checked_with_proxy`], with the pixels it decoded: `None` beside the blob when it
|
||||
/// decoded none (an image past the allowance).
|
||||
pub fn checked_with_pixels(mut self, proxy: bool) -> Option<(Self, Option<image::DynamicImage>)> {
|
||||
let max = crate::MAX_RASTER_ALLOC;
|
||||
let reader = || image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok();
|
||||
let (w, h) = reader()?.into_dimensions().ok()?;
|
||||
if u64::from(w) * u64::from(h) > max / 4 {
|
||||
return Some((self, None));
|
||||
}
|
||||
let mut r = reader()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(max);
|
||||
r.limits(limits);
|
||||
let img = match r.decode() {
|
||||
Ok(img) => img,
|
||||
Err(image::ImageError::Limits(_)) => return Some((self, None)),
|
||||
Err(_) => return None,
|
||||
};
|
||||
if proxy && self.proxy.is_none() && w.max(h) > PROXY_SIZE {
|
||||
self.proxy = proxy_of(&img).map(Arc::new);
|
||||
}
|
||||
Some((self, Some(img)))
|
||||
}
|
||||
|
||||
/// Are the bytes the preview (the linked file hasn't been read)?
|
||||
pub fn is_proxy(&self) -> bool {
|
||||
self.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &self.bytes))
|
||||
@@ -149,7 +183,12 @@ fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(crate::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let small = reader.decode().ok()?.thumbnail(PROXY_SIZE, PROXY_SIZE);
|
||||
proxy_of(&reader.decode().ok()?)
|
||||
}
|
||||
|
||||
/// Decoded image `img` shrunk to fit [`PROXY_SIZE`] as a PNG.
|
||||
fn proxy_of(img: &image::DynamicImage) -> Option<Vec<u8>> {
|
||||
let small = img.thumbnail(PROXY_SIZE, PROXY_SIZE);
|
||||
let mut png = Vec::new();
|
||||
small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
Some(png)
|
||||
@@ -442,6 +481,31 @@ mod tests {
|
||||
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).with_proxy().proxy.is_none(), "undecodable");
|
||||
}
|
||||
|
||||
/// A checked image's pixels are decoded once: the preview comes from that decode, and an image
|
||||
/// whose header reads but whose pixels don't is refused.
|
||||
#[test]
|
||||
fn a_checked_image_is_refused_when_its_pixels_cant_be_read() {
|
||||
let full = png(600, 300);
|
||||
let big = ImageBlob::new("image/png", full.clone()).checked_with_proxy(true).unwrap();
|
||||
let p = image::load_from_memory(big.proxy.as_ref().unwrap()).unwrap();
|
||||
assert_eq!((p.width(), p.height()), (256, 128));
|
||||
assert!(ImageBlob::new("image/png", full.clone()).checked_with_proxy(false).unwrap().proxy.is_none(), "no preview asked for");
|
||||
assert!(ImageBlob::new("image/png", png(40, 20)).checked_with_proxy(true).unwrap().proxy.is_none(), "small images keep their pixels");
|
||||
let cut = full[..full.len() / 2].to_vec();
|
||||
assert_eq!(image::ImageReader::new(Cursor::new(&cut)).with_guessed_format().unwrap().into_dimensions().unwrap(), (600, 300));
|
||||
assert!(ImageBlob::new("image/png", cut).checked_with_proxy(true).is_none(), "cut to half its length");
|
||||
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).checked_with_proxy(false).is_none(), "not an image");
|
||||
}
|
||||
|
||||
/// A checked image hands on the pixels it decoded, so they need not be decoded again.
|
||||
#[test]
|
||||
fn a_checked_image_hands_on_its_pixels() {
|
||||
let full = png(600, 300);
|
||||
let (blob, pixels) = ImageBlob::new("image/png", full.clone()).checked_with_pixels(true).unwrap();
|
||||
assert!(blob.proxy.is_some(), "the preview is made as checked_with_proxy makes it");
|
||||
assert_eq!(pixels.map(|p| p.to_rgba8()), Some(image::load_from_memory(&full).unwrap().to_rgba8()));
|
||||
}
|
||||
|
||||
/// PNG CRC-32 (the same polynomial ZIP uses), so a hand-written header is one a decoder accepts.
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc = !0u32;
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
//! and [`container_keeping_type`] list the other conditions). The renderer still draws its outlines.
|
||||
//!
|
||||
//! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an
|
||||
//! object of its own, strokes turned into filled art by the caller (their outlines or brush art).
|
||||
//! object of its own, a stroke a path stroked by it alone (brushed strokes their brush art, made by
|
||||
//! the caller).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -440,15 +441,17 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Turns one stroke of a path (the path after its geometry effects, its fill rule, the stroke
|
||||
/// without geometry effects) into filled art for [`expand_leaf`]: its outline filled with its
|
||||
/// paint, or its brush art. `None` when it paints nothing.
|
||||
/// Turns one brushed stroke of a path (the path after its geometry effects, its fill rule, the
|
||||
/// stroke without geometry effects) into its brush art for [`expand_leaf`]. `None` when it paints
|
||||
/// nothing.
|
||||
pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &StrokeLayer) -> Option<Node>;
|
||||
|
||||
/// Object → Expand Appearance of a path or compound path `n`: its geometry effects applied, every
|
||||
/// visible fill becomes a copy of the path painted by that fill alone and every visible stroke the
|
||||
/// filled art `stroke_art` makes of it (each with its fill's or stroke's opacity and blend mode as
|
||||
/// its own), in paint order, as the members of a group that keeps
|
||||
/// visible fill becomes a copy of the path painted by that fill alone and every visible stroke a
|
||||
/// copy stroked by that stroke alone (its weight, caps, joins, dashes, arrowheads, alignment and
|
||||
/// width profile stay stroke attributes, which Object → Expand or Outline Stroke outline later,
|
||||
/// #3089), or for a brushed stroke the brush art `stroke_art` makes of it; each with its fill's or
|
||||
/// stroke's opacity and blend mode as its own, in paint order, as the members of a group that keeps
|
||||
/// `n`'s id, name, transparency, opacity mask and remaining (raster) effects. A single piece
|
||||
/// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and
|
||||
/// effects are dropped. `None` for other kinds of objects.
|
||||
@@ -466,8 +469,8 @@ pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<
|
||||
clear_item_effects(&mut it);
|
||||
it.effects_mut().retain(|e| e.visible);
|
||||
let p = match &it {
|
||||
AppearanceItem::Stroke(st) => stroke_art(d, &ig, rule, st),
|
||||
AppearanceItem::Fill(_) => Some(piece(d, n, ig, rule, it)),
|
||||
AppearanceItem::Stroke(st) if st.brush.is_some() => stroke_art(d, &ig, rule, st),
|
||||
_ => Some(piece(d, n, ig, rule, it)),
|
||||
};
|
||||
pieces.extend(p.map(hoist_transparency));
|
||||
}
|
||||
|
||||
@@ -593,6 +593,12 @@ pub use flatten::FlattenOptions;
|
||||
/// A built-in or saved flattener preset ([`crate::Prefs::flattener_presets`]).
|
||||
pub use flatten::FlattenerPreset;
|
||||
|
||||
/// A saved Image Trace preset ([`crate::Prefs::trace_presets`]), and a change to the saved presets.
|
||||
pub use buildcmds::{TracePreset, TracePresetChange};
|
||||
|
||||
/// The saved Image Trace presets' revision and latest changes ([`crate::Session`] keeps them).
|
||||
pub(crate) use buildcmds::TracePresetChanges;
|
||||
|
||||
/// The shapes a group selected alone gives the Pathfinder panel.
|
||||
pub use pathops::shape_count;
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
//! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the
|
||||
//! image as its Contents row says; new ones go right below that row.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::appearance::SharedStack;
|
||||
@@ -123,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Eyedropper",
|
||||
[],
|
||||
None,
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead; `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
|
||||
has_doc,
|
||||
copy_from
|
||||
),
|
||||
@@ -844,15 +846,27 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => outermost(&s.doc()?.doc, v),
|
||||
None => selected_roots(s)?,
|
||||
};
|
||||
// Reversed, the first selected object's attributes go onto the clicked object.
|
||||
let (src_id, mut targets) = if reverse {
|
||||
let first = leaf_targets(s, &selected)?.into_iter().next().ok_or_else(|| bad(C, "select the object to copy from"))?;
|
||||
(first, leaf_targets(s, &[clicked])?)
|
||||
// Reversed, the first selected object's attributes go onto the clicked object; with nothing
|
||||
// selected, what the Eyedropper picked up last does (#4382).
|
||||
let (source, mut targets) = if reverse {
|
||||
let source = match leaf_targets(s, &selected)?.into_iter().next() {
|
||||
Some(first) => Some(first),
|
||||
None if s.eyedropper_sample.is_some() => None,
|
||||
None => return Err(bad(C, "select the object to copy from")),
|
||||
};
|
||||
(source, leaf_targets(s, &[clicked])?)
|
||||
} else {
|
||||
(clicked, leaf_targets(s, &selected)?)
|
||||
(Some(clicked), leaf_targets(s, &selected)?)
|
||||
};
|
||||
targets.retain(|id| *id != src_id);
|
||||
let picked = Picked::of(s.doc()?.doc.node(src_id).ok_or(EngineError::NoNode(src_id))?);
|
||||
targets.retain(|id| Some(*id) != source);
|
||||
let node = match source {
|
||||
Some(id) => Arc::new(s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.clone()),
|
||||
None => s.eyedropper_sample.clone().ok_or_else(|| bad(C, "select the object to copy from"))?,
|
||||
};
|
||||
let picked = Picked::of(&node);
|
||||
if !reverse {
|
||||
s.eyedropper_sample = Some(node);
|
||||
}
|
||||
let range = range.filter(|(id, _, _)| targets.contains(id));
|
||||
// Character styles cannot represent object transparency. Refuse before even changing
|
||||
// paint defaults; applying it to the object would also alter the unselected characters.
|
||||
|
||||
@@ -40,7 +40,9 @@ mod trace;
|
||||
pub use live_paint::live_paint_gaps;
|
||||
use live_paint::{lp_expand, lp_fill, lp_gap_options, lp_info, lp_make, lp_merge, lp_release, lp_stroke_edge};
|
||||
use shape_builder::{sb_merge, sb_regions};
|
||||
use trace::{trace_expand, trace_make, trace_release, trace_set_view};
|
||||
pub(crate) use trace::TracePresetChanges;
|
||||
pub use trace::{TracePreset, TracePresetChange};
|
||||
use trace::{trace_expand, trace_make, trace_presets_delete, trace_presets_save, trace_release, trace_set_view};
|
||||
|
||||
/// Name of an Image Trace group.
|
||||
pub const TRACE_NAME: &str = "Image Trace";
|
||||
@@ -142,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
@@ -182,9 +184,29 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_or_ids,
|
||||
trace_set_view
|
||||
),
|
||||
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params, enhanced: the params of the preset's enhanced version, null if it has none}]}", always, |_, _| {
|
||||
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p, "enhanced": tr::enhanced_preset(n) })).collect::<Vec<_>>() }))
|
||||
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, builtIn, params, enhanced: the params of the preset's enhanced version, null if it has none}], revision: grows each time the saved presets change} the built-in presets, then the saved ones (builtIn false, enhanced null); any of these names works as imageTrace.make's `preset`", always, |s, _| {
|
||||
let builtin = tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "builtIn": true, "params": p, "enhanced": tr::enhanced_preset(n) }));
|
||||
let saved = s.prefs.trace_presets.iter().map(|p| json!({ "name": p.name, "builtIn": false, "params": p.params, "enhanced": null }));
|
||||
Ok(json!({ "presets": builtin.chain(saved).collect::<Vec<_>>(), "revision": s.trace_presets_revision() }))
|
||||
}),
|
||||
cmd!(
|
||||
query "imageTrace.presets.save",
|
||||
"Save Image Trace Preset",
|
||||
[],
|
||||
None,
|
||||
"{name?: (default: a new \"Tracing Preset N\"), newName?: rename the saved preset `name`, preset?: the preset to start from (default: the saved preset `name`, else Default), enhanced?, params?: {settings, as imageTrace.make takes them, over the preset's}} create or change a saved tracing preset, kept with the preferences (built-in ones can't change) → {name, params, created, renamedFrom: the name before `newName` changed it, else null}",
|
||||
always,
|
||||
trace_presets_save
|
||||
),
|
||||
cmd!(
|
||||
query "imageTrace.presets.delete",
|
||||
"Delete Image Trace Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} delete a saved tracing preset (built-in ones stay) → {deleted: name}",
|
||||
always,
|
||||
trace_presets_delete
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use vectorcraft_color::Swatch;
|
||||
|
||||
use super::{
|
||||
Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point,
|
||||
Result, Session, StrokeLayer, TRACE_NAME, TraceView, Value, bad, fill_only, ids_json, json, root_ids, shape_node, str_param, switch_or, tr,
|
||||
@@ -18,24 +20,183 @@ fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
|
||||
v.as_str().and_then(TraceView::from_id).map(Some).ok_or_else(|| bad(cmd, format!("view must be one of {}", ids())))
|
||||
}
|
||||
|
||||
/// The parameters command `c` traces with: the preset's, with `params` over them.
|
||||
fn trace_params(c: &str, p: &Value) -> Result<tr::TraceParams> {
|
||||
let name = str_param(p, "preset").unwrap_or("Default");
|
||||
/// A saved Image Trace preset ([`crate::Prefs::trace_presets`]): a name and its settings.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TracePreset {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub params: tr::TraceParams,
|
||||
}
|
||||
|
||||
/// A change to the saved tracing presets: `imageTrace.presets.save` saved `name` (made it,
|
||||
/// changed its settings, or renamed it from `renamed_from`), or `imageTrace.presets.delete`
|
||||
/// deleted it. `revision` is [`Session::trace_presets_revision`] after the change.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TracePresetChange {
|
||||
pub revision: u64,
|
||||
pub name: String,
|
||||
pub renamed_from: Option<String>,
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
/// The revision of the saved tracing presets and their latest changes ([`TracePresetChange`]).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TracePresetChanges {
|
||||
revision: u64,
|
||||
/// Every change the commands made in this session, oldest first, so a panel that was closed
|
||||
/// meanwhile still follows a rename (one record for each save or delete).
|
||||
changes: Vec<TracePresetChange>,
|
||||
}
|
||||
|
||||
impl TracePresetChanges {
|
||||
/// A change the commands made: a new revision that records it.
|
||||
fn note(&mut self, mut change: TracePresetChange) {
|
||||
self.revision += 1;
|
||||
change.revision = self.revision;
|
||||
self.changes.push(change);
|
||||
}
|
||||
|
||||
/// A change made by replacing the preferences: a new revision with no record.
|
||||
pub(crate) fn replaced(&mut self) {
|
||||
self.revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// A number that grows each time the saved tracing presets change: saved, renamed or deleted
|
||||
/// by `imageTrace.presets.save` and `.delete`, or replaced with the preferences
|
||||
/// ([`Session::apply_prefs`]). A list read at one revision holds until the number changes.
|
||||
pub fn trace_presets_revision(&self) -> u64 {
|
||||
self.trace_preset_changes.revision
|
||||
}
|
||||
|
||||
/// The changes `imageTrace.presets.save` and `.delete` made after `revision`, oldest first.
|
||||
pub fn trace_preset_changes_since(&self, revision: u64) -> impl Iterator<Item = &TracePresetChange> {
|
||||
let changes = &self.trace_preset_changes.changes;
|
||||
let from = changes.partition_point(|c| c.revision <= revision);
|
||||
changes.get(from..).unwrap_or_default().iter()
|
||||
}
|
||||
|
||||
/// The saved tracing preset `name`, any case.
|
||||
pub fn trace_preset(&self, name: &str) -> Option<&TracePreset> {
|
||||
self.prefs.trace_presets.iter().find(|q| q.name.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
/// The index of the saved tracing preset `name`.
|
||||
fn trace_preset_index(&self, name: &str) -> Option<usize> {
|
||||
self.prefs.trace_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
/// Whether `name` is a built-in preset's, "Custom" (changed settings, as the panel and a traced
|
||||
/// object show them), or a saved preset's other than the one at `except`.
|
||||
fn trace_preset_taken(&self, name: &str, except: Option<usize>) -> bool {
|
||||
tr::preset(name).is_some() || name.trim().eq_ignore_ascii_case("Custom") || self.trace_preset_index(name).is_some_and(|i| Some(i) != except)
|
||||
}
|
||||
|
||||
/// The first free "Tracing Preset N": the name a new tracing preset gets.
|
||||
pub fn new_trace_preset_name(&self) -> String {
|
||||
(1..).map(|i| format!("Tracing Preset {i}")).find(|n| !self.trace_preset_taken(n, None)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn trace_presets_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "imageTrace.presets.save";
|
||||
// A name or preset of the wrong type is an error, not dropped: dropping it would save other
|
||||
// settings, or under another name.
|
||||
let new_name = opt_str(C, p, "newName")?;
|
||||
let preset = opt_str(C, p, "preset")?;
|
||||
let name = match opt_str(C, p, "name")?.map(str::trim) {
|
||||
Some("") => return Err(bad(C, "`name` is empty")),
|
||||
Some(n) => n.to_string(),
|
||||
None => s.new_trace_preset_name(),
|
||||
};
|
||||
if tr::preset(&name).is_some() {
|
||||
return Err(bad(C, format!("`{name}` is a built-in preset and can't change: save it under another name")));
|
||||
}
|
||||
if name.eq_ignore_ascii_case("Custom") {
|
||||
return Err(bad(C, "`Custom` stands for changed settings: save the preset under another name"));
|
||||
}
|
||||
let at = s.trace_preset_index(&name);
|
||||
// The saved preset's name as it was saved.
|
||||
let old = at.and_then(|i| s.prefs.trace_presets.get(i)).map(|q| q.name.clone());
|
||||
if old.is_none() && new_name.is_some() {
|
||||
return Err(bad(C, format!("no saved preset named `{name}` to rename")));
|
||||
}
|
||||
// A saved preset changes from its own settings unless `preset` says where to start.
|
||||
let mut q = p.clone();
|
||||
if let (Some(old), Some(o), None) = (&old, q.as_object_mut(), preset) {
|
||||
o.insert("preset".into(), json!(old));
|
||||
}
|
||||
let params = trace_params(s, C, &q)?;
|
||||
let name = match new_name.map(str::trim) {
|
||||
Some("") => return Err(bad(C, "`newName` is empty")),
|
||||
Some(n) if s.trace_preset_taken(n, at) => return Err(bad(C, format!("a preset named `{n}` exists"))),
|
||||
Some(n) => n.to_string(),
|
||||
None => old.clone().unwrap_or(name),
|
||||
};
|
||||
let json_params = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let preset = TracePreset { name: name.clone(), params };
|
||||
match at.and_then(|i| s.prefs.trace_presets.get_mut(i)) {
|
||||
Some(slot) => *slot = preset,
|
||||
None => s.prefs.trace_presets.push(preset),
|
||||
}
|
||||
let renamed_from = old.as_ref().filter(|o| **o != name).cloned();
|
||||
s.trace_preset_changes.note(TracePresetChange { revision: 0, name: name.clone(), renamed_from: renamed_from.clone(), deleted: false });
|
||||
Ok(json!({"name": name, "params": json_params, "created": old.is_none(), "renamedFrom": renamed_from}))
|
||||
}
|
||||
|
||||
pub(super) fn trace_presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "imageTrace.presets.delete";
|
||||
let name = opt_str(C, p, "name")?.ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
if tr::preset(name).is_some() {
|
||||
return Err(bad(C, format!("`{name}` is a built-in preset and stays")));
|
||||
}
|
||||
let i = s.trace_preset_index(name).ok_or_else(|| bad(C, format!("no saved preset named `{name}`")))?;
|
||||
let deleted = s.prefs.trace_presets.remove(i).name;
|
||||
s.trace_preset_changes.note(TracePresetChange { revision: 0, name: deleted.clone(), renamed_from: None, deleted: true });
|
||||
Ok(json!({ "deleted": deleted }))
|
||||
}
|
||||
|
||||
/// The string `key` of `p` (`None` when it is missing or null); any other type is an error of
|
||||
/// command `c`.
|
||||
fn opt_str<'a>(c: &str, p: &'a Value, key: &str) -> Result<Option<&'a str>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(v)) => Ok(Some(v)),
|
||||
Some(_) => Err(bad(c, format!("`{key}` must be a string"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameters command `c` traces with: the preset's (built-in, else the `savedPreset`
|
||||
/// settings a journal entry records, else saved), with `params` over them.
|
||||
fn trace_params(s: &Session, c: &str, p: &Value) -> Result<tr::TraceParams> {
|
||||
let name = opt_str(c, p, "preset")?.unwrap_or("Default");
|
||||
let enhanced = switch_or(p, "enhanced", false, c)?;
|
||||
// (A preset without an enhanced version is traced as it is.)
|
||||
let recorded = match p.get("savedPreset").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(serde_json::from_value::<tr::TraceParams>(v.clone()).map_err(|e| bad(c, format!("savedPreset: {e}")))?),
|
||||
None => None,
|
||||
};
|
||||
// (A preset without an enhanced version, a saved one too, is traced as it is.)
|
||||
let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name));
|
||||
let base = base.ok_or_else(|| bad(c, format!("unknown preset `{name}` (see imageTrace.presets)")))?;
|
||||
let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) };
|
||||
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad(c, "params must be an object")) };
|
||||
// A key that isn't one of the settings is an error, with the nearest setting.
|
||||
if let Some(key) = src.keys().find(|k| !o.contains_key(k.as_str())) {
|
||||
return Err(crate::cmd::params::unknown(c, key, &o.keys().map(String::as_str).collect::<Vec<_>>()));
|
||||
}
|
||||
for (k, val) in src {
|
||||
o.insert(k.clone(), val.clone());
|
||||
}
|
||||
let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
|
||||
let base = base.or(recorded).or_else(|| s.trace_preset(name).map(|q| q.params.clone()));
|
||||
let base = base.ok_or_else(|| bad(c, format!("unknown preset `{name}`: not a built-in or saved preset (see imageTrace.presets)")))?;
|
||||
let params: tr::TraceParams = match p.get("params").filter(|v| !v.is_null()) {
|
||||
None => base,
|
||||
Some(over) => {
|
||||
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad(c, "params must be an object")) };
|
||||
// A key that isn't one of the settings is an error, with the nearest setting.
|
||||
if let Some(key) = src.keys().find(|k| !o.contains_key(k.as_str())) {
|
||||
return Err(crate::cmd::params::unknown(c, key, &o.keys().map(String::as_str).collect::<Vec<_>>()));
|
||||
}
|
||||
for (k, val) in src {
|
||||
o.insert(k.clone(), val.clone());
|
||||
}
|
||||
serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?
|
||||
}
|
||||
};
|
||||
// (Recorded and saved settings are checked too.)
|
||||
if !params.fills && !params.strokes {
|
||||
return Err(bad(c, "Create needs fills, strokes or both"));
|
||||
}
|
||||
@@ -58,41 +219,59 @@ fn trace_params(c: &str, p: &Value) -> Result<tr::TraceParams> {
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
/// Document swatch `w`'s solid color with the paint a trace fills with it: a global or spot color,
|
||||
/// or a tint of one, linked to its swatch as `swatch_solid` (paint.rs) links it; any other color
|
||||
/// unlinked. The color is the one the paint shows (a Lab spot color's CMYK equivalent when the Spot
|
||||
/// Colors options use CMYK values).
|
||||
fn trace_swatch_paint(d: &Document, w: &Swatch) -> Option<(Color, Paint)> {
|
||||
let linked = d.swatch_link(&w.name).and_then(|(base, tint)| d.tint_paint(&base, tint));
|
||||
let color = linked.as_ref().and_then(Paint::color).or_else(|| w.paint.color())?;
|
||||
Some((color, linked.unwrap_or_else(|| Paint::solid(color))))
|
||||
}
|
||||
|
||||
/// Document Library with `colorGroup`: the solid colors of the active document's color group
|
||||
/// `name` (so named exactly, else ignoring case). Errors name command `c`.
|
||||
/// ([`trace_params`] has checked that the library is the document's swatches.)
|
||||
fn trace_group_colors(c: &str, s: &Session, name: &str) -> Result<Vec<Color>> {
|
||||
let groups = &s.doc()?.doc.swatch_groups;
|
||||
/// `name` (so named exactly, else ignoring case), each with its paint ([`trace_swatch_paint`]).
|
||||
/// Errors name command `c`. ([`trace_params`] has checked that the library is the document's
|
||||
/// swatches.)
|
||||
fn trace_group_colors(c: &str, s: &Session, name: &str) -> Result<Vec<(Color, Paint)>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let groups = &d.swatch_groups;
|
||||
let lower = name.to_lowercase();
|
||||
let group = groups
|
||||
.iter()
|
||||
.find(|g| g.name == name)
|
||||
.or_else(|| groups.iter().find(|g| g.name.to_lowercase() == lower))
|
||||
.ok_or_else(|| bad(c, format!("no color group `{name}` in the document (see swatch.list)")))?;
|
||||
Ok(group.swatches.iter().filter_map(|w| w.paint.color()).collect())
|
||||
Ok(group.swatches.iter().filter_map(|w| trace_swatch_paint(d, w)).collect())
|
||||
}
|
||||
|
||||
/// Document Library in Color mode: look the library's colors (or its color group's) up into
|
||||
/// `params.swatches` → each one as traced (sRGB) with the color to fill with. Errors name command `c`.
|
||||
fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8; 3], Color)>> {
|
||||
/// `params.swatches` → each one as traced (sRGB) with the paint to fill with. The document's
|
||||
/// global and spot colors fill linked to their swatches ([`trace_swatch_paint`]); a swatch
|
||||
/// library's colors fill unlinked. Errors name command `c`.
|
||||
fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8; 3], Paint)>> {
|
||||
if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let group = params.color_group.as_deref().filter(|g| !g.is_empty());
|
||||
let key = group.unwrap_or(params.library.as_str());
|
||||
let colors = match group {
|
||||
let missing = || bad(c, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)"));
|
||||
let colors: Vec<(Color, Paint)> = match group {
|
||||
Some(name) => trace_group_colors(c, s, name)?,
|
||||
None => crate::cmd::swatchlib::library_colors(s, key)
|
||||
.ok_or_else(|| bad(c, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?,
|
||||
None if key == crate::cmd::swatchlib::DOCUMENT_SWATCHES => {
|
||||
let d = &s.active().ok_or_else(missing)?.doc;
|
||||
d.swatches_iter().filter_map(|w| trace_swatch_paint(d, w)).collect()
|
||||
}
|
||||
None => crate::cmd::swatchlib::library_colors(s, key).ok_or_else(missing)?.into_iter().map(|c| (c, Paint::solid(c))).collect(),
|
||||
};
|
||||
if colors.is_empty() {
|
||||
return Err(bad(c, format!("`{key}` has no colours")));
|
||||
}
|
||||
let library: Vec<([u8; 3], Color)> = colors
|
||||
let library: Vec<([u8; 3], Paint)> = colors
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
.map(|(c, paint)| {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
([r, g, b], c)
|
||||
([r, g, b], paint)
|
||||
})
|
||||
.collect();
|
||||
params.swatches = library.iter().map(|l| l.0).collect();
|
||||
@@ -192,14 +371,25 @@ fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec
|
||||
|
||||
pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" };
|
||||
let mut params = trace_params(c, p)?;
|
||||
let mut params = trace_params(s, c, p)?;
|
||||
let view = view_param(c, p)?;
|
||||
let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// The preset's name if the parameters are exactly that preset's or its enhanced version's,
|
||||
// else "Custom".
|
||||
// The preset's name if the parameters are exactly that preset's or its enhanced version's (a
|
||||
// saved preset's under its saved name), else "Custom".
|
||||
let named = str_param(p, "preset").unwrap_or("Default");
|
||||
let is = |b: Option<tr::TraceParams>| b.and_then(|b| serde_json::to_value(&b).ok()).as_ref() == Some(¶ms_json);
|
||||
let preset_label = if is(tr::preset(named)) || is(tr::enhanced_preset(named)) { named.to_string() } else { "Custom".to_string() };
|
||||
let saved = s.trace_preset(named).filter(|q| tr::preset(named).is_none() && is(Some(q.params.clone()))).map(|q| q.name.clone());
|
||||
let preset_label = match saved {
|
||||
Some(name) => name,
|
||||
None if is(tr::preset(named)) || is(tr::enhanced_preset(named)) => named.to_string(),
|
||||
None => "Custom".to_string(),
|
||||
};
|
||||
// A saved preset's settings join the journal entry, so a replay traces as this run did after
|
||||
// the preset is changed, renamed or deleted.
|
||||
if let Some(q) = s.trace_preset(named).filter(|_| tr::preset(named).is_none()) {
|
||||
let recorded = serde_json::to_value(&q.params).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
s.note_journal("savedPreset", recorded);
|
||||
}
|
||||
let roots = root_ids(s, p)?;
|
||||
// The image (inside a trace group, or selected directly).
|
||||
let (target, img) = {
|
||||
@@ -260,10 +450,14 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
|
||||
*path = shape.to_path();
|
||||
*live = Some(Box::new(shape));
|
||||
}
|
||||
// A library colour fills with the swatch's own colour (its model kept).
|
||||
let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1);
|
||||
// A library color fills with the swatch's own color (its model kept), linked to a
|
||||
// global or spot swatch of the document.
|
||||
let solid = library
|
||||
.iter()
|
||||
.find(|(rgb, _)| *rgb == tp.color)
|
||||
.map_or_else(|| Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2])), |l| l.1.clone());
|
||||
// Gradients: a path the tracer found to be a linear gradient is filled with it.
|
||||
let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf));
|
||||
let paint = tp.gradient.as_ref().map_or(solid, |g| traced_gradient(g, xf));
|
||||
n.appearance = match tp.stroke {
|
||||
// Traced lines: round corners, and ends round or square as the tracer found them.
|
||||
Some(width) => Appearance {
|
||||
|
||||
@@ -10,8 +10,8 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, ImageObject, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use super::super::fileio;
|
||||
use super::super::place::pt_per_px;
|
||||
use super::super::{fileio, links};
|
||||
use super::{Clipboard, CommandSpec, Result, Session, always, bad, cmd, f64_or, has_doc, looks_like_svg, point_param, str_param};
|
||||
|
||||
/// Plain text: a type-only copy's text, else the SVG markup (Include SVG Code).
|
||||
@@ -178,7 +178,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load Image into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64 (PNG, JPEG, GIF, WebP, TIFF or BMP bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centred on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste",
|
||||
"{dataBase64 (PNG, JPEG, GIF, WebP, TIFF or BMP bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centred on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error",
|
||||
has_doc,
|
||||
import_image
|
||||
),
|
||||
@@ -418,7 +418,9 @@ fn import_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(m) = str_param(p, "mime").filter(|m| !m.starts_with("image/") || *m == SVG) {
|
||||
return Err(bad(C, format!("`{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText")));
|
||||
}
|
||||
let img = fileio::raster_image(&data_param(p, C)?).map_err(|e| bad(C, e.to_string()))?;
|
||||
let data = data_param(p, C)?;
|
||||
let mut img = fileio::raster_image(&data).map_err(|e| bad(C, e.to_string()))?;
|
||||
img.blob = links::readable_and_kept(img.blob, links::kept_unread(&data), false).map_err(|e| bad(C, e.to_string()))?;
|
||||
let (sx, sy) = pt_per_px(img.ppi);
|
||||
let (w, h) = (img.width, img.height);
|
||||
let image = ImageObject {
|
||||
|
||||
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Flare",
|
||||
[],
|
||||
None,
|
||||
"{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg), seed?} → {id}",
|
||||
"{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg), seed?} → {id}",
|
||||
has_doc,
|
||||
flare
|
||||
),
|
||||
@@ -395,6 +395,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"rays",
|
||||
"longest",
|
||||
"rayFuzziness",
|
||||
"rotation",
|
||||
"rings",
|
||||
"largest",
|
||||
"x2",
|
||||
@@ -413,6 +414,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let rays = p.get("rays").and_then(Value::as_u64).unwrap_or(15).min(50) as usize;
|
||||
let longest = pct("longest", 300.0);
|
||||
let ray_fuzz = pct("rayFuzziness", 100.0).min(1.0);
|
||||
let ray_turn = f64_or(p, "rotation", 0.0).to_radians();
|
||||
let rings = p.get("rings").and_then(Value::as_u64).unwrap_or(10).min(50) as usize;
|
||||
let largest = pct("largest", 50.0);
|
||||
let end = match (p.get("x2").and_then(Value::as_f64), p.get("y2").and_then(Value::as_f64)) {
|
||||
@@ -443,11 +445,12 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let inner = (1.0 - fuzz as f32 * 0.6).clamp(0.05, 0.95);
|
||||
parts.push(("Halo", circle(c, halo_r), radial(c, halo_r, vec![stop(0.0, 0.0), stop(inner * 0.75, 0.0), stop(inner, 0.5), stop(1.0, 0.0)]), 1.0));
|
||||
// Rays: thin spikes of random length (Longest = % of the centre radius), fading outwards.
|
||||
// `rotation` turns them clockwise about the center.
|
||||
if rays > 0 {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
let mut reach: f64 = 0.0;
|
||||
for i in 0..rays {
|
||||
let a = std::f64::consts::TAU * (i as f64 + rnd() * 0.6) / rays as f64;
|
||||
let a = std::f64::consts::TAU * (i as f64 + rnd() * 0.6) / rays as f64 + ray_turn;
|
||||
let len = r * longest * (1.0 - ray_fuzz * 0.75 * rnd()).max(0.1);
|
||||
reach = reach.max(len);
|
||||
let w = r * 0.035;
|
||||
|
||||
@@ -197,9 +197,9 @@ fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> {
|
||||
|
||||
/// Called inside every edit, so dimensions and their undo history stay in step with the art they
|
||||
/// measure. Returns at once in documents without dimensions.
|
||||
pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> {
|
||||
pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> {
|
||||
if !doc.dimensions {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
let mut dimensions = Vec::new();
|
||||
doc.walk(|n| {
|
||||
@@ -208,6 +208,7 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> {
|
||||
}
|
||||
});
|
||||
doc.dimensions = !dimensions.is_empty();
|
||||
let mut changed = false;
|
||||
for (id, old) in dimensions {
|
||||
let mut next = old.clone();
|
||||
let moved = next.moved.take();
|
||||
@@ -215,6 +216,7 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> {
|
||||
// A deleted/degenerate source has no valid measurement. Remove its annotation in
|
||||
// this same undo step instead of keeping a silently wrong number.
|
||||
doc.remove(id)?;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
// Moved on its own (the art it measures stayed put): it keeps its new place.
|
||||
@@ -233,9 +235,10 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> {
|
||||
|| before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next)
|
||||
{
|
||||
replace(doc, id, next)?;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Redraw the dimensions of an opened document whose art isn't in its colour mode: files saved
|
||||
|
||||
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Appearance",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
can_expand,
|
||||
expand_appearance
|
||||
),
|
||||
@@ -471,7 +471,7 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> {
|
||||
/// fills or strokes, effects, transparency of a fill or stroke, a brush), a group or layer with
|
||||
/// fills, strokes or effects of its own or such members, other objects with effects, or type with
|
||||
/// fills or strokes of its own?
|
||||
fn expandable(n: &Node) -> bool {
|
||||
pub(crate) fn expandable(n: &Node) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { clipping: true, .. } | NodeKind::Path { guide: true, .. } => false,
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => !n.appearance.is_basic() || vectorcraft_brush::has_brush(n),
|
||||
@@ -582,8 +582,9 @@ fn fresh_ids_listed(d: &mut Document, c: &mut Node, seen: &mut std::collections:
|
||||
}
|
||||
|
||||
/// Expand Appearance of `id` (and, recursively, of a group's or layer's members): every fill and
|
||||
/// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects
|
||||
/// are baked, raster effects become an embedded image, and the object keeps its transparency.
|
||||
/// stroke becomes an object of its own (a stroke a path stroked by it alone, brushes as their art),
|
||||
/// geometry effects are baked, raster effects become an embedded image, and the object keeps its
|
||||
/// transparency.
|
||||
pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
|
||||
let Some(n) = d.node(id).cloned() else { return };
|
||||
let mut listed = vec![];
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
|
||||
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -49,16 +49,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Options",
|
||||
[],
|
||||
None,
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to)",
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient}))
|
||||
Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient, "appearance": has_appearance(s)}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Has the selection appearance attributes that Expand Appearance expands (effects, several fills
|
||||
/// or strokes, a fill or stroke with its own opacity, blend mode or effects, a brush)? Object ›
|
||||
/// Expand… is dimmed in the menus for them until Expand Appearance has turned them into plain art
|
||||
/// (#3089); `object.expand` itself still bakes them.
|
||||
pub fn has_appearance(s: &Session) -> bool {
|
||||
let (Ok(st), Ok(roots)) = (s.doc(), selected_roots(s)) else { return false };
|
||||
roots.iter().filter_map(|r| st.doc.node(*r)).any(super::effectcmd::expandable)
|
||||
}
|
||||
|
||||
/// Every placed document in `roots` of `d` (at any depth) replaced by an editable copy of the art it
|
||||
/// shows, keeping its id and place; the resources the art uses join `d` (a name `d` uses for
|
||||
/// something else gets a free variant).
|
||||
@@ -186,7 +195,7 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let to = GradientTo::parse(p)?;
|
||||
let from = s.doc()?.history.undo.len();
|
||||
let rev0 = s.doc()?.revision;
|
||||
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
// Placed documents first: their art (type, strokes, gradients) expands below.
|
||||
if object && e.placed {
|
||||
let roots = selected_roots(s)?;
|
||||
@@ -210,6 +219,9 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if object && e.effects {
|
||||
let _ = run_raw(s, "effect.expandAppearance", &json!({}));
|
||||
// What is left to expand is in the baked art: Expand Appearance leaves strokes strokes
|
||||
// (#3089), a group's own as stroked paths among its members, which Stroke outlines below.
|
||||
e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
}
|
||||
if object && e.envelope {
|
||||
let roots = selected_roots(s)?;
|
||||
|
||||
@@ -869,6 +869,11 @@ impl SaveJob {
|
||||
/// its own also ends its Data Recovery copy.
|
||||
pub fn complete(self, s: &mut Session, uid: u64) {
|
||||
let saved = self.plan.retargets() && self.plan.path.is_some();
|
||||
if saved && self.plan.format.id == "vectorcraft" {
|
||||
let mut last = self.plan.options.clone();
|
||||
last.retain(|k, _| !ARTBOARD_PARAMS.contains(&k.as_str()) && !matches!(k.as_str(), "separateArtboards" | "compress"));
|
||||
s.prefs.native_save_options = last;
|
||||
}
|
||||
// The other open documents that place this one show the new version.
|
||||
let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template"));
|
||||
if let Some(st) = s.document_mut(uid) {
|
||||
@@ -934,7 +939,12 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = format(id)
|
||||
.filter(|f| f.write || SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id))
|
||||
.ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?;
|
||||
let saved = st.filter(|d| d.format == f.id).map(remembered_save_options);
|
||||
// A document not yet saved as a native file starts from the native options used last.
|
||||
let saved = st
|
||||
.filter(|d| d.format == f.id)
|
||||
.map(remembered_save_options)
|
||||
.filter(|m| !m.is_empty())
|
||||
.or_else(|| (f.id == "vectorcraft" && !s.prefs.native_save_options.is_empty()).then(|| s.prefs.native_save_options.clone()));
|
||||
// A native save compresses as Use Compression says, unless told otherwise.
|
||||
let prefs = with_compression_pref(&s.prefs, &json!({}));
|
||||
let mut v = f.to_json();
|
||||
@@ -1010,7 +1020,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Format Options",
|
||||
[],
|
||||
None,
|
||||
"{format?: a writable format id or ait (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else the default",
|
||||
"{format?: a writable format id or ait (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else (native, for a document not yet saved as one) as the last native save used it, else the default",
|
||||
always,
|
||||
format_options
|
||||
),
|
||||
|
||||
@@ -282,16 +282,10 @@ fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() {
|
||||
|
||||
#[test]
|
||||
fn a_raster_export_says_when_an_image_could_not_be_decoded() {
|
||||
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short:
|
||||
// the renderer can't decode it and draws nothing, which the export must say.
|
||||
// A PNG whose header reads but whose pixel data is cut short: the renderer can't decode it
|
||||
// and draws nothing, which the export must say.
|
||||
let mut s = session(72.0, 36.0, 1);
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let cut = png_chunk(&png, b"IDAT").map(|d| d.as_ptr() as usize - png.as_ptr() as usize).unwrap() + 4;
|
||||
png.truncate(cut);
|
||||
s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
let r = s.execute("document.export", &json!({"format": "png", "background": "transparent"})).unwrap();
|
||||
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}");
|
||||
|
||||
@@ -356,7 +356,12 @@ fn svg_darkness_uses_rendered_shadow_in_non_srgb_working_space() {
|
||||
.env(CHILD, "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
|
||||
"isolated color profile test failed:\n{stdout}\n{}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
return;
|
||||
}
|
||||
use vectorcraft_color::{Color, Paint, cms};
|
||||
|
||||
@@ -66,9 +66,14 @@ fn rules(root: &Path) -> Rules {
|
||||
Rules { home: Some(root.join("Users/me")), user_content: vec![root.to_path_buf()], refused: vec![root.join("refused")] }
|
||||
}
|
||||
|
||||
/// The search's progress once it ended (or after 10 seconds).
|
||||
/// How long a test waits for a search to end or to read a file. The wait only ends a test whose
|
||||
/// search never ends. A machine busy with other builds can hold a walker for seconds. It is shorter than a
|
||||
/// search's default 60 s deadline, which would end a search that does not end by itself.
|
||||
const WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The search's progress once it ended (or after [`WAIT`]).
|
||||
fn wait(s: &Search) -> Progress {
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
let until = Instant::now() + WAIT;
|
||||
loop {
|
||||
let p = s.progress();
|
||||
if p.end.is_some() || Instant::now() > until {
|
||||
@@ -205,14 +210,17 @@ fn the_search_ends_at_its_limits_and_when_stopped() {
|
||||
fn the_search_answers_at_its_deadline_while_a_walker_is_blocked() {
|
||||
let root = tree("blocked", &["f.ttf"]);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let started = Instant::now();
|
||||
let s = begin(root.clone(), rules(&root), Limits { seconds: 0.2, ..Limits::default() }, 1, 1, Arc::new(Blocked(Mutex::new(rx))));
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let p = s.progress();
|
||||
// The search ended when it answered, and it ran no longer than the test has waited.
|
||||
let waited = started.elapsed().as_secs_f64();
|
||||
assert_eq!((p.state(), p.stopped()), ("stopped", Some("time")), "{p:?}");
|
||||
assert!(p.seconds >= 0.2 && p.seconds < 5.0, "{p:?}");
|
||||
assert!(p.seconds >= 0.2 && p.seconds <= waited, "{p:?}, {waited} s");
|
||||
let (tx2, rx2) = mpsc::channel();
|
||||
let s2 = begin(root.clone(), rules(&root), Limits::default(), 1, 1, Arc::new(Blocked(Mutex::new(rx2))));
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
let until = Instant::now() + WAIT;
|
||||
while s2.progress().read == 0 && Instant::now() < until {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
@@ -246,7 +254,7 @@ fn files_found_are_kept_as_they_are_read() {
|
||||
let root = tree("kept", &["a.ttf", "b.ttf", "c.ttf"]);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let s = begin(root.clone(), rules(&root), Limits::default(), 1, 2, Arc::new(SecondBlocks(Mutex::new((0, rx)))));
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
let until = Instant::now() + WAIT;
|
||||
while s.progress().read < 2 && Instant::now() < until {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
@@ -426,7 +434,7 @@ fn a_search_starts_with_the_threads_left_and_fails_when_none_is() {
|
||||
assert_eq!(c.err().as_deref(), Some("the search couldn't start: try again in a moment"));
|
||||
drop((tx_a, tx_b));
|
||||
assert_eq!((wait(&a).end, wait(&b).end), (Some(End::Done), Some(End::Done)));
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
let until = Instant::now() + WAIT;
|
||||
while POOL.live() > 0 && Instant::now() < until {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Layer…",
|
||||
["Window", "Layers"],
|
||||
Some("Cmd+L"),
|
||||
"{name?, top?: bool, below?: bool, color?, template?, locked?, visible?, printable?, preview?, dimImages?} a layer directly above the current layer, at its level (a sublayer beside a current sublayer); `top`: above all layers (Ctrl/Cmd-click the button); `below`: directly below the current layer, at its level (Ctrl/Cmd+Alt-click the button), not with `top`. Options as layer.setProps → {id}",
|
||||
has_doc,
|
||||
"{name?, top?: bool, below?: bool, color?, template?, locked?, visible?, printable?, preview?, dimImages?} a layer directly above the current layer, at its level (a sublayer beside a current sublayer); `top`: above all layers (Ctrl/Cmd-click the button); `below`: directly below the current layer, at its level (Ctrl/Cmd+Alt-click the button), not with `top`. Options as layer.setProps; refused while an opacity mask is edited (the Layers panel lists only the mask) → {id}",
|
||||
super::maskedit::outside_mask_editing,
|
||||
new_layer
|
||||
),
|
||||
cmd!(
|
||||
@@ -38,8 +38,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Sublayer…",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{parent?: layer id (default: the highlighted row's layer, else the current layer), bottom?: bool, name?, …options as layer.new} a sublayer on top of the parent's contents; `bottom`: under the parent's contents, above a clipping layer's clipping path (Ctrl/Cmd+Alt-click the button) → {id}",
|
||||
has_doc,
|
||||
"{parent?: layer id (default: the highlighted row's layer, else the current layer), bottom?: bool, name?, …options as layer.new} a sublayer on top of the parent's contents; `bottom`: under the parent's contents, above a clipping layer's clipping path (Ctrl/Cmd+Alt-click the button); refused while an opacity mask is edited → {id}",
|
||||
super::maskedit::outside_mask_editing,
|
||||
new_sublayer
|
||||
),
|
||||
cmd!(
|
||||
|
||||
@@ -7,14 +7,20 @@
|
||||
//! merged into the document's). Items can be put in user-named groups, and a library can be
|
||||
//! exported to a `.vclibrary` file and imported from one (#926). Items and groups are kept in the
|
||||
//! order the Libraries panel shows them, which dragging them there changes.
|
||||
//!
|
||||
//! A gradient is kept without the placement it had on its art, and fits each object it paints. A
|
||||
//! pattern is kept as a native document holding one swatch's worth of the pattern (its
|
||||
//! definition and the resources its art uses) and a thumbnail; using it adds the pattern to the
|
||||
//! document, as its swatch, and paints the selection with it.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::TextStyleDef;
|
||||
use vectorcraft_color::{Color, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId, TextStyleDef};
|
||||
|
||||
use super::clipboard::SwatchChoices;
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, color_value, has_doc, ids_param, index, ok, point_param, str_param};
|
||||
use crate::Clipboard;
|
||||
|
||||
@@ -34,6 +40,8 @@ const MAX_NAME: usize = 256;
|
||||
const MAX_THUMBNAIL: usize = 4 << 20;
|
||||
/// Pixels a side a thumbnail may claim (they are made at most 256).
|
||||
const MAX_THUMBNAIL_SIDE: u32 = 1024;
|
||||
/// Stops a gradient read from a file may have (fewer than 2 is no gradient).
|
||||
const MAX_STOPS: usize = 1024;
|
||||
|
||||
/// A library: its name and its items, newest last.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -48,6 +56,11 @@ pub struct Library {
|
||||
pub para_styles: Vec<TextStyleDef>,
|
||||
#[serde(default)]
|
||||
pub graphics: Vec<LibraryGraphic>,
|
||||
/// Files written before gradients and patterns have none.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub gradients: Vec<LibraryGradient>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub patterns: Vec<LibraryPattern>,
|
||||
/// User-named groups of its items, in the order they were made (#926); an item is in one
|
||||
/// group at most, the others are ungrouped. Files written before groups have none.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -77,6 +90,8 @@ pub enum ItemKind {
|
||||
CharStyle,
|
||||
ParaStyle,
|
||||
Graphic,
|
||||
Gradient,
|
||||
Pattern,
|
||||
/// A kind this version doesn't know (a newer one wrote it): dropped when the file is read.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
@@ -104,6 +119,27 @@ pub struct LibraryGraphic {
|
||||
pub thumbnail: String,
|
||||
}
|
||||
|
||||
/// A gradient: its stops, kind and angle, without a placement or swatch links.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LibraryGradient {
|
||||
pub name: String,
|
||||
pub gradient: GradientPaint,
|
||||
}
|
||||
|
||||
/// A pattern: a native document whose objects are painted with pattern `pattern` (its definition
|
||||
/// and the resources its art uses), and its thumbnail.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LibraryPattern {
|
||||
pub name: String,
|
||||
/// The pattern's name in `data`.
|
||||
pub pattern: String,
|
||||
/// The native document, base64.
|
||||
pub data: String,
|
||||
/// A PNG of one swatch's worth of the pattern, at most 256 pixels a side, base64.
|
||||
#[serde(default)]
|
||||
pub thumbnail: String,
|
||||
}
|
||||
|
||||
/// The libraries of a session ([`Session::libraries`]).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Libraries {
|
||||
@@ -243,8 +279,8 @@ impl Libraries {
|
||||
}
|
||||
|
||||
impl Library {
|
||||
/// Every item as (kind, key: its name, a graphic's id, name shown): colours, then character
|
||||
/// and paragraph styles, then graphics.
|
||||
/// Every item as (kind, key: its name, a graphic's id, name shown): colors, gradients and
|
||||
/// patterns, then character and paragraph styles, then graphics.
|
||||
pub fn items(&self) -> impl Iterator<Item = (ItemKind, &str, &str)> {
|
||||
fn styles(kind: ItemKind, list: &[TextStyleDef]) -> impl Iterator<Item = (ItemKind, &str, &str)> {
|
||||
list.iter().map(move |t| (kind, t.name.as_str(), t.name.as_str()))
|
||||
@@ -252,6 +288,8 @@ impl Library {
|
||||
self.colors
|
||||
.iter()
|
||||
.map(|c| (ItemKind::Color, c.name.as_str(), c.name.as_str()))
|
||||
.chain(self.gradients.iter().map(|g| (ItemKind::Gradient, g.name.as_str(), g.name.as_str())))
|
||||
.chain(self.patterns.iter().map(|p| (ItemKind::Pattern, p.name.as_str(), p.name.as_str())))
|
||||
.chain(styles(ItemKind::CharStyle, &self.char_styles))
|
||||
.chain(styles(ItemKind::ParaStyle, &self.para_styles))
|
||||
.chain(self.graphics.iter().map(|g| (ItemKind::Graphic, g.id.as_str(), g.name.as_str())))
|
||||
@@ -294,6 +332,8 @@ impl Library {
|
||||
let (from, to_key) = (r.item.as_str(), to.item.as_str());
|
||||
match r.kind {
|
||||
ItemKind::Color => shift(&mut self.colors, |c| c.name.as_str(), from, to_key, after),
|
||||
ItemKind::Gradient => shift(&mut self.gradients, |g| g.name.as_str(), from, to_key, after),
|
||||
ItemKind::Pattern => shift(&mut self.patterns, |p| p.name.as_str(), from, to_key, after),
|
||||
ItemKind::CharStyle => shift(&mut self.char_styles, |c| c.name.as_str(), from, to_key, after),
|
||||
ItemKind::ParaStyle => shift(&mut self.para_styles, |c| c.name.as_str(), from, to_key, after),
|
||||
ItemKind::Graphic => shift(&mut self.graphics, |g| g.id.as_str(), from, to_key, after),
|
||||
@@ -304,16 +344,27 @@ impl Library {
|
||||
/// Make a library read from a file (hand-edited, imported) safe and consistent: at most
|
||||
/// [`MAX_ITEMS`] items of each kind and [`MAX_GROUPS`] groups, names clipped to [`MAX_NAME`]
|
||||
/// characters and unique within their kind (a repeated one gets a number), sizes finite, a
|
||||
/// thumbnail only when it is a small PNG, and groups holding only items there are, each in one
|
||||
/// group at most.
|
||||
/// thumbnail only when it is a small PNG, gradients of 2 to [`MAX_STOPS`] stops in range
|
||||
/// ([`clean_gradient`]; others are dropped), and groups holding only items there are, each in
|
||||
/// one group at most.
|
||||
fn sanitize(&mut self) {
|
||||
clip(&mut self.name);
|
||||
self.colors.truncate(MAX_ITEMS);
|
||||
self.gradients.retain_mut(|g| clean_gradient(&mut g.gradient));
|
||||
self.gradients.truncate(MAX_ITEMS);
|
||||
self.patterns.truncate(MAX_ITEMS);
|
||||
self.char_styles.truncate(MAX_ITEMS);
|
||||
self.para_styles.truncate(MAX_ITEMS);
|
||||
self.graphics.truncate(MAX_ITEMS);
|
||||
self.groups.truncate(MAX_GROUPS);
|
||||
uniquify(self.colors.iter_mut().map(|c| &mut c.name), "Color", " ", false);
|
||||
uniquify(self.gradients.iter_mut().map(|g| &mut g.name), "Gradient", " ", false);
|
||||
uniquify(self.patterns.iter_mut().map(|p| &mut p.name), "Pattern", " ", false);
|
||||
for p in &mut self.patterns {
|
||||
if !thumbnail_ok(&p.thumbnail) {
|
||||
p.thumbnail.clear();
|
||||
}
|
||||
}
|
||||
uniquify(self.char_styles.iter_mut().map(|t| &mut t.name), "Character Style", " ", false);
|
||||
uniquify(self.para_styles.iter_mut().map(|t| &mut t.name), "Paragraph Style", " ", false);
|
||||
uniquify(self.graphics.iter_mut().map(|g| &mut g.name), "Graphic", " ", false);
|
||||
@@ -339,6 +390,33 @@ impl Library {
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `g` a library gradient: no placement, freeform points or swatch links (a linked stop keeps
|
||||
/// the color it shows), the angle its placement showed (a finite one), and stops in order with
|
||||
/// offsets, opacities and midpoints in range. False when it has fewer than 2 or more than
|
||||
/// [`MAX_STOPS`] stops.
|
||||
fn clean_gradient(g: &mut GradientPaint) -> bool {
|
||||
if !(2..=MAX_STOPS).contains(&g.gradient.stops.len()) {
|
||||
return false;
|
||||
}
|
||||
g.angle = g.geom.map_or(g.angle, |geom| geom.angle_deg());
|
||||
g.geom = None;
|
||||
g.freeform = None;
|
||||
g.swatch = None;
|
||||
if !g.angle.is_finite() {
|
||||
g.angle = 0.0;
|
||||
}
|
||||
let within = |v: f32, lo: f32, hi: f32, or: f32| if v.is_finite() { v.clamp(lo, hi) } else { or };
|
||||
for st in &mut g.gradient.stops {
|
||||
st.offset = within(st.offset, 0.0, 1.0, 0.0);
|
||||
st.opacity = within(st.opacity, 0.0, 1.0, 1.0);
|
||||
st.midpoint = within(st.midpoint, 0.13, 0.87, 0.5);
|
||||
st.swatch = None;
|
||||
st.tint = 1.0;
|
||||
}
|
||||
g.gradient.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
|
||||
true
|
||||
}
|
||||
|
||||
/// `name` cut to [`MAX_NAME`] characters.
|
||||
fn clip(name: &mut String) {
|
||||
if let Some((at, _)) = name.char_indices().nth(MAX_NAME) {
|
||||
@@ -429,6 +507,10 @@ enum Kind {
|
||||
Graphic,
|
||||
FillColor,
|
||||
StrokeColor,
|
||||
FillGradient,
|
||||
StrokeGradient,
|
||||
FillPattern,
|
||||
StrokePattern,
|
||||
CharStyle,
|
||||
ParaStyle,
|
||||
}
|
||||
@@ -440,21 +522,38 @@ impl Kind {
|
||||
Some("fillColor") => Kind::FillColor,
|
||||
Some("strokeColor") => Kind::StrokeColor,
|
||||
Some("color") => Kind::FillColor,
|
||||
Some("fillGradient" | "gradient") => Kind::FillGradient,
|
||||
Some("strokeGradient") => Kind::StrokeGradient,
|
||||
Some("fillPattern" | "pattern") => Kind::FillPattern,
|
||||
Some("strokePattern") => Kind::StrokePattern,
|
||||
Some("charStyle") => Kind::CharStyle,
|
||||
Some("paraStyle") => Kind::ParaStyle,
|
||||
_ => return Err(bad(cmd, "kind must be graphic, fillColor, strokeColor, charStyle or paraStyle")),
|
||||
_ => {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle",
|
||||
));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The kind as a group names it (fill and stroke colours are one list).
|
||||
/// The kind as a group names it (fill and stroke colors, gradients and patterns are one list
|
||||
/// each).
|
||||
fn item_kind(self) -> ItemKind {
|
||||
match self {
|
||||
Kind::Graphic => ItemKind::Graphic,
|
||||
Kind::FillColor | Kind::StrokeColor => ItemKind::Color,
|
||||
Kind::FillGradient | Kind::StrokeGradient => ItemKind::Gradient,
|
||||
Kind::FillPattern | Kind::StrokePattern => ItemKind::Pattern,
|
||||
Kind::CharStyle => ItemKind::CharStyle,
|
||||
Kind::ParaStyle => ItemKind::ParaStyle,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the kind paints strokes by default.
|
||||
fn stroke(self) -> bool {
|
||||
matches!(self, Kind::StrokeColor | Kind::StrokeGradient | Kind::StrokePattern)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -464,7 +563,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Libraries",
|
||||
[],
|
||||
None,
|
||||
"{} → {libraries: [{id, name, graphics, colors, charStyles, paraStyles, groups} (counts)], current: the current library's id or null, folder: the library folder, or null when libraries last for the session only (the web, headless)}",
|
||||
"{} → {libraries: [{id, name, graphics, colors, gradients, patterns, charStyles, paraStyles, groups} (counts)], current: the current library's id or null, folder: the library folder, or null when libraries last for the session only (the web, headless)}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
@@ -473,7 +572,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Library",
|
||||
[],
|
||||
None,
|
||||
"{library?: id or name (default: the current library)} → {id, name, graphics: [{id, name, width, height, thumbnail: PNG base64}], colors: [{name, hex, color}], charStyles: [{name, attrs}], paraStyles: [{name, attrs}], groups: [{name, items: [{kind: color|charStyle|paraStyle|graphic, item: name (graphic: id)}]}] (an item is in one group at most; the others are ungrouped)}",
|
||||
"{library?: id or name (default: the current library)} → {id, name, graphics: [{id, name, width, height, thumbnail: PNG base64}], colors: [{name, hex, color}], gradients: [{name, gradient: {gradient: {kind, stops}, angle}}], patterns: [{name, pattern: its name in the item, thumbnail: PNG base64}], charStyles: [{name, attrs}], paraStyles: [{name, attrs}], groups: [{name, items: [{kind: color|gradient|pattern|charStyle|paraStyle|graphic, item: name (graphic: id)}]}] (an item is in one group at most; the others are ungrouped)}",
|
||||
always,
|
||||
get
|
||||
),
|
||||
@@ -518,7 +617,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add to Library",
|
||||
[],
|
||||
None,
|
||||
"{library?: id or name (default: the current library, made when there is none), kind: graphic|fillColor|strokeColor|charStyle|paraStyle, ids?: objects (default: the selection), name?, color?: colour (a colour to add, instead of the selection's)} add an item from the selection: graphic: a copy of the objects with every resource they use; fillColor/strokeColor: the first selected object's solid fill/stroke colour (else the default fill/stroke); charStyle/paraStyle: the selected text's character/paragraph attributes (the Type tool's range, else the first selected text object). An item the library already has (same kind and contents) isn't added again → {library, kind, name, id?: graphic id, existing: bool}",
|
||||
"{library?: id or name (default: the current library, made when there is none), kind: graphic|fillColor|strokeColor|fillGradient|strokeGradient|fillPattern|strokePattern|charStyle|paraStyle, ids?: objects (default: the selection), name?, color?: colour (a colour to add, instead of the selection's), swatch?: a gradient or pattern swatch of the document (fillGradient…strokePattern: added instead of the selection's paint)} add an item from the selection: graphic: a copy of the objects with every resource they use; fillColor/strokeColor: the first selected object's solid fill/stroke colour (else the default fill/stroke); fillGradient/strokeGradient: the first selected object's fill/stroke gradient (else the default's), without its placement or swatch links, named after its swatch, else \"Gradient\"; fillPattern/strokePattern: the first selected object's fill/stroke pattern (else the default's) with the resources its art uses, named after it; charStyle/paraStyle: the selected text's character/paragraph attributes (the Type tool's range, else the first selected text object). An item the library already has (same kind and contents) isn't added again → {library, kind, name, id?: graphic id, existing: bool}",
|
||||
has_doc,
|
||||
add
|
||||
),
|
||||
@@ -527,7 +626,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Use Library Item",
|
||||
[],
|
||||
None,
|
||||
"{library?, kind, item: name (graphic: id or name), center?: [x, y] (graphic; default: the first artboard's centre), to?: fill|stroke (colours; default: the kind's), recorded?: the item as it was when used (recorded in the journal so a replay matches)} graphic: place a copy centred on `center`, its resources pasted along, selected, one undo step; colours: paint the selection (and the defaults) as paint.setFill/paint.setStroke; charStyle/paraStyle: add the style to the document (a name taken by different attributes gets a number) and apply it to the selected text, one undo step → {kind, ids?, style?}",
|
||||
"{library?, kind, item: name (graphic: id or name), center?: [x, y] (graphic; default: the first artboard's centre), to?: fill|stroke (colors, gradients, patterns; default: the kind's), recorded?: the item as it was when used (recorded in the journal so a replay matches)} graphic: place a copy centred on `center`, its resources pasted along, selected, one undo step; colors and gradients: paint the selection (and the defaults) as paint.setFill/paint.setStroke, a gradient fitting each object; patterns: add the pattern and its swatch to the document (a name the document gives another pattern gets a number; the same pattern is reused) and paint the selection (and the defaults) with it, one undo step; charStyle/paraStyle: add the style to the document (a name taken by different attributes gets a number) and apply it to the selected text, one undo step → {kind, ids?, style?}",
|
||||
has_doc,
|
||||
use_item
|
||||
),
|
||||
@@ -590,7 +689,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Library",
|
||||
[],
|
||||
None,
|
||||
"{library?: id or name (default: the current library), path?} write a library (its colours, styles, graphics with their art and thumbnails, and groups) as a .vclibrary file (JSON, as libraries are kept) → {path, name}; without path → {data: the file's text, name}",
|
||||
"{library?: id or name (default: the current library), path?} write a library (its colours, gradients, patterns, styles, graphics with their art and thumbnails, and groups) as a .vclibrary file (JSON, as libraries are kept) → {path, name}; without path → {data: the file's text, name}",
|
||||
always,
|
||||
export
|
||||
),
|
||||
@@ -599,7 +698,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Library",
|
||||
[],
|
||||
None,
|
||||
"{path? | data?: file text | dataBase64?, name?: the library's name (default: the file's)} add the library of a .vclibrary file (as library.export writes) as a new library, made the current one; a name another library has gets a number (`Brand (2)`) → {id, name, graphics, colors, charStyles, paraStyles, groups (counts)}",
|
||||
"{path? | data?: file text | dataBase64?, name?: the library's name (default: the file's)} add the library of a .vclibrary file (as library.export writes) as a new library, made the current one; a name another library has gets a number (`Brand (2)`) → {id, name, graphics, colors, gradients, patterns, charStyles, paraStyles, groups (counts)}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
@@ -626,6 +725,8 @@ fn counts(lib: &Library) -> Value {
|
||||
json!({
|
||||
"graphics": lib.graphics.len(),
|
||||
"colors": lib.colors.len(),
|
||||
"gradients": lib.gradients.len(),
|
||||
"patterns": lib.patterns.len(),
|
||||
"charStyles": lib.char_styles.len(),
|
||||
"paraStyles": lib.para_styles.len(),
|
||||
"groups": lib.groups.len(),
|
||||
@@ -656,6 +757,8 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"name": lib.name,
|
||||
"graphics": lib.graphics.iter().map(|g| json!({"id": g.id, "name": g.name, "width": g.width, "height": g.height, "thumbnail": g.thumbnail})).collect::<Vec<_>>(),
|
||||
"colors": lib.colors.iter().map(|c| json!({"name": c.name, "hex": c.color.to_hex(), "color": c.color})).collect::<Vec<_>>(),
|
||||
"gradients": lib.gradients.iter().map(|g| json!({"name": g.name, "gradient": g.gradient})).collect::<Vec<_>>(),
|
||||
"patterns": lib.patterns.iter().map(|p| json!({"name": p.name, "pattern": p.pattern, "thumbnail": p.thumbnail})).collect::<Vec<_>>(),
|
||||
"charStyles": lib.char_styles.iter().map(|t| json!({"name": t.name, "attrs": t.attrs})).collect::<Vec<_>>(),
|
||||
"paraStyles": lib.para_styles.iter().map(|t| json!({"name": t.name, "attrs": t.attrs})).collect::<Vec<_>>(),
|
||||
"groups": lib.groups,
|
||||
@@ -706,7 +809,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Read what is added before a library is made for it, so a failure makes none.
|
||||
let item = match kind {
|
||||
Kind::Graphic => Item::Graphic(graphic_of(s, p, C)?),
|
||||
Kind::FillColor | Kind::StrokeColor => Item::Color(color_of(s, p, kind == Kind::StrokeColor, C)?),
|
||||
Kind::FillColor | Kind::StrokeColor => Item::Color(color_of(s, p, kind.stroke(), C)?),
|
||||
Kind::FillGradient | Kind::StrokeGradient => Item::Gradient(gradient_of(s, p, kind.stroke(), C)?),
|
||||
Kind::FillPattern | Kind::StrokePattern => Item::Pattern(pattern_of(s, p, kind.stroke(), C)?),
|
||||
Kind::CharStyle | Kind::ParaStyle => {
|
||||
let para = kind == Kind::ParaStyle;
|
||||
let attrs = super::textstyles::selected_style_attrs(s, para)?.ok_or_else(|| bad(C, "select some type first"))?;
|
||||
@@ -740,6 +845,26 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
lib.colors.push(LibraryColor { name: name.clone(), color });
|
||||
json!({ "name": name, "existing": false })
|
||||
}
|
||||
Item::Gradient(mut g) => {
|
||||
full(lib.gradients.len())?;
|
||||
if let Some(old) = lib.gradients.iter().find(|o| o.gradient == g.gradient) {
|
||||
return Ok(json!({ "name": old.name, "existing": true }));
|
||||
}
|
||||
g.name = unique(&given.clone().unwrap_or(g.name), |n| lib.gradients.iter().any(|o| o.name == n));
|
||||
let v = json!({ "name": g.name, "existing": false });
|
||||
lib.gradients.push(g);
|
||||
v
|
||||
}
|
||||
Item::Pattern(mut pat) => {
|
||||
full(lib.patterns.len())?;
|
||||
if let Some(old) = lib.patterns.iter().find(|o| o.data == pat.data) {
|
||||
return Ok(json!({ "name": old.name, "existing": true }));
|
||||
}
|
||||
pat.name = unique(&given.clone().unwrap_or(pat.name), |n| lib.patterns.iter().any(|o| o.name == n));
|
||||
let v = json!({ "name": pat.name, "existing": false });
|
||||
lib.patterns.push(pat);
|
||||
v
|
||||
}
|
||||
Item::Style(para, attrs) => {
|
||||
let list = if para { &mut lib.para_styles } else { &mut lib.char_styles };
|
||||
full(list.len())?;
|
||||
@@ -762,6 +887,8 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
enum Item {
|
||||
Graphic(LibraryGraphic),
|
||||
Color((String, Color)),
|
||||
Gradient(LibraryGradient),
|
||||
Pattern(LibraryPattern),
|
||||
Style(bool, Map<String, Value>),
|
||||
}
|
||||
|
||||
@@ -804,23 +931,7 @@ fn color_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<(String,
|
||||
let color = color_value(c).ok_or_else(|| bad(cmd, "bad colour"))?;
|
||||
return Ok((color.to_hex(), color));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => st.selection.objects.clone(),
|
||||
};
|
||||
let from_art = ids.iter().filter_map(|id| st.doc.node(*id)).find_map(|n| {
|
||||
let paint = if stroke { n.appearance.stroke().map(|x| &x.paint) } else { n.appearance.fill().map(|f| &f.paint) };
|
||||
paint.cloned()
|
||||
});
|
||||
let paint = match from_art {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let d = &s.paint;
|
||||
if stroke { d.stroke.clone() } else { d.fill.clone() }
|
||||
}
|
||||
};
|
||||
match paint {
|
||||
match selected_paint(s, p, stroke)? {
|
||||
Paint::Solid { color, swatch, .. } => {
|
||||
let name = swatch.filter(|n| !n.is_empty()).unwrap_or_else(|| color.to_hex());
|
||||
Ok((name, color))
|
||||
@@ -830,6 +941,87 @@ fn color_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<(String,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first selected object's (or `ids`') fill (`stroke`: stroke) paint (type's: its first
|
||||
/// character's, as the proxy shows it), else the default one.
|
||||
fn selected_paint(s: &Session, p: &Value, stroke: bool) -> Result<Paint> {
|
||||
let st = s.doc()?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => st.selection.objects.clone(),
|
||||
};
|
||||
let from_art = ids.iter().filter_map(|id| st.doc.node(*id)).find_map(|n| {
|
||||
let (paint, to_doc, _) = n.proxy_paint(stroke, None)?;
|
||||
// Type's characters paint in its own space: a gradient there takes its placement in the
|
||||
// document, so that it keeps the direction it shows.
|
||||
match (paint, n.proxy_gradient(stroke, None)) {
|
||||
(Paint::Gradient(g), Some((_, geom))) if to_doc != vectorcraft_geom::Affine::IDENTITY => {
|
||||
Some(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..(**g).clone() })))
|
||||
}
|
||||
_ => Some(paint.clone()),
|
||||
}
|
||||
});
|
||||
Ok(match from_art {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let d = &s.paint;
|
||||
if stroke { d.stroke.clone() } else { d.fill.clone() }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Gradient swatch `swatch` of the document, else the first selected object's fill (`stroke`:
|
||||
/// stroke) gradient, else the default one, as a library gradient ([`clean_gradient`]) named after
|
||||
/// its swatch, else "Gradient".
|
||||
fn gradient_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<LibraryGradient> {
|
||||
let (name, paint) = match str_param(p, "swatch") {
|
||||
Some(name) => {
|
||||
let sw = s.doc()?.doc.swatch(name).filter(|w| matches!(w.paint, Paint::Gradient(_)));
|
||||
(Some(name.to_string()), sw.ok_or_else(|| bad(cmd, format!("no gradient swatch `{name}`")))?.paint.clone())
|
||||
}
|
||||
None => (None, selected_paint(s, p, stroke)?),
|
||||
};
|
||||
let Paint::Gradient(mut g) = paint else {
|
||||
return Err(bad(cmd, if stroke { "the selection has no gradient stroke" } else { "the selection has no gradient fill" }));
|
||||
};
|
||||
let name = name.or_else(|| g.swatch.clone()).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Gradient".to_string());
|
||||
if !clean_gradient(&mut g) {
|
||||
return Err(bad(cmd, "the library item is damaged"));
|
||||
}
|
||||
Ok(LibraryGradient { name, gradient: *g })
|
||||
}
|
||||
|
||||
/// Pattern swatch `swatch` of the document, else the first selected object's fill (`stroke`:
|
||||
/// stroke) pattern, else the default one, as a library pattern named after it: a document of one
|
||||
/// rectangle painted with it, one period of its tiling, with the resources its art uses.
|
||||
fn pattern_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<LibraryPattern> {
|
||||
let d = &s.doc()?.doc;
|
||||
let name = match str_param(p, "swatch") {
|
||||
Some(name) => match d.swatch(name).map(|w| &w.paint) {
|
||||
Some(Paint::Pattern { pattern, .. }) => pattern.clone(),
|
||||
_ if d.pattern(name).is_some() => name.to_string(),
|
||||
_ => return Err(bad(cmd, format!("no pattern swatch `{name}`"))),
|
||||
},
|
||||
None => match selected_paint(s, p, stroke)? {
|
||||
Paint::Pattern { pattern, .. } => pattern,
|
||||
_ => return Err(bad(cmd, if stroke { "the selection has no pattern stroke" } else { "the selection has no pattern fill" })),
|
||||
},
|
||||
};
|
||||
let def = d.pattern(&name).ok_or_else(|| bad(cmd, format!("no pattern swatch `{name}`")))?;
|
||||
let (w, h) = def.period();
|
||||
let r = vectorcraft_geom::Rect::from_origin_size((0.0, 0.0), (w, h));
|
||||
let paint = vectorcraft_doc::pattern::pattern_paint(&name);
|
||||
let node = Node::path(NodeId(1), vectorcraft_geom::shapes::rectangle(r), Appearance::basic(paint, Paint::None, 0.0));
|
||||
let mut doc = Clipboard::gather(d, vec![node]).to_document();
|
||||
doc.artboards.truncate(1);
|
||||
match doc.artboards.first_mut() {
|
||||
Some(a) => a.rect = r,
|
||||
None => return Err(bad(cmd, "the graphic's document has no artboard")),
|
||||
}
|
||||
let thumbnail = super::fileio::preview_png(&doc)?.map(|b| vectorcraft_format::base64_encode(&b)).unwrap_or_default();
|
||||
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&doc, false));
|
||||
Ok(LibraryPattern { name: name.clone(), pattern: name, data, thumbnail })
|
||||
}
|
||||
|
||||
/// A character style's name from its font, style and size ("Source Sans 3 Bold 24 pt"); a
|
||||
/// paragraph style's from its alignment and size too, else "Paragraph Style".
|
||||
fn style_name(attrs: &Map<String, Value>, para: bool) -> String {
|
||||
@@ -871,6 +1063,14 @@ fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = lib.colors.iter().find(|c| c.name == item).ok_or_else(missing)?;
|
||||
json!({ "color": c.color })
|
||||
}
|
||||
Kind::FillGradient | Kind::StrokeGradient => {
|
||||
let g = lib.gradients.iter().find(|g| g.name == item).ok_or_else(missing)?;
|
||||
json!({ "gradient": g.gradient })
|
||||
}
|
||||
Kind::FillPattern | Kind::StrokePattern => {
|
||||
let pat = lib.patterns.iter().find(|x| x.name == item).ok_or_else(missing)?;
|
||||
json!({ "pattern": pat.pattern, "data": pat.data })
|
||||
}
|
||||
Kind::CharStyle | Kind::ParaStyle => {
|
||||
let list = if kind == Kind::ParaStyle { &lib.para_styles } else { &lib.char_styles };
|
||||
let t = list.iter().find(|t| t.name == item).ok_or_else(missing)?;
|
||||
@@ -902,16 +1102,30 @@ fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(r)
|
||||
}
|
||||
Kind::FillColor | Kind::StrokeColor => {
|
||||
let stroke = match str_param(p, "to") {
|
||||
Some("fill") => false,
|
||||
Some("stroke") => true,
|
||||
Some(_) => return Err(bad(C, "to must be fill or stroke")),
|
||||
None => kind == Kind::StrokeColor,
|
||||
};
|
||||
let stroke = to_stroke(p, kind)?;
|
||||
let color = data.get("color").cloned().ok_or_else(|| bad(C, "no colour"))?;
|
||||
s.execute(if stroke { "paint.setStroke" } else { "paint.setFill" }, &json!({ "color": color }))?;
|
||||
Ok(json!({ "kind": if stroke { "strokeColor" } else { "fillColor" } }))
|
||||
}
|
||||
Kind::FillGradient | Kind::StrokeGradient => {
|
||||
let stroke = to_stroke(p, kind)?;
|
||||
let damaged = || bad(C, "the library item is damaged");
|
||||
let mut g: GradientPaint = data.get("gradient").cloned().and_then(|v| serde_json::from_value(v).ok()).ok_or_else(damaged)?;
|
||||
if !clean_gradient(&mut g) {
|
||||
return Err(damaged());
|
||||
}
|
||||
// RGB stops in a CMYK document are stored as CMYK, as a library color is.
|
||||
for st in &mut g.gradient.stops {
|
||||
st.color = super::paint::new_color(s, &json!({}), st.color);
|
||||
}
|
||||
super::paint::apply_paint(s, &json!({}), Paint::Gradient(Box::new(g)), !stroke)?;
|
||||
Ok(json!({ "kind": if stroke { "strokeGradient" } else { "fillGradient" } }))
|
||||
}
|
||||
Kind::FillPattern | Kind::StrokePattern => {
|
||||
let stroke = to_stroke(p, kind)?;
|
||||
let pattern = use_pattern(s, &data, stroke)?;
|
||||
Ok(json!({ "kind": if stroke { "strokePattern" } else { "fillPattern" }, "pattern": pattern }))
|
||||
}
|
||||
Kind::CharStyle | Kind::ParaStyle => {
|
||||
let para = kind == Kind::ParaStyle;
|
||||
let name = data.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Style").to_string();
|
||||
@@ -932,6 +1146,62 @@ fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `to` param of `library.use`: whether the item paints the stroke (default: the kind's).
|
||||
fn to_stroke(p: &Value, kind: Kind) -> Result<bool> {
|
||||
match str_param(p, "to") {
|
||||
Some("fill") => Ok(false),
|
||||
Some("stroke") => Ok(true),
|
||||
Some(_) => Err(bad("library.use", "to must be fill or stroke")),
|
||||
None => Ok(kind.stroke()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add library pattern `data` (`{pattern, data}`) to the document, with its swatch and the
|
||||
/// resources its art uses (the same pattern there already is reused; a name the document gives
|
||||
/// another one gets a number), and paint the selection's fill (`stroke`: stroke) and the default
|
||||
/// with it, one undo step → the pattern's name in the document.
|
||||
fn use_pattern(s: &mut Session, data: &Value, stroke: bool) -> Result<String> {
|
||||
const C: &str = "library.use";
|
||||
let damaged = || bad(C, "the library item is damaged");
|
||||
let bytes = data.get("data").and_then(Value::as_str).and_then(vectorcraft_format::base64_decode).ok_or_else(damaged)?;
|
||||
let doc = vectorcraft_format::load(&bytes).map_err(|_| damaged())?;
|
||||
let name = data.get("pattern").and_then(Value::as_str).filter(|n| doc.pattern(n).is_some()).ok_or_else(damaged)?.to_string();
|
||||
// Only what paints with the pattern is brought along.
|
||||
let clip = Clipboard::from_document(&doc);
|
||||
let clip = Clipboard::gather(&doc, clip.nodes.into_iter().filter(|n| vectorcraft_doc::pattern::uses_pattern(n, Some(&name))).collect());
|
||||
if clip.nodes.is_empty() {
|
||||
return Err(damaged());
|
||||
}
|
||||
let choices = SwatchChoices::default();
|
||||
// The pattern's name in the document, worked out on a copy: nothing to add is no edit.
|
||||
let mut probe = (*s.doc()?.doc).clone();
|
||||
let imported = clip.import_into(&mut probe, &choices, false);
|
||||
let renamed = imported.renamed.iter().find(|r| r["kind"] == "pattern" && r["from"] == name.as_str()).and_then(|r| r["to"].as_str());
|
||||
let target = renamed.unwrap_or(&name).to_string();
|
||||
let paint = vectorcraft_doc::pattern::pattern_paint(&target);
|
||||
// A pattern the document holds without a swatch (an imported SVG's) gets one.
|
||||
let has_swatch =
|
||||
|d: &vectorcraft_doc::Document| d.swatches_iter().any(|w| matches!(&w.paint, Paint::Pattern { pattern, .. } if *pattern == target));
|
||||
let added = imported.added > 0 || !has_swatch(&probe);
|
||||
drop(probe);
|
||||
in_one_step(s, |s| {
|
||||
if added {
|
||||
s.edit("Add to Swatches", |d, _| {
|
||||
clip.import_into(d, &choices, false);
|
||||
if !has_swatch(d) {
|
||||
let swatch = d.free_swatch_name(&target);
|
||||
d.swatches.push(vectorcraft_color::Swatch { name: swatch, paint: paint.clone(), global: false, spot: false });
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
// The pattern itself, not a swatch of its name (a color swatch can share it); `swatch` keeps
|
||||
// the placement of a pattern the targets already have, as picking its swatch does.
|
||||
super::paint::apply_paint(s, &json!({ "swatch": target }), paint.clone(), !stroke)
|
||||
})?;
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// Run `f`'s commands as one undo step, or as part of the step of an undo group open in the active
|
||||
/// document, and undo them together when it fails ([`Session::begin_undo_scope`]).
|
||||
fn in_one_step<T>(s: &mut Session, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
|
||||
@@ -952,6 +1222,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match kind {
|
||||
Kind::Graphic => lib.graphics.retain(|g| g.id != r.item),
|
||||
Kind::FillColor | Kind::StrokeColor => lib.colors.retain(|c| c.name != r.item),
|
||||
Kind::FillGradient | Kind::StrokeGradient => lib.gradients.retain(|g| g.name != r.item),
|
||||
Kind::FillPattern | Kind::StrokePattern => lib.patterns.retain(|x| x.name != r.item),
|
||||
Kind::CharStyle => lib.char_styles.retain(|t| t.name != r.item),
|
||||
Kind::ParaStyle => lib.para_styles.retain(|t| t.name != r.item),
|
||||
}
|
||||
|
||||
@@ -399,14 +399,56 @@ pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec
|
||||
// ---------- reading files into the document ----------
|
||||
|
||||
/// Keep `full` (a file's pixels) as image `key` of `d`, unless pixels are there already; a
|
||||
/// preview there is kept, and a linked image gets one (the one `full` carries, when it has one).
|
||||
/// preview there is kept, and a linked image gets the one `full` carries ([`readable`] makes it
|
||||
/// when the file is read). Nothing is decoded here.
|
||||
pub(crate) fn store_image(d: &mut Document, key: &str, full: ImageBlob, linked: bool) {
|
||||
let made = full.proxy.clone().filter(|_| linked);
|
||||
let blob = match d.images.get(key) {
|
||||
Some(b) if !b.is_proxy() => b.clone(),
|
||||
Some(b) if !b.is_proxy() => ImageBlob { proxy: b.proxy.clone().or(made), ..b.clone() },
|
||||
old => ImageBlob { proxy: old.and_then(|b| b.proxy.clone()).or(made), ..full },
|
||||
};
|
||||
d.images.insert(key.to_string(), if linked { blob.with_proxy() } else { blob });
|
||||
d.images.insert(key.to_string(), blob);
|
||||
}
|
||||
|
||||
/// Whether `bytes` are a PNG, JPEG, GIF or WebP file: the formats [`fileio::raster_image`] keeps
|
||||
/// as they are after reading only their header.
|
||||
pub(crate) fn kept_unread(bytes: &[u8]) -> bool {
|
||||
use image::ImageFormat as F;
|
||||
matches!(image::guess_format(bytes), Ok(F::Png | F::Jpeg | F::Gif | F::WebP))
|
||||
}
|
||||
|
||||
/// `blob`, an image [`fileio::raster_image`] read from a file, ready to keep. When `unread` (the
|
||||
/// file is one [`kept_unread`] names), its pixels are decoded once to check that they can be read,
|
||||
/// and a `linked` image's preview is made from that decode ([`ImageBlob::checked_with_proxy`]);
|
||||
/// otherwise a `linked` image gets its preview ([`ImageBlob::with_proxy`]). An error when the
|
||||
/// pixels can't be read.
|
||||
pub(crate) fn readable(blob: ImageBlob, unread: bool, linked: bool) -> Result<ImageBlob> {
|
||||
if !unread {
|
||||
return Ok(if linked { blob.with_proxy() } else { blob });
|
||||
}
|
||||
blob.checked_with_proxy(linked).ok_or_else(|| EngineError::Other("the image's pixels can't be read".into()))
|
||||
}
|
||||
|
||||
/// [`readable`], and the pixels it decoded are kept in the image store the renderers share
|
||||
/// ([`vectorcraft_render::keep_decoded_image`]), so the first frame that draws the image finds
|
||||
/// them there and does not decode the file again.
|
||||
pub(crate) fn readable_and_kept(blob: ImageBlob, unread: bool, linked: bool) -> Result<ImageBlob> {
|
||||
if !unread {
|
||||
return readable(blob, unread, linked);
|
||||
}
|
||||
let (blob, pixels) = blob.checked_with_pixels(linked).ok_or_else(|| EngineError::Other("the image's pixels can't be read".into()))?;
|
||||
if let Some(pixels) = pixels {
|
||||
vectorcraft_render::keep_decoded_image(&blob, pixels);
|
||||
}
|
||||
Ok(blob)
|
||||
}
|
||||
|
||||
/// The linked image file `bytes`, read ([`fileio::raster_image`]) with its preview; an error when
|
||||
/// its pixels can't be read ([`readable`]).
|
||||
fn linked_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let mut img = fileio::raster_image(bytes)?;
|
||||
img.blob = readable(img.blob, kept_unread(bytes), true)?;
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// The file read for a link: the link to it and its image.
|
||||
@@ -415,7 +457,7 @@ type FileRead = (LinkInfo, RasterImage);
|
||||
/// The file at `f` read for a link like `base` (its image decoded).
|
||||
fn read_image(f: &mut Found, base: &LinkInfo) -> Result<FileRead> {
|
||||
let bytes = f.read()?;
|
||||
Ok((f.link(&bytes, base), fileio::raster_image(&bytes)?))
|
||||
Ok((f.link(&bytes, base), linked_image(&bytes)?))
|
||||
}
|
||||
|
||||
/// A linked file read for the objects that show it.
|
||||
@@ -553,7 +595,7 @@ pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_
|
||||
// the app connect to another computer.
|
||||
let path = fileio::document_path(path, doc_path).map_err(|r| EngineError::Other(format!("the image `{path}` isn't followed: {r}")))?;
|
||||
let bytes = read_file(&path)?;
|
||||
let read = (link_info(&path, &bytes), fileio::raster_image(&bytes)?);
|
||||
let read = (link_info(&path, &bytes), linked_image(&bytes)?);
|
||||
install(d, &[id], read, Some(Preserve::Bounds))
|
||||
}
|
||||
|
||||
@@ -678,11 +720,11 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
|
||||
// Read when the images show the preview, or when the file's details changed.
|
||||
let bytes = if (preview && !eps_preview) || f.bytes.is_some() { f.read().ok() } else { None };
|
||||
if preview && !eps_preview {
|
||||
let Some((b, img)) = bytes.as_deref().and_then(|b| Some((b, fileio::raster_image(b).ok()?))) else {
|
||||
let Some((b, img)) = bytes.as_deref().and_then(|b| Some((b, linked_image(b).ok()?))) else {
|
||||
out.missing.extend(row);
|
||||
continue;
|
||||
};
|
||||
let blob = img.blob.with_proxy();
|
||||
let blob = img.blob;
|
||||
read_images.insert(f.path.clone(), (blob.clone(), hash_bytes(b)));
|
||||
store_image(d, &g.key, blob, true);
|
||||
} else if let Some(b) = d.images.get(&g.key).filter(|b| b.proxy.is_none()) {
|
||||
@@ -935,7 +977,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
// Read before it is copied: a file that can't be shown isn't copied.
|
||||
let img = if images.is_empty() { None } else { Some(fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?) };
|
||||
let img = if images.is_empty() { None } else { Some(linked_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?) };
|
||||
let mut sources = vec![];
|
||||
for ((page, bounding, page_box), ids) in documents {
|
||||
sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, page_box, C)?));
|
||||
@@ -1275,8 +1317,10 @@ fn unembed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "name": format!("{stem}.{ext}"), "dataBase64": vectorcraft_format::base64_encode(&bytes) }));
|
||||
};
|
||||
let path = absolute_path(&if fileio::extension(path).is_empty() { format!("{path}.{ext}") } else { path.to_string() });
|
||||
// Read before writing: pixels that can't be read leave no file behind.
|
||||
let img = linked_image(&bytes)?;
|
||||
write_file(&path, &bytes)?;
|
||||
let read = (link_info(&path, &bytes), fileio::raster_image(&bytes)?);
|
||||
let read = (link_info(&path, &bytes), img);
|
||||
s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds)))?;
|
||||
Ok(json!({ "id": id.0, "path": path }))
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
//! effect, as in the reference app). Leaving the mode removes the layer, and saving or exporting
|
||||
//! never writes it ([`Document::without_edit_modes`]). Meanwhile the Transparency panel's commands
|
||||
//! act on the masked object (see `opacitymask::transparency_targets`). Mask editing is not entered
|
||||
//! in isolation mode ([`outside_isolation_mode`]), and isolation mode is not entered while a mask is
|
||||
//! edited ([`outside_mask_editing`]).
|
||||
//! in isolation mode or pattern editing mode ([`can_edit_mask`]), and neither mode is entered while
|
||||
//! a mask is edited ([`outside_mask_editing`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -29,8 +29,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Opacity Mask",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does; refused in isolation mode → {layer}",
|
||||
outside_isolation_mode,
|
||||
"{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does; refused in isolation mode and in pattern editing mode → {layer}",
|
||||
can_edit_mask,
|
||||
enter
|
||||
),
|
||||
cmd!(
|
||||
@@ -47,8 +47,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"View Opacity Mask",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{on?: bool (default: toggle), id?} show only the opacity mask of `id` (default: the object whose mask is edited, else the first selected object with a mask) on the canvas, as greyscale coverage, and edit it, as Alt-clicking the mask thumbnail does; off shows the artwork again (still editing); refused in isolation mode → {on, id}",
|
||||
outside_isolation_mode,
|
||||
"{on?: bool (default: toggle), id?} show only the opacity mask of `id` (default: the object whose mask is edited, else the first selected object with a mask) on the canvas, as greyscale coverage, and edit it, as Alt-clicking the mask thumbnail does; off shows the artwork again (still editing); refused in isolation mode and in pattern editing mode → {on, id}",
|
||||
can_edit_mask,
|
||||
view_mask
|
||||
),
|
||||
]
|
||||
@@ -60,15 +60,27 @@ pub(crate) fn in_isolation_mode(st: &crate::DocState) -> bool {
|
||||
st.isolation.is_some() && st.doc.pattern_edit.is_none() && st.doc.mask_edit.is_none()
|
||||
}
|
||||
|
||||
/// A document is open and it is not in isolation mode ([`in_isolation_mode`]): opacity mask
|
||||
/// editing is not entered in isolation mode.
|
||||
pub(crate) fn outside_isolation_mode(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if in_isolation_mode(st) { Err("exit isolation mode first".into()) } else { Ok(()) }
|
||||
/// Why opacity mask editing is not entered now, if it is not: in isolation mode
|
||||
/// ([`in_isolation_mode`]) and in pattern editing mode, whose tile layer the mask editing layer
|
||||
/// would replace as the isolated layer.
|
||||
pub(crate) fn mask_editing_refused(st: &crate::DocState) -> Option<&'static str> {
|
||||
if st.doc.pattern_edit.is_some() {
|
||||
Some("finish editing the pattern first")
|
||||
} else if in_isolation_mode(st) {
|
||||
Some("exit isolation mode first")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A document is open and no opacity mask is being edited: isolation mode is not entered while
|
||||
/// an opacity mask is edited.
|
||||
/// A document is open and opacity mask editing can be entered ([`mask_editing_refused`]).
|
||||
pub(crate) fn can_edit_mask(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
mask_editing_refused(st).map_or(Ok(()), |why| Err(why.into()))
|
||||
}
|
||||
|
||||
/// A document is open and no opacity mask is being edited: isolation mode and pattern editing mode
|
||||
/// are not entered while an opacity mask is edited.
|
||||
pub(crate) fn outside_mask_editing(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.doc.mask_edit.is_some() { Err("stop editing the opacity mask first".into()) } else { Ok(()) }
|
||||
@@ -169,8 +181,11 @@ pub(crate) fn begin(d: &mut Document, sel: &mut Selection, id: NodeId) -> Result
|
||||
Ok(layer)
|
||||
}
|
||||
|
||||
fn leave(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// Leave mask editing in one undo step (`transparency.stopEditingOpacityMask`). `object.exitIsolation` runs it too
|
||||
/// while a mask is edited.
|
||||
pub(crate) fn leave(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(me) = s.doc()?.doc.mask_edit else { return Err(bad("transparency.stopEditingOpacityMask", "not editing an opacity mask")) };
|
||||
s.end_text_edit();
|
||||
s.edit("Stop Editing Opacity Mask", |d, sel| {
|
||||
finish(d, sel);
|
||||
Ok(())
|
||||
|
||||
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Unselected Artwork",
|
||||
["Object", "Hide"],
|
||||
Some("Cmd+Alt+Shift+3"),
|
||||
"{} hide, in one undo step, every object select.all takes that is not selected and holds no selected object; in a group that holds one it hides the other members one by one, and a clip group, compound path or other object that holds one stays as it is; guide paths stay as they are while Lock Guides is on; object.showAll shows them again; disabled in isolation mode → {count}",
|
||||
"{} hide, in one undo step, every object select.all takes that is not selected and holds no selected object; in a group that holds one it hides the other members one by one, and a clip group, compound path or other object that holds one stays as it is; the objects of a selected layer count as selected; while Lock Guides is on, guide paths count as selected and stay as they are; object.showAll shows them again; disabled in isolation mode → {count}",
|
||||
has_selection_outside_isolation,
|
||||
hide_unselected
|
||||
),
|
||||
@@ -322,17 +322,26 @@ fn other_layers(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// The objects [`super::select::walk_selectable`] visits that are not among the selected objects `roots` and hold none of
|
||||
/// them. The walk goes into a plain group that holds one of `roots`; a clip group, a Shaper group, a compound path and
|
||||
/// every other object that holds one is left out with its members. Guide paths are left out while `guides_locked`.
|
||||
fn unselected(d: &vectorcraft_doc::Document, iso: Option<NodeId>, roots: &[NodeId], guides_locked: bool) -> Vec<NodeId> {
|
||||
let holds: HashSet<NodeId> = d.index_paths(roots.iter().copied()).values().filter_map(|p| d.ancestry_at(p)).flatten().collect();
|
||||
let roots: HashSet<NodeId> = roots.iter().copied().collect();
|
||||
/// them. The objects of a selected layer count as selected. Guide paths are left out while `guides_locked`, and so are
|
||||
/// the objects that hold one. The walk goes into a plain group that holds a selected object or such a guide path; a
|
||||
/// clip group, a Shaper group, a compound path and every other object that holds one is left out with its members.
|
||||
fn unselected(d: &vectorcraft_doc::Document, iso: Option<NodeId>, selection: &[NodeId], roots: &[NodeId], guides_locked: bool) -> Vec<NodeId> {
|
||||
let mut roots: HashSet<NodeId> = roots.iter().copied().collect();
|
||||
roots.extend(selection.iter().filter_map(|id| d.node(*id)).filter(|n| n.is_layer()).flat_map(|l| l.layer_art(true)));
|
||||
let mut kept: Vec<NodeId> = roots.iter().copied().collect();
|
||||
if guides_locked {
|
||||
super::select::walk_selectable(d, iso, |n| {
|
||||
if matches!(n.kind, NodeKind::Path { guide: true, .. }) {
|
||||
kept.push(n.id);
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
let holds: HashSet<NodeId> = d.index_paths(kept).values().filter_map(|p| d.ancestry_at(p)).flatten().collect();
|
||||
let mut out = vec![];
|
||||
super::select::walk_selectable(d, iso, |n| {
|
||||
if !holds.contains(&n.id) {
|
||||
if !(guides_locked && matches!(n.kind, NodeKind::Path { guide: true, .. })) {
|
||||
out.push(n.id);
|
||||
}
|
||||
out.push(n.id);
|
||||
return false;
|
||||
}
|
||||
!roots.contains(&n.id) && matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.extras().shaper.is_none()
|
||||
@@ -344,7 +353,7 @@ fn hide_unselected(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let roots = selected_roots_to_transform(s)?;
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
let st = s.doc()?;
|
||||
let ids = unselected(&st.doc, st.isolation, &roots, guides_locked);
|
||||
let ids = unselected(&st.doc, st.isolation, &st.selection.objects, &roots, guides_locked);
|
||||
// With nothing to hide, the document and its undo history stay as they are.
|
||||
if !ids.is_empty() {
|
||||
s.edit("Hide", |d, _| {
|
||||
|
||||
@@ -269,22 +269,34 @@ pub(super) fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
|
||||
.ok_or_else(|| bad("object.isolate", "missing id"))?;
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
let (isolation, prev) = {
|
||||
let st = s.doc_mut()?;
|
||||
// Groups, layers and single objects (a path, a compound path, an image…); type isn't
|
||||
// isolated: a double-click on it edits its text.
|
||||
if matches!(st.doc.node(id).ok_or(EngineError::NoNode(id))?.kind, NodeKind::Text(_)) {
|
||||
return Err(bad("object.isolate", "type objects can't be isolated"));
|
||||
}
|
||||
let isolation =
|
||||
st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id);
|
||||
// A hidden or locked object, one in a hidden or locked group or layer, and a guide path while
|
||||
// Lock Guides is on aren't isolated either: a click with the Selection tool doesn't select them.
|
||||
// A Shaper Group isolates its source group, so that group is the one checked.
|
||||
if !st.doc.is_editable(isolation) {
|
||||
return Err(bad("object.isolate", "the object is hidden or locked"));
|
||||
}
|
||||
if guides_locked && crate::is_guide_path(&st.doc, isolation) {
|
||||
return Err(EngineError::Other("guides are locked".into()));
|
||||
}
|
||||
(isolation, st.isolation)
|
||||
};
|
||||
if let Some(prev) = prev
|
||||
&& prev != isolation
|
||||
{
|
||||
crate::cmd::settle_interaction(s)?;
|
||||
let st = s.doc_mut()?;
|
||||
crate::cmd::distortcmds::finish_edit_text(st, prev);
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
// Groups, layers and single objects (a path, a compound path, an image…); type isn't
|
||||
// isolated: a double-click on it edits its text.
|
||||
if matches!(st.doc.node(id).ok_or(EngineError::NoNode(id))?.kind, NodeKind::Text(_)) {
|
||||
return Err(bad("object.isolate", "type objects can't be isolated"));
|
||||
}
|
||||
let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id);
|
||||
// A hidden or locked object, one in a hidden or locked group or layer, and a guide path while
|
||||
// Lock Guides is on aren't isolated either: a click with the Selection tool doesn't select them.
|
||||
// A Shaper Group isolates its source group, so that group is the one checked.
|
||||
if !st.doc.is_editable(isolation) {
|
||||
return Err(bad("object.isolate", "the object is hidden or locked"));
|
||||
}
|
||||
if guides_locked && crate::is_guide_path(&st.doc, isolation) {
|
||||
return Err(EngineError::Other("guides are locked".into()));
|
||||
}
|
||||
st.isolation = Some(isolation);
|
||||
st.selection.clear();
|
||||
// New art on a locked artboard, now deselected, is protected.
|
||||
@@ -293,10 +305,30 @@ pub(super) fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
pub(super) fn exit_isolation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Opacity mask editing and pattern editing isolate their editing layer. Leaving isolation mode
|
||||
// then leaves that mode, as Stop Editing Opacity Mask and the isolation bar's Done do.
|
||||
let st = s.doc()?;
|
||||
let leave_mode: Option<fn(&mut Session, &Value) -> Result<Value>> = if st.doc.mask_edit.is_some() {
|
||||
Some(crate::cmd::maskedit::leave)
|
||||
} else if st.doc.pattern_edit.is_some() {
|
||||
Some(crate::cmd::patterncmds::pattern_done)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(leave_mode) = leave_mode {
|
||||
if let Some(i) = st.isolation {
|
||||
crate::cmd::distortcmds::finish_edit_text(s.doc_mut()?, i);
|
||||
}
|
||||
leave_mode(s, p)?;
|
||||
return ok();
|
||||
}
|
||||
let menu = s.menu.clone();
|
||||
let st = s.doc_mut()?;
|
||||
if let Some(i) = st.isolation.take() {
|
||||
let prev = s.active().and_then(|d| d.isolation);
|
||||
if let Some(i) = prev {
|
||||
crate::cmd::settle_interaction(s)?;
|
||||
let st = s.doc_mut()?;
|
||||
st.isolation = None;
|
||||
crate::cmd::distortcmds::finish_edit_text(st, i);
|
||||
let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.extras().shaper.is_some())).unwrap_or(i);
|
||||
st.selection.set([target]);
|
||||
@@ -305,6 +337,7 @@ pub(super) fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// The art on a locked artboard can't be selected.
|
||||
st.protect_locked_artboards(&[], &Default::default(), None);
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
st.revision += 1;
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make Opacity Mask",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode → {id, editing}",
|
||||
"{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode and in pattern editing mode → {id, editing}",
|
||||
can_make,
|
||||
make
|
||||
),
|
||||
@@ -163,19 +163,20 @@ fn masked_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
}
|
||||
|
||||
/// Make Opacity Mask: a document is open, and one selected object, which [`make`] gives an empty
|
||||
/// mask and takes into mask editing, is not in isolation mode
|
||||
/// ([`super::maskedit::outside_isolation_mode`]). The selection is one object when it has one
|
||||
/// subject root, which `make` masks without `ids`, and at most one outermost object, which it
|
||||
/// masks with `ids` (a call's `ids` stand in for the selection here). `make` itself refuses any
|
||||
/// single target in isolation mode, a layer included, so a selection with a target counts as one
|
||||
/// object.
|
||||
/// mask and takes into mask editing, is where mask editing can be entered
|
||||
/// ([`super::maskedit::can_edit_mask`]: not in isolation mode or pattern editing mode). The
|
||||
/// selection is one object when it has one subject root, which `make` masks without `ids`, and at
|
||||
/// most one outermost object, which it masks with `ids` (a call's `ids` stand in for the selection
|
||||
/// here). A targeted layer is one subject root however many objects it selects. `make` itself
|
||||
/// refuses any single target in those modes, a layer included, so a selection with a target counts
|
||||
/// as one object.
|
||||
fn can_make(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
let Some(st) = s.active() else { return Ok(()) };
|
||||
let roots = super::appearance::subject_roots(s).map_or(0, |r| r.len());
|
||||
let outermost = super::edit::outermost(&st.doc, st.selection.in_paint_order(&st.doc)).len();
|
||||
let targeted = st.selection.target.is_some();
|
||||
if roots == 1 && (outermost <= 1 || targeted) { super::maskedit::outside_isolation_mode(s) } else { Ok(()) }
|
||||
if roots == 1 && (outermost <= 1 || targeted) { super::maskedit::can_edit_mask(s) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -194,9 +195,12 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => super::appearance::subject_roots(s)?,
|
||||
};
|
||||
let Some((&top, below)) = ids.split_last() else { return Err(bad(C, "select the art and, on top of it, the mask object")) };
|
||||
// One target gets an empty mask and enters mask editing, which isolation mode does not allow.
|
||||
if below.is_empty() && super::maskedit::in_isolation_mode(s.doc()?) {
|
||||
return Err(bad(C, "exit isolation mode first"));
|
||||
// One target gets an empty mask and enters mask editing, which isolation mode and pattern
|
||||
// editing mode do not allow.
|
||||
if below.is_empty()
|
||||
&& let Some(why) = super::maskedit::mask_editing_refused(s.doc()?)
|
||||
{
|
||||
return Err(bad(C, why));
|
||||
}
|
||||
let clip = bool_or(p, "clip", !s.menu.new_masks_unclipped);
|
||||
let invert = bool_or(p, "invert", s.menu.new_masks_inverted);
|
||||
|
||||
@@ -423,7 +423,7 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint, cmd: &str) -> Result
|
||||
/// Set the fill (or stroke) of the targets of `p` (the `item` and `ids` params, see
|
||||
/// `paint.setFill`) and of the defaults for new art, focus its proxy unless `focus` is false, and
|
||||
/// remember the paint. Replies `{objects}`: how many objects took the paint.
|
||||
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
|
||||
pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
|
||||
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
|
||||
let focus = switch_or(p, "focus", true, cmd)?;
|
||||
let span = spans_targets(p, &paint).map_err(|e| bad(cmd, e))?;
|
||||
|
||||
@@ -32,8 +32,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Pattern"],
|
||||
None,
|
||||
"{ids?, name? (trimmed; blank: New Pattern N), tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode → {name}",
|
||||
has_selection,
|
||||
"{ids?, name? (trimmed; blank: New Pattern N), tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode; refused while an opacity mask is edited → {name}",
|
||||
can_make_pattern,
|
||||
pattern_make
|
||||
),
|
||||
cmd!(
|
||||
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Pattern",
|
||||
["Object", "Pattern"],
|
||||
Some("Cmd+Shift+F8"),
|
||||
"{name?} edit a pattern (default: the selected object's fill pattern) in pattern editing mode",
|
||||
"{name?} edit a pattern (default: the selected object's fill pattern) in pattern editing mode; refused while an opacity mask is edited",
|
||||
can_edit_pattern,
|
||||
pattern_edit
|
||||
),
|
||||
@@ -161,11 +161,20 @@ fn in_pattern_edit(s: &Session) -> std::result::Result<(), String> {
|
||||
editing(s).map(|_| ()).ok_or_else(|| "not in pattern editing mode".into())
|
||||
}
|
||||
|
||||
/// Make: a selection, and no opacity mask is being edited
|
||||
/// ([`super::maskedit::outside_mask_editing`]), since the tile layer would replace the mask editing
|
||||
/// layer as the isolated layer.
|
||||
fn can_make_pattern(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
super::maskedit::outside_mask_editing(s)
|
||||
}
|
||||
|
||||
fn can_edit_pattern(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if editing(s).is_some() {
|
||||
return Err("already editing a pattern".into());
|
||||
}
|
||||
super::maskedit::outside_mask_editing(s)?;
|
||||
if s.active().is_some_and(|st| st.doc.patterns.is_empty()) { Err("the document has no patterns".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
@@ -355,7 +364,12 @@ fn pattern_name(p: &Paint) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn pattern_done(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// Save the edited tile art into the pattern and leave pattern editing mode (`object.pattern.done`).
|
||||
/// `object.exitIsolation` runs it too while a pattern is edited.
|
||||
pub(crate) fn pattern_done(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if editing(s).is_some() {
|
||||
s.end_text_edit();
|
||||
}
|
||||
let name = s.edit("Done Editing Pattern", |d, sel| {
|
||||
let (pe, art) = leave_edit(d, sel)?;
|
||||
if let Some(def) = d.pattern_mut(&pe.pattern) {
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
@@ -479,15 +479,17 @@ impl PlaceJob {
|
||||
str_param(&self.params, "path").or_else(|| str_param(&self.params, "name")).map(fileio::file_name).unwrap_or_else(|| "Untitled".into())
|
||||
}
|
||||
|
||||
/// Read and decode the file, as `file.place` does, without the session. A linked image's
|
||||
/// preview is made here too.
|
||||
/// Read and decode the file, as `file.place` does, without the session. The pixels of a PNG,
|
||||
/// JPEG, GIF or WebP file are decoded here to check that they can be read (an error when they
|
||||
/// can't), a linked image's preview is made from that decode, and the decoded pixels are kept in
|
||||
/// the renderers' image store for the first frame that draws them
|
||||
/// ([`super::links::readable_and_kept`]).
|
||||
pub fn read(self) -> Result<PlaceRead> {
|
||||
let mut loaded = load(&self.params, PLACE, self.target, true)?;
|
||||
if loaded.link.is_some()
|
||||
&& bool_or(&self.params, "link", true)
|
||||
&& let Art::Image(img) = &mut loaded.art
|
||||
{
|
||||
img.blob = img.blob.clone().with_proxy();
|
||||
let linked = loaded.link.is_some() && bool_or(&self.params, "link", true);
|
||||
if let Art::Image(img) = &mut loaded.art {
|
||||
let unread = super::links::kept_unread(&img.blob.bytes);
|
||||
img.blob = super::links::readable_and_kept(img.blob.clone(), unread, linked)?;
|
||||
}
|
||||
Ok(PlaceRead { job: self, loaded })
|
||||
}
|
||||
@@ -719,8 +721,19 @@ fn read_queue(p: Value, queued: Vec<Value>) -> Result<QueueRead> {
|
||||
q[k] = v.clone();
|
||||
}
|
||||
}
|
||||
// Read for its size and thumbnail: each click places it with file.place.
|
||||
match load(&q, QUEUE, None, false) {
|
||||
// Read for its size and thumbnail: each click places it with file.place, so a file whose
|
||||
// pixels file.place can't read is skipped. With a thumbnail, the decoded pixels are kept
|
||||
// so that drawing it does not decode the file again.
|
||||
let checked = load(&q, QUEUE, None, false).and_then(|mut l| {
|
||||
let linked = l.link.is_some() && bool_or(&q, "link", true);
|
||||
if let Art::Image(img) = &mut l.art {
|
||||
let unread = super::links::kept_unread(&img.blob.bytes);
|
||||
let check = if thumbnail.is_some() { super::links::readable_and_kept } else { super::links::readable };
|
||||
img.blob = check(img.blob.clone(), unread, linked)?;
|
||||
}
|
||||
Ok(l)
|
||||
});
|
||||
match checked {
|
||||
Ok(l) => {
|
||||
let (w, h) = (l.natural.width(), l.natural.height());
|
||||
let mut f = json!({ "name": l.name, "format": l.format.id, "width": w, "height": h });
|
||||
|
||||
@@ -562,6 +562,9 @@ impl Session {
|
||||
let fonts_folder_changed = p.fonts_folder != self.prefs.fonts_folder;
|
||||
let hyphen_exceptions_changed = p.hyphenation_exceptions != self.prefs.hyphenation_exceptions;
|
||||
let corner_radius_changed = p.corner_radius != self.prefs.corner_radius;
|
||||
if p.trace_presets != self.prefs.trace_presets {
|
||||
self.trace_preset_changes.replaced();
|
||||
}
|
||||
self.prefs = p;
|
||||
if corner_radius_changed {
|
||||
// General › Corner Radius is the Rounded Rectangle tool's radius (#394): a tool already
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
use super::pathops::Beside;
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, point_param};
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
sync::Arc,
|
||||
};
|
||||
use vectorcraft_doc::shaper::{EdgeEdit, FILL_PREFIX, FaceEdit, FaceSelector, SOURCES, STROKE_PREFIX, ShaperSpec};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, Shape as _};
|
||||
@@ -386,17 +389,37 @@ fn generated(d: &mut Document, src: &[Arc<Node>], spec: &ShaperSpec, map: &Build
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Re-evaluate only groups whose original art changed. Errors reach Session::edit's rollback.
|
||||
pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
let mut ids = vec![];
|
||||
d.walk(|n| {
|
||||
if n.extras().shaper.is_some() {
|
||||
ids.push(n.id)
|
||||
fn changed_shaper_groups(before: &[Arc<Node>], after: &[Arc<Node>], out: &mut Vec<NodeId>) {
|
||||
let mut by_id: Option<HashMap<NodeId, &Arc<Node>>> = None;
|
||||
for (i, a) in after.iter().enumerate() {
|
||||
let b = match before.get(i) {
|
||||
Some(b) if b.id == a.id => Some(b),
|
||||
_ => by_id.get_or_insert_with(|| before.iter().map(|b| (b.id, b)).collect()).get(&a.id).copied(),
|
||||
};
|
||||
if b.is_some_and(|b| Arc::ptr_eq(b, a)) {
|
||||
continue;
|
||||
}
|
||||
});
|
||||
if a.extras().shaper.is_some() {
|
||||
out.push(a.id);
|
||||
}
|
||||
if let Some(children) = a.children() {
|
||||
changed_shaper_groups(b.and_then(|b| b.children()).map_or(&[], Vec::as_slice), children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-evaluate only groups whose original art changed. Errors reach Session::edit's rollback.
|
||||
/// Returns whether any Shaper group's artwork was rebuilt or modified.
|
||||
pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<bool> {
|
||||
let mut ids = vec![];
|
||||
changed_shaper_groups(&before.layers, &d.layers, &mut ids);
|
||||
if ids.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
// Groups whose source art is all gone are removed through `emptied`, which is made when the
|
||||
// first one is found.
|
||||
let mut emptied: Option<Beside> = None;
|
||||
let mut rebuilt_any = false;
|
||||
for &id in &ids {
|
||||
let Some(current) = d.node(id).cloned() else { continue };
|
||||
let Some(old) = before.node(id) else { continue };
|
||||
@@ -413,6 +436,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
}
|
||||
if src.is_empty() {
|
||||
emptied.get_or_insert_with(|| Beside::replacing(d, &ids)).put(d, id, vec![])?;
|
||||
rebuilt_any = true;
|
||||
continue;
|
||||
}
|
||||
let map = map_of(&src)?;
|
||||
@@ -510,8 +534,10 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
if let Some(ch) = n.children_mut() {
|
||||
*ch = children;
|
||||
}
|
||||
rebuilt_any = true;
|
||||
}
|
||||
emptied.map_or(Ok(()), |e| e.finish(d))
|
||||
emptied.map_or(Ok(()), |e| e.finish(d))?;
|
||||
Ok(rebuilt_any)
|
||||
}
|
||||
|
||||
fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result<Vec<NodeId>> {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! resolves, for each area text object, the outlines of the wrap objects above it into
|
||||
//! `TextObject::wrap` (text space); the layout engine subtracts them from each line's span.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextWrap, WrapShape};
|
||||
use vectorcraft_geom::{PathData, shapes};
|
||||
@@ -149,21 +151,18 @@ fn collect<'a>(n: &'a Node, out: &mut Vec<Item<'a>>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn any_wrap_state(n: &Node) -> bool {
|
||||
n.extras().wrap.is_some()
|
||||
|| matches!(&n.kind, NodeKind::Text(t) if !t.wrap.is_empty())
|
||||
|| n.children().is_some_and(|ch| ch.iter().any(|c| any_wrap_state(c)))
|
||||
}
|
||||
|
||||
/// Re-resolve the wrap shapes of every area text object (called after each edit).
|
||||
pub(crate) fn refresh(doc: &mut Document) {
|
||||
if !doc.layers.iter().any(|l| any_wrap_state(l)) {
|
||||
return;
|
||||
}
|
||||
pub(crate) fn refresh(before: &Document, doc: &mut Document) {
|
||||
let mut updates: Vec<(NodeId, Vec<WrapShape>)> = vec![];
|
||||
for layer in &doc.layers {
|
||||
for (idx, layer) in doc.layers.iter().enumerate() {
|
||||
if before.layers.get(idx).is_some_and(|b| Arc::ptr_eq(b, layer)) || before.layers.iter().any(|b| b.id == layer.id && Arc::ptr_eq(b, layer)) {
|
||||
continue;
|
||||
}
|
||||
let mut items = vec![];
|
||||
collect(layer, &mut items);
|
||||
if items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for (i, it) in items.iter().enumerate() {
|
||||
let Item::Text(id) = it else { continue };
|
||||
let Some(NodeKind::Text(t)) = doc.node(*id).map(|n| &n.kind) else { continue };
|
||||
|
||||
@@ -103,6 +103,9 @@ pub struct Session {
|
||||
/// The Appearance panel's active fill/stroke row (`appearance.setActiveItem`); not saved. Read
|
||||
/// it through [`Session::appearance_item`], which drops it once the selection changes.
|
||||
pub(crate) active_appearance_item: Option<cmd::appearance::ActiveItem>,
|
||||
/// What the Eyedropper picked up last (`appearance.copyFrom`): an Alt-click with nothing
|
||||
/// selected puts it on the object clicked (#4382). Not saved.
|
||||
pub(crate) eyedropper_sample: Option<Arc<vectorcraft_doc::Node>>,
|
||||
/// The last solid colour applied (the toolbar's Color button; `,` applies it again).
|
||||
pub last_solid: Color,
|
||||
/// The last gradient applied (the toolbar's Gradient button; `.` applies it again).
|
||||
@@ -139,6 +142,8 @@ pub struct Session {
|
||||
plugin_output: Option<cmd::plugin::HandedIn>,
|
||||
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
|
||||
pub recent_urls: Vec<String>,
|
||||
/// The saved tracing presets' revision and latest changes ([`Session::trace_presets_revision`]).
|
||||
pub(crate) trace_preset_changes: cmd::TracePresetChanges,
|
||||
/// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame;
|
||||
/// `None` without one (headless), where an explicit `interfaceLanguage` preference counts.
|
||||
/// Japanese gives new type the Japanese defaults ([`Session::japanese_interface`]).
|
||||
|
||||
@@ -202,6 +202,11 @@ pub struct Prefs {
|
||||
/// edit it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub flattener_presets: Vec<cmd::FlattenerPreset>,
|
||||
/// Image Trace panel › Manage Presets: the user's tracing presets (the built-in ones aren't
|
||||
/// stored). Not a `prefs.set` key or group (resetting the preferences keeps them):
|
||||
/// `imageTrace.presets.*` edit it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub trace_presets: Vec<cmd::TracePreset>,
|
||||
/// Width profiles saved to the Stroke panel's Profile list (`stroke.widthProfile.*`). Not a
|
||||
/// Preferences dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -242,6 +247,12 @@ pub struct Prefs {
|
||||
/// Preferences dialog field: resetting the preferences keeps it; `webExport.presets.*` edit it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub web_export_presets: Vec<cmd::webexport::WebPreset>,
|
||||
/// The VectorCraft Options a native Save or Save As used last (Preview, Embed ICC Profiles,
|
||||
/// Create PDF-Compatible File…; not the artboards it picked, nor Use Compression, which its
|
||||
/// preference decides): `file.formatOptions` offers them for a document not yet saved as a
|
||||
/// native file. Not a Preferences dialog field.
|
||||
#[serde(skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub native_save_options: serde_json::Map<String, serde_json::Value>,
|
||||
/// Save for Web: the settings the dialog opens on (`webExport.settings`); none until set.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub web_export_settings: Option<cmd::webexport::WebSettings>,
|
||||
@@ -424,6 +435,7 @@ impl Default for Prefs {
|
||||
eyedropper: Default::default(),
|
||||
pathfinder: Default::default(),
|
||||
flattener_presets: vec![],
|
||||
trace_presets: vec![],
|
||||
width_profiles: vec![],
|
||||
japanese_crop_marks: false,
|
||||
new_art_basic: true,
|
||||
@@ -436,6 +448,7 @@ impl Default for Prefs {
|
||||
append_converted: true,
|
||||
use_compression: false,
|
||||
web_export_presets: vec![],
|
||||
native_save_options: serde_json::Map::new(),
|
||||
web_export_settings: None,
|
||||
print_presets: vec![],
|
||||
constrain_proportions: false,
|
||||
|
||||
@@ -49,6 +49,7 @@ impl Session {
|
||||
menu: Default::default(),
|
||||
gradient_stop: None,
|
||||
active_appearance_item: None,
|
||||
eyedropper_sample: None,
|
||||
last_solid: Color::WHITE,
|
||||
last_gradient: GradientPaint::new(Default::default()),
|
||||
recent_colors: vec![],
|
||||
@@ -64,6 +65,7 @@ impl Session {
|
||||
plugin_dir: None,
|
||||
plugin_output: None,
|
||||
recent_urls: vec![],
|
||||
trace_preset_changes: Default::default(),
|
||||
ui_language: None,
|
||||
journal_note: Default::default(),
|
||||
note_depth: 1,
|
||||
|
||||
@@ -19,6 +19,7 @@ impl Session {
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
// The Global Edit members the edit reached besides the selection.
|
||||
let mut spread = vec![];
|
||||
let mut dim_changed = false;
|
||||
// Objects attached to a path follow it, and dimensions follow the art they measure.
|
||||
let result = match f(doc, &mut st.selection).and_then(|v| {
|
||||
// Global Edit: the other members take what the edit changed about the one it edited.
|
||||
@@ -26,18 +27,20 @@ impl Session {
|
||||
spread = cmd::globaledit::propagate(&before, doc, ge, [&before_sel.objects, &st.selection.objects]);
|
||||
}
|
||||
cmd::objects_on_path::refresh(&before, doc);
|
||||
cmd::dimension::refresh(&before, doc)?;
|
||||
dim_changed = cmd::dimension::refresh(&before, doc)?;
|
||||
Ok(v)
|
||||
}) {
|
||||
Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) {
|
||||
Err(e) => Err(e),
|
||||
Ok(()) => {
|
||||
Ok(shaper_changed) => {
|
||||
// Attached Shaper groups whose art was rebuilt go back on their path.
|
||||
cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc));
|
||||
if shaper_changed || dim_changed {
|
||||
cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc));
|
||||
}
|
||||
// Inline graphics in text follow their symbols.
|
||||
cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc));
|
||||
// Text Wrap: area type follows its wrap objects; then threads re-flow.
|
||||
cmd::textwrap::refresh(Arc::make_mut(&mut st.doc));
|
||||
cmd::textwrap::refresh(&before, Arc::make_mut(&mut st.doc));
|
||||
// Type in no thread numbers its list paragraphs from 1 (before the mask copies
|
||||
// its editing layer's art).
|
||||
cmd::threads::restart_unthreaded_counts(&mut st.doc);
|
||||
|
||||
@@ -1460,6 +1460,7 @@ fn shape_commands_refuse_a_number_or_boolean_given_as_text() {
|
||||
("shape.flare", with(&flare, "opacity", json!("50")), "opacity"),
|
||||
("shape.flare", with(&flare, "largest", json!(true)), "largest"),
|
||||
("shape.flare", with(&flare, "rays", json!("15")), "rays"),
|
||||
("shape.flare", with(&flare, "rotation", json!("90")), "rotation"),
|
||||
("shape.flare", json!({"cx": 100, "cy": 100, "x2": "300", "y2": 300}), "x2"),
|
||||
("shape.flare", with(&flare, "direction", json!("45")), "direction"),
|
||||
("shape.flare", with(&flare, "seed", json!("7")), "seed"),
|
||||
@@ -3160,6 +3161,33 @@ fn flare_tool_two_step_gesture_is_one_undo() {
|
||||
assert_eq!(rays.path_data().unwrap().subpaths.len(), 13);
|
||||
}
|
||||
|
||||
/// `shape.flare`'s `rotation` turns the rays clockwise about the center, and leaves the halo, the
|
||||
/// center and the rings as they are.
|
||||
#[test]
|
||||
fn flare_rotation_turns_only_the_rays() {
|
||||
use vectorcraft_geom::Affine;
|
||||
let mut s = session();
|
||||
let parts = |s: &mut Session, rotation: f64| {
|
||||
let p = json!({"cx": 200, "cy": 200, "diameter": 60, "rays": 9, "rings": 4, "x2": 400, "y2": 300, "seed": 3, "rotation": rotation});
|
||||
let id = NodeId(s.execute("shape.flare", &p).unwrap()["id"].as_u64().unwrap());
|
||||
let NodeKind::Group { children, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("flare is a group") };
|
||||
children.iter().map(|c| (c.name.clone(), c.path_data().unwrap().clone())).collect::<Vec<_>>()
|
||||
};
|
||||
let plain = parts(&mut s, 0.0);
|
||||
let turned = parts(&mut s, 90.0);
|
||||
assert_eq!(plain.len(), turned.len());
|
||||
let quarter = Affine::translate((200.0, 200.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate((-200.0, -200.0));
|
||||
for ((name, a), (_, b)) in plain.iter().zip(&turned) {
|
||||
let pa: Vec<Point> = a.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|k| k.p)).collect();
|
||||
let pb: Vec<Point> = b.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|k| k.p)).collect();
|
||||
let want: Vec<Point> = if name.as_deref() == Some("Rays") { pa.iter().map(|&p| quarter * p).collect() } else { pa };
|
||||
assert_eq!(pb.len(), want.len(), "{name:?}");
|
||||
for (p, q) in pb.iter().zip(&want) {
|
||||
assert!(p.distance(*q) < 1e-6, "{name:?}: {p:?} is not {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_moves_the_grabbed_point_and_its_neighbourhood() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -119,6 +119,72 @@ fn a_blend_and_an_object_make_one_blend_with_more_keys() {
|
||||
assert_eq!(vectorcraft_doc::live::expand_live(&n).len(), 3 + 2 * 3);
|
||||
}
|
||||
|
||||
/// Choosing the Blend tool again (its button, W, `tool.select`) finishes the blend: the first
|
||||
/// pick and the chain are dropped, and the document and the selection stay. Choosing another
|
||||
/// tool again keeps its work: the Pen's open path stays open.
|
||||
#[test]
|
||||
fn choosing_the_blend_tool_again_finishes_the_blend() {
|
||||
let mut s = session();
|
||||
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("blend", v).unwrap();
|
||||
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
|
||||
click(&mut s, 20.0, 20.0);
|
||||
assert!(s.tool_busy());
|
||||
let (layers, depth) = (s.doc().unwrap().doc.layers.clone(), s.doc().unwrap().history.undo.len());
|
||||
s.select_tool("blend", v).unwrap();
|
||||
assert!(!s.tool_busy(), "choosing the tool again drops the pick");
|
||||
assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "and changes nothing");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
// The next two clicks blend b and c; a is not part of it.
|
||||
click(&mut s, 220.0, 20.0);
|
||||
assert_eq!(s.doc().unwrap().doc.layers, layers, "b is picked first");
|
||||
click(&mut s, 420.0, 20.0);
|
||||
let g = s.doc().unwrap().selection.objects[0];
|
||||
assert_eq!(spec(&s, g).0, vec![b, c]);
|
||||
// Chosen again after the blend is made: the next click starts a new blend instead of adding.
|
||||
s.select_tool("blend", v).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g], "the blend stays selected");
|
||||
click(&mut s, 20.0, 20.0);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(spec(&s, g).0, vec![b, c], "a was not added");
|
||||
// The Pen chosen again goes on drawing its open path.
|
||||
s.select_tool("pen", v).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let pen = |s: &mut Session, x: f64, y: f64| {
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap();
|
||||
}
|
||||
};
|
||||
pen(&mut s, 100.0, 300.0);
|
||||
pen(&mut s, 200.0, 300.0);
|
||||
let p = s.doc().unwrap().selection.objects[0];
|
||||
s.select_tool("pen", v).unwrap();
|
||||
pen(&mut s, 300.0, 300.0);
|
||||
let anchors = match node(&s, p).kind {
|
||||
NodeKind::Path { path, .. } => path.subpaths[0].anchors.len(),
|
||||
_ => 0,
|
||||
};
|
||||
assert_eq!(anchors, 3, "the Pen keeps drawing the same path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_blend_tool_picks_again_when_its_first_pick_is_deleted() {
|
||||
let mut s = session();
|
||||
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
|
||||
s.select_tool("blend", ViewInfo::default()).unwrap();
|
||||
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
|
||||
click(&mut s, 20.0, 20.0);
|
||||
s.execute("edit.clear", &json!({"ids": [a.0]})).unwrap();
|
||||
let (layers, depth) = (s.doc().unwrap().doc.layers.clone(), s.doc().unwrap().history.undo.len());
|
||||
click(&mut s, 220.0, 20.0);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b], "b is picked first");
|
||||
assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "the click changes no art");
|
||||
click(&mut s, 420.0, 20.0);
|
||||
let g = s.doc().unwrap().selection.objects[0];
|
||||
assert_eq!(spec(&s, g).0, vec![b, c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_blend_tool_blends_from_clicked_anchors_and_keeps_adding() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -3432,6 +3432,124 @@ fn image_trace_document_library_uses_a_color_group() {
|
||||
trace(&mut s, json!({"mode": "grayscale", "colorGroup": ""})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_document_library_links_fills_to_global_swatches() {
|
||||
let mut s = session();
|
||||
// A global color, a tint of another global color and a process color.
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": "#c82828", "global": true})).unwrap();
|
||||
s.execute("swatch.new", &json!({"name": "Sky", "color": "#2846c8", "global": true})).unwrap();
|
||||
s.execute("swatch.new", &json!({"swatch": "Sky", "tint": 50})).unwrap();
|
||||
s.execute("swatch.new", &json!({"name": "Leaf", "color": "#28a03c"})).unwrap();
|
||||
let hex_of = |s: &mut Session, name: &str| {
|
||||
let list = s.execute("swatch.list", &json!({})).unwrap();
|
||||
list["swatches"].as_array().unwrap().iter().find(|w| w["name"] == name).unwrap()["hex"].as_str().unwrap().to_lowercase()
|
||||
};
|
||||
let tint = hex_of(&mut s, "Sky 50%");
|
||||
let [tr, tg, tb] = [1, 3, 5].map(|i| u8::from_str_radix(&tint[i..i + 2], 16).unwrap());
|
||||
let r = vectorcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) {
|
||||
(true, _) => [200, 40, 40, 255],
|
||||
(false, true) => [40, 160, 60, 255],
|
||||
(false, false) => [tr, tg, tb, 255],
|
||||
});
|
||||
let img = add_image(&mut s, &r, Affine::IDENTITY);
|
||||
let params = json!({"mode": "color", "palette": "documentLibrary", "library": "document", "colors": 3});
|
||||
// Each fill as (its color, the swatch it links to, its tint), one per color.
|
||||
let fills = |s: &Session, id: &Value| {
|
||||
let mut v: Vec<(String, Option<String>, f32)> = node(s, NodeId(id.as_u64().unwrap()))
|
||||
.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|n| match n.appearance.fill_paint() {
|
||||
Paint::Solid { color, swatch, tint } => {
|
||||
let [r, g, b, _] = color.to_rgba8(1.0);
|
||||
Some((format!("#{r:02x}{g:02x}{b:02x}"), swatch, tint))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
v.dedup();
|
||||
v
|
||||
};
|
||||
let fill = |hex: &str, swatch: Option<&str>, tint: f32| (hex.to_string(), swatch.map(String::from), tint);
|
||||
let res = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap();
|
||||
let got = fills(&s, &res["id"]);
|
||||
assert_eq!(got.len(), 3, "{got:?}");
|
||||
assert!(got.contains(&fill("#c82828", Some("Ink"), 1.0)), "{got:?}");
|
||||
assert!(got.contains(&fill(&tint, Some("Sky"), 0.5)), "{got:?}");
|
||||
assert!(got.contains(&fill("#28a03c", None, 1.0)), "{got:?}");
|
||||
// Editing the global swatches recolors the trace: the tint at its own tint.
|
||||
s.execute("swatch.edit", &json!({"name": "Ink", "color": "#ff8800"})).unwrap();
|
||||
s.execute("swatch.edit", &json!({"name": "Sky", "color": "#000000"})).unwrap();
|
||||
let tint = hex_of(&mut s, "Sky 50%");
|
||||
let got = fills(&s, &res["id"]);
|
||||
assert!(got.contains(&fill("#ff8800", Some("Ink"), 1.0)), "{got:?}");
|
||||
assert!(got.contains(&fill(&tint, Some("Sky"), 0.5)), "{got:?}");
|
||||
assert!(got.contains(&fill("#28a03c", None, 1.0)), "{got:?}");
|
||||
for _ in 0..3 {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
// A color group's global swatches link too.
|
||||
s.execute("swatch.newGroup", &json!({"name": "Brand", "swatches": ["Ink", "Leaf"]})).unwrap();
|
||||
let mut group = params.clone();
|
||||
group["colorGroup"] = json!("Brand");
|
||||
let res = s.execute("imageTrace.makeAndExpand", &json!({"id": img.0, "params": group})).unwrap();
|
||||
let got = fills(&s, &res["id"]);
|
||||
assert!(got.contains(&fill("#c82828", Some("Ink"), 1.0)) && got.contains(&fill("#28a03c", None, 1.0)), "{got:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// A swatch library's colors stay unlinked.
|
||||
let mut web = params.clone();
|
||||
web["library"] = json!("web-safe-216");
|
||||
let res = s.execute("imageTrace.makeAndExpand", &json!({"id": img.0, "params": web})).unwrap();
|
||||
let got = fills(&s, &res["id"]);
|
||||
assert!(!got.is_empty() && got.iter().all(|f| f.1.is_none()), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_document_library_matches_a_lab_spot_by_its_cmyk_equivalent() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = session();
|
||||
// A Lab color outside the CMYK gamut, whose CMYK equivalent shows another color.
|
||||
let rgb = |c: Color| {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
[r, g, b]
|
||||
};
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
let off = |lab: Color| {
|
||||
let cmyk = cms.convert(&lab, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent);
|
||||
rgb(lab).iter().zip(rgb(cmyk)).map(|(a, b)| u32::from(a.abs_diff(b))).sum::<u32>()
|
||||
};
|
||||
let lab = [(30.0, 60.0, -100.0), (60.0, -80.0, 60.0), (55.0, 60.0, 40.0), (90.0, -40.0, 100.0)]
|
||||
.map(|(l, a, b)| Color::Lab { l, a, b })
|
||||
.into_iter()
|
||||
.max_by_key(|c| off(*c))
|
||||
.unwrap();
|
||||
assert!(off(lab) >= 30, "{lab:?} is off by {}", off(lab));
|
||||
let Color::Lab { l, a, b } = lab else { panic!("{lab:?}") };
|
||||
// A spot color of it shown by its CMYK equivalent (Spot Colors options with CMYK values).
|
||||
s.execute("swatch.new", &json!({"name": "Lab Ink", "color": {"l": l, "a": a, "b": b}, "spot": true})).unwrap();
|
||||
s.execute("swatch.spotOptions", &json!({"useLab": false})).unwrap();
|
||||
let shown = s.doc().unwrap().doc.global_color("Lab Ink").unwrap();
|
||||
let [kr, kg, kb] = rgb(shown);
|
||||
let [lr, lg, lb] = rgb(lab);
|
||||
// A process color between the two, nearer the color the spot shows.
|
||||
let near = |k: u8, l: u8| (f32::from(k) + (f32::from(l) - f32::from(k)) * 0.3).round() as u8;
|
||||
let [pr, pg, pb] = [near(kr, lr), near(kg, lg), near(kb, lb)];
|
||||
s.execute("swatch.new", &json!({"name": "Near", "color": format!("#{pr:02x}{pg:02x}{pb:02x}")})).unwrap();
|
||||
let process = Color::rgb8(pr, pg, pb);
|
||||
s.execute("swatch.new", &json!({"name": "Leaf", "color": "#28a03c"})).unwrap();
|
||||
// Art in the color the spot shows traces to the spot.
|
||||
let r = vectorcraft_trace::Raster::from_fn(60, 60, |x, _| if x < 30 { [kr, kg, kb, 255] } else { [40, 160, 60, 255] });
|
||||
add_image(&mut s, &r, Affine::IDENTITY);
|
||||
let params = json!({"mode": "color", "palette": "documentLibrary", "library": "document", "colors": 3});
|
||||
let res = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap();
|
||||
let g = node(&s, NodeId(res["id"].as_u64().unwrap()));
|
||||
let got: Vec<Paint> = g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect();
|
||||
let spot = Paint::Solid { color: shown, swatch: Some("Lab Ink".into()), tint: 1.0 };
|
||||
assert!(got.contains(&spot), "{got:?}");
|
||||
assert!(!got.iter().any(|p| p.color() == Some(process)), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_create_strokes_makes_stroked_centre_lines() {
|
||||
let mut s = session();
|
||||
@@ -3480,6 +3598,205 @@ fn image_trace_presets_query_and_errors() {
|
||||
assert!(find_command("livePaint.make").unwrap().menu == ["Object", "Live Paint"]);
|
||||
}
|
||||
|
||||
/// Manage Presets: a saved preset is listed after the built-in ones, traces by name in any case,
|
||||
/// changes from its own settings, and can be renamed and deleted.
|
||||
#[test]
|
||||
fn image_trace_saved_presets_are_made_used_renamed_and_deleted() {
|
||||
let mut s = session();
|
||||
let img = image_doc(&mut s);
|
||||
let r = s.execute("imageTrace.presets.save", &json!({"name": "Disc Only", "params": {"ignoreWhite": true, "paths": 70}})).unwrap();
|
||||
assert_eq!((r["name"].as_str(), r["created"].as_bool(), r["params"]["ignoreWhite"].as_bool()), (Some("Disc Only"), Some(true), Some(true)));
|
||||
let list = s.execute("imageTrace.presets", &json!({})).unwrap();
|
||||
let list = list["presets"].as_array().unwrap();
|
||||
assert_eq!(list.len(), 16);
|
||||
assert!(list[..15].iter().all(|p| p["builtIn"] == true));
|
||||
assert_eq!((list[15]["name"].as_str(), list[15]["builtIn"].as_bool()), (Some("Disc Only"), Some(false)));
|
||||
assert!(list[15]["enhanced"].is_null());
|
||||
assert_eq!(list[15]["params"]["paths"], 70.0);
|
||||
// It traces by name, any case, and the object shows its saved name.
|
||||
let r = s.execute("imageTrace.make", &json!({"id": img.0, "preset": "disc only"})).unwrap();
|
||||
assert_eq!(r["paths"], 1, "the white is left out");
|
||||
let g = NodeId(r["id"].as_u64().unwrap());
|
||||
let t = node(&s, g).extras().trace.clone().unwrap();
|
||||
assert_eq!((t["preset"].as_str(), t["params"]["ignoreWhite"].as_bool()), (Some("Disc Only"), Some(true)));
|
||||
let r = s.execute("imageTrace.make", &json!({"id": g.0, "preset": "Disc Only", "params": {"paths": 20}})).unwrap();
|
||||
let g = NodeId(r["id"].as_u64().unwrap());
|
||||
assert_eq!(node(&s, g).extras().trace.clone().unwrap()["preset"], "Custom");
|
||||
// Saving it again changes it from its own settings.
|
||||
let r = s.execute("imageTrace.presets.save", &json!({"name": "disc only", "params": {"corners": 10}})).unwrap();
|
||||
assert_eq!((r["name"].as_str(), r["created"].as_bool()), (Some("Disc Only"), Some(false)));
|
||||
assert_eq!(
|
||||
(r["params"]["ignoreWhite"].as_bool(), r["params"]["paths"].as_f64(), r["params"]["corners"].as_f64()),
|
||||
(Some(true), Some(70.0), Some(10.0))
|
||||
);
|
||||
// Rename: the old name no longer traces.
|
||||
s.execute("imageTrace.presets.save", &json!({"name": "Light", "preset": "6 Colors"})).unwrap();
|
||||
let e = s.execute("imageTrace.presets.save", &json!({"name": "Disc Only", "newName": "light"})).unwrap_err().to_string();
|
||||
assert!(e.contains("a preset named `light` exists"), "{e}");
|
||||
assert!(s.execute("imageTrace.presets.save", &json!({"name": "Disc Only", "newName": "6 colors"})).is_err());
|
||||
let e = s.execute("imageTrace.presets.save", &json!({"name": "Nothing", "newName": "Something"})).unwrap_err().to_string();
|
||||
assert!(e.contains("no saved preset named `Nothing` to rename"), "{e}");
|
||||
let r = s.execute("imageTrace.presets.save", &json!({"name": "Disc Only", "newName": "Disc"})).unwrap();
|
||||
assert_eq!((r["name"].as_str(), r["params"]["corners"].as_f64()), (Some("Disc"), Some(10.0)));
|
||||
let e = s.execute("imageTrace.make", &json!({"id": g.0, "preset": "Disc Only"})).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown preset `Disc Only`: not a built-in or saved preset"), "{e}");
|
||||
assert_eq!(s.execute("imageTrace.make", &json!({"id": g.0, "preset": "Disc"})).unwrap()["paths"], 1);
|
||||
// Delete.
|
||||
assert_eq!(s.execute("imageTrace.presets.delete", &json!({"name": "DISC"})).unwrap()["deleted"], "Disc");
|
||||
assert!(s.execute("imageTrace.presets.delete", &json!({"name": "Disc"})).is_err());
|
||||
assert!(s.execute("imageTrace.make", &json!({"id": g.0, "preset": "Disc"})).is_err());
|
||||
let names: Vec<String> = s.prefs.trace_presets.iter().map(|p| p.name.clone()).collect();
|
||||
assert_eq!(names, ["Light"]);
|
||||
}
|
||||
|
||||
/// The saved presets' revision grows with each save, rename and delete, and not with a refused
|
||||
/// one; the changes are recorded, and the list and the save's reply carry them.
|
||||
#[test]
|
||||
fn image_trace_saved_presets_count_their_changes() {
|
||||
let mut s = session();
|
||||
let revision = |s: &mut Session| s.execute("imageTrace.presets", &json!({})).unwrap()["revision"].as_u64();
|
||||
assert_eq!((s.trace_presets_revision(), revision(&mut s)), (0, Some(0)));
|
||||
let r = s.execute("imageTrace.presets.save", &json!({"name": "A"})).unwrap();
|
||||
assert!(r["renamedFrom"].is_null());
|
||||
let r = s.execute("imageTrace.presets.save", &json!({"name": "a", "newName": "B", "params": {"paths": 30}})).unwrap();
|
||||
assert_eq!(r["renamedFrom"], "A");
|
||||
assert!(s.execute("imageTrace.presets.save", &json!({"name": "B", "newName": "Default"})).is_err());
|
||||
assert!(s.execute("imageTrace.presets.delete", &json!({"name": "A"})).is_err());
|
||||
assert!(s.execute("imageTrace.presets.save", &json!({"name": "b", "newName": "B"})).unwrap()["renamedFrom"].is_null());
|
||||
s.execute("imageTrace.presets.delete", &json!({"name": "B"})).unwrap();
|
||||
assert_eq!((s.trace_presets_revision(), revision(&mut s)), (4, Some(4)));
|
||||
let changes: Vec<_> = s.trace_preset_changes_since(1).map(|c| (c.revision, c.name.as_str(), c.renamed_from.as_deref(), c.deleted)).collect();
|
||||
assert_eq!(changes, [(2, "B", Some("A"), false), (3, "B", None, false), (4, "B", None, true)]);
|
||||
// Preferences with other saved presets give a new revision; the same ones don't.
|
||||
let mut prefs = s.prefs.clone();
|
||||
s.apply_prefs(prefs.clone());
|
||||
assert_eq!(s.trace_presets_revision(), 4);
|
||||
s.execute("imageTrace.presets.save", &json!({"name": "C"})).unwrap();
|
||||
prefs.trace_presets.clear();
|
||||
s.apply_prefs(prefs);
|
||||
assert_eq!(s.trace_presets_revision(), 6);
|
||||
assert_eq!(s.trace_preset_changes_since(4).count(), 1, "replacing the preferences records no change");
|
||||
// A rename stays on record after many later saves.
|
||||
s.execute("imageTrace.presets.save", &json!({"name": "C"})).unwrap();
|
||||
let seen = s.trace_presets_revision();
|
||||
s.execute("imageTrace.presets.save", &json!({"name": "C", "newName": "D"})).unwrap();
|
||||
for i in 0..40 {
|
||||
s.execute("imageTrace.presets.save", &json!({"name": format!("E{i}")})).unwrap();
|
||||
}
|
||||
let first = s.trace_preset_changes_since(seen).next().map(|c| (c.name.clone(), c.renamed_from.clone()));
|
||||
assert_eq!(first, Some(("D".to_string(), Some("C".to_string()))));
|
||||
assert_eq!(s.trace_preset_changes_since(seen).count(), 41);
|
||||
}
|
||||
|
||||
/// Built-in presets stay as they are, and a saved preset's settings are checked as imageTrace.make
|
||||
/// checks them.
|
||||
#[test]
|
||||
fn image_trace_built_in_presets_cannot_be_saved_over_or_deleted() {
|
||||
let mut s = session();
|
||||
for name in ["6 Colors", "[Default]", "flat logo", "Custom"] {
|
||||
let e = s.execute("imageTrace.presets.save", &json!({ "name": name })).unwrap_err().to_string();
|
||||
assert!(e.contains("save it under another name") || e.contains("save the preset under another name"), "{name}: {e}");
|
||||
}
|
||||
for name in ["Default", "outline"] {
|
||||
let e = s.execute("imageTrace.presets.delete", &json!({ "name": name })).unwrap_err().to_string();
|
||||
assert!(e.contains("is a built-in preset and stays"), "{e}");
|
||||
}
|
||||
assert!(s.execute("imageTrace.presets.save", &json!({"name": " "})).is_err());
|
||||
assert!(s.execute("imageTrace.presets.delete", &json!({})).is_err());
|
||||
let e = s.execute("imageTrace.presets.save", &json!({"params": {"fills": false, "strokes": false}})).unwrap_err().to_string();
|
||||
assert!(e.contains("`imageTrace.presets.save`") && e.contains("Create needs fills, strokes or both"), "{e}");
|
||||
assert!(s.execute("imageTrace.presets.save", &json!({"preset": "No Such"})).is_err());
|
||||
assert!(s.prefs.trace_presets.is_empty(), "nothing saved");
|
||||
// Without a name, the first free "Tracing Preset N"; a preset and its enhanced version as a start.
|
||||
let r = s.execute("imageTrace.presets.save", &json!({"preset": "16 Colors", "enhanced": true})).unwrap();
|
||||
assert_eq!(r["name"], "Tracing Preset 1");
|
||||
assert_eq!(r["params"], serde_json::to_value(vectorcraft_trace::enhanced_preset("16 Colors").unwrap()).unwrap());
|
||||
assert_eq!(s.execute("imageTrace.presets.save", &json!({})).unwrap()["name"], "Tracing Preset 2");
|
||||
// Built-in presets come first in the list and are unchanged.
|
||||
let list = s.execute("imageTrace.presets", &json!({})).unwrap();
|
||||
let six = list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "6 Colors").unwrap().clone();
|
||||
assert_eq!(six["params"], serde_json::to_value(vectorcraft_trace::preset("6 Colors").unwrap()).unwrap());
|
||||
}
|
||||
|
||||
/// Saved tracing presets are kept with the preferences, are not a preference key, and survive
|
||||
/// resetting the preferences.
|
||||
#[test]
|
||||
fn image_trace_saved_presets_persist_with_the_preferences() {
|
||||
assert!(Prefs::default().to_json().get("tracePresets").is_none(), "nothing saved while there are none");
|
||||
assert!(serde_json::from_value::<Prefs>(json!({"keyboardIncrement": 2.0})).unwrap().trace_presets.is_empty());
|
||||
let mut s = session();
|
||||
s.execute("imageTrace.presets.save", &json!({"name": "Keep", "preset": "3 Colors", "params": {"noise": 40}})).unwrap();
|
||||
let back: Prefs = serde_json::from_str(&serde_json::to_string(&s.prefs).unwrap()).unwrap();
|
||||
assert_eq!(back.trace_presets, s.prefs.trace_presets);
|
||||
let mut t = session();
|
||||
t.apply_prefs(back);
|
||||
image_doc(&mut t);
|
||||
let r = t.execute("imageTrace.make", &json!({"preset": "keep"})).unwrap();
|
||||
let t_node = node(&t, NodeId(r["id"].as_u64().unwrap()));
|
||||
let stored = t_node.extras().trace.clone().unwrap();
|
||||
assert_eq!(
|
||||
(stored["preset"].as_str(), stored["params"]["noise"].as_u64(), stored["params"]["colors"].as_u64()),
|
||||
(Some("Keep"), Some(40), Some(3))
|
||||
);
|
||||
assert!(t.execute("prefs.set", &json!({"key": "tracePresets", "value": []})).is_err());
|
||||
t.execute("prefs.reset", &json!({})).unwrap();
|
||||
assert_eq!(t.prefs.trace_presets.len(), 1);
|
||||
assert_eq!(t.execute("prefs.get", &json!({})).unwrap()["prefs"]["tracePresets"][0]["name"], "Keep");
|
||||
}
|
||||
|
||||
/// A preset or name that isn't a string is an error, and changes no saved preset.
|
||||
#[test]
|
||||
fn image_trace_presets_reject_names_that_are_not_strings() {
|
||||
let mut s = session();
|
||||
let img = image_doc(&mut s);
|
||||
s.execute("imageTrace.presets.save", &json!({"name": "Keep", "preset": "3 Colors"})).unwrap();
|
||||
let kept = s.prefs.trace_presets.clone();
|
||||
for p in [json!({"name": "Keep", "preset": 123}), json!({"name": 7}), json!({"name": "Keep", "newName": 5}), json!({"name": ["Keep"]})] {
|
||||
let e = s.execute("imageTrace.presets.save", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("must be a string"), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(s.prefs.trace_presets, kept, "nothing saved or changed");
|
||||
let e = s.execute("imageTrace.presets.delete", &json!({"name": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("`name` must be a string"), "{e}");
|
||||
let e = s.execute("imageTrace.make", &json!({"id": img.0, "preset": 3})).unwrap_err().to_string();
|
||||
assert!(e.contains("`preset` must be a string"), "{e}");
|
||||
}
|
||||
|
||||
/// The journal records a saved preset's settings, so a replay traces with them after the preset is
|
||||
/// changed or deleted.
|
||||
#[test]
|
||||
fn image_trace_replays_a_saved_preset_with_the_settings_it_had() {
|
||||
let mut s = session();
|
||||
let img = image_doc(&mut s);
|
||||
s.execute("imageTrace.presets.save", &json!({"name": "Keep", "params": {"ignoreWhite": true}})).unwrap();
|
||||
assert_eq!(s.execute("imageTrace.make", &json!({"id": img.0, "preset": "Keep"})).unwrap()["paths"], 1, "the white is left out");
|
||||
let (cmd, p) = s.journal.last().unwrap().clone();
|
||||
assert_eq!((cmd.as_str(), &p["savedPreset"]["ignoreWhite"]), ("imageTrace.make", &json!(true)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("imageTrace.presets.save", &json!({"name": "Keep", "params": {"ignoreWhite": false}})).unwrap();
|
||||
assert_ne!(s.execute("imageTrace.make", &json!({"id": img.0, "preset": "Keep"})).unwrap()["paths"], 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Changed: the replay traces as the first run did, and the object no longer names Keep.
|
||||
let r = s.execute(&cmd, &p).unwrap();
|
||||
assert_eq!(r["paths"], 1);
|
||||
assert_eq!(node(&s, NodeId(r["id"].as_u64().unwrap())).extras().trace.clone().unwrap()["preset"], "Custom");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Deleted.
|
||||
s.execute("imageTrace.presets.delete", &json!({"name": "Keep"})).unwrap();
|
||||
assert_eq!(s.execute(&cmd, &p).unwrap()["paths"], 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.execute("imageTrace.make", &json!({"id": img.0, "preset": "Keep", "savedPreset": 5})).is_err());
|
||||
// Recorded settings are checked as `params` are.
|
||||
let e = s
|
||||
.execute("imageTrace.make", &json!({"id": img.0, "preset": "Keep", "savedPreset": {"fills": false, "strokes": false}}))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(e.contains("Create needs fills, strokes or both"), "{e}");
|
||||
// A built-in preset records nothing.
|
||||
s.execute("imageTrace.make", &json!({"id": img.0, "preset": "6 Colors"})).unwrap();
|
||||
assert!(s.journal.last().unwrap().1.get("savedPreset").is_none());
|
||||
}
|
||||
|
||||
/// A soft-edged red disc (r = 30 px at (50.3, 49.6)) on transparency, placed at (50, 50) scaled ×2.
|
||||
fn soft_logo_doc(s: &mut Session) -> NodeId {
|
||||
let r = vectorcraft_trace::Raster::from_fn(100, 100, |x, y| {
|
||||
|
||||
@@ -150,6 +150,42 @@ fn import_image_then_paste_gives_an_image() {
|
||||
assert!(s.execute("clipboard.importImage", &json!({"dataBase64": "!!"})).is_err());
|
||||
}
|
||||
|
||||
/// A pasted PNG cut to half its length (a copied file still downloading) has a header but not
|
||||
/// all its pixels: importing it is an error, and the clipboard keeps what it held.
|
||||
#[test]
|
||||
fn a_bitmap_whose_pixels_cant_be_read_is_not_pasted() {
|
||||
let mut s = session();
|
||||
copy_red_square(&mut s);
|
||||
let held = s.clipboard.clone();
|
||||
let cut = crate::tests_links::cut_png(600, 300);
|
||||
let e = s.execute("clipboard.importImage", &json!({"dataBase64": vectorcraft_format::base64_encode(&cut), "mime": "image/png"})).unwrap_err();
|
||||
assert!(e.to_string().ends_with("the image's pixels can't be read"), "{e}");
|
||||
assert_eq!(s.clipboard.nodes, held.nodes);
|
||||
}
|
||||
|
||||
/// The pixels `clipboard.importImage` decodes to check a pasted bitmap are kept in the renderers'
|
||||
/// image store, so the first render of the pasted image draws them without decoding it again.
|
||||
#[test]
|
||||
fn a_pasted_bitmap_is_drawn_without_decoding_it_again() {
|
||||
// The image has pixels no other test uses, since every test shares the image store.
|
||||
let img = image::RgbImage::from_fn(300, 200, |x, y| image::Rgb([(x % 253) as u8, (y % 233) as u8, 17]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut s = session();
|
||||
run(&mut s, "clipboard.importImage", json!({"dataBase64": vectorcraft_format::base64_encode(&png), "mime": "image/png"}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
let n = &pasted(&s)[0];
|
||||
let NodeKind::Image(im) = &n.kind else { panic!("not an image: {:?}", n.kind) };
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
assert!(vectorcraft_render::image_cached(&d.images[&im.key]), "kept before it is drawn");
|
||||
let decodes = vectorcraft_render::image_decodes();
|
||||
let r = vectorcraft_render::Renderer::new().render(&d, 400, 300, vectorcraft_geom::Affine::IDENTITY, &Default::default());
|
||||
assert_eq!(vectorcraft_render::image_decodes(), decodes, "drawn without a decode");
|
||||
let at = im.xf * Point::new(150.0, 100.0);
|
||||
let px = r.pixel(at.x as u32, at.y as u32);
|
||||
assert!(px[3] > 0 && px[2] < 100, "the image is drawn at its middle: {px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_svgz_files_paste_as_svg() {
|
||||
let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\"><rect width=\"40\" height=\"40\" fill=\"#ff0000\"/></svg>";
|
||||
|
||||
@@ -218,7 +218,8 @@ fn an_images_stroke_with_outline_object_draws_exports_and_expands_along_its_fram
|
||||
});
|
||||
}
|
||||
assert_eq!((images, frames), (1, 1));
|
||||
// Expand Appearance leaves the stroke's outline (a filled path) below the image, in one step.
|
||||
// Expand Appearance leaves the frame, stroked 4 pt, below the image, in one step (the stroke
|
||||
// stays a stroke for Object › Expand to outline, #3089).
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
@@ -226,9 +227,11 @@ fn an_images_stroke_with_outline_object_draws_exports_and_expands_along_its_fram
|
||||
let kids = n.children().unwrap();
|
||||
assert_eq!(kids.len(), 2, "{n:?}");
|
||||
assert!(matches!(kids[1].kind, NodeKind::Image(_)), "{n:?}");
|
||||
let outline = kids[0].visual_bounds().unwrap();
|
||||
assert!((outline.x0 - 48.0).abs() < 0.01 && (outline.x1 - 82.0).abs() < 0.01, "{outline:?}");
|
||||
assert_eq!(kids[0].appearance.fill_paint(), Paint::solid(Color::BLACK));
|
||||
let frame = kids[0].visual_bounds().unwrap();
|
||||
assert!((frame.x0 - 48.0).abs() < 0.01 && (frame.x1 - 82.0).abs() < 0.01, "{frame:?}");
|
||||
assert_eq!(kids[0].appearance.stroke_paint(), Paint::solid(Color::BLACK));
|
||||
assert_eq!(kids[0].appearance.stroke_width(), 4.0);
|
||||
assert!(kids[0].appearance.fill_paint().is_none());
|
||||
}
|
||||
|
||||
/// Click with the Selection tool at (x, y) on a 100% view: what is selected afterwards.
|
||||
@@ -308,13 +311,13 @@ fn group_appearance_round_trips_and_expands() {
|
||||
let ch = n.children().unwrap();
|
||||
assert_eq!(ch.len(), 4, "fill, two members, stroke");
|
||||
// Each is a group of one path per member (sharing the item's opacity and blend mode); the
|
||||
// stroke's paths are its outlines, filled.
|
||||
// stroke's paths are stroked by it (#3089).
|
||||
let first = |n: &Node| n.children().unwrap()[0].appearance.items[0].is_fill();
|
||||
assert!(first(&ch[0]) && first(&ch[3]) && ch[0].children().unwrap().len() == 2);
|
||||
let outline = ch[3].children().unwrap()[0].geometric_bounds().unwrap();
|
||||
assert!((outline.width() - 41.0).abs() < 0.1, "{outline:?}");
|
||||
// Moved down 50 pt; the outlined 1 pt stroke reaches half a point beyond.
|
||||
assert!((n.geometric_bounds().unwrap().y0 - 69.5).abs() < 1e-6);
|
||||
assert!(first(&ch[0]) && !first(&ch[3]) && ch[0].children().unwrap().len() == 2);
|
||||
let stroked = ch[3].children().unwrap()[0].visual_bounds().unwrap();
|
||||
assert!((stroked.width() - 41.0).abs() < 0.1, "{stroked:?}");
|
||||
// Moved down 50 pt; the 1 pt stroke reaches half a point beyond.
|
||||
assert!((n.visual_bounds().unwrap().y0 - 69.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -69,9 +69,14 @@ fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() {
|
||||
let ch = n.children().expect("a group of the fills and the stroke");
|
||||
assert_eq!(ch.len(), 3);
|
||||
assert!(ch.iter().all(|c| matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && c.appearance.items.len() == 1));
|
||||
// In paint order: red fill, the outlined stroke (a filled ring 10 pt out), the green fill.
|
||||
assert!(ch.iter().all(|c| c.appearance.items[0].is_fill() && c.appearance.items[0].effects().is_empty()));
|
||||
// In paint order: red fill, the stroke on the path offset 10 pt out (still a 4 pt stroke, which
|
||||
// Object › Expand would outline, #3089), the green fill.
|
||||
assert!(ch.iter().all(|c| c.appearance.items[0].effects().is_empty()));
|
||||
assert!(ch[0].appearance.items[0].is_fill() && !ch[1].appearance.items[0].is_fill() && ch[2].appearance.items[0].is_fill());
|
||||
assert_eq!(ch[1].appearance.stroke_width(), 4.0);
|
||||
let ring = ch[1].geometric_bounds().unwrap();
|
||||
assert!((ring.width() - 120.0).abs() < 0.5, "{ring:?}");
|
||||
let ring = ch[1].visual_bounds().unwrap();
|
||||
assert!((ring.width() - 124.0).abs() < 0.5, "{ring:?}");
|
||||
assert!((ch[2].opacity - 0.5).abs() < 1e-6, "the fill's opacity is the path's own");
|
||||
assert!(ch[2].appearance.is_basic());
|
||||
@@ -149,11 +154,11 @@ fn expanded_art_keeps_the_random_transforms_the_canvas_drew() {
|
||||
art = (*art.children().unwrap()[0]).clone();
|
||||
assert_eq!((&r["ids"][0], &r["ids"][1]), (&json!(id.0), &json!(art.id.0)), "{case}");
|
||||
}
|
||||
// No object keeps an effect, and in the copies cases no object keeps a stroke: every copy
|
||||
// is expanded.
|
||||
// No object keeps an effect, and in the copies cases every object has a basic appearance:
|
||||
// every copy is expanded (its stroke a stroked path of its own, #3089).
|
||||
art.walk(&mut |n| {
|
||||
let stroked = case.ends_with("copies") && n.appearance.stroke().is_some_and(|st| !st.paint.is_none());
|
||||
assert!(n.appearance.effects.is_empty() && !stroked, "{case}: {n:?}");
|
||||
let unexpanded = case.ends_with("copies") && !n.appearance.is_basic();
|
||||
assert!(n.appearance.effects.is_empty() && !unexpanded, "{case}: {n:?}");
|
||||
});
|
||||
}
|
||||
assert_eq!(differ, RANDOM_CASES.map(|case| (case, 0)));
|
||||
@@ -276,8 +281,13 @@ fn a_targeted_layer_with_a_fill_expands_into_art_among_its_members() {
|
||||
let ch = l.children().unwrap();
|
||||
assert_eq!(ch.len(), 2, "the square, then the layer's stroke above it");
|
||||
assert_eq!(ch[0].id, a);
|
||||
// The layer's stroke is outlined: a filled path.
|
||||
assert!(ch[1].appearance.items.iter().all(|i| i.is_fill()));
|
||||
// The layer's stroke stays a stroke, on a path of its own (#3089).
|
||||
let mut six = 0;
|
||||
ch[1].walk(&mut |n| {
|
||||
assert!(n.appearance.items.iter().all(|i| !i.is_fill()), "{n:?}");
|
||||
six += usize::from(n.appearance.stroke_width() == 6.0);
|
||||
});
|
||||
assert!(six > 0, "the layer's 6 pt stroke: {:?}", ch[1]);
|
||||
let after = render(&s);
|
||||
assert!(differing(&before, &after, 8) < 200, "{} pixels differ", differing(&before, &after, 8));
|
||||
}
|
||||
@@ -331,3 +341,63 @@ fn a_blurred_layer_keeps_its_image_as_its_only_member() {
|
||||
let ch = l.children().unwrap();
|
||||
assert!(ch.len() == 1 && matches!(ch[0].kind, NodeKind::Image(_)));
|
||||
}
|
||||
|
||||
/// A selected open curve with no fill, stroked in blue with `stroke` (stroke.set's params) and
|
||||
/// given a Zig Zag (#3089).
|
||||
fn zig_zag_curve(s: &mut Session, stroke: Value) -> NodeId {
|
||||
let id = NodeId(run(s, "path.create", json!({"d": "M 30 100 C 70 40 130 160 170 100"}))["id"].as_u64().unwrap());
|
||||
run(s, "paint.setFill", json!({"none": true}));
|
||||
run(s, "paint.setStroke", json!({"color": "#0000ff"}));
|
||||
run(s, "stroke.set", stroke);
|
||||
run(s, "effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 8, "ridges": 5, "relative": false}}));
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_stays_a_stroke_with_its_dashes_arrowheads_alignment_and_width_profile() {
|
||||
for stroke in [
|
||||
json!({"weight": 4, "cap": "round", "join": "round", "dash": [10, 5]}),
|
||||
json!({"weight": 3, "startArrow": "Arrow", "endArrow": "Circle"}),
|
||||
json!({"weight": 8, "profile": "lens"}),
|
||||
json!({"weight": 6, "align": "inside"}),
|
||||
] {
|
||||
let mut s = session();
|
||||
let id = zig_zag_curve(&mut s, stroke.clone());
|
||||
let was = node(&s, id).appearance.stroke().unwrap().clone();
|
||||
let before = render(&s);
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
// The zig-zag is baked into the path, which keeps the very same stroke and no fill: no outline.
|
||||
let n = node(&s, id);
|
||||
assert!(matches!(n.kind, NodeKind::Path { .. }), "{stroke}: a path, not a group: {:?}", n.kind);
|
||||
assert!(n.appearance.effects.is_empty() && n.appearance.is_basic(), "{stroke}");
|
||||
assert_eq!(n.appearance.stroke(), Some(&was), "{stroke}: the stroke's attributes stay");
|
||||
assert!(n.appearance.fill().is_none_or(|f| f.paint.is_none()), "{stroke}: no fill");
|
||||
assert!(n.path_data().unwrap().anchors().count() > 4, "{stroke}: the zig-zag is baked");
|
||||
let after = render(&s);
|
||||
assert!(differing(&before, &after, 8) < 40, "{stroke}: {} pixels differ", differing(&before, &after, 8));
|
||||
assert!(!expandable(&s), "{stroke}: nothing left for Expand Appearance");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_strokes_become_stroked_paths_in_appearance_order() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
// [red fill, 4 pt blue stroke, 12 pt green stroke] with a Zig Zag on the object.
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "stroke.set", json!({"item": 2, "weight": 12}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#00ff00"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 6, "ridges": 4, "relative": false}}));
|
||||
let items = node(&s, id).appearance.items.clone();
|
||||
let before = render(&s);
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let n = node(&s, id);
|
||||
let ch = n.children().expect("a group of the fill and the two strokes");
|
||||
assert_eq!(ch.len(), 3);
|
||||
for (c, item) in ch.iter().zip(&items) {
|
||||
assert!(matches!(c.kind, NodeKind::Path { .. }) && c.appearance.items.len() == 1, "{:?}", c.kind);
|
||||
assert_eq!(&c.appearance.items[0], item, "each piece keeps its own fill or stroke, in appearance order");
|
||||
}
|
||||
let after = render(&s);
|
||||
assert!(differing(&before, &after, 8) < 60, "{} pixels differ", differing(&before, &after, 8));
|
||||
}
|
||||
|
||||
@@ -123,6 +123,22 @@ fn alt_click_applies_the_selection_to_the_clicked_object() {
|
||||
assert!(s.execute("appearance.copyFrom", &json!({"source": src.0, "reverse": true})).is_err(), "nothing to copy from");
|
||||
}
|
||||
|
||||
/// #4382: with nothing selected, a click picks up an object's attributes and an Alt-click puts
|
||||
/// them on the object clicked, as the reference app's Eyedropper does.
|
||||
#[test]
|
||||
fn alt_click_with_nothing_selected_applies_what_was_picked_up() {
|
||||
let mut s = session();
|
||||
let (src, dst) = source_and_target(&mut s);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
click(&mut s, 325.0, 35.0, Mods::default());
|
||||
// The source changes afterwards: what was picked up stays as it was.
|
||||
s.execute("paint.setFill", &json!({"ids": [src.0], "color": "#00ff00"})).unwrap();
|
||||
click(&mut s, 35.0, 35.0, Mods { alt: true, ..Default::default() });
|
||||
let n = node(&s, dst);
|
||||
assert_eq!((n.appearance.fill_paint().color().unwrap().to_hex(), n.appearance.stroke_width(), n.opacity), ("#ff0000".into(), 5.0, 0.5));
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty(), "nothing gets selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_alt_click_appends_the_appearance() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -30,9 +30,14 @@ fn session_for(root: &Path) -> Session {
|
||||
s
|
||||
}
|
||||
|
||||
/// `text.findFontFiles {}` once the search ended (10 seconds at most).
|
||||
/// How long a test waits for a search to end or to read a file. The wait only ends a test whose
|
||||
/// search never ends. A machine busy with other builds can hold a walker for seconds. It is shorter than a
|
||||
/// search's default `maxSeconds` of 60, which would end a search that does not end by itself.
|
||||
const WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// `text.findFontFiles {}` once the search ended ([`WAIT`] at most).
|
||||
fn finished(s: &mut Session) -> Value {
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
let until = Instant::now() + WAIT;
|
||||
loop {
|
||||
let r = s.execute("text.findFontFiles", &json!({})).unwrap();
|
||||
if r["state"] != "searching" || Instant::now() > until {
|
||||
@@ -262,7 +267,7 @@ fn stop_ends_only_the_search_it_names() {
|
||||
let search = findfiles::start(root.clone(), s.search_rules.clone(), Limits::default(), 1, 1, Arc::new(Blocked(Mutex::new(rx)))).unwrap();
|
||||
let fonts = vec![Sought { family: "A".into(), style: "Regular".into(), status: FontMatch::Missing }];
|
||||
s.font_search = Some(FontSearch { id: 7, folder: root.to_string_lossy().into_owned(), fonts, search });
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
let until = Instant::now() + WAIT;
|
||||
while s.execute("text.findFontFiles", &json!({})).unwrap()["searched"]["fontFiles"] != 1 && Instant::now() < until {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
|
||||
@@ -111,6 +111,293 @@ fn colours_come_from_the_selection_and_paint_it() {
|
||||
assert!(s.execute("library.use", &json!({"library": lib, "kind": "fillColor", "item": "#nope"})).is_err());
|
||||
}
|
||||
|
||||
/// The fill (`stroke`: stroke) paint of object `id`.
|
||||
fn paint_of(s: &Session, id: &Value, stroke: bool) -> vectorcraft_color::Paint {
|
||||
let n = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone();
|
||||
if stroke { n.appearance.stroke().unwrap().paint.clone() } else { n.appearance.fill().unwrap().paint.clone() }
|
||||
}
|
||||
|
||||
/// The gradient of paint `p`.
|
||||
fn gradient(p: &vectorcraft_color::Paint) -> &vectorcraft_color::GradientPaint {
|
||||
match p {
|
||||
vectorcraft_color::Paint::Gradient(g) => g,
|
||||
other => panic!("not a gradient: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient added from a placed fill keeps its stops, kind and angle without the placement, and
|
||||
/// paints another object's fill or stroke with the same stops; a gradient swatch adds under its
|
||||
/// name.
|
||||
#[test]
|
||||
fn a_gradient_goes_into_a_library_and_paints_another_object() {
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
|
||||
let a = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20}))["id"].clone();
|
||||
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 0.4, "color": "#00ff00", "opacity": 0.5, "midpoint": 0.3}, {"offset": 1, "color": "#0000ff"}]);
|
||||
let g = json!({"kind": "radial", "stops": stops, "start": [0, 0], "end": [40, 20]});
|
||||
run(&mut s, "paint.setFill", json!({"gradient": g, "ids": [a]}));
|
||||
run(&mut s, "select.set", json!({"ids": [a]}));
|
||||
assert!(s.execute("library.add", &json!({"library": lib, "kind": "strokeGradient"})).is_err(), "its stroke is solid");
|
||||
let added = run(&mut s, "library.add", json!({"library": lib, "kind": "fillGradient"}));
|
||||
assert_eq!((added["name"].as_str(), added["existing"].as_bool()), (Some("Gradient"), Some(false)));
|
||||
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "fillGradient"}))["existing"], true);
|
||||
let source = gradient(&paint_of(&s, &a, false)).clone();
|
||||
// In another document: a rectangle's fill, then its stroke.
|
||||
run(&mut s, "file.new", json!({"width": 300, "height": 300}));
|
||||
let b = run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 80, "height": 80}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [b]}));
|
||||
let r = run(&mut s, "library.use", json!({"library": lib, "kind": "fillGradient", "item": "Gradient"}));
|
||||
assert_eq!(r["kind"], "fillGradient");
|
||||
let fill = gradient(&paint_of(&s, &b, false)).clone();
|
||||
assert_eq!(fill.gradient, source.gradient, "the same kind and stops");
|
||||
assert_eq!((fill.angle, fill.geom), (source.angle, None), "fitted to the object it paints");
|
||||
run(&mut s, "library.use", json!({"library": lib, "kind": "fillGradient", "item": "Gradient", "to": "stroke"}));
|
||||
assert_eq!(gradient(&paint_of(&s, &b, true)).gradient, source.gradient);
|
||||
// A gradient swatch adds under its name.
|
||||
run(
|
||||
&mut s,
|
||||
"swatch.new",
|
||||
json!({"name": "Sunset", "gradient": {"stops": [{"offset": 0, "color": "#ffcc00"}, {"offset": 1, "color": "#cc3300"}]}}),
|
||||
);
|
||||
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "fillGradient", "swatch": "Sunset"}))["name"], "Sunset");
|
||||
assert!(s.execute("library.add", &json!({"library": lib, "kind": "fillGradient", "swatch": "White"})).is_err(), "not a gradient swatch");
|
||||
let got = run(&mut s, "library.get", json!({"library": lib}));
|
||||
let names: Vec<&str> = got["gradients"].as_array().unwrap().iter().map(|g| g["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Gradient", "Sunset"]);
|
||||
assert!(s.execute("library.use", &json!({"library": lib, "kind": "fillGradient", "item": "Nope"})).is_err());
|
||||
}
|
||||
|
||||
/// A gradient added from an object that was rotated keeps the direction it shows there.
|
||||
#[test]
|
||||
fn a_gradient_from_rotated_art_keeps_its_direction() {
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
|
||||
let a = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20}))["id"].clone();
|
||||
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
|
||||
run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}, "ids": [a]}));
|
||||
run(&mut s, "select.set", json!({"ids": [a]}));
|
||||
run(&mut s, "object.rotate", json!({"angle": 90}));
|
||||
let shown = gradient(&paint_of(&s, &a, false)).geom.expect("placed by the rotation").angle_deg();
|
||||
assert!((shown.abs() - 90.0).abs() < 1e-6, "{shown}");
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "fillGradient"}));
|
||||
let got = run(&mut s, "library.get", json!({"library": lib}));
|
||||
let angle = got["gradients"][0]["gradient"]["angle"].as_f64().unwrap();
|
||||
assert!((angle - shown).abs() < 1e-6, "{angle} vs {shown}");
|
||||
}
|
||||
|
||||
/// Type's fill is its characters' paint: a gradient or a color on selected type is what it adds,
|
||||
/// not the default for new art.
|
||||
#[test]
|
||||
fn type_adds_the_paint_of_its_characters() {
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
|
||||
let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Hi"}))["id"].clone();
|
||||
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
|
||||
run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}, "ids": [t]}));
|
||||
run(&mut s, "paint.setStroke", json!({"color": "#00ff00", "ids": [t]}));
|
||||
// The defaults for new art differ from the type's paint.
|
||||
run(&mut s, "select.set", json!({"ids": []}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#123456"}));
|
||||
run(&mut s, "paint.setStroke", json!({"color": "#654321"}));
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "fillGradient"}));
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "strokeColor"}));
|
||||
let got = run(&mut s, "library.get", json!({"library": lib}));
|
||||
assert_eq!(got["gradients"][0]["gradient"]["gradient"]["stops"].as_array().map(Vec::len), Some(2), "{got}");
|
||||
assert_eq!(got["colors"][0]["name"].as_str().map(str::to_lowercase).as_deref(), Some("#00ff00"), "{got}");
|
||||
// Rotated type: the gradient keeps the direction it shows in the document.
|
||||
run(&mut s, "object.rotate", json!({"angle": 90}));
|
||||
let shown = s.doc().unwrap().doc.node(NodeId(t.as_u64().unwrap())).unwrap().proxy_gradient(false, None).unwrap().1.angle_deg();
|
||||
assert!((shown.abs() - 90.0).abs() < 1e-6, "{shown}");
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "fillGradient"}));
|
||||
let got = run(&mut s, "library.get", json!({"library": lib}));
|
||||
let angle = got["gradients"].as_array().unwrap().last().unwrap()["gradient"]["angle"].as_f64().unwrap();
|
||||
assert!((angle - shown).abs() < 1e-6, "{angle} vs {shown}");
|
||||
}
|
||||
|
||||
/// Pattern `name` made from object `id`, in a 20-point tile.
|
||||
fn make_pattern(s: &mut Session, id: &Value, name: &str) {
|
||||
run(s, "select.set", json!({"ids": [id]}));
|
||||
run(s, "object.pattern.make", json!({"name": name, "width": 20, "height": 20}));
|
||||
run(s, "object.pattern.done", json!({}));
|
||||
}
|
||||
|
||||
/// A pattern added from a fill comes with its art; used in a new document it defines the pattern
|
||||
/// and its swatch and paints the selection in one undo step, used again it reuses that pattern,
|
||||
/// and a document whose pattern of that name differs gets it under another name.
|
||||
#[test]
|
||||
fn a_pattern_item_defines_its_pattern_in_a_new_document() {
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
|
||||
let dot = run(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#cc3300", "ids": [dot]}));
|
||||
make_pattern(&mut s, &dot, "Dots");
|
||||
let a = run(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 40, "height": 40}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Dots", "ids": [a]}));
|
||||
run(&mut s, "select.set", json!({"ids": [a]}));
|
||||
assert!(s.execute("library.add", &json!({"library": lib, "kind": "strokePattern"})).is_err(), "its stroke is solid");
|
||||
let added = run(&mut s, "library.add", json!({"library": lib, "kind": "fillPattern"}));
|
||||
assert_eq!((added["name"].as_str(), added["existing"].as_bool()), (Some("Dots"), Some(false)));
|
||||
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "fillPattern", "swatch": "Dots"}))["existing"], true);
|
||||
let got = run(&mut s, "library.get", json!({"library": lib}));
|
||||
assert!(!got["patterns"][0]["thumbnail"].as_str().unwrap().is_empty(), "a thumbnail of the pattern");
|
||||
let art = s.doc().unwrap().doc.pattern("Dots").unwrap().art.clone();
|
||||
// A new document has no pattern until the item is used.
|
||||
run(&mut s, "file.new", json!({"width": 300, "height": 300}));
|
||||
let b = run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 80, "height": 80}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [b]}));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert!(s.doc().unwrap().doc.pattern("Dots").is_none());
|
||||
let r = run(&mut s, "library.use", json!({"library": lib, "kind": "fillPattern", "item": "Dots"}));
|
||||
assert_eq!((r["kind"].as_str(), r["pattern"].as_str()), (Some("fillPattern"), Some("Dots")));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.pattern("Dots").unwrap().art.len(), art.len());
|
||||
assert!(d.swatch("Dots").is_some(), "its swatch comes along");
|
||||
assert!(matches!(paint_of(&s, &b, false), vectorcraft_color::Paint::Pattern { ref pattern, .. } if pattern == "Dots"));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
// Used again, on the stroke: the same pattern.
|
||||
run(&mut s, "library.use", json!({"library": lib, "kind": "fillPattern", "item": "Dots", "to": "stroke"}));
|
||||
assert!(matches!(paint_of(&s, &b, true), vectorcraft_color::Paint::Pattern { ref pattern, .. } if pattern == "Dots"));
|
||||
assert_eq!(s.doc().unwrap().doc.patterns.len(), 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().doc.pattern("Dots").is_none(), "undone with the paint");
|
||||
// Another document's own "Dots" stays; the library's comes in under another name.
|
||||
run(&mut s, "file.new", json!({"width": 300, "height": 300}));
|
||||
let sq = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 6, "height": 6}))["id"].clone();
|
||||
// Not the library's pattern, which the default fill and stroke now name.
|
||||
run(&mut s, "paint.setFill", json!({"color": "#0044aa", "ids": [sq]}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true, "ids": [sq]}));
|
||||
make_pattern(&mut s, &sq, "Dots");
|
||||
let c = run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 80, "height": 80}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [c]}));
|
||||
let r = run(&mut s, "library.use", json!({"library": lib, "kind": "fillPattern", "item": "Dots"}));
|
||||
let renamed = r["pattern"].as_str().unwrap().to_string();
|
||||
assert_ne!(renamed, "Dots");
|
||||
assert!(matches!(paint_of(&s, &c, false), vectorcraft_color::Paint::Pattern { ref pattern, .. } if *pattern == renamed));
|
||||
assert_eq!(s.doc().unwrap().doc.patterns.len(), 2);
|
||||
}
|
||||
|
||||
/// A pattern used in a document that already holds it paints with the pattern even when a color
|
||||
/// swatch there has the pattern's name (an imported SVG pattern can be named `White`).
|
||||
#[test]
|
||||
fn a_pattern_item_paints_its_pattern_over_a_color_swatch_of_its_name() {
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
|
||||
let dot = run(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#cc3300", "ids": [dot]}));
|
||||
make_pattern(&mut s, &dot, "Dots");
|
||||
let a = run(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 40, "height": 40}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Dots", "ids": [a]}));
|
||||
run(&mut s, "select.set", json!({"ids": [a]}));
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "fillPattern"}));
|
||||
// The swatch named `Dots` becomes a solid color; the pattern of that name stays.
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
for sw in &mut doc.swatches {
|
||||
if sw.name == "Dots" {
|
||||
sw.paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0));
|
||||
}
|
||||
}
|
||||
assert!(doc.pattern("Dots").is_some());
|
||||
let b = run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 40, "height": 40}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [b]}));
|
||||
let r = run(&mut s, "library.use", json!({"library": lib, "kind": "fillPattern", "item": "Dots"}));
|
||||
let used = r["pattern"].as_str().unwrap().to_string();
|
||||
let paint = paint_of(&s, &b, false);
|
||||
assert!(matches!(paint, vectorcraft_color::Paint::Pattern { ref pattern, .. } if *pattern == used), "{paint:?}");
|
||||
}
|
||||
|
||||
/// A pattern used in a document that holds it without a swatch (as an imported SVG can) gets its
|
||||
/// swatch, in the same undo step as the paint.
|
||||
#[test]
|
||||
fn a_pattern_item_adds_the_swatch_of_a_pattern_the_document_holds_without_one() {
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
|
||||
let dot = run(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#cc3300", "ids": [dot]}));
|
||||
make_pattern(&mut s, &dot, "Dots");
|
||||
let a = run(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 40, "height": 40}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Dots", "ids": [a]}));
|
||||
run(&mut s, "select.set", json!({"ids": [a]}));
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "fillPattern"}));
|
||||
let is_dots = |w: &vectorcraft_color::Swatch| matches!(&w.paint, vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots");
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).swatches.retain(|w| !is_dots(w));
|
||||
let b = run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 40, "height": 40}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [b]}));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "library.use", json!({"library": lib, "kind": "fillPattern", "item": "Dots"}));
|
||||
assert_eq!(s.doc().unwrap().doc.patterns.len(), 1, "the pattern there is reused");
|
||||
assert!(s.doc().unwrap().doc.swatches_iter().any(is_dots), "its swatch is added");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.doc().unwrap().doc.swatches_iter().any(is_dots), "undone with the paint");
|
||||
}
|
||||
|
||||
/// Gradients and patterns go out to a library file and come back; read from a file, a gradient
|
||||
/// with too few stops is dropped, one out of range is put in range, and a pattern whose data is
|
||||
/// damaged or doesn't hold its pattern fails cleanly when used.
|
||||
#[test]
|
||||
fn library_gradients_and_patterns_round_trip_and_damaged_ones_fail_cleanly() {
|
||||
let mut s = session();
|
||||
run(&mut s, "library.create", json!({"name": "Brand"}));
|
||||
let a = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].clone();
|
||||
make_pattern(&mut s, &a, "Squares");
|
||||
run(
|
||||
&mut s,
|
||||
"swatch.new",
|
||||
json!({"name": "Sunset", "gradient": {"stops": [{"offset": 0, "color": "#ffcc00"}, {"offset": 1, "color": "#cc3300"}]}}),
|
||||
);
|
||||
run(&mut s, "library.add", json!({"kind": "fillPattern", "swatch": "Squares"}));
|
||||
run(&mut s, "library.add", json!({"kind": "fillGradient", "swatch": "Sunset"}));
|
||||
run(
|
||||
&mut s,
|
||||
"library.createGroup",
|
||||
json!({"name": "Fills", "items": [{"kind": "gradient", "item": "Sunset"}, {"kind": "pattern", "item": "Squares"}]}),
|
||||
);
|
||||
let data = run(&mut s, "library.export", json!({}))["data"].as_str().unwrap().to_string();
|
||||
let r = run(&mut s, "library.import", json!({"data": data}));
|
||||
assert_eq!((r["gradients"].as_u64(), r["patterns"].as_u64(), r["groups"].as_u64()), (Some(1), Some(1), Some(1)));
|
||||
let (x, y) = (run(&mut s, "library.get", json!({"library": "Brand"})), run(&mut s, "library.get", json!({"library": r["id"]})));
|
||||
for k in ["gradients", "patterns", "groups"] {
|
||||
assert_eq!(x[k], y[k], "{k}");
|
||||
}
|
||||
run(&mut s, "file.new", json!({"width": 100, "height": 100}));
|
||||
run(&mut s, "library.use", json!({"library": r["id"], "kind": "fillPattern", "item": "Squares"}));
|
||||
assert!(s.doc().unwrap().doc.pattern("Squares").is_some(), "an imported pattern defines itself");
|
||||
let stop = |o: f64| json!({"offset": o, "color": {"model": "rgb", "r": 1, "g": 0, "b": 0}});
|
||||
let paint = |stops: Vec<Value>| json!({"gradient": {"kind": "Linear", "stops": stops}, "angle": 30});
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let empty = vectorcraft_format::base64_encode(&vectorcraft_format::save(&doc, false));
|
||||
let messy = json!({
|
||||
"name": "Messy",
|
||||
"gradients": [
|
||||
{"name": "One", "gradient": paint(vec![stop(0.0)])},
|
||||
{"name": "Wild", "gradient": paint(vec![stop(7.0), {let mut v = stop(-2.0); v["midpoint"] = json!(5); v["opacity"] = json!(-1); v}])}
|
||||
],
|
||||
"patterns": [
|
||||
{"name": "Broken", "pattern": "P", "data": "%%%"},
|
||||
{"name": "Missing", "pattern": "Nowhere", "data": empty}
|
||||
],
|
||||
"groups": [{"name": "G", "items": [{"kind": "gradient", "item": "One"}, {"kind": "gradient", "item": "Wild"}]}]
|
||||
});
|
||||
let r = run(&mut s, "library.import", json!({"data": messy.to_string()}));
|
||||
assert_eq!((r["gradients"].as_u64(), r["patterns"].as_u64()), (Some(1), Some(2)));
|
||||
let got = run(&mut s, "library.get", json!({}));
|
||||
let wild = &got["gradients"][0]["gradient"];
|
||||
assert_eq!(wild["gradient"]["stops"][0]["offset"], 0.0);
|
||||
assert!((wild["gradient"]["stops"][0]["midpoint"].as_f64().unwrap() - 0.87).abs() < 1e-6, "{wild}");
|
||||
assert_eq!(wild["gradient"]["stops"][0]["opacity"], 0.0);
|
||||
assert_eq!(wild["gradient"]["stops"][1]["offset"], 1.0);
|
||||
assert_eq!(got["groups"], json!([{"name": "G", "items": [{"kind": "gradient", "item": "Wild"}]}]));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for item in ["Broken", "Missing"] {
|
||||
let e = s.execute("library.use", &json!({"kind": "fillPattern", "item": item})).unwrap_err().to_string();
|
||||
assert!(e.contains("the library item is damaged"), "{item}: {e}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing changed");
|
||||
run(&mut s, "library.use", json!({"kind": "fillGradient", "item": "Wild"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_styles_come_from_the_selected_type_and_apply_in_one_step() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -42,9 +42,14 @@ fn placed(s: &mut Session, path: &Path) -> u64 {
|
||||
place(s, &path.to_string_lossy()).0
|
||||
}
|
||||
|
||||
/// `links.findFiles {}` once the search ended (10 seconds at most).
|
||||
/// How long a test waits for a search to end or to read a file. The wait only ends a test whose
|
||||
/// search never ends. A machine busy with other builds can hold a walker for seconds. It is shorter than a
|
||||
/// search's default `maxSeconds` of 60, which would end a search that does not end by itself.
|
||||
const WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// `links.findFiles {}` once the search ended ([`WAIT`] at most).
|
||||
fn finished(s: &mut Session) -> Value {
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
let until = Instant::now() + WAIT;
|
||||
loop {
|
||||
let r = s.execute("links.findFiles", &json!({})).unwrap();
|
||||
if r["state"] != "searching" || Instant::now() > until {
|
||||
@@ -206,7 +211,7 @@ fn stop_ends_only_the_search_it_names() {
|
||||
let search = findfiles::start(root.clone(), s.search_rules.clone(), Limits::default(), 1, 2, Arc::new(Blocked(Mutex::new(rx)))).unwrap();
|
||||
let links = vec![Sought { name: "a.png".into(), path: "/gone/a.png".into(), ids: vec![], size: None, hash: None }];
|
||||
s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), links, sizes: Arc::default(), search });
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
let until = Instant::now() + WAIT;
|
||||
while s.execute("links.findFiles", &json!({})).unwrap()["searched"]["read"] != 1 && Instant::now() < until {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
|
||||
@@ -255,6 +255,45 @@ fn a_missing_link_renders_its_preview_and_relinks() {
|
||||
assert!(s.execute("links.relink", &json!({"ids": [id.0]})).is_err(), "path or folder");
|
||||
}
|
||||
|
||||
/// A `w`×`h` PNG of noise cut to half its length: its header reads, its pixels don't.
|
||||
pub(crate) fn cut_png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbImage::from_fn(w, h, |x, y| {
|
||||
let v = x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(40_503).rotate_left(13);
|
||||
image::Rgb([v as u8, (v >> 8) as u8, (v >> 16) as u8])
|
||||
});
|
||||
let mut out = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out.truncate(out.len() / 2);
|
||||
out
|
||||
}
|
||||
|
||||
/// A linked file whose pixels can't be read (cut short) isn't shown: Relink is an error, and
|
||||
/// Update Links and Open (Update Links: Automatically) count it as missing. The image keeps what
|
||||
/// it showed.
|
||||
#[test]
|
||||
fn a_linked_file_whose_pixels_cant_be_read_is_not_shown() {
|
||||
let dir = Folder::new("cut");
|
||||
let pic = dir.file("photo.png");
|
||||
write(&pic, &png(600, 300, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &pic);
|
||||
let shown = image(&s, id);
|
||||
let doc_path = dir.file("poster.vectorcraft");
|
||||
save(&mut s, &doc_path);
|
||||
let cut = dir.file("cut.png");
|
||||
write(&cut, &cut_png(300, 100));
|
||||
let e = s.execute("links.relink", &json!({"ids": [id.0], "path": cut})).unwrap_err();
|
||||
assert!(e.to_string().ends_with("the image's pixels can't be read"), "{e}");
|
||||
write(&pic, &cut_png(300, 100));
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [], "missing": [id.0]}));
|
||||
assert_eq!(image(&s, id), shown);
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||||
s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap();
|
||||
let r = open(&mut s, &doc_path);
|
||||
assert_eq!((r["missingLinks"][0]["ids"].clone(), r["updatedLinks"].clone()), (json!([id.0]), json!([])), "{r}");
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "the image still shows its preview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_modified_link_follows_the_update_links_preference() {
|
||||
let dir = Folder::new("modified");
|
||||
|
||||
@@ -68,7 +68,8 @@ fn an_image_too_large_to_draw_is_reported() {
|
||||
|
||||
/// A gray image is counted as RGBA, 4 bytes a pixel: 23,171 × 23,170 1-bit pixels decode to 537 MB
|
||||
/// of gray and are 2,147,488,280 bytes as RGBA, past the 2 GiB allowance (shown at reduced size);
|
||||
/// 23,170 × 23,170 are 2,147,395,600 bytes, within it.
|
||||
/// 23,170 × 23,170 are 2,147,395,600 bytes, within it. The file within the allowance holds no
|
||||
/// pixels, so Place decodes it and refuses it without the note.
|
||||
#[test]
|
||||
fn a_gray_image_too_large_as_rgba_is_reported_on_place() {
|
||||
const NOTE: &str = "an image of 23171 × 23170 pixels is shown at reduced size";
|
||||
@@ -78,10 +79,12 @@ fn a_gray_image_too_large_as_rgba_is_reported_on_place() {
|
||||
write(&within, &png_header(23_170, 23_170));
|
||||
let mut s = session();
|
||||
assert_eq!(run(&mut s, "file.place", json!({"path": over, "at": [200, 150], "link": false}))["warnings"], json!([NOTE]));
|
||||
assert_eq!(run(&mut s, "file.place", json!({"path": within, "at": [20, 20], "link": false}))["warnings"], json!([]));
|
||||
assert_eq!(run(&mut s, "file.place.info", json!({"path": within}))["warnings"], json!([]), "the image within the allowance has nothing to say");
|
||||
let e = s.execute("file.place", &json!({"path": within, "at": [20, 20], "link": false})).unwrap_err();
|
||||
assert_eq!(e.to_string(), "the image's pixels can't be read");
|
||||
let l = run(&mut s, "links.list", json!({}));
|
||||
let notes: Vec<&Value> = l["links"].as_array().unwrap().iter().map(|r| &r["note"]).collect();
|
||||
assert_eq!(notes, [&Value::Null, &json!(NOTE)], "top first: the image within the allowance has nothing to say");
|
||||
assert_eq!(notes, [&json!(NOTE)]);
|
||||
}
|
||||
|
||||
/// File › Open says the same of a gray image past the allowance as RGBA, and nothing of one within it.
|
||||
@@ -182,6 +185,24 @@ fn unembed_writes_the_bytes_and_links_to_them() {
|
||||
assert!(s.execute("links.unembed", &json!({"id": id.0, "path": dir.file("photo.psd")})).is_err(), "not a format it writes");
|
||||
}
|
||||
|
||||
/// An embedded image whose pixels can't be read (a document stores its bytes without decoding
|
||||
/// them) is not unembedded, and no file is written.
|
||||
#[test]
|
||||
fn unembed_of_pixels_that_cant_be_read_writes_no_file() {
|
||||
let dir = Folder::new("unembed-unread");
|
||||
let pic = dir.file("photo.png");
|
||||
write(&pic, &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
let id = place_embedded(&mut s, &pic);
|
||||
let key = image(&s, id).key;
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).images.insert(key, vectorcraft_doc::ImageBlob::png(png_header(60, 30)));
|
||||
let out = dir.file("out.png");
|
||||
let e = s.execute("links.unembed", &json!({"id": id.0, "path": out})).unwrap_err();
|
||||
assert!(e.to_string().ends_with("the image's pixels can't be read"), "{e}");
|
||||
assert!(!std::path::Path::new(&out).exists(), "no file left behind");
|
||||
assert!(image(&s, id).link.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_selects_the_image() {
|
||||
let dir = Folder::new("goto");
|
||||
|
||||
@@ -38,8 +38,8 @@ fn sel(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
/// A placed 64 × 32 px PNG whose header reads (so it places at its size) but whose pixel data is
|
||||
/// cut short: the renderer can't decode it and draws nothing.
|
||||
/// A placed 64 × 32 px PNG whose header reads but whose pixel data is cut short
|
||||
/// ([`cut_pixels`]): the renderer can't decode it and draws nothing.
|
||||
pub(crate) fn place_undecodable_png(s: &mut Session) -> NodeId {
|
||||
place_undecodable_png_sized(s, 64, 32)
|
||||
}
|
||||
@@ -51,10 +51,34 @@ pub(crate) fn place_undecodable_png_sized(s: &mut Session, w: u32, h: u32) -> No
|
||||
image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
let id = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
cut_pixels(s, id);
|
||||
id
|
||||
}
|
||||
|
||||
/// Cut the pixel data of the PNG that placed image `id` shows short, right after its first `IDAT`
|
||||
/// chunk's type, as a document saved with a damaged image holds it: the header still reads, but
|
||||
/// the renderer can't decode the image. Place refuses such a file, so tests cut a placed one. The
|
||||
/// image is stored under the key of its new bytes.
|
||||
pub(crate) fn cut_pixels(s: &mut Session, id: NodeId) {
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let Some(NodeKind::Image(im)) = doc.node(id).map(|n| &n.kind) else { panic!("{id:?} is not an image") };
|
||||
let old = im.key.clone();
|
||||
let mut png = doc.images[&old].bytes.to_vec();
|
||||
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
|
||||
png.truncate(idat + 8);
|
||||
let r = s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
NodeId(r["ids"][0].as_u64().unwrap())
|
||||
let cut = vectorcraft_doc::ImageBlob::new("image/png", png);
|
||||
let key = cut.content_key();
|
||||
doc.images.insert(key.clone(), cut);
|
||||
if let Some(Node { kind: NodeKind::Image(im), .. }) = doc.node_mut(id) {
|
||||
im.key = key;
|
||||
}
|
||||
let mut shown = false;
|
||||
doc.visit_images(|_, im| shown |= im.key == old);
|
||||
if !shown {
|
||||
doc.images.remove(&old);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether reply `r` warns that an image couldn't be decoded, as raster exports do.
|
||||
@@ -598,6 +622,34 @@ fn hide_unselected_artwork_keeps_locked_guides_and_is_disabled_in_isolation_mode
|
||||
assert_eq!(shown(&s, &[g, c, outer, hole]), [false, true, true, true]);
|
||||
}
|
||||
|
||||
/// While Lock Guides is on, Hide › Unselected Artwork keeps a group that holds a guide path visible and hides its other
|
||||
/// members one by one. With a layer selected, the objects on that layer stay visible and the rest are hidden.
|
||||
#[test]
|
||||
fn hide_unselected_artwork_keeps_groups_with_locked_guides_and_the_selected_layers_art() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
let guide = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||||
let x = rect(&mut s, 60.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[guide, x]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
sel(&mut s, &[guide]);
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||||
let other = id_of(&s.execute("layer.new", &json!({})).unwrap());
|
||||
let p = rect(&mut s, 0.0, 100.0, 10.0, 10.0);
|
||||
let hide = |s: &mut Session| s.execute("object.hide.unselected", &json!({})).unwrap();
|
||||
let shown = |s: &Session, ids: &[NodeId]| ids.iter().map(|id| node(s, *id).visible).collect::<Vec<_>>();
|
||||
sel(&mut s, &[a]);
|
||||
assert_eq!(hide(&mut s), json!({"count": 3}));
|
||||
assert_eq!(shown(&s, &[a, b, g, guide, x, p]), [true, false, true, true, false, false]);
|
||||
s.execute("object.showAll", &json!({})).unwrap();
|
||||
sel(&mut s, &[other]);
|
||||
assert_eq!(hide(&mut s), json!({"count": 3}));
|
||||
assert_eq!(shown(&s, &[a, b, g, guide, x, p]), [false, false, true, true, false, true]);
|
||||
assert_eq!(selected(&s), [other]);
|
||||
}
|
||||
|
||||
// ---------- Transform Each ----------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -254,7 +254,10 @@ fn a_pattern_fill_becomes_its_tiles_clipped_by_the_object() {
|
||||
let mut s = session();
|
||||
pattern_rect(&mut s, Rect::new(25.0, 25.0, 85.0, 65.0), false);
|
||||
// Fill expands the pattern; Expand Gradient To has no gradient fill to apply to.
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": true, "fill": true, "stroke": false, "gradient": false}));
|
||||
assert_eq!(
|
||||
run(&mut s, "object.expand.info", json!({})),
|
||||
json!({"object": true, "fill": true, "stroke": false, "gradient": false, "appearance": false})
|
||||
);
|
||||
let before = render(&s);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
@@ -276,7 +279,10 @@ fn a_pattern_fill_becomes_its_tiles_clipped_by_the_object() {
|
||||
assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same");
|
||||
// The tiles are the pattern's art as it is (a stroked live rectangle that Object and Stroke can
|
||||
// expand).
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": true, "fill": false, "stroke": true, "gradient": false}));
|
||||
assert_eq!(
|
||||
run(&mut s, "object.expand.info", json!({})),
|
||||
json!({"object": true, "fill": false, "stroke": true, "gradient": false, "appearance": false})
|
||||
);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(matches!(selected(&s).appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Dots"));
|
||||
// Fill off: the pattern stays live.
|
||||
@@ -905,7 +911,10 @@ fn fill_off_leaves_the_fill_live_and_stroke_off_keeps_the_stroke() {
|
||||
fn defaults_match_the_dialog_and_info_tells_what_applies() {
|
||||
let mut s = session();
|
||||
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), white_black(), true);
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": true, "fill": true, "stroke": true, "gradient": true}));
|
||||
assert_eq!(
|
||||
run(&mut s, "object.expand.info", json!({})),
|
||||
json!({"object": true, "fill": true, "stroke": true, "gradient": true, "appearance": false})
|
||||
);
|
||||
let r = run(&mut s, "object.expand", json!({}));
|
||||
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
|
||||
// Object, Fill and Stroke on; 255 objects: the live rectangle, its stroke and its gradient.
|
||||
@@ -916,7 +925,10 @@ fn defaults_match_the_dialog_and_info_tells_what_applies() {
|
||||
assert_eq!(strips.len(), 255);
|
||||
assert!(children[1].appearance.stroke_paint().is_none(), "outlined stroke");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Expand");
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": false, "fill": false, "stroke": false, "gradient": false}));
|
||||
assert_eq!(
|
||||
run(&mut s, "object.expand.info", json!({})),
|
||||
json!({"object": false, "fill": false, "stroke": false, "gradient": false, "appearance": false})
|
||||
);
|
||||
assert!(s.execute("object.expand", &json!({})).is_err(), "nothing left to expand");
|
||||
// Bad options are refused before anything changes.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
@@ -924,3 +936,90 @@ fn defaults_match_the_dialog_and_info_tells_what_applies() {
|
||||
assert!(s.execute("object.expand", &json!({"steps": 0})).is_err());
|
||||
assert!(matches!(selected(&s).kind, NodeKind::Path { live: Some(_), .. }));
|
||||
}
|
||||
|
||||
/// A selected open line with a 2 pt black stroke, no fill and a Zig Zag effect (#3089).
|
||||
fn zig_zag_line(s: &mut Session) -> NodeId {
|
||||
let id = NodeId(run(s, "path.create", json!({"d": "M 20 100 L 180 100"}))["id"].as_u64().unwrap());
|
||||
run(s, "paint.setFill", json!({"none": true}));
|
||||
run(s, "paint.setStroke", json!({"color": "#000000"}));
|
||||
run(s, "stroke.set", json!({"weight": 2}));
|
||||
run(s, "effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 10, "ridges": 6, "relative": false}}));
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_info_says_when_expand_appearance_comes_first() {
|
||||
let mut s = session();
|
||||
zig_zag_line(&mut s);
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], json!(true), "Zig Zag is an appearance attribute");
|
||||
// Expand Appearance bakes the zig-zag into the line, which keeps its stroke and gets no fill.
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let line = selected(&s);
|
||||
assert!(matches!(line.kind, NodeKind::Path { .. }), "a path: {:?}", line.kind);
|
||||
assert!(line.path_data().unwrap().anchors().count() > 6, "the zig-zag is baked");
|
||||
assert!(line.appearance.effects.is_empty());
|
||||
assert!(!line.appearance.stroke_paint().is_none(), "still a stroked line");
|
||||
assert!(line.appearance.fill().is_none_or(|f| f.paint.is_none()), "no fill");
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], json!(false));
|
||||
// Then Expand outlines the stroke into a filled shape.
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
let outline = selected(&s);
|
||||
assert!(!outline.appearance.fill_paint().is_none(), "a filled outline");
|
||||
assert!(outline.appearance.stroke_paint().is_none());
|
||||
}
|
||||
|
||||
/// The paths under `n` (itself included): (how many have a stroke, how many are filled black).
|
||||
fn strokes_and_black_fills(n: &Node) -> (usize, usize) {
|
||||
let (mut stroked, mut black) = (0, 0);
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
stroked += usize::from(!c.appearance.stroke_paint().is_none());
|
||||
black += usize::from(c.appearance.fill_paint() == Paint::solid(Color::BLACK));
|
||||
}
|
||||
});
|
||||
(stroked, black)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_expand_outlines_a_stroked_line_with_an_effect_in_one_step() {
|
||||
let mut s = session();
|
||||
zig_zag_line(&mut s);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
let n = selected(&s);
|
||||
assert!(n.appearance.effects.is_empty(), "the zig-zag is baked");
|
||||
assert_eq!(strokes_and_black_fills(&n), (0, 1), "the stroke is outlined: {n:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_expand_outlines_the_stroke_a_groups_appearance_expands_to() {
|
||||
// A group of two red squares without strokes; the group has a black stroke and a Transform
|
||||
// effect of its own, so Stroke has nothing to outline until Expand Appearance has run.
|
||||
let mut s = session();
|
||||
let a = NodeId(run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40}))["id"].as_u64().unwrap());
|
||||
let b = NodeId(run(&mut s, "shape.rectangle", json!({"x": 100, "y": 20, "width": 40, "height": 40}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||||
run(&mut s, "object.group", json!({}));
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 2, "color": "#000000"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveV": 50}}));
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({}))["stroke"], json!(false), "no stroked path yet");
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
let g = selected(&s);
|
||||
assert!(g.appearance.items.is_empty() && g.appearance.effects.is_empty(), "the appearance is expanded");
|
||||
let (stroked, black) = strokes_and_black_fills(&g);
|
||||
assert_eq!(stroked, 0, "no stroke is left: {g:?}");
|
||||
assert_eq!(black, 2, "each square's copy of the group's stroke is outlined: {g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_info_counts_two_fills_but_not_a_plain_rectangle() {
|
||||
let mut s = session();
|
||||
run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 60}));
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], json!(false), "a basic appearance");
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], json!(true), "two fills");
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_render::{RenderOptions, Renderer};
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use crate::cmd::maskedit::MASK_EDIT_LAYER;
|
||||
use crate::tests::{history, select_with_a_step_to_redo};
|
||||
@@ -150,6 +151,23 @@ fn mask_options_already_set_record_no_undo_step() {
|
||||
assert!(s.execute("transparency.setOpacityMask", &json!({"id": a.0})).is_err(), "no flag given");
|
||||
}
|
||||
|
||||
/// #4538: while a mask is edited the Layers panel lists only the mask, so New Layer and New
|
||||
/// Sublayer are refused instead of adding layers no row shows; they work again afterwards.
|
||||
#[test]
|
||||
fn new_layers_are_refused_while_a_mask_is_edited() {
|
||||
let mut s = session();
|
||||
editing_mask(&mut s);
|
||||
let layers = doc(&s).layers.len();
|
||||
for cmd in ["layer.new", "layer.newSublayer"] {
|
||||
assert!(s.execute(cmd, &json!({})).is_err(), "{cmd} while the mask is edited");
|
||||
}
|
||||
assert_eq!(doc(&s).layers.len(), layers);
|
||||
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
|
||||
let layers = doc(&s).layers.len();
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s).layers.len(), layers + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_while_editing_leaves_editing_first() {
|
||||
let mut s = session();
|
||||
@@ -170,6 +188,52 @@ fn release_while_editing_leaves_editing_first() {
|
||||
assert!(mask(&s, obj).is_some() && doc(&s).mask_edit.is_some());
|
||||
}
|
||||
|
||||
/// Exit Isolation Mode while a mask is edited leaves mask editing as Stop Editing Opacity Mask
|
||||
/// does: the mask keeps the edit, the editing layer is removed, nothing is isolated, the masked
|
||||
/// object is selected on its layer, and one undo step returns to mask editing.
|
||||
#[test]
|
||||
fn exit_isolation_while_editing_leaves_mask_editing() {
|
||||
let mut s = session();
|
||||
let obj = editing_mask(&mut s);
|
||||
s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap();
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.mask_edit.is_none() && st.isolation.is_none() && !has_edit_layer(&st.doc));
|
||||
assert_eq!(st.selection.objects, vec![obj]);
|
||||
assert_eq!(st.active_layer, Some(st.doc.layers[0].id));
|
||||
assert_eq!(st.history.undo.last().unwrap().label, "Stop Editing Opacity Mask");
|
||||
assert_eq!(mask(&s, obj).unwrap().art.geometric_bounds().unwrap().x0, 50.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let layer = st.doc.mask_edit.map(|m| m.layer);
|
||||
assert!(layer.is_some() && st.isolation == layer, "back in mask editing");
|
||||
}
|
||||
|
||||
/// Point type that a Type tool click placed in the mask and left empty is discarded when Stop
|
||||
/// Editing Opacity Mask or Exit Isolation Mode leaves mask editing, so the mask does not keep it.
|
||||
#[test]
|
||||
fn empty_point_type_is_not_saved_into_the_mask() {
|
||||
for cmd in ["transparency.stopEditingOpacityMask", "object.exitIsolation"] {
|
||||
let mut s = session();
|
||||
s.prefs.placeholder_text = false;
|
||||
let obj = editing_mask(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, 250.0, 250.0), v).unwrap();
|
||||
}
|
||||
assert!(s.tool_wants_text(), "{cmd}: the click places point type to edit");
|
||||
s.execute(cmd, &json!({})).unwrap();
|
||||
assert!(!s.tool_wants_text(), "{cmd}");
|
||||
let art = &mask(&s, obj).unwrap().art;
|
||||
assert!(art.children().is_none() && !matches!(art.kind, vectorcraft_doc::NodeKind::Text(_)), "{cmd}: the mask keeps only the square");
|
||||
assert_eq!(art.geometric_bounds().unwrap().x1, 100.0, "{cmd}");
|
||||
let labels = s.doc().unwrap().history.undo.iter().map(|e| e.label.as_str()).collect::<Vec<_>>();
|
||||
// The mask took the type in when it was placed, so its removal is a step of its own.
|
||||
assert_eq!(labels[labels.len() - 3..], ["Type", "Discard Empty Type", "Stop Editing Opacity Mask"], "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Releasing the masks of a group and of an object inside it puts each mask's art directly above
|
||||
/// its object, as releasing one mask at a time does.
|
||||
#[test]
|
||||
@@ -194,6 +258,76 @@ fn release_of_a_masked_group_and_a_masked_object_inside_it_keeps_order_and_ids()
|
||||
assert!(*doc(&s) == before, "Undo gives back the document");
|
||||
}
|
||||
|
||||
/// Opacity mask editing is not entered in pattern editing mode: Edit Opacity Mask and View Opacity
|
||||
/// Mask are off for a masked object in the tile, and so is Make Opacity Mask for one object, which
|
||||
/// would edit its new empty mask; a single target given in `ids` is refused. Make Opacity Mask with
|
||||
/// two objects still masks one with the other. Pattern editing goes on, its layer still isolated.
|
||||
/// After Done the mask is edited again.
|
||||
#[test]
|
||||
fn opacity_mask_editing_is_not_entered_in_pattern_editing_mode() {
|
||||
let mut s = session();
|
||||
let (obj, m) = (rect(&mut s, 0.0, 200.0), rect(&mut s, 0.0, 100.0));
|
||||
s.execute("transparency.makeOpacityMask", &json!({"ids": [obj.0, m.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [obj.0]})).unwrap();
|
||||
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
|
||||
let tile = doc(&s).pattern_edit.as_ref().unwrap().layer;
|
||||
let copy = doc(&s).node(tile).unwrap().children().unwrap()[0].id;
|
||||
assert!(mask(&s, copy).is_some(), "the tile's copy has the mask");
|
||||
let why = "finish editing the pattern first";
|
||||
let off = |s: &mut Session, cmd: &str, p: Value| {
|
||||
let err = s.execute(cmd, &p).unwrap_err();
|
||||
assert!(matches!(&err, EngineError::Disabled(id, w) if id == cmd && w == why), "{cmd} {p}: {err}");
|
||||
};
|
||||
off(&mut s, "transparency.editOpacityMask", json!({}));
|
||||
off(&mut s, "transparency.editOpacityMask", json!({"id": copy.0}));
|
||||
off(&mut s, "transparency.viewOpacityMask", json!({}));
|
||||
off(&mut s, "transparency.viewOpacityMask", json!({"on": true, "id": copy.0}));
|
||||
let one = rect(&mut s, 220.0, 30.0);
|
||||
s.execute("select.set", &json!({"ids": [one.0]})).unwrap();
|
||||
off(&mut s, "transparency.makeOpacityMask", json!({}));
|
||||
off(&mut s, "transparency.makeOpacityMask", json!({"ids": [one.0]}));
|
||||
// With two objects selected the command is on, and one target given in `ids` is refused.
|
||||
s.execute("select.set", &json!({"ids": [copy.0, one.0]})).unwrap();
|
||||
for target in [one, tile] {
|
||||
let err = s.execute("transparency.makeOpacityMask", &json!({"ids": [target.0]})).unwrap_err();
|
||||
assert!(err.to_string().ends_with(why), "{err}");
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.mask_edit.is_none() && st.mask_view.is_none() && mask(&s, one).is_none());
|
||||
assert_eq!((st.isolation, st.active_layer), (Some(tile), Some(tile)));
|
||||
// Two objects: the top one masks the other, without mask editing.
|
||||
let top = rect(&mut s, 220.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [one.0, top.0]})).unwrap();
|
||||
let r = s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"id": one.0, "editing": false}));
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(tile));
|
||||
assert!(doc(&s).pattern_edit.is_some());
|
||||
s.execute("object.pattern.done", &json!({})).unwrap();
|
||||
s.execute("transparency.editOpacityMask", &json!({"id": obj.0})).unwrap();
|
||||
assert_eq!(doc(&s).mask_edit.map(|e| e.object), Some(obj));
|
||||
}
|
||||
|
||||
/// In pattern editing mode a targeted sublayer holding two objects is one target, so Make Opacity
|
||||
/// Mask is off for it.
|
||||
#[test]
|
||||
fn make_opacity_mask_is_off_for_a_targeted_sublayer_in_pattern_editing_mode() {
|
||||
let mut s = session();
|
||||
let obj = rect(&mut s, 0.0, 200.0);
|
||||
s.execute("select.set", &json!({"ids": [obj.0]})).unwrap();
|
||||
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
|
||||
let tile = doc(&s).pattern_edit.as_ref().unwrap().layer;
|
||||
let sub = s.execute("layer.newSublayer", &json!({"parent": tile.0})).unwrap()["id"].as_u64().unwrap();
|
||||
let (a, b) = (rect(&mut s, 220.0, 30.0), rect(&mut s, 260.0, 30.0));
|
||||
s.execute("layer.move", &json!({"ids": [a.0, b.0], "target": sub})).unwrap();
|
||||
let kids = doc(&s).node(NodeId(sub)).unwrap().children().unwrap().iter().map(|n| n.id).collect::<Vec<_>>();
|
||||
assert_eq!(kids, vec![a, b], "the new objects are drawn in the sublayer");
|
||||
s.execute("layer.target", &json!({"id": sub})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects.len(), 2, "the sublayer's two objects are selected");
|
||||
let err = s.execute("transparency.makeOpacityMask", &json!({})).unwrap_err();
|
||||
let why = "finish editing the pattern first";
|
||||
assert!(matches!(&err, EngineError::Disabled(id, w) if id == "transparency.makeOpacityMask" && w == why), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_object_gets_an_empty_mask_drawn_in_editing_mode() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -238,3 +238,27 @@ fn ranged_set_format_sets_direction_mojikumi_and_leading_model_per_paragraph() {
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "direction": "auto"})).unwrap();
|
||||
assert!(text(&s, id).paragraph_styles().iter().all(|p| p.direction.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_return_in_the_type_tool_inserts_soft_return() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let id = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "First Second", "size": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.select_tool("type", v).unwrap();
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id));
|
||||
let l0 = &lay.lines[0];
|
||||
let (x, y) = (100.0 + (l0.x0 + l0.x1) / 2.0, 100.0 + l0.baseline - 5.0);
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
|
||||
}
|
||||
s.tool_key(ToolKey::Home, Mods::default(), v).unwrap();
|
||||
for _ in 0..6 {
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
}
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
s.tool_key(ToolKey::Enter, shift, v).unwrap();
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
assert_eq!(text(&s, id).plain_text(), "First \u{2028}Second");
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id));
|
||||
assert_eq!(lay.lines.len(), 2, "broken into two lines in one paragraph");
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use crate::tests_menucmds::place_undecodable_png;
|
||||
use crate::*;
|
||||
@@ -87,6 +88,52 @@ fn done_saves_edited_art_and_leaves_mode() {
|
||||
assert!(s.doc().unwrap().isolation.is_none());
|
||||
}
|
||||
|
||||
/// Exit Isolation Mode while a pattern is edited (the canvas context menu and the Layers panel
|
||||
/// menu offer it) leaves pattern editing as Done does, with the drawn art saved into the pattern.
|
||||
#[test]
|
||||
fn exit_isolation_while_editing_a_pattern_saves_it_and_leaves_the_mode() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[r]);
|
||||
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
|
||||
rect(&mut s, 2.0, 2.0, 3.0, 3.0);
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
let d = doc(&s);
|
||||
assert!(d.pattern_edit.is_none());
|
||||
assert_eq!(d.pattern("P").unwrap().art.len(), 2);
|
||||
assert!(d.layers.iter().all(|l| l.name.as_deref() != Some(PATTERN_EDIT_LAYER)));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.isolation, st.active_layer), (None, st.doc.default_layer()));
|
||||
assert_eq!(st.history.undo.last().unwrap().label, "Done Editing Pattern");
|
||||
}
|
||||
|
||||
/// Point type that a Type tool click placed in the tile and left empty is discarded when Done or
|
||||
/// Exit Isolation Mode leaves pattern editing, so the pattern does not keep it.
|
||||
#[test]
|
||||
fn empty_point_type_is_not_saved_into_the_pattern() {
|
||||
for cmd in ["object.pattern.done", "object.exitIsolation"] {
|
||||
let mut s = session();
|
||||
s.prefs.placeholder_text = false;
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[r]);
|
||||
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, 200.0, 200.0), v).unwrap();
|
||||
}
|
||||
assert!(s.tool_wants_text(), "{cmd}: the click places point type to edit");
|
||||
s.execute(cmd, &json!({})).unwrap();
|
||||
assert!(!s.tool_wants_text(), "{cmd}");
|
||||
let art = &doc(&s).pattern("P").unwrap().art;
|
||||
assert_eq!(art.len(), 1, "{cmd}: the tile keeps only the square");
|
||||
assert!(!matches!(art[0].kind, NodeKind::Text(_)), "{cmd}");
|
||||
let labels = s.doc().unwrap().history.undo.iter().map(|e| e.label.as_str()).collect::<Vec<_>>();
|
||||
assert_eq!(labels.last(), Some(&"Done Editing Pattern"), "{cmd}");
|
||||
assert!(!labels.contains(&"Type"), "{cmd}: {labels:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_new_pattern_removes_it() {
|
||||
let mut s = session();
|
||||
@@ -112,6 +159,38 @@ fn edit_then_cancel_restores_definition() {
|
||||
assert_eq!(def.tile.width(), 20.0);
|
||||
}
|
||||
|
||||
/// Pattern editing mode is not entered while an opacity mask is edited: Edit Pattern and Make are
|
||||
/// off, with or without a name, and Make also with `edit: false`. Mask editing goes on, its layer
|
||||
/// still isolated. After Stop Editing Opacity Mask, Edit Pattern works again.
|
||||
#[test]
|
||||
fn pattern_editing_is_not_entered_while_an_opacity_mask_is_edited() {
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
let obj = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
let m = rect(&mut s, 100.0, 100.0, 50.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [m.0], "color": "#ffffff"})).unwrap();
|
||||
s.execute("transparency.makeOpacityMask", &json!({"ids": [obj.0, m.0]})).unwrap();
|
||||
let layer = s.execute("transparency.editOpacityMask", &json!({"id": obj.0})).unwrap()["layer"].as_u64().map(NodeId);
|
||||
let why = "stop editing the opacity mask first";
|
||||
for (cmd, p) in [
|
||||
("object.pattern.edit", json!({"name": "Dots"})),
|
||||
("object.pattern.edit", json!({})),
|
||||
("object.pattern.make", json!({})),
|
||||
("object.pattern.make", json!({"name": "Mask Art", "edit": false})),
|
||||
] {
|
||||
let err = s.execute(cmd, &p).unwrap_err();
|
||||
assert!(matches!(&err, EngineError::Disabled(id, w) if id == cmd && w == why), "{cmd} {p}: {err}");
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.pattern_edit.is_none() && st.doc.pattern("Mask Art").is_none() && st.doc.pattern("New Pattern 1").is_none());
|
||||
assert_eq!(st.doc.mask_edit.map(|e| (e.object, Some(e.layer))), Some((obj, layer)));
|
||||
assert_eq!((st.isolation, st.active_layer), (layer, layer));
|
||||
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
|
||||
s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.isolation, st.doc.pattern_edit.as_ref().map(|e| e.layer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_defaults_to_selected_objects_pattern() {
|
||||
let mut s = session();
|
||||
@@ -480,17 +559,10 @@ fn pdf_export_writes_patterns_nested_too_deep_as_tiles_with_a_warning() {
|
||||
|
||||
#[test]
|
||||
fn every_raster_export_says_when_a_pattern_holds_an_image_that_could_not_be_decoded() {
|
||||
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short, made
|
||||
// into a pattern and then cleared from the page: only the pattern's tile draws it.
|
||||
// A PNG whose header reads but whose pixel data is cut short, made into a pattern and then
|
||||
// cleared from the page: only the pattern's tile draws it.
|
||||
let mut s = session();
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(20, 20, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
|
||||
png.truncate(idat + 8);
|
||||
let placed = s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
let placed = NodeId(placed["ids"][0].as_u64().unwrap());
|
||||
let placed = crate::tests_menucmds::place_undecodable_png_sized(&mut s, 20, 20);
|
||||
sel(&mut s, &[placed]);
|
||||
s.execute("object.pattern.make", &json!({"name": "Cut", "width": 20, "height": 20})).unwrap();
|
||||
s.execute("object.pattern.done", &json!({})).unwrap();
|
||||
|
||||
@@ -158,6 +158,50 @@ fn edit_text_is_off_while_an_opacity_mask_is_edited() {
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolating_ancestor_after_undo_clears_editing_from_redo_history() {
|
||||
let (mut s, rect) = persp_session();
|
||||
let id = persp_text(&mut s);
|
||||
s.execute("select.set", &json!({"ids": [id.0, rect.0]})).unwrap();
|
||||
let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
|
||||
// Start editing text in perspective and make an edit so undo/redo has entries
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("perspective.editText", &json!({})).unwrap();
|
||||
s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": 0, "insert": "Hello "})).unwrap();
|
||||
|
||||
// Undo the text edit so redo stack holds an entry while doc is not actively editing
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
|
||||
// Isolating the parent group must clear editing flag in redo entries too
|
||||
s.execute("object.isolate", &json!({"id": g})).unwrap();
|
||||
for e in &s.doc().unwrap().history.redo {
|
||||
if let Some(p) = e.doc.node(id).and_then(|n| n.extras().perspective.as_deref()) {
|
||||
assert!(!p.editing, "redo entry must not retain editing=true");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolate_settles_pending_typing_interaction() {
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
let (mut s, rect) = persp_session();
|
||||
let v = crate::ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, 200.0, 200.0), v).unwrap();
|
||||
}
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
s.tool_text("abc", v).unwrap();
|
||||
assert!(s.in_interaction());
|
||||
|
||||
s.execute("object.isolate", &json!({"id": rect.0})).unwrap();
|
||||
assert!(!s.in_interaction(), "isolating another object settles typing interaction");
|
||||
let n = node(&s, id);
|
||||
let NodeKind::Text(ref t) = n.kind else { panic!("not text") };
|
||||
assert_eq!(t.plain_text(), "abc");
|
||||
}
|
||||
|
||||
/// Edit Text ending while a background save writes the file leaves the document saved once the
|
||||
/// file is written: the flat view is never saved. Saved while editing and edited after: undoing
|
||||
/// the edit once Edit Text has ended is back at what the file holds.
|
||||
|
||||
@@ -378,6 +378,59 @@ fn a_linked_image_keeps_the_preview_made_when_its_file_was_read() {
|
||||
assert!(d.images["embedded"].proxy.is_none());
|
||||
}
|
||||
|
||||
/// Keeping an image's pixels decodes nothing: a linked image read without a preview keeps none,
|
||||
/// and pixels already in the document take the preview the file was read with.
|
||||
#[test]
|
||||
fn keeping_an_image_decodes_nothing() {
|
||||
let bytes = png(600, 300, [9, 9, 9, 255], None);
|
||||
let mut d = vectorcraft_doc::Document::new(100.0, 100.0);
|
||||
cmd::links::store_image(&mut d, "k", vectorcraft_doc::ImageBlob::new("image/png", bytes.clone()), true);
|
||||
assert!(d.images["k"].proxy.is_none(), "no preview is made here");
|
||||
let read = vectorcraft_doc::ImageBlob::new("image/png", bytes).with_proxy();
|
||||
let made = read.proxy.clone().unwrap();
|
||||
cmd::links::store_image(&mut d, "k", read, true);
|
||||
assert!(d.images["k"].proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &made)), "the preview it was read with");
|
||||
}
|
||||
|
||||
/// `w`×`h` pixels of noise, which compress little, encoded as `format`.
|
||||
fn noise(w: u32, h: u32, format: image::ImageFormat) -> Vec<u8> {
|
||||
let img = image::RgbImage::from_fn(w, h, |x, y| {
|
||||
let v = x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(40_503).rotate_left(13);
|
||||
image::Rgb([v as u8, (v >> 8) as u8, (v >> 16) as u8])
|
||||
});
|
||||
let mut out = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// A PNG cut to half its length, as a download or a sync still running leaves it, has a header
|
||||
/// but not all its pixels: placing it, embedded or linked, is an error and places nothing. A JPEG
|
||||
/// cut short still decodes, with its missing rows empty, and is placed.
|
||||
#[test]
|
||||
fn an_image_whose_pixels_cant_be_read_is_not_placed() {
|
||||
let mut cut = noise(600, 300, image::ImageFormat::Png);
|
||||
cut.truncate(cut.len() / 2);
|
||||
let mut s = session();
|
||||
let info = s.execute("file.place.info", &file("cut.png", &cut)).unwrap();
|
||||
assert_eq!((info["pixelWidth"].as_u64(), info["pixelHeight"].as_u64()), (Some(600), Some(300)), "its header reads");
|
||||
let dir = crate::tests_links::Folder::new("cut-png");
|
||||
let path = dir.file("cut.png");
|
||||
crate::tests_links::write(&path, &cut);
|
||||
for p in [file("cut.png", &cut), json!({"path": path}), json!({"path": path, "link": false})] {
|
||||
let (undo, journal) = (s.doc().unwrap().history.undo.len(), s.journal.len());
|
||||
let e = s.execute("file.place", &p).unwrap_err();
|
||||
assert_eq!(e.to_string(), "the image's pixels can't be read", "{p}");
|
||||
assert!(s.prepare_place(&p).unwrap().read().is_err(), "{p}: read apart from the session");
|
||||
assert!(top_children(&s).is_empty(), "{p}: nothing placed");
|
||||
assert_eq!((s.doc().unwrap().history.undo.len(), s.journal.len()), (undo, journal), "{p}");
|
||||
}
|
||||
let mut jpeg = noise(600, 300, image::ImageFormat::Jpeg);
|
||||
jpeg.truncate(jpeg.len() / 2);
|
||||
let r = place(&mut s, "cut.jpg", &jpeg, json!({}));
|
||||
assert_eq!(r["format"], "jpg");
|
||||
assert_eq!(top_children(&s).len(), 1);
|
||||
}
|
||||
|
||||
/// Type and a DXF drawing fitted to the artboard, read apart from the session while the artboard
|
||||
/// under `at` changes size, are placed against the artboard as it is when they are placed: as
|
||||
/// `file.place` places them after the change, which is what a replay of the journal does.
|
||||
@@ -724,6 +777,36 @@ fn files_read_for_the_place_cursor_apart_from_the_session_load_it_as_file_place_
|
||||
assert_eq!(c.tool_id(), "selection");
|
||||
}
|
||||
|
||||
/// A file whose pixels `file.place` can't read (a PNG cut short) is skipped by the place cursor's
|
||||
/// queue, read with the session or apart from it.
|
||||
#[test]
|
||||
fn the_place_queue_skips_an_image_whose_pixels_cant_be_read() {
|
||||
let mut cut = noise(600, 300, image::ImageFormat::Png);
|
||||
cut.truncate(cut.len() / 2);
|
||||
let p = json!({"files": [file("a.png", &png(40, 20, [9, 9, 9, 255], None)), file("cut.png", &cut)], "link": false});
|
||||
let (mut a, mut b) = (session(), session());
|
||||
let r = a.execute("file.place.queue", &p).unwrap();
|
||||
assert_eq!(r["count"], 1);
|
||||
assert_eq!(r["skipped"], json!([{"name": "cut.png", "error": "the image's pixels can't be read"}]));
|
||||
let read = b.prepare_place_queue(&p).unwrap().read().unwrap();
|
||||
assert_eq!(b.place_queue_read(read).unwrap(), r);
|
||||
}
|
||||
|
||||
/// The place queue's thumbnail of an image is drawn from the pixels its check decoded, so each
|
||||
/// image is decoded once.
|
||||
#[test]
|
||||
fn the_place_queue_draws_a_thumbnail_without_decoding_the_image_again() {
|
||||
// Pixels no other test uses, since every test shares the image store.
|
||||
let img = image::RgbImage::from_fn(320, 160, |x, y| image::Rgb([(x % 251) as u8, 23, (y % 241) as u8]));
|
||||
let mut bytes = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
let p = json!({"files": [file("q.png", &bytes)], "link": false, "thumbnail": 64});
|
||||
let decodes = vectorcraft_render::image_decodes();
|
||||
let r = session().execute("file.place.queue", &p).unwrap();
|
||||
assert!(r["files"][0]["thumbnailBase64"].as_str().is_some_and(|t| !t.is_empty()), "{r}");
|
||||
assert_eq!(vectorcraft_render::image_decodes(), decodes, "the thumbnail is drawn without a decode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_place_params_are_errors() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -99,6 +99,25 @@ fn save_as_takes_on_path_format_and_options() {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
/// #4523: a new document's VectorCraft Options start as the last native save used them (not its
|
||||
/// artboard range, nor Use Compression, which its preference decides).
|
||||
#[test]
|
||||
fn a_new_document_offers_the_native_options_used_last() {
|
||||
let d = dir("lastnative");
|
||||
let mut s = session();
|
||||
let opts = json!({"preview": true, "embedProfiles": false, "pdfCompatible": true, "separateArtboards": true, "range": "1"});
|
||||
s.execute("file.saveAs", &json!({"path": path(&d, "a.vectorcraft"), "options": opts})).unwrap();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let o = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap();
|
||||
let value = |k: &str| o["options"][k]["value"].clone();
|
||||
assert_eq!((value("preview"), value("embedProfiles"), value("pdfCompatible")), (json!(true), json!(false), json!(true)));
|
||||
assert_eq!(
|
||||
(value("separateArtboards"), value("range")),
|
||||
(o["options"]["separateArtboards"]["default"].clone(), o["options"]["range"]["default"].clone())
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_format_without_a_path_hands_the_bytes_back() {
|
||||
let d = dir("otherfmt");
|
||||
|
||||
@@ -308,17 +308,6 @@ fn files_are_written_beside_the_path() {
|
||||
assert_eq!(r["bytes"].as_u64().unwrap(), std::fs::metadata(&page).unwrap().len() + std::fs::metadata(dir.join("images/page.png")).unwrap().len());
|
||||
}
|
||||
|
||||
/// A 64×32 PNG whose header reads (so it places at its size) but whose pixel data is cut short.
|
||||
fn cut_png() -> Vec<u8> {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
|
||||
png.truncate(idat + 8);
|
||||
png
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_says_when_an_image_could_not_be_decoded() {
|
||||
let undecoded =
|
||||
@@ -328,7 +317,7 @@ fn an_export_says_when_an_image_could_not_be_decoded() {
|
||||
assert_eq!(r["warnings"], json!([]), "{r}");
|
||||
assert_eq!(run(&mut s, "document.exportForWeb.preview", json!({}))["warnings"], json!([]));
|
||||
// The renderer can't decode the placed image and draws nothing in its place.
|
||||
run(&mut s, "file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&cut_png())}));
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
for format in ["gif", "jpg", "png8", "png24"] {
|
||||
let r = run(&mut s, "document.exportForWeb", json!({"format": format}));
|
||||
assert!(undecoded(&r), "{format}: {}", r["warnings"]);
|
||||
|
||||
@@ -124,7 +124,15 @@ impl Session {
|
||||
{
|
||||
crate::cmd::distortcmds::silent(self, |g| g.visible = true)?;
|
||||
}
|
||||
self.switch_tool(id, view)
|
||||
// When the tool already active, or the tool lent a selection tool for a drag, is chosen again,
|
||||
// `select_tool` calls its [`Tool::chosen_again`] (the Blend tool finishes its blend).
|
||||
let again = self.lender.as_ref().unwrap_or(&self.tool).id() == id;
|
||||
self.switch_tool(id, view)?;
|
||||
if again {
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.chosen_again(cx));
|
||||
self.apply_actions(acts)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Switch to tool `id` without choosing it afresh (back from a temporary tool: a hidden
|
||||
@@ -929,6 +937,19 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// End the Type tool's text editing before a command saves the art of an editing layer (pattern
|
||||
/// editing's Done, Stop Editing Opacity Mask): point type its click placed that is still empty is
|
||||
/// discarded first, so it is not saved into the pattern tile or the mask.
|
||||
pub(crate) fn end_text_edit(&mut self) {
|
||||
if self.in_tool_actions || !self.tool.wants_text() {
|
||||
return;
|
||||
}
|
||||
let acts = self.with_tool_cx(self.last_view, |t, cx| t.deactivate(cx));
|
||||
if let Err(e) = self.apply_actions(acts) {
|
||||
log::warn!("ending the text edit: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the active tool that `edit.undo` or `edit.redo` ran, on its own or as a step of
|
||||
/// `command.batch`, so the Type tool puts the caret where the step changed the text; or that a
|
||||
/// `command.batch` began, ended or was rolled back (`batch.begin`, `batch.end`,
|
||||
|
||||
@@ -10,9 +10,14 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::findfiles::{Rules, plain};
|
||||
|
||||
/// `text.findFontFiles {}` once the search ended (10 seconds at most).
|
||||
/// How long the test waits for a search to end. The wait only ends the test if the search never
|
||||
/// ends. A machine busy with other builds can hold a walker for seconds. It is shorter than a search's
|
||||
/// default `maxSeconds` of 60, which would end a search that does not end by itself.
|
||||
const WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// `text.findFontFiles {}` once the search ended ([`WAIT`] at most).
|
||||
fn finished(s: &mut Session) -> Value {
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
let until = Instant::now() + WAIT;
|
||||
loop {
|
||||
let r = s.execute("text.findFontFiles", &json!({})).unwrap();
|
||||
if r["state"] != "searching" || Instant::now() > until {
|
||||
|
||||
@@ -1148,9 +1148,14 @@ fn libraries(what: &str, data: &str) -> Result<(), TestCaseError> {
|
||||
let lib = r["id"].clone();
|
||||
let Ok(got) = no_panic(s.execute("library.get", &json!({"library": lib}))) else { return };
|
||||
let _ = no_panic(s.execute("select.all", &json!({})));
|
||||
for (list, kind, key) in
|
||||
[("graphics", "graphic", "id"), ("colors", "fillColor", "name"), ("charStyles", "charStyle", "name"), ("paraStyles", "paraStyle", "name")]
|
||||
{
|
||||
for (list, kind, key) in [
|
||||
("graphics", "graphic", "id"),
|
||||
("colors", "fillColor", "name"),
|
||||
("gradients", "fillGradient", "name"),
|
||||
("patterns", "fillPattern", "name"),
|
||||
("charStyles", "charStyle", "name"),
|
||||
("paraStyles", "paraStyle", "name"),
|
||||
] {
|
||||
if let Some(item) = got[list].get(0) {
|
||||
let _ = no_panic(s.execute("library.use", &json!({"library": lib, "kind": kind, "item": item[key]})));
|
||||
let to = json!({"library": lib, "kind": kind, "item": item[key], "group": got["groups"][0]["name"]});
|
||||
@@ -1172,8 +1177,8 @@ fn libraries(what: &str, data: &str) -> Result<(), TestCaseError> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A Libraries panel library of the rich document's first object (a graphic), a colour, its
|
||||
/// type's character and paragraph styles and a group, exported, made once.
|
||||
/// A Libraries panel library of the rich document's first object (a graphic) and its gradient, a
|
||||
/// colour, a pattern, its type's character and paragraph styles and a group, exported, made once.
|
||||
fn saved_library() -> String {
|
||||
static TEXT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
TEXT.get_or_init(|| {
|
||||
@@ -1182,6 +1187,11 @@ fn saved_library() -> String {
|
||||
let first = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
||||
let g = s.execute("library.add", &json!({"kind": "graphic", "ids": [first]})).unwrap()["id"].clone();
|
||||
s.execute("library.add", &json!({"kind": "fillColor", "color": "#336699"})).unwrap();
|
||||
s.execute("library.add", &json!({"kind": "fillGradient", "ids": [first]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [first]})).unwrap();
|
||||
s.execute("object.pattern.make", &json!({"name": "Tiles", "width": 200, "height": 150})).unwrap();
|
||||
s.execute("object.pattern.done", &json!({})).unwrap();
|
||||
s.execute("library.add", &json!({"kind": "fillPattern", "swatch": "Tiles"})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
for kind in ["charStyle", "paraStyle"] {
|
||||
s.execute("library.add", &json!({"kind": kind})).unwrap();
|
||||
|
||||
@@ -92,6 +92,11 @@ impl<'a> Lexer<'a> {
|
||||
self.pos
|
||||
}
|
||||
|
||||
/// Look for the next token at `pos` again (the end of the data at most).
|
||||
pub fn seek(&mut self, pos: usize) {
|
||||
self.pos = pos.min(self.src.len());
|
||||
}
|
||||
|
||||
/// The data from `from` to `to` (empty where that isn't in it).
|
||||
pub fn slice(&self, from: usize, to: usize) -> &'a [u8] {
|
||||
self.src.get(from..to).unwrap_or_default()
|
||||
|
||||
@@ -69,19 +69,25 @@ pub(super) fn palette(data: &[u8]) -> Palette {
|
||||
let mut cell: Option<Cell> = None;
|
||||
// Inside a gradient's `Bb` … `BB`.
|
||||
let mut in_gradient = false;
|
||||
// Inside versioned content's alternate, which repeats what came before it.
|
||||
let mut alternate = false;
|
||||
// How deep in versioned content, and the depth of the block whose later alternatives (which
|
||||
// repeat its first) are being skipped, nested blocks in them included.
|
||||
let (mut depth, mut skipping) = (0usize, None::<usize>);
|
||||
while let Some(t) = lex.next_token() {
|
||||
if let Tok::Comment(c) = t.tok {
|
||||
let line = String::from_utf8_lossy(c);
|
||||
if line.contains("_Alternate_Content") {
|
||||
alternate = true;
|
||||
} else if line.contains("_End_Versioned_Content") || line.contains("_Begin_Content_if_version") {
|
||||
alternate = false;
|
||||
if line.contains("_Begin_Content_if_version") {
|
||||
depth = depth.saturating_add(1);
|
||||
} else if line.contains("_Alternate_Content") {
|
||||
skipping = skipping.or(Some(depth));
|
||||
} else if line.contains("_End_Versioned_Content") {
|
||||
if skipping == Some(depth) {
|
||||
skipping = None;
|
||||
}
|
||||
depth = depth.saturating_sub(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if alternate {
|
||||
if skipping.is_some() {
|
||||
continue;
|
||||
}
|
||||
let w = match t.tok {
|
||||
@@ -211,6 +217,19 @@ mod tests {
|
||||
assert_eq!(p.swatches.len(), 1);
|
||||
}
|
||||
|
||||
/// Three alternatives, and versioned content nested in a later one: still one swatch.
|
||||
#[test]
|
||||
fn versioned_swatches_with_more_alternatives_are_read_once() {
|
||||
let p = palette(
|
||||
b"0 0 Pb\n%AI17_Begin_Content_if_version_gt:24 15\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n\
|
||||
%AI17_Alternate_Content_if_version_gt:24 10\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n\
|
||||
%AI17_Begin_Content_if_version_gt:23 1\n0 0 0 1 k\n(Black)\nPc\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n\
|
||||
0 0 1 0 k\n(Yellow)\nPc\n\
|
||||
%AI17_Alternate_Content\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n%AI17_End_Versioned_Content\n0 0 0 1 k\n(After)\nPc\nPB\n",
|
||||
);
|
||||
assert_eq!(p.swatches.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["Red ink", "After"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_it_doesnt_know_leaves_the_other_swatches() {
|
||||
let p = palette(b"0 0 Pb\n1 2 3 Zz\n(Odd)\nPc\n(no paint) Pc\n0 0 0 1 k\n(Black)\nPc\nPB\n");
|
||||
|
||||
@@ -79,7 +79,7 @@ pub(crate) use document::{slot_frame, slot_of};
|
||||
pub use types::IMAGE_TOO_LARGE;
|
||||
use types::{BLEND_BUDGET, FLIP, Frame, GState, Kind, PatternHead, Place, Raster};
|
||||
#[cfg(test)]
|
||||
pub use types::{NON_NATIVE_ART, NON_NATIVE_ART_UNREAD};
|
||||
pub use types::{NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED};
|
||||
pub(super) use types::{blend_mode, blend_name};
|
||||
|
||||
#[path = "legacy_text.rs"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::art::{four, sane, two};
|
||||
use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED};
|
||||
use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED};
|
||||
use super::{Arc, Node, PatternDef, Reader, Rect, Tok, V, marker};
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
@@ -69,6 +69,18 @@ impl<'a> Reader<'a> {
|
||||
if m == "BeginEncoding" {
|
||||
self.type_state.encoding_comment(line);
|
||||
}
|
||||
// Versioned content holds alternatives of the same art, the newest first: the first is
|
||||
// read, and the next one (`%AI17_Alternate_Content`, or `…_if_version_gt:` for a version
|
||||
// in between) starts what is skipped, up to the end of the block, nested blocks included.
|
||||
if m.starts_with("Alternate_Content") {
|
||||
let at = self.lex.offset();
|
||||
if self.skip_to("End_Versioned_Content").is_err() {
|
||||
// A block that doesn't end: the rest of the file is read rather than lost.
|
||||
self.lex.seek(at);
|
||||
self.warn(VERSIONED_UNENDED);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) {
|
||||
return self.skip_to(end);
|
||||
}
|
||||
|
||||
@@ -244,9 +244,12 @@ pub(super) const SKIPPED: &[(&str, &str)] = &[
|
||||
("BeginSymbolList", "EndSymbolList"),
|
||||
("BeginTextDocument", "EndTextDocument"),
|
||||
("BeginEncoding", "EndEncoding"),
|
||||
("Alternate_Content", "End_Versioned_Content"),
|
||||
];
|
||||
|
||||
/// The note for versioned content whose `%AI17_End_Versioned_Content` is missing: every
|
||||
/// alternative after the first is read too, so some art may come in twice.
|
||||
pub const VERSIONED_UNENDED: &str = "versioned content that doesn't end was read whole: some art may come in twice";
|
||||
|
||||
/// The note for a `/ForeignObject` (non-native art, the content of a placed PDF, which the editor
|
||||
/// shows but doesn't edit) whose PDF can't be read.
|
||||
pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content) that VectorCraft can't read is left out";
|
||||
|
||||
@@ -953,6 +953,66 @@ fn sections_it_skips() {
|
||||
assert_eq!(art(&doc).len(), 1);
|
||||
}
|
||||
|
||||
/// A filled square at x = `10 × i` (5 pt wide), to tell the alternatives of versioned content apart.
|
||||
fn alt_square(i: u32) -> String {
|
||||
let x = f64::from(i) * 10.0;
|
||||
rect_path(x, 0.0, x + 5.0, 5.0) + "f\n"
|
||||
}
|
||||
|
||||
/// The left edges of the layer's art, in order: which [`alt_square`]s came in.
|
||||
fn squares(doc: &Document) -> Vec<f64> {
|
||||
art(doc).iter().filter_map(|n| n.geometric_bounds()).map(|b| b.x0.round()).collect()
|
||||
}
|
||||
|
||||
/// Versioned content holds alternatives of the same art for newer and older versions (newer
|
||||
/// files write a Dimension tool group, then the same group again for a version in between, then
|
||||
/// plain art): only the first alternative is read, so the art comes in once, however many
|
||||
/// alternatives follow and whatever versioned content is nested in them.
|
||||
#[test]
|
||||
fn versioned_content_reads_one_alternative() {
|
||||
let (begin, between, alternate, end) = (
|
||||
"%AI17_Begin_Content_if_version_gt:24 11\n",
|
||||
"%AI17_Alternate_Content_if_version_gt:24 10\n",
|
||||
"%AI17_Alternate_Content\n",
|
||||
"%AI17_End_Versioned_Content\n",
|
||||
);
|
||||
let (a, b, c) = (alt_square(1), alt_square(2), alt_square(3));
|
||||
let cases = [
|
||||
("one alternative", format!("{begin}{a}{end}"), vec![10.0]),
|
||||
("two alternatives", format!("{begin}{a}{alternate}{b}{end}"), vec![10.0]),
|
||||
("three alternatives", format!("{begin}{a}{between}{b}{alternate}{c}{end}"), vec![10.0]),
|
||||
// Blocks nested in the first alternative read their own first; those in a later one aren't read.
|
||||
(
|
||||
"nested",
|
||||
format!(
|
||||
"{begin}{a}{begin}{b}{alternate}{c}{end}{between}{}{begin}{}{alternate}{}{end}{alternate}{}{end}",
|
||||
alt_square(4),
|
||||
alt_square(5),
|
||||
alt_square(6),
|
||||
alt_square(7)
|
||||
),
|
||||
vec![10.0, 20.0],
|
||||
),
|
||||
// Art after the block is read as usual.
|
||||
("art after it", format!("{begin}{a}{between}{b}{alternate}{c}{end}{}", alt_square(8)), vec![10.0, 80.0]),
|
||||
];
|
||||
for (case, body, want) in cases {
|
||||
let s = read_ok(&layer(&body));
|
||||
assert_eq!(squares(&s.doc), want, "{case}");
|
||||
assert!(!s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{case}: {:?}", s.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
/// Versioned content whose end is missing is read whole, with a note, rather than losing the rest
|
||||
/// of the file.
|
||||
#[test]
|
||||
fn versioned_content_that_does_not_end_is_read_with_a_note() {
|
||||
let body = format!("%AI17_Begin_Content_if_version_gt:24 11\n{}%AI17_Alternate_Content_if_version_gt:24 10\n{}", alt_square(1), alt_square(2));
|
||||
let s = read_ok(&layer(&body));
|
||||
assert_eq!(squares(&s.doc), [10.0, 20.0]);
|
||||
assert!(s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{:?}", s.warnings);
|
||||
}
|
||||
|
||||
/// Non-native art (a placed PDF's content) keeps its PDF as ASCII85 in comment lines after
|
||||
/// `/Data ,`. ASCII85 has `_`, so a line may start with `%_`, which otherwise reads as hidden
|
||||
/// tokens, and a `(` in it would swallow the rest of the layer (a letterhead saved by Illustrator
|
||||
|
||||
@@ -24,7 +24,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaStyle, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect};
|
||||
|
||||
/// Longest story read (bytes).
|
||||
@@ -618,7 +618,7 @@ impl Story {
|
||||
let r = match self.frames.first()? {
|
||||
FrameKind::Area(s) => bounds(s)?,
|
||||
FrameKind::OnPath { segments, .. } => bounds(segments)?.inflate(size * 1.5, size * 1.5),
|
||||
FrameKind::Point(_) => {
|
||||
FrameKind::Point(m) => {
|
||||
let longest = self.text.split(['\r', '\x03']).map(|l| l.chars().count()).max().unwrap_or(0) as f64;
|
||||
let reach = longest * size + size;
|
||||
let points: Vec<Point> = self.line_starts(template).into_iter().map(|(_, (x, y))| to_doc * Point::new(x, y)).collect();
|
||||
@@ -626,7 +626,19 @@ impl Story {
|
||||
let (x, y) = self.place(template);
|
||||
to_doc * Point::new(x, y)
|
||||
});
|
||||
points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)).inflate(reach, size * 1.5)
|
||||
// Along the baseline as far as a line reaches, across it a line and a half: turned
|
||||
// with the type (a dimension's label turned a quarter runs down the page).
|
||||
// (The canvas is y down, the art y up: see [`to_art`].)
|
||||
let along = to_doc * Point::new(m[0], -m[1]) - to_doc * Point::ZERO;
|
||||
let along = if along.hypot() > 1e-9 && along.is_finite() { along / along.hypot() } else { kurbo::Vec2::new(1.0, 0.0) };
|
||||
let across = kurbo::Vec2::new(-along.y, along.x);
|
||||
let mut r = Rect::from_points(first, first);
|
||||
for p in points.iter().chain([&first]) {
|
||||
for (t, n) in [(-reach, -1.5), (-reach, 1.5), (reach, -1.5), (reach, 1.5)] {
|
||||
r = r.union_pt(*p + along * t + across * (n * size));
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
};
|
||||
r.is_finite().then_some(r)
|
||||
@@ -790,6 +802,9 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle {
|
||||
space_before: p.space_before * scale,
|
||||
space_after: p.space_after * scale,
|
||||
hyphenate: p.hyphenate,
|
||||
// A right-to-left paragraph lays its text out from the right whatever letters it has
|
||||
// (" 120.5 mm " reads "mm 120.5"); without the key, from its first strong letter.
|
||||
direction: p.rtl.then_some(ParaDirection::RightToLeft),
|
||||
..ParaStyle::default()
|
||||
}
|
||||
}
|
||||
@@ -838,6 +853,37 @@ mod tests {
|
||||
assert_eq!(justify(b"/0 1 /33 0"), Justify::Right);
|
||||
}
|
||||
|
||||
/// Where point type lies runs along its baseline: a label turned a quarter (as a vertical
|
||||
/// dimension's is) lies in a tall box, not a wide one.
|
||||
#[test]
|
||||
fn point_type_lies_along_its_baseline() {
|
||||
let para =
|
||||
Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: Val::Dict(BTreeMap::new()), frames: vec![] }
|
||||
.para(None);
|
||||
let mut s = story(" 80 mm ", &[0.0], ¶);
|
||||
let size = |s: &Story| s.region((0.0, 0.0), Affine::IDENTITY).map(|r| (r.width(), r.height())).unwrap();
|
||||
let (w, h) = size(&s);
|
||||
assert!(w > 3.0 * h, "level: {w} × {h}");
|
||||
// Turned −90° on the canvas (y down): the baseline runs down the page.
|
||||
s.frames = vec![FrameKind::Point([0.0, 1.0, -1.0, 0.0, 0.0, 0.0])];
|
||||
let (w, h) = size(&s);
|
||||
assert!(h > 3.0 * w, "turned: {w} × {h}");
|
||||
}
|
||||
|
||||
/// A right-to-left paragraph is laid out right to left even when it has no right-to-left
|
||||
/// letters (a dimension's label " 120.5 mm " reads "mm 120.5"); a left-to-right one, or one
|
||||
/// that doesn't say, goes by its letters.
|
||||
#[test]
|
||||
fn right_to_left_paragraphs_set_their_direction() {
|
||||
let texts =
|
||||
Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: read(b"/33 1").unwrap(), frames: vec![] };
|
||||
let direction = |own: &[u8]| para_style(&texts.para(Some(&read(own).unwrap())), 1.0).direction;
|
||||
assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults");
|
||||
assert_eq!(direction(b"/0 2 /33 0"), None, "the paragraph's own");
|
||||
let plain = Texts { para_defaults: Val::Dict(BTreeMap::new()), ..texts };
|
||||
assert_eq!(para_style(&plain.para(None), 1.0).direction, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_read_nested_dictionaries_lists_and_utf16_strings() {
|
||||
let v = read(b" /1 << /0 [ 1 2.5 -3 ] /2 (\xfe\xff\x00h\x00i) /3 true /99 /Name >> /0 (a\\)b\\n)").unwrap();
|
||||
|
||||
@@ -44,8 +44,20 @@ pub(super) fn build(
|
||||
) -> Result<(Document, Vec<String>), String> {
|
||||
// An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art
|
||||
// space, as its PDF part may (#2031).
|
||||
let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, restyled, warnings } =
|
||||
ai::read(data, matches!(page, Page::Art))?;
|
||||
build_from(ai::read(data, matches!(page, Page::Art))?, data, visible, page, outlined, trust, unit)
|
||||
}
|
||||
|
||||
/// [`build`] from `structure`, the editing copy `data` already read.
|
||||
pub(super) fn build_from(
|
||||
structure: Structure,
|
||||
data: &[u8],
|
||||
visible: Option<&Document>,
|
||||
page: Page,
|
||||
outlined: bool,
|
||||
trust: bool,
|
||||
unit: f64,
|
||||
) -> Result<(Document, Vec<String>), String> {
|
||||
let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, restyled, warnings } = structure;
|
||||
// Editing data in the units of the page's user space ([`in_units`]) is compared with the page
|
||||
// scaled down to those units, and the document read from it is scaled up by `unit`.
|
||||
let board = artboard.map(|a| to_doc.transform_rect_bbox(a));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::build::{Page, build};
|
||||
use super::build::{Page, build, build_from};
|
||||
use super::slots::LAYERS_UNREAD;
|
||||
use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai};
|
||||
|
||||
@@ -6,11 +6,20 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect
|
||||
/// its program in the legacy format, see [`editing_copy`]): its layers (the
|
||||
/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the
|
||||
/// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it
|
||||
/// has one that can't be used, `visible` with a warning that says why, and with the swatches of
|
||||
/// the editing copy's palette ([`use_palette`]) when it could be read.
|
||||
/// has one that can't be used, `visible` with a warning that says why, with the swatches of the
|
||||
/// editing copy's palette ([`use_palette`]) when it could be read, and with its layer tree, empty,
|
||||
/// below the page's layer when that could be read (#2033).
|
||||
pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Imported {
|
||||
let Some(data) = editing_copy(ps) else { return visible };
|
||||
let result = data.as_deref().map_err(Clone::clone).and_then(|data| build(data, Some(&visible.document), Page::Art, false, trust, 1.0));
|
||||
// The layer tree is kept from the structure read, for the page to come in on when the layers
|
||||
// can't be used.
|
||||
let (tree, result) = match data.as_deref() {
|
||||
Ok(d) => match ai::read(d, true) {
|
||||
Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), Page::Art, false, trust, 1.0)),
|
||||
Err(why) => (vec![], Err(why)),
|
||||
},
|
||||
Err(why) => (vec![], Err(why.clone())),
|
||||
};
|
||||
match result {
|
||||
Ok((document, mut warnings)) => {
|
||||
for w in &visible.warnings {
|
||||
@@ -25,12 +34,44 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Im
|
||||
if let Ok(data) = &data {
|
||||
use_palette(data, &mut v.document);
|
||||
}
|
||||
v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer"));
|
||||
if tree.is_empty() {
|
||||
v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer"));
|
||||
return v;
|
||||
}
|
||||
let doc = &mut v.document;
|
||||
let mut layers: Vec<Arc<Node>> = tree.iter().map(|l| Arc::new(doc.reid(l))).collect();
|
||||
let taken: Vec<Option<String>> = layers.iter().map(|l| l.name.clone()).collect();
|
||||
layers.append(&mut doc.layers);
|
||||
doc.layers = layers;
|
||||
// The page's layer takes a name of its own: the file's layers usually have its `Layer 1`.
|
||||
for i in taken.len()..doc.layers.len() {
|
||||
if doc.layers.get(i).is_some_and(|l| taken.contains(&l.name)) {
|
||||
let name = doc.next_layer_name();
|
||||
if let Some(l) = doc.layers.get_mut(i) {
|
||||
Arc::make_mut(l).name = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, on a layer above its layers, which come in empty"));
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layers in `nodes` with only their sublayers in them: names, order and options without the art.
|
||||
fn layer_tree(nodes: &[Arc<Node>]) -> Vec<Arc<Node>> {
|
||||
let layers = nodes.iter().filter(|n| matches!(n.kind, NodeKind::Layer { .. }));
|
||||
layers
|
||||
.map(|l| {
|
||||
let mut l = Node::clone(l);
|
||||
if let Some(children) = l.children_mut() {
|
||||
*children = layer_tree(children);
|
||||
}
|
||||
Arc::new(l)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `.ai` `visible` (read as its PDF part), or a PDF saved with the editing data, read through
|
||||
/// its editing copy (`private`, the joined `AIPrivateData` or `AIPDFPrivateData` streams) instead,
|
||||
/// as [`layered`] reads an EPS: with the layers and objects of the file, its artboards (with the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::layers::closed_where_ends_meet;
|
||||
use super::slots::{Content, SlotInfo, assign, covered, extent, join_pieces, letters, same_type};
|
||||
use super::slots::{Content, SlotInfo, assign, covered, extent, join_pieces, letters, same_type, words};
|
||||
use super::{Affine, Arc, Node, NodeId, NodeKind, Point, Rect, Story, TextKind};
|
||||
|
||||
/// Match page objects to the nearest slot whose story describes them, including hidden slots.
|
||||
@@ -103,28 +103,28 @@ pub(super) fn plan(
|
||||
// with its words? (A copied object's lines are where its original's are, so they don't say.)
|
||||
let hidden_type = |object: &[Arc<Node>]| {
|
||||
let Some(r) = extent(object) else { return false };
|
||||
let words: Vec<String> =
|
||||
object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text())) } else { None }).collect();
|
||||
let texts: Vec<Vec<String>> =
|
||||
object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(words(&t.plain_text())) } else { None }).collect();
|
||||
hidden_at.iter().any(|(k, place)| {
|
||||
place.intersect(r).area() >= 0.5 * r.area().max(1e-9)
|
||||
&& lines
|
||||
.get(*k)
|
||||
.and_then(Option::as_ref)
|
||||
.is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str()))))
|
||||
&& lines.get(*k).and_then(Option::as_ref).is_some_and(|l| {
|
||||
!texts.is_empty()
|
||||
&& texts.iter().all(|ws| !ws.is_empty() && ws.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str()))))
|
||||
})
|
||||
})
|
||||
};
|
||||
// The letters of each slot's story, in lower case.
|
||||
let spelled: Vec<String> = stories.iter().map(|s| s.as_ref().map(|s| letters(s.text()).to_ascii_lowercase()).unwrap_or_default()).collect();
|
||||
// For each slot: is its made type where `object` is, with the letters of one of `object`'s texts in
|
||||
// its story? All are false unless such slots have the letters of every text between them.
|
||||
// For each slot: is its made type where `object` is, with the words of one of `object`'s texts in
|
||||
// its story? All are false unless such slots have the words of every text between them.
|
||||
let spelling = |object: &[Arc<Node>], content: &[Content]| -> Vec<bool> {
|
||||
let r = extent(object);
|
||||
let words: Vec<String> = object
|
||||
let texts: Vec<Vec<String>> = object
|
||||
.iter()
|
||||
.filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text()).to_ascii_lowercase()) } else { None })
|
||||
.filter(|w| !w.is_empty())
|
||||
.filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(words(&t.plain_text().to_ascii_lowercase())) } else { None })
|
||||
.filter(|ws| !ws.is_empty())
|
||||
.collect();
|
||||
let has = |k: usize| spelled.get(k).is_some_and(|s| words.is_empty() || words.iter().any(|w| s.contains(w.as_str())));
|
||||
let has = |k: usize| spelled.get(k).is_some_and(|s| texts.is_empty() || texts.iter().any(|ws| ws.iter().all(|w| s.contains(w.as_str()))));
|
||||
let near: Vec<bool> = content
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -133,7 +133,7 @@ pub(super) fn plan(
|
||||
_ => false,
|
||||
})
|
||||
.collect();
|
||||
let every = words.iter().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str())));
|
||||
let every = texts.iter().flatten().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str())));
|
||||
if every { near } else { vec![] }
|
||||
};
|
||||
let mut spare: Vec<Vec<Arc<Node>>> = vec![];
|
||||
@@ -150,7 +150,7 @@ pub(super) fn plan(
|
||||
Some(c) => *c = Content::Page(object.clone()),
|
||||
None => {}
|
||||
},
|
||||
// What lies where made type is and has that type's letters is that type as the page draws it.
|
||||
// What lies where made type is and has that type's words is that type as the page draws it.
|
||||
None if covered(object, &content, spells) => {
|
||||
let r = extent(object).unwrap_or(Rect::ZERO);
|
||||
for (k, c) in content.iter_mut().enumerate() {
|
||||
|
||||
@@ -323,6 +323,13 @@ pub(super) fn letters(s: &str) -> String {
|
||||
s.chars().filter(char::is_ascii_alphanumeric).collect()
|
||||
}
|
||||
|
||||
/// The [`letters`] of each word of `s`, the empty ones left out. Type is compared word by word:
|
||||
/// the page draws a right-to-left paragraph's words in the order they show (" 120.5 mm " as
|
||||
/// "mm 120.5"), its text object keeps them in the order they are typed.
|
||||
pub(super) fn words(s: &str) -> Vec<String> {
|
||||
s.split_whitespace().map(letters).filter(|w| !w.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Does `n` hold type that is where `story` lays one of its lines out: the line, or a piece of it
|
||||
/// (the page splits a line where its kerning or its styles change), on that line's baseline and
|
||||
/// after its start? How far from where the line starts it is, if so.
|
||||
|
||||
@@ -395,8 +395,8 @@ fn editing_data_for_a_page_of_another_size_names_both_sizes() {
|
||||
let art = native(&layer("Jig", true, &square(10, 10))).replace(" 0 0 100 100", " 0 0 600 400");
|
||||
let file = String::from_utf8(eps(&page_square(10, 10), &art)).unwrap().replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 612 792");
|
||||
let r = import(file.as_bytes()).unwrap();
|
||||
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
|
||||
let note = "the file's layers weren't read (its editing data is for a 600 × 400 pt page, not the file's 612 × 792 pt page): it comes in as its page, in one layer";
|
||||
assert_eq!(names(&r.document), ["Jig", "Layer 1"], "{:?}", r.warnings);
|
||||
let note = "the file's layers weren't read (its editing data is for a 600 × 400 pt page, not the file's 612 × 792 pt page): it comes in as its page, on a layer above its layers, which come in empty";
|
||||
assert!(r.warnings.iter().any(|w| w == note), "{:?}", r.warnings);
|
||||
// A `.ai` whose PDF part has an A4 page.
|
||||
let (_, w, read) = crate::layered_ai(&private(&art), Document::new(595.2756, 841.8898), vec![], false, false, 1.0);
|
||||
@@ -404,6 +404,35 @@ fn editing_data_for_a_page_of_another_size_names_both_sizes() {
|
||||
assert!(!read && w.iter().any(|w| w == note), "{w:?}");
|
||||
}
|
||||
|
||||
/// #2033: an EPS whose layers can't be used (here its editing data is for another page) still
|
||||
/// keeps its layer tree: the layers come in empty, in their order and with their names and
|
||||
/// options (sublayers too), and the page comes in on a layer above them, under a name none of
|
||||
/// them has.
|
||||
#[test]
|
||||
fn an_eps_whose_layers_cant_be_used_keeps_its_layer_tree() {
|
||||
// Unlocked 0 (locked), printing 0.
|
||||
let locked = layer("Locked", true, &layer("Sub", true, &square(20, 20))).replacen("1 1 1 1 0 0 1 0", "1 1 0 0 0 0 1 0", 1);
|
||||
let layers = [layer("Layer 1", true, ""), locked, layer("Hidden", false, &square(50, 50))].concat();
|
||||
let art = native(&layers).replace(" 0 0 100 100", " 0 0 600 400");
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
let d = &r.document;
|
||||
assert_eq!(names(d), ["Layer 1", "Locked", "Hidden", "Layer 6"], "{:?}", r.warnings);
|
||||
let [back, locked, hidden, page] = &d.layers[..] else { panic!("{:?}", names(d)) };
|
||||
let options = |l: &Node| (l.visible, l.locked, matches!(l.kind, NodeKind::Layer { printable: true, .. }));
|
||||
assert_eq!(
|
||||
[options(back), options(locked), options(hidden), options(page)],
|
||||
[(true, false, true), (true, true, false), (false, false, true), (true, false, true)]
|
||||
);
|
||||
assert!(back.children().unwrap().is_empty() && hidden.children().unwrap().is_empty());
|
||||
let [sub] = &locked.children().unwrap()[..] else { panic!("{locked:?}") };
|
||||
assert!(sub.name.as_deref() == Some("Sub") && sub.children().unwrap().is_empty());
|
||||
assert_eq!(page.children().unwrap().len(), 1, "the page's square");
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
d.walk(|n| assert!(ids.insert(n.id), "{:?} twice", n.id));
|
||||
let note = "the file's layers weren't read (its editing data is for a 600 × 400 pt page, not the file's 100 × 100 pt page): it comes in as its page, on a layer above its layers, which come in empty";
|
||||
assert!(r.warnings.iter().any(|w| w == note), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// #2034: editing data whose page is the file's at another scale (a Large Canvas file without its
|
||||
/// scale, for one) gives that scale, the editing data's page to the file's.
|
||||
#[test]
|
||||
@@ -411,14 +440,14 @@ fn editing_data_for_the_page_at_another_scale_names_the_scale() {
|
||||
let art = native(&layer("Jig", true, &square(10, 10)));
|
||||
let note = |file: &str| {
|
||||
let r = import(file.as_bytes()).unwrap();
|
||||
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
|
||||
assert_eq!(names(&r.document), ["Jig", "Layer 1"], "{:?}", r.warnings);
|
||||
r.warnings.into_iter().find(|w| w.contains("its editing data is for")).unwrap_or_default()
|
||||
};
|
||||
let file = String::from_utf8(eps(&page_square(10, 10), &art)).unwrap();
|
||||
let ten = file.replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 1000 1000");
|
||||
assert_eq!(
|
||||
note(&ten),
|
||||
"the file's layers weren't read (its editing data is for a 100 × 100 pt page, the file's 1000 × 1000 pt page at 1:10): it comes in as its page, in one layer"
|
||||
"the file's layers weren't read (its editing data is for a 100 × 100 pt page, the file's 1000 × 1000 pt page at 1:10): it comes in as its page, on a layer above its layers, which come in empty"
|
||||
);
|
||||
let wide = file.replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 250 250");
|
||||
assert!(note(&wide).contains("for a 100 × 100 pt page, the file's 250 × 250 pt page at 1:2.5)"));
|
||||
@@ -466,7 +495,7 @@ fn a_large_canvas_eps_keeps_its_layers_at_the_pages_size() {
|
||||
// A declared scale the art doesn't fit still makes it another page's.
|
||||
let art = native_large_canvas(20.0, 10.0, &layer("Art", true, &square(10, 10)));
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
|
||||
assert_eq!(names(&r.document), ["Art", "Layer 1"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("for a 200 × 200 pt page, the file's 100 × 100 pt page at 2:1")), "{:?}", r.warnings);
|
||||
// A `.ai` keeps its art space: its PDF part is read at the size it declares.
|
||||
let art = native_large_canvas(100.0, 10.0, &layer("Art", true, &square(10, 10)));
|
||||
@@ -779,6 +808,25 @@ fn type_of_the_page_with_the_letters_of_made_type_where_it_lies_is_that_type() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A right-to-left paragraph's page type has its words in the order they show, not the order they
|
||||
/// are typed (a dimension's label " 120.5 mm " is drawn "mm 120.5"): it is still that text
|
||||
/// object's type as the page draws it, not a second copy of it with the nearest text object.
|
||||
#[test]
|
||||
fn type_of_the_page_with_the_words_of_made_type_in_another_order_is_that_type() {
|
||||
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
|
||||
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 58.5 moveto (there Hi) show";
|
||||
let r = import(&eps(page, &art)).unwrap();
|
||||
let mut all = vec![];
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
all.push(t.plain_text());
|
||||
}
|
||||
});
|
||||
assert_eq!(all, ["Hi there"], "{:?}", r.warnings);
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("with another's")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Page type of no text object that lies where the made type of two text objects is, with the
|
||||
/// letters of one of them in each of its pieces, is the type of both as the page draws it.
|
||||
#[test]
|
||||
@@ -1025,7 +1073,7 @@ fn more_text_objects_than_are_matched_leave_the_page() {
|
||||
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &many)), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
|
||||
assert_eq!(names(&r.document), ["Back", "Spare", "Layer 1"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("too many text objects") && crate::is_loss(w)), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
@@ -1137,7 +1185,7 @@ fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() {
|
||||
let extra = "0 0 1 0 k\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf";
|
||||
let art = native(&layer("Back", true, &(square(10, 10) + "\n" + extra)));
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
|
||||
assert_eq!(names(&r.document), ["Back", "Layer 1"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("differs from the page's")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
@@ -1189,7 +1237,7 @@ fn art_the_layers_print_outside_an_eps_page_of_its_art_makes_it_come_in_as_its_p
|
||||
let file = |far: &str| eps_of_art([10, 10, 14, 14], &page_square(10, 10), &(layer("Art", true, &square(10, 10)) + far));
|
||||
// A square the layers print far from it: the page would have it.
|
||||
let r = import(&file(&layer("Far", true, &square(80, 80)))).unwrap();
|
||||
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
|
||||
assert_eq!(names(&r.document), ["Art", "Far", "Layer 1"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read")), "{:?}", r.warnings);
|
||||
// Hidden, or on a layer that doesn't print, the page doesn't have it.
|
||||
let r = import(&file(&layer("Far", false, &square(80, 80)))).unwrap();
|
||||
|
||||
@@ -144,18 +144,22 @@ impl Rig {
|
||||
}
|
||||
}
|
||||
|
||||
const LONG: Duration = Duration::from_secs(20);
|
||||
const QUICK: Duration = Duration::from_secs(3);
|
||||
/// How long a test waits for the server to start a command, to answer or to log. The wait only ends
|
||||
/// a test whose server never does. A machine busy with other builds can hold a thread for seconds.
|
||||
const WAIT: Duration = Duration::from_secs(20);
|
||||
/// How long [`Slow`]'s long command runs unless it is cancelled: longer than [`WAIT`], so a request
|
||||
/// that waits behind it is not answered within the wait.
|
||||
const LONG: Duration = Duration::from_secs(60);
|
||||
|
||||
#[test]
|
||||
fn ping_is_answered_while_a_command_runs() {
|
||||
let mut rig = Rig::new(LONG, Duration::from_millis(50));
|
||||
rig.run(1, "document.export");
|
||||
assert_eq!(rig.started.recv_timeout(QUICK).unwrap(), "document.export");
|
||||
assert_eq!(rig.started.recv_timeout(WAIT).unwrap(), "document.export");
|
||||
// Past `busy_after` the call counts as long-running.
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
rig.request(2, "ping", json!({}));
|
||||
let pong = rig.reply(2, QUICK).expect("ping must not wait for the running command");
|
||||
let pong = rig.reply(2, WAIT).expect("ping must not wait for the running command");
|
||||
assert_eq!(pong["result"], json!({}));
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 1}));
|
||||
rig.finish();
|
||||
@@ -165,11 +169,11 @@ fn ping_is_answered_while_a_command_runs() {
|
||||
fn cancelling_stops_the_running_command_and_sends_no_reply() {
|
||||
let mut rig = Rig::new(LONG, Duration::from_secs(60));
|
||||
rig.run(1, "document.export");
|
||||
rig.started.recv_timeout(QUICK).unwrap();
|
||||
rig.started.recv_timeout(WAIT).unwrap();
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 1, "reason": "user"}));
|
||||
// The engine is free again: the next command runs and answers.
|
||||
rig.run(2, "pdf.preset.list");
|
||||
let end = Instant::now() + QUICK;
|
||||
let end = Instant::now() + WAIT;
|
||||
loop {
|
||||
if rig.log.lock().unwrap().iter().any(|l| l == "cancelled document.export") {
|
||||
break;
|
||||
@@ -187,10 +191,10 @@ fn cancelling_stops_the_running_command_and_sends_no_reply() {
|
||||
fn a_request_that_would_wait_behind_a_long_call_is_refused_as_busy() {
|
||||
let mut rig = Rig::new(LONG, Duration::from_millis(50));
|
||||
rig.run(1, "document.export");
|
||||
rig.started.recv_timeout(QUICK).unwrap();
|
||||
rig.started.recv_timeout(WAIT).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
rig.run(2, "pdf.preset.list");
|
||||
let busy = rig.reply(2, QUICK).expect("a busy answer, not a wait");
|
||||
let busy = rig.reply(2, WAIT).expect("a busy answer, not a wait");
|
||||
assert_eq!(busy["error"]["code"], -32003, "{busy}");
|
||||
assert!(busy["error"]["message"].as_str().unwrap().contains("document.export"), "{busy}");
|
||||
assert_eq!(busy["error"]["data"]["busy"]["requestId"], "1", "{busy}");
|
||||
@@ -208,7 +212,7 @@ fn quick_calls_pipelined_together_queue_and_answer_in_order() {
|
||||
rig.run(id, &format!("cmd{id}"));
|
||||
}
|
||||
for id in 1..=4 {
|
||||
let r = rig.reply(id, QUICK).unwrap_or_else(|| panic!("no reply to {id}"));
|
||||
let r = rig.reply(id, WAIT).unwrap_or_else(|| panic!("no reply to {id}"));
|
||||
assert!(r.get("error").is_none(), "{r}");
|
||||
}
|
||||
assert_eq!(*rig.log.lock().unwrap(), ["done cmd1", "done cmd2", "done cmd3", "done cmd4"]);
|
||||
@@ -219,13 +223,13 @@ fn quick_calls_pipelined_together_queue_and_answer_in_order() {
|
||||
fn a_queued_request_that_is_cancelled_never_runs() {
|
||||
let mut rig = Rig::new(LONG, Duration::from_secs(60));
|
||||
rig.run(1, "document.export");
|
||||
rig.started.recv_timeout(QUICK).unwrap();
|
||||
rig.started.recv_timeout(WAIT).unwrap();
|
||||
rig.run(2, "never.runs");
|
||||
rig.run(3, "quick.last");
|
||||
// Request 2 may not have been read yet when the cancel is: it is the order of lines that counts.
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 2}));
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 1}));
|
||||
let r = rig.reply(3, QUICK).expect("the engine goes on to the next request");
|
||||
let r = rig.reply(3, WAIT).expect("the engine goes on to the next request");
|
||||
assert!(r.get("error").is_none(), "{r}");
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 3}));
|
||||
let log = rig.log.lock().unwrap().clone();
|
||||
@@ -239,13 +243,13 @@ fn a_queued_request_that_is_cancelled_never_runs() {
|
||||
fn cancelling_an_unknown_or_finished_request_changes_nothing() {
|
||||
let mut rig = Rig::new(Duration::from_millis(10), Duration::from_secs(60));
|
||||
rig.run(1, "quick");
|
||||
assert!(rig.reply(1, QUICK).unwrap().get("result").is_some());
|
||||
assert!(rig.reply(1, WAIT).unwrap().get("result").is_some());
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 1}));
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 99}));
|
||||
rig.notify("notifications/cancelled", json!({"requestId": [1], "reason": "junk"}));
|
||||
rig.notify("notifications/cancelled", json!("junk"));
|
||||
rig.run(2, "quick");
|
||||
assert!(rig.reply(2, QUICK).unwrap().get("result").is_some());
|
||||
assert!(rig.reply(2, WAIT).unwrap().get("result").is_some());
|
||||
rig.finish();
|
||||
}
|
||||
|
||||
@@ -254,7 +258,7 @@ fn initialize_cannot_be_cancelled() {
|
||||
let mut rig = Rig::new(LONG, Duration::from_secs(60));
|
||||
rig.request(1, "initialize", json!({}));
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 1}));
|
||||
assert!(rig.reply(1, QUICK).unwrap().get("result").is_some());
|
||||
assert!(rig.reply(1, WAIT).unwrap().get("result").is_some());
|
||||
rig.finish();
|
||||
}
|
||||
|
||||
@@ -264,7 +268,7 @@ fn progress_reaches_a_client_that_gave_a_token_before_the_reply() {
|
||||
rig.request(1, "tools/call", json!({"name": "command_run", "arguments": {"id": "progress.demo"}, "_meta": {"progressToken": "tok"}}));
|
||||
let mut seen = vec![];
|
||||
let reply = loop {
|
||||
let v = rig.next(QUICK).expect("a reply");
|
||||
let v = rig.next(WAIT).expect("a reply");
|
||||
if v["method"] == "notifications/progress" {
|
||||
seen.push(v["params"].clone());
|
||||
} else {
|
||||
@@ -279,7 +283,7 @@ fn progress_reaches_a_client_that_gave_a_token_before_the_reply() {
|
||||
assert_eq!(seen[1]["message"], "page 2");
|
||||
// Without a token nothing is sent.
|
||||
rig.run(2, "progress.demo");
|
||||
let next = rig.next(QUICK).unwrap();
|
||||
let next = rig.next(WAIT).unwrap();
|
||||
assert_eq!(next["id"], 2, "{next}");
|
||||
rig.finish();
|
||||
}
|
||||
@@ -321,16 +325,18 @@ fn a_cancelled_export_through_the_real_engine_writes_nothing_and_leaves_the_sess
|
||||
let (started_tx, started) = mpsc::channel();
|
||||
let thread = std::thread::spawn(move || {
|
||||
let backend = Gated { inner: crate::Headless::with_document(), started: started_tx };
|
||||
Server::new(Box::new(backend)).serve(BufReader::new(read), Lines(tx, vec![])).unwrap();
|
||||
// Request 2 waits for the cancelled export to return, also when the export ran past the
|
||||
// default busy limit (2 s) before it saw the cancel.
|
||||
Server::new(Box::new(backend)).with_busy_after(Duration::from_secs(60)).serve(BufReader::new(read), Lines(tx, vec![])).unwrap();
|
||||
});
|
||||
let send = |w: &mut std::io::PipeWriter, v: Value| writeln!(w, "{v}").unwrap();
|
||||
let params = json!({"name": "command_run", "arguments": {"id": "document.export", "params": {"path": path.to_str().unwrap()}}});
|
||||
send(&mut to_server, json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params}));
|
||||
started.recv_timeout(QUICK).unwrap();
|
||||
started.recv_timeout(WAIT).unwrap();
|
||||
send(&mut to_server, json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}));
|
||||
// The next request is answered: the cancelled export neither blocks nor breaks the engine.
|
||||
send(&mut to_server, json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "doc_inspect", "arguments": {}}}));
|
||||
let reply: Value = serde_json::from_str(&replies.recv_timeout(QUICK).unwrap()).unwrap();
|
||||
let reply: Value = serde_json::from_str(&replies.recv_timeout(WAIT).unwrap()).unwrap();
|
||||
assert_eq!(reply["id"], 2, "request 1 is not answered: {reply}");
|
||||
assert_eq!(reply["result"]["isError"], false, "{reply}");
|
||||
assert!(!path.exists(), "a cancelled export writes no file");
|
||||
@@ -344,12 +350,13 @@ fn a_remote_call_stops_waiting_for_the_app_when_cancelled() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
// An app that takes the request and never answers.
|
||||
let (done, held) = mpsc::channel::<()>();
|
||||
let app = std::thread::spawn(move || {
|
||||
let (conn, _) = listener.accept().unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(conn.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||||
// Hold the connection open until the test is done with it.
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
// Hold the connection open until the test is done with it (or for `WAIT`).
|
||||
let _ = held.recv_timeout(WAIT);
|
||||
drop(conn);
|
||||
});
|
||||
let mut remote = crate::Remote::connect(&addr).unwrap();
|
||||
@@ -359,10 +366,11 @@ fn a_remote_call_stops_waiting_for_the_app_when_cancelled() {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
raise.cancel();
|
||||
});
|
||||
let start = Instant::now();
|
||||
let r = cancel::scope(&token, None, || remote.call("engine.execute", json!({"command": "document.export"})));
|
||||
let why = r.unwrap_err();
|
||||
assert!(why.contains("cancelled"), "{why}");
|
||||
assert!(start.elapsed() < Duration::from_secs(2), "returned when cancelled, not when the app answered");
|
||||
// The app still holds the connection open.
|
||||
assert!(!app.is_finished(), "returned when cancelled, not when the app answered");
|
||||
done.send(()).ok();
|
||||
app.join().unwrap();
|
||||
}
|
||||
|
||||
@@ -327,7 +327,7 @@ pub const SMOOTH_AUTO_LEVEL: f64 = 50.0;
|
||||
/// within a tolerance from 0.1 % of the path's longer side at level 0 to 2.5 % at level 100. A refit
|
||||
/// never has more anchor points than the subpath had: a shape whose corners the tolerance can't
|
||||
/// round, such as a rectangle, stays as it is. Closed subpaths stay closed, with no kink left where
|
||||
/// they start, and open ones keep their end points. A level outside 0..=100 counts as the nearer end, and one that is not a number as
|
||||
/// they start unless rounding it would pass the tolerance, and open ones keep their end points. A level outside 0..=100 counts as the nearer end, and one that is not a number as
|
||||
/// [`SMOOTH_AUTO_LEVEL`].
|
||||
pub fn smooth_level(path: &PathData, level: f64) -> PathData {
|
||||
let level = if level.is_finite() { level.clamp(0.0, 100.0) } else { SMOOTH_AUTO_LEVEL };
|
||||
@@ -338,7 +338,7 @@ pub fn smooth_level(path: &PathData, level: f64) -> PathData {
|
||||
let opts = SimplifyOptions { tolerance: size * (0.001 + 0.024 * level / 100.0), corner_angle_deg: SMOOTH_CORNER_DEG, straight_lines: false };
|
||||
let smoothed = |sp: &SubPath| {
|
||||
let Some(mut fit) = simplify_subpath(sp, &opts).filter(|f| f != sp) else { return sp.clone() };
|
||||
smooth_seam(&mut fit);
|
||||
smooth_seam(&mut fit, sp, opts.tolerance);
|
||||
fit
|
||||
};
|
||||
PathData::new(path.subpaths.iter().map(smoothed).collect())
|
||||
@@ -349,18 +349,65 @@ const SMOOTH_CORNER_DEG: f64 = 179.0;
|
||||
|
||||
/// A closed subpath's refit leaves its start anchor with the tangents of the original segments on
|
||||
/// either side of it, a kink when the path had one there: turn it into a smooth anchor with the same
|
||||
/// handle lengths, unless the path turns back there.
|
||||
fn smooth_seam(sp: &mut SubPath) {
|
||||
/// handle lengths, unless the path turns back there or the rounded path would stray from `original`
|
||||
/// by more than `tol` (or than the refit already does).
|
||||
fn smooth_seam(sp: &mut SubPath, original: &SubPath, tol: f64) {
|
||||
if !sp.closed {
|
||||
return;
|
||||
}
|
||||
let Some(a) = sp.anchors.first_mut() else { return };
|
||||
let Some(a) = sp.anchors.first() else { return };
|
||||
let (Some(i), Some(o)) = (crate::fit::unit(a.p - a.h_in), crate::fit::unit(a.h_out - a.p)) else { return };
|
||||
if i.dot(o) < SMOOTH_CORNER_DEG.to_radians().cos() {
|
||||
return;
|
||||
}
|
||||
let Some(dir) = crate::fit::unit(i + o) else { return };
|
||||
*a = Anchor::with_handles(a.p, a.p - dir * a.h_in.distance(a.p), a.p + dir * a.h_out.distance(a.p));
|
||||
let mut rounded = sp.clone();
|
||||
if let Some(r) = rounded.anchors.first_mut() {
|
||||
*r = Anchor::with_handles(a.p, a.p - dir * a.h_in.distance(a.p), a.p + dir * a.h_out.distance(a.p));
|
||||
}
|
||||
if seam_error(original, sp, &rounded, tol) <= tol.max(seam_error(original, sp, sp, tol)) {
|
||||
*sp = rounded;
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the segments on either side of the start anchor of `fit` (a refit of the closed
|
||||
/// `original` whose other segments match `before`) are from `original`, both ways: the largest
|
||||
/// distance from those segments to `original`, and from the parts of `original` that `before`'s
|
||||
/// segments there cover (within `tol`) to `fit`.
|
||||
fn seam_error(original: &SubPath, before: &SubPath, fit: &SubPath, tol: f64) -> f64 {
|
||||
use kurbo::ParamCurveNearest;
|
||||
let n = fit.segment_count();
|
||||
if n == 0 || before.segment_count() != n || original.segment_count() == 0 {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
let is_seam = |j: usize| j == 0 || j + 1 == n;
|
||||
let dist = |q: Point, sp: &SubPath, seam: bool| {
|
||||
(0..sp.segment_count())
|
||||
.filter(|&j| is_seam(j) == seam)
|
||||
.map(|j| sp.segment(j).nearest(q, 1e-9).distance_sq)
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
.sqrt()
|
||||
};
|
||||
let samples = |sp: &SubPath, seam_only: bool| {
|
||||
let mut pts = Vec::new();
|
||||
for j in (0..sp.segment_count()).filter(|&j| !seam_only || is_seam(j)) {
|
||||
let c = sp.segment(j);
|
||||
let m = ((c.arclen(1e-6) / (tol.max(1e-6) * 0.5)).ceil() as usize).clamp(4, 256);
|
||||
sample(&c, m, &mut pts, true);
|
||||
}
|
||||
pts
|
||||
};
|
||||
let all = |q: Point| dist(q, original, true).min(dist(q, original, false));
|
||||
let forward = samples(fit, true).into_iter().map(all).fold(0.0, f64::max);
|
||||
let back = samples(original, false)
|
||||
.into_iter()
|
||||
.filter(|&q| dist(q, before, true) <= tol)
|
||||
.map(|q| {
|
||||
let d = dist(q, fit, true);
|
||||
if d > tol { d.min(dist(q, fit, false)) } else { d }
|
||||
})
|
||||
.fold(0.0, f64::max);
|
||||
forward.max(back)
|
||||
}
|
||||
|
||||
/// Remove anchors that can be deleted without changing the shape by more than `tolerance`
|
||||
|
||||
@@ -445,6 +445,52 @@ fn smooth_level_keeps_corners_it_cannot_round_and_curves_polygons() {
|
||||
assert_eq!(smooth_level(&polygon, 0.0), polygon, "0.1 pt is less than the sagitta");
|
||||
}
|
||||
|
||||
/// The largest distance from a point of `a` to `b` (sampled on `a`, exact on `b`).
|
||||
fn one_way_distance(a: &PathData, b: &PathData) -> f64 {
|
||||
use kurbo::ParamCurveNearest;
|
||||
let mut worst = 0.0_f64;
|
||||
for sa in &a.subpaths {
|
||||
for i in 0..sa.segment_count() {
|
||||
let c = sa.segment(i);
|
||||
for k in 0..=64 {
|
||||
let q = c.eval(f64::from(k) / 64.0);
|
||||
let d = b
|
||||
.subpaths
|
||||
.iter()
|
||||
.flat_map(|sb| (0..sb.segment_count()).map(move |j| sb.segment(j).nearest(q, 1e-9).distance_sq))
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
worst = worst.max(d.sqrt());
|
||||
}
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
/// Object › Path › Smooth at level 0 keeps a closed path within 0.1 % of its longer side, a sharp
|
||||
/// corner where the path starts included (a drop with a 90° tip at its first anchor).
|
||||
#[test]
|
||||
fn smooth_level_0_keeps_a_closed_paths_start_corner_within_tolerance() {
|
||||
let n = 24;
|
||||
let step = 1.5 * PI / f64::from(n);
|
||||
let at = |t: f64| {
|
||||
let (r, dr) = (100.0 * (2.0 * t / 3.0).sin(), 200.0 / 3.0 * (2.0 * t / 3.0).cos());
|
||||
(Point::new(r * t.cos(), r * t.sin()), kurbo::Vec2::new(dr * t.cos() - r * t.sin(), dr * t.sin() + r * t.cos()))
|
||||
};
|
||||
let anchors = (0..n)
|
||||
.map(|i| {
|
||||
let (p, d_out) = at(f64::from(i) * step);
|
||||
let d_in = if i == 0 { at(1.5 * PI).1 } else { d_out };
|
||||
vectorcraft_geom::Anchor::with_handles(p, p - d_in * (step / 3.0), p + d_out * (step / 3.0))
|
||||
})
|
||||
.collect();
|
||||
let p = PathData::single(SubPath::new(anchors, true));
|
||||
let b = p.bounds().unwrap();
|
||||
let limit = 0.001 * b.width().max(b.height());
|
||||
let s = smooth_level(&p, 0.0);
|
||||
let moved = one_way_distance(&p, &s).max(one_way_distance(&s, &p));
|
||||
assert!(moved <= limit, "level 0 moved the path by {moved} pt, more than {limit} pt: {:?}", s.subpaths[0].anchors[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_redundant_collinear_and_curve() {
|
||||
let p = PathData::single(SubPath::polyline(
|
||||
|
||||
@@ -198,6 +198,18 @@ impl ImageStore {
|
||||
pm
|
||||
}
|
||||
|
||||
/// Keep `pm` as `key` as [`ImageStore::insert`] does, holding no reference to it afterwards,
|
||||
/// then drop entries over budget → whether the store holds `key`'s pixels. Pixels larger than
|
||||
/// the budget are not kept.
|
||||
pub(crate) fn keep(&mut self, key: ImageKey, pm: Arc<Pixmap>) -> bool {
|
||||
if pixmap_bytes(&pm) > self.budget {
|
||||
return false;
|
||||
}
|
||||
drop(self.insert(key.clone(), pm));
|
||||
self.evict();
|
||||
self.entries.contains_key(&key)
|
||||
}
|
||||
|
||||
/// Drop the least recently used entries no one else holds until within budget.
|
||||
fn evict(&mut self) {
|
||||
if self.bytes <= self.budget {
|
||||
@@ -271,6 +283,42 @@ pub fn image_cache_bytes() -> usize {
|
||||
store().bytes()
|
||||
}
|
||||
|
||||
/// Keep `img`, the decoded pixels of `blob`, in the store as a renderer keeps the pixels it
|
||||
/// decodes, so the first render that draws `blob` uses them instead of decoding it. Returns whether
|
||||
/// `img` was kept or the store already held `blob`'s pixels. It returns false, without looking at
|
||||
/// the store, for a CMYK image and for pixels a renderer would refuse to decode: more than the
|
||||
/// decode allowance as RGBA, or more than fits beside the pixels being drawn now. The store then
|
||||
/// drops the least recently used entries beyond its budget, as it does whenever a renderer adds
|
||||
/// pixels, and returns false when that leaves `img` out: pixels larger than the budget are never
|
||||
/// kept.
|
||||
pub fn keep_decoded_image(blob: &ImageBlob, img: image::DynamicImage) -> bool {
|
||||
let (key, lut, _) = crate::blobs::blob_key(blob);
|
||||
if lut.is_some() || !crate::paint::within_allowance(img.width(), img.height()) {
|
||||
return false;
|
||||
}
|
||||
let kept = store().get(&key);
|
||||
let size = match kept {
|
||||
Some(pm) => (pm.width(), pm.height()),
|
||||
None => {
|
||||
let Some(pm) = crate::paint::decoded_pixmap(img) else { return false };
|
||||
let size = (pm.width(), pm.height());
|
||||
if !store().keep(key, Arc::new(pm)) {
|
||||
return false;
|
||||
}
|
||||
size
|
||||
}
|
||||
};
|
||||
remember_size(blob, size);
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the store holds the decoded pixels of `blob` at full size.
|
||||
pub fn image_cached(blob: &ImageBlob) -> bool {
|
||||
// The key first: working it out takes the store's lock too.
|
||||
let (key, _, _) = crate::blobs::blob_key(blob);
|
||||
store().peek(&key).is_some()
|
||||
}
|
||||
|
||||
/// `blob`'s content key and kind, hashed and sniffed once per blob allocation.
|
||||
pub(crate) fn blob_id(blob: &ImageBlob) -> BlobId {
|
||||
if let Some(id) = store().known_id(&blob.bytes) {
|
||||
@@ -370,6 +418,29 @@ mod tests {
|
||||
assert_eq!(s.bytes(), 400);
|
||||
}
|
||||
|
||||
/// Pixels kept for a first frame are dropped at once when the budget has no room for them,
|
||||
/// since nothing draws them yet.
|
||||
#[test]
|
||||
fn kept_pixels_larger_than_the_budget_are_not_kept() {
|
||||
// 10×10 pixmaps: 400 bytes each.
|
||||
let mut s = ImageStore::new(300);
|
||||
assert!(!s.keep(key("big"), Arc::new(Pixmap::new(10, 10))));
|
||||
assert_eq!(s.bytes(), 0);
|
||||
assert!(s.peek(&key("big")).is_none());
|
||||
// Room for one: the older one, held by no one, goes.
|
||||
let mut s = ImageStore::new(400);
|
||||
assert!(s.keep(key("a"), Arc::new(Pixmap::new(10, 10))));
|
||||
assert!(s.keep(key("b"), Arc::new(Pixmap::new(10, 10))));
|
||||
assert!(s.peek(&key("a")).is_none() && s.peek(&key("b")).is_some());
|
||||
assert_eq!(s.bytes(), 400);
|
||||
// The one kept is held while drawn: a new one that doesn't fit beside it is dropped.
|
||||
let held = s.get(&key("b"));
|
||||
assert!(!s.keep(key("c"), Arc::new(Pixmap::new(10, 10))));
|
||||
assert!(s.peek(&key("c")).is_none());
|
||||
assert_eq!(s.bytes(), 400);
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn default_budget_is_an_eighth_of_installed_memory_at_most_1_gib() {
|
||||
|
||||
@@ -14,11 +14,12 @@ mod freeform;
|
||||
mod fx;
|
||||
mod group;
|
||||
mod images;
|
||||
pub use images::{image_cached, keep_decoded_image};
|
||||
mod ink;
|
||||
mod live;
|
||||
mod lru;
|
||||
mod paint;
|
||||
pub use paint::{decode_failures, reduced_size};
|
||||
pub use paint::{decode_failures, image_decodes, reduced_size};
|
||||
mod pattern;
|
||||
pub mod placed_document;
|
||||
pub mod proof;
|
||||
|
||||
@@ -75,6 +75,8 @@ thread_local! {
|
||||
/// Images that couldn't be decoded (and so drew nothing) on this thread, counted for exports to
|
||||
/// report.
|
||||
static DECODE_FAILURES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
/// Images decoded, or tried, on this thread ([`image_decodes`]).
|
||||
static DECODES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -116,6 +118,12 @@ pub fn decode_failures() -> u64 {
|
||||
DECODE_FAILURES.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// How many images [`decode_pixmap_or_why`] and [`decode_pixmap_fit`] have decoded on this thread so
|
||||
/// far, failed decodes included. Images other threads decode are not counted.
|
||||
pub fn image_decodes() -> u64 {
|
||||
DECODES.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// [`decode_in_room`]'s image or error.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn decode_within_allowance(bytes: &[u8]) -> image::ImageResult<image::DynamicImage> {
|
||||
@@ -157,6 +165,12 @@ fn room_for(rgba: u64, pinned: u64) -> Result<(), Undecoded> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `w`×`h` pixels may be decoded whole ([`room_for`]) beside the images paints hold now.
|
||||
pub(crate) fn within_allowance(w: u32, h: u32) -> bool {
|
||||
let bytes = (u64::from(w) * u64::from(h)).saturating_mul(4);
|
||||
room_for(bytes, crate::images::store().pinned_bytes() as u64).is_ok()
|
||||
}
|
||||
|
||||
/// Whether `bytes` of decoded pixels fit beside the images paints hold, which can't be evicted from
|
||||
/// the cache: a new image is decoded only while those and it fit twice [`MAX_DECODE_ALLOC`].
|
||||
fn fits_beside_pinned(bytes: u64) -> bool {
|
||||
@@ -397,7 +411,7 @@ pub fn decode_pixmap(bytes: &[u8]) -> Option<vello_cpu::Pixmap> {
|
||||
/// [`reduced_size`] when it has one; a failure is counted in [`decode_failures`].
|
||||
pub(crate) fn decode_pixmap_or_why(bytes: &[u8]) -> Result<vello_cpu::Pixmap, Undecoded> {
|
||||
counted(match decode_or_reduction(bytes) {
|
||||
Ok(img) => pixmap_of(fit_pixmap(img).to_rgba8()).ok_or(Undecoded::Unreadable),
|
||||
Ok(img) => decoded_pixmap(img).ok_or(Undecoded::Unreadable),
|
||||
Err(r) => r.and_then(|r| reduced_pixmap(bytes, &r)),
|
||||
})
|
||||
}
|
||||
@@ -407,6 +421,7 @@ pub(crate) fn decode_pixmap_or_why(bytes: &[u8]) -> Result<vello_cpu::Pixmap, Un
|
||||
fn decode_or_reduction(bytes: &[u8]) -> Result<image::DynamicImage, Result<Reduction, Undecoded>> {
|
||||
#[cfg(test)]
|
||||
DECODE_ATTEMPTS.with(|n| n.set(n.get() + 1));
|
||||
DECODES.with(|n| n.set(n.get() + 1));
|
||||
match decode_in_room(bytes) {
|
||||
Ok(img) => Ok(img),
|
||||
Err((image::ImageError::Limits(_), Undecoded::Unreadable)) => Err(reduction(bytes, allowance()).ok_or(Undecoded::Unreadable)),
|
||||
@@ -471,6 +486,11 @@ fn pixmap_of(img: image::RgbaImage) -> Option<vello_cpu::Pixmap> {
|
||||
Some(vello_cpu::Pixmap::from_parts(data, w as u16, h as u16))
|
||||
}
|
||||
|
||||
/// Decoded image `img` as [`decode_pixmap_or_why`] makes it a pixmap.
|
||||
pub(crate) fn decoded_pixmap(img: image::DynamicImage) -> Option<vello_cpu::Pixmap> {
|
||||
pixmap_of(fit_pixmap(img).to_rgba8())
|
||||
}
|
||||
|
||||
/// The substitution highlights' pink: Preferences › Highlight Substituted Fonts and Highlight
|
||||
/// Alternate Glyphs, and Document Setup's Highlight Substituted Glyphs (screen only).
|
||||
pub(crate) const SUBSTITUTED: peniko::Color = peniko::Color::from_rgba8(255, 120, 190, 110);
|
||||
|
||||
@@ -903,6 +903,39 @@ fn decode_failures_counts_this_threads_images_only() {
|
||||
assert_eq!(decode_failures(), before + 1, "this thread's failure");
|
||||
}
|
||||
|
||||
/// Pixels decoded elsewhere and kept with [`keep_decoded_image`] are the pixels a renderer decodes,
|
||||
/// premultiplied alike, and a render that draws the image uses them without decoding it. A CMYK
|
||||
/// image's pixels are not kept.
|
||||
#[test]
|
||||
fn a_kept_decoded_image_is_drawn_without_decoding_it() {
|
||||
use vectorcraft_doc::cmyk::Inks;
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
|
||||
// The image has pixels no other test uses, since every test shares the store, and half of
|
||||
// them are partly transparent.
|
||||
let img = image::RgbaImage::from_fn(37, 23, |x, y| image::Rgba([(x * 7) as u8, (y * 11) as u8, 99, if (x + y) % 2 == 0 { 255 } else { 77 }]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let blob = ImageBlob::new("image/png", png.clone());
|
||||
assert!(!image_cached(&blob));
|
||||
assert!(keep_decoded_image(&blob, image::load_from_memory(&png).unwrap()));
|
||||
assert!(image_cached(&blob));
|
||||
let key = blobs::blob_key(&blob).0;
|
||||
let kept = images::store().peek(&key).unwrap();
|
||||
assert!(kept.data() == paint::decode_pixmap(&png).unwrap().data(), "the pixels a renderer decodes");
|
||||
let mut d = Document::new(37.0, 23.0);
|
||||
d.images.insert("kept".into(), blob);
|
||||
let id = d.alloc_id();
|
||||
let im = ImageObject { key: "kept".into(), width: 37, height: 23, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
|
||||
d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
|
||||
let decodes = image_decodes();
|
||||
let r = Renderer::new().render(&d, 37, 23, Affine::IDENTITY, &RenderOptions::default());
|
||||
assert_eq!(image_decodes(), decodes, "drawn from the kept pixels");
|
||||
assert!(r.pixel(18, 11)[3] > 0, "the image is drawn");
|
||||
let cmyk = ImageBlob::cmyk_tiff(&Inks::new(2, 2, [10, 20, 30, 40].repeat(4)).unwrap()).unwrap();
|
||||
assert!(!keep_decoded_image(&cmyk, image::DynamicImage::new_rgba8(2, 2)));
|
||||
assert!(!image_cached(&cmyk));
|
||||
}
|
||||
|
||||
/// An image drawn larger than its pixels samples smoothly, or with `smooth_images` off each screen
|
||||
/// pixel takes its nearest image pixel (Pixel Preview with File Handling › Display Bitmaps as
|
||||
/// Anti-aliased Images off, #394).
|
||||
|
||||
@@ -342,14 +342,37 @@ fn top_level_commas(list: &str) -> impl Iterator<Item = &str> {
|
||||
|
||||
/// Rules from every `<style>` element, in source order.
|
||||
fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
|
||||
// Drop comments.
|
||||
// Drop comments outside strings.
|
||||
let mut text = String::with_capacity(css.len());
|
||||
let mut rest = css;
|
||||
while let Some(i) = rest.find("/*") {
|
||||
text.push_str(&rest[..i]);
|
||||
rest = rest[i + 2..].find("*/").map_or("", |j| &rest[i + 2 + j + 2..]);
|
||||
let mut chars = css.chars().peekable();
|
||||
let mut quote = None;
|
||||
let mut escaped = false;
|
||||
while let Some(c) = chars.next() {
|
||||
if let Some(q) = quote {
|
||||
text.push(c);
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if c == '\\' {
|
||||
escaped = true;
|
||||
} else if c == q {
|
||||
quote = None;
|
||||
}
|
||||
} else if c == '\'' || c == '"' {
|
||||
quote = Some(c);
|
||||
text.push(c);
|
||||
} else if c == '/' && chars.peek() == Some(&'*') {
|
||||
chars.next();
|
||||
let mut prev_star = false;
|
||||
for c in chars.by_ref() {
|
||||
if prev_star && c == '/' {
|
||||
break;
|
||||
}
|
||||
prev_star = c == '*';
|
||||
}
|
||||
} else {
|
||||
text.push(c);
|
||||
}
|
||||
}
|
||||
text.push_str(rest);
|
||||
let mut s = text.as_str();
|
||||
loop {
|
||||
s = s.trim_start();
|
||||
|
||||
@@ -249,6 +249,13 @@ fn font_variation_settings_become_variable_font_settings() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_markers_inside_css_strings_are_not_comments() {
|
||||
let d = open(r#"<style>text { font-family: "A/*B"; fill: red }</style><text y="10">x</text>"#);
|
||||
let t = only_text(&d);
|
||||
assert_eq!(t.first_style().fill.color().map(|c| c.to_hex()).as_deref(), Some("#ff0000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_selectors_reach_text() {
|
||||
let d = open(
|
||||
|
||||
@@ -52,6 +52,38 @@ pub fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
exec(s, "select.set", json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}));
|
||||
}
|
||||
|
||||
/// Place `png` as `cut.png` with `params` (`file.place`'s, besides `name` and `dataBase64`), then
|
||||
/// cut the placed image's pixel data short right after its first `IDAT` chunk's type, as a
|
||||
/// document saved with a damaged image holds it: the header still reads, but the renderer can't
|
||||
/// decode the image and draws nothing. Place refuses such a file, so tests cut a placed one. The
|
||||
/// image is stored under the key of its new bytes. → `file.place`'s reply.
|
||||
pub fn place_cut_png(s: &mut Session, png: &[u8], params: Value) -> Value {
|
||||
let mut p = json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(png)});
|
||||
if let (Some(o), Value::Object(extra)) = (p.as_object_mut(), params) {
|
||||
o.extend(extra);
|
||||
}
|
||||
let r = exec(s, "file.place", p);
|
||||
let id = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let Some(NodeKind::Image(im)) = doc.node(id).map(|n| &n.kind) else { panic!("{id:?} is not an image") };
|
||||
let old = im.key.clone();
|
||||
let mut bytes = doc.images[&old].bytes.to_vec();
|
||||
let idat = bytes.windows(4).position(|w| w == b"IDAT").unwrap();
|
||||
bytes.truncate(idat + 8);
|
||||
let cut = vectorcraft_doc::ImageBlob::new("image/png", bytes);
|
||||
let key = cut.content_key();
|
||||
doc.images.insert(key.clone(), cut);
|
||||
if let Some(Node { kind: NodeKind::Image(im), .. }) = doc.node_mut(id) {
|
||||
im.key = key;
|
||||
}
|
||||
let mut shown = false;
|
||||
doc.visit_images(|_, im| shown |= im.key == old);
|
||||
if !shown {
|
||||
doc.images.remove(&old);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The three standard fixture states for sweeping commands.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Fixture {
|
||||
|
||||
@@ -55,7 +55,7 @@ pub(crate) fn is_word_space(g: &SGlyph) -> bool {
|
||||
|
||||
/// A glyph letter spacing follows: a visible glyph followed by another one in the same word.
|
||||
fn letter_gap(g: &SGlyph, next: Option<&SGlyph>) -> bool {
|
||||
!g.is_space() && g.inline.is_none() && next.is_some_and(|n| !n.is_space())
|
||||
!g.is_space() && g.ch != '\u{2028}' && g.inline.is_none() && next.is_some_and(|n| !n.is_space() && n.ch != '\u{2028}')
|
||||
}
|
||||
|
||||
/// The width of a word space in `g`'s font and size: what letter spacing is a share of.
|
||||
@@ -83,7 +83,7 @@ pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_g
|
||||
f.word = (base * (w_max - w_des).max(0.0), base * (w_des - w_min).max(0.0));
|
||||
continue;
|
||||
}
|
||||
if g.is_space() || g.inline.is_some() {
|
||||
if g.is_space() || g.inline.is_some() || g.ch == '\u{2028}' {
|
||||
continue;
|
||||
}
|
||||
let base = g.adv;
|
||||
|
||||
@@ -182,7 +182,7 @@ impl Ctx<'_> {
|
||||
|
||||
fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) {
|
||||
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
|
||||
let blank = g.is_soft_hyphen() || g.ch.is_control();
|
||||
let blank = g.is_soft_hyphen() || g.ch.is_control() || g.ch == '\u{2028}';
|
||||
// A colour glyph draws its art (COLR layers, an SVG document) or its image (a bitmap, or
|
||||
// COLR paints documents have no form for), not its outline; COLR, then SVG, then bitmaps.
|
||||
// Art in the text's colours takes the run's fill (black when it isn't a colour) and
|
||||
|
||||
@@ -169,7 +169,7 @@ impl Pen<'_> {
|
||||
pub(super) fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> (f64, &'t str) {
|
||||
use vectorcraft_doc::TabAlign;
|
||||
let rel = x - origin;
|
||||
let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t').collect();
|
||||
let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t' && g.ch != '\u{2028}').collect();
|
||||
let width: f64 = seg.iter().map(|g| g.adv).sum();
|
||||
let target = |pos: f64, align: TabAlign, on: char| match align {
|
||||
TabAlign::Left => pos,
|
||||
@@ -282,6 +282,9 @@ pub(super) fn break_line(
|
||||
kinsoku: Kinsoku,
|
||||
) -> (usize, bool) {
|
||||
if !width.is_finite() {
|
||||
if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') {
|
||||
return (i + pos + 1, false);
|
||||
}
|
||||
return (g.len(), false);
|
||||
}
|
||||
let mut x = 0.0;
|
||||
@@ -297,9 +300,9 @@ pub(super) fn break_line(
|
||||
cluster_end += 1;
|
||||
}
|
||||
// No Break: the marked characters stay on one line, including across a space inside the run.
|
||||
if gl.no_break {
|
||||
if gl.no_break && gl.ch != '\u{2028}' {
|
||||
while let Some(n) = g.get(cluster_end) {
|
||||
if !n.no_break {
|
||||
if !n.no_break || n.ch == '\u{2028}' {
|
||||
break;
|
||||
}
|
||||
let b = n.byte;
|
||||
@@ -314,6 +317,9 @@ pub(super) fn break_line(
|
||||
} else {
|
||||
g[j..cluster_end].iter().map(|glyph| glyph.adv).sum()
|
||||
};
|
||||
if gl.ch == '\u{2028}' {
|
||||
return (cluster_end, false);
|
||||
}
|
||||
if j > first && !gl.is_space() && x + cluster_width > width + EPS {
|
||||
// It ends the line with the spaces after it.
|
||||
if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) {
|
||||
|
||||
@@ -36,7 +36,15 @@ fn compose_para(
|
||||
// can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs).
|
||||
// The tab after a list marker always starts the first line and has a fixed width.
|
||||
let has_tab = sg.iter().any(|g| g.ch == '\t' && g.len > 0);
|
||||
if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang || has_tab {
|
||||
let has_soft_break = sg.iter().any(|g| g.ch == '\u{2028}');
|
||||
if composer != Composer::EveryLine
|
||||
|| pen.regions.is_none()
|
||||
|| sg.len() < 2
|
||||
|| sg.len() > MAX_COMPOSE_GLYPHS
|
||||
|| may_hang
|
||||
|| has_tab
|
||||
|| has_soft_break
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let m = Metrics::of(&sg[0]);
|
||||
@@ -382,7 +390,15 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut
|
||||
let mut hyphen_run = 0;
|
||||
let mut line_in_paragraph = 0;
|
||||
let mut index = 0;
|
||||
let mut emitted_empty_continuation = false;
|
||||
loop {
|
||||
let pm = if index >= n
|
||||
&& let Some(last) = paragraph.sg.last()
|
||||
{
|
||||
Metrics::of(last)
|
||||
} else {
|
||||
paragraph.pm
|
||||
};
|
||||
let mut placement = PlacementContext {
|
||||
para: paragraph.para,
|
||||
pr: ¶graph.pr,
|
||||
@@ -390,7 +406,7 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut
|
||||
continued: paragraph.continued,
|
||||
rtl: paragraph.rtl,
|
||||
mirror: paragraph.mirror,
|
||||
pm: paragraph.pm,
|
||||
pm,
|
||||
composed: ¶graph.composed,
|
||||
hyphen_run,
|
||||
hyphen_limit: paragraph.hyphen_limit,
|
||||
@@ -407,7 +423,13 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut
|
||||
line_in_paragraph += 1;
|
||||
index = geometry.position.end;
|
||||
if index >= n {
|
||||
break;
|
||||
if emitted_empty_continuation {
|
||||
break;
|
||||
}
|
||||
emitted_empty_continuation = paragraph.sg.last().is_some_and(|g| g.ch == '\u{2028}');
|
||||
if !emitted_empty_continuation {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
pen.pending += paragraph.para.space_after;
|
||||
@@ -422,7 +444,7 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData
|
||||
if paragraph.regions.is_some() { (position.x0 + position.ind_l, position.x1 - position.ind_r) } else { (position.x0, position.x1) };
|
||||
let width = ax1 - ax0;
|
||||
let mut trimmed = position.end;
|
||||
while trimmed > index && sg[trimmed - 1].is_space() {
|
||||
while trimmed > index && (sg[trimmed - 1].is_space() || sg[trimmed - 1].ch == '\u{2028}') {
|
||||
trimmed -= 1;
|
||||
}
|
||||
let hyphen = (position.hyph && position.end > index).then(|| hyphen_glyph(&sg[position.end - 1]));
|
||||
@@ -521,7 +543,7 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData
|
||||
// Justification adds nothing to the list marker's glyphs.
|
||||
let mut held = held;
|
||||
held.extend(index..paragraph.marker.min(body));
|
||||
let last_of_para = position.end >= n;
|
||||
let last_of_para = position.end >= n || position.end.checked_sub(1).and_then(|i| sg.get(i)).is_some_and(|g| g.ch == '\u{2028}');
|
||||
LineGeometry {
|
||||
position,
|
||||
index,
|
||||
@@ -786,16 +808,21 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
|
||||
} else {
|
||||
position.m.first_baseline(pen.fb, pen.fb_min)
|
||||
},
|
||||
start: if index < sg.len() { sg[index].byte } else { paragraph.pr.start },
|
||||
end: if line.last_of_para { paragraph.pr.end } else { sg[position.end].byte },
|
||||
start: if index < sg.len() { sg[index].byte } else { paragraph.pr.end },
|
||||
end: if position.end >= sg.len() { paragraph.pr.end } else { sg[position.end].byte },
|
||||
glyph_start,
|
||||
glyph_end: cx.out.glyphs.len(),
|
||||
avail: if paragraph.regions.is_some() { (ax0, ax1) } else { (start_x, x_end) },
|
||||
region: if paragraph.regions.is_some() { pen.ri } else { 0 },
|
||||
});
|
||||
pen.settled(position.baseline, position.m);
|
||||
// Further spans of this line band share the settled baseline.
|
||||
for queued in &mut pen.queued {
|
||||
queued.0 = position.baseline;
|
||||
let forced_break = position.end > index && sg.get(position.end - 1).is_some_and(|g| g.ch == '\u{2028}');
|
||||
if forced_break {
|
||||
pen.queued.clear();
|
||||
} else {
|
||||
// Further spans of this line band share the settled baseline.
|
||||
for queued in &mut pen.queued {
|
||||
queued.0 = position.baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ pub(super) fn whole_character_widths(glyphs: &mut [SGlyph]) {
|
||||
/// before the automatic digit rule.
|
||||
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
|
||||
pub(super) fn tate_chu_yoko(g: &mut [SGlyph], style: impl Fn(&SGlyph) -> (f64, bool)) {
|
||||
let manual: Vec<bool> = g.iter().map(|gl| style(gl).1).collect();
|
||||
let manual: Vec<bool> = g.iter().map(|gl| gl.ch != '\u{2028}' && style(gl).1).collect();
|
||||
let digits: Vec<bool> = g.iter().map(|gl| gl.inline.is_none() && gl.ch.is_ascii_digit()).collect();
|
||||
let mut i = 0;
|
||||
while i < g.len() {
|
||||
|
||||
@@ -134,7 +134,10 @@ impl SGlyph {
|
||||
}
|
||||
/// A line may break after this glyph.
|
||||
pub fn break_after(&self) -> bool {
|
||||
self.is_space() || self.word_end || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/' | SOFT_HYPHEN) || is_cjk(self.ch)
|
||||
self.is_space()
|
||||
|| self.word_end
|
||||
|| matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/' | SOFT_HYPHEN | '\u{2028}')
|
||||
|| is_cjk(self.ch)
|
||||
}
|
||||
/// A soft (discretionary) hyphen: invisible unless a line breaks after it.
|
||||
pub fn is_soft_hyphen(&self) -> bool {
|
||||
@@ -524,7 +527,7 @@ fn drawn_chars(cluster: &str, digits: Digits) -> impl Iterator<Item = char> + '_
|
||||
cluster
|
||||
.chars()
|
||||
.map(move |c| digits.map(c))
|
||||
.filter(|&c| !(c.is_control() || matches!(c, '\u{200C}' | '\u{200D}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}')))
|
||||
.filter(|&c| !(c.is_control() || matches!(c, '\u{200C}' | '\u{200D}' | '\u{2028}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}')))
|
||||
}
|
||||
|
||||
/// Does the face with charmap `map` have a glyph for `c` (any face does when it has no charmap)?
|
||||
@@ -838,7 +841,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl;
|
||||
let ch = first_char(cl);
|
||||
let mut adv = xa as f64 * k * hs;
|
||||
if ch == SOFT_HYPHEN {
|
||||
if ch == SOFT_HYPHEN || ch == '\u{2028}' {
|
||||
adv = 0.0;
|
||||
} else if last_in_cluster {
|
||||
let tracking = gst.tracking / 1000.0 * size;
|
||||
|
||||
@@ -1339,3 +1339,58 @@ fn thai_words_are_words_to_the_caret() {
|
||||
assert_eq!(edit::word_at(s, s.len() - 1), end + 1..s.len());
|
||||
assert_eq!(edit::next_word("hello world", 0), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_return_breaks_line_without_new_paragraph() {
|
||||
let mut t = TextObject::point(Point::ZERO, "Line 1\u{2028}Line 2", style(12.0));
|
||||
t.para.space_before = 20.0;
|
||||
t.para.space_after = 20.0;
|
||||
let lay = layout(db(), &t);
|
||||
assert_eq!(lay.lines.len(), 2, "broken into two lines");
|
||||
let l0 = &lay.lines[0];
|
||||
let l1 = &lay.lines[1];
|
||||
let line_gap = l1.baseline - l0.baseline;
|
||||
assert!((line_gap - 12.0 * 1.2).abs() < 1.0, "normal line leading: {line_gap}");
|
||||
assert_eq!(&t.plain_text()[l0.start..l0.end], "Line 1\u{2028}");
|
||||
assert_eq!(&t.plain_text()[l1.start..l1.end], "Line 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_return_in_justified_text_does_not_spread() {
|
||||
let text = "Short\u{2028}A much longer second line of text inside the frame.";
|
||||
let t = area(text, style(12.0), Rect::new(0.0, 0.0, 300.0, 200.0), Justify::JustifyLeft);
|
||||
let lay = layout(db(), &t);
|
||||
assert!(lay.lines.len() >= 2);
|
||||
let l0 = &lay.lines[0];
|
||||
assert!(l0.x1 - l0.x0 < 100.0, "short line ending with soft return is not justified: {}", l0.x1 - l0.x0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_return_in_list_does_not_repeat_marker() {
|
||||
use vectorcraft_doc::text::{Bullet, ParaList};
|
||||
let mut t = area("First line\u{2028}Continuation line", style(12.0), Rect::new(0.0, 0.0, 300.0, 200.0), Justify::Left);
|
||||
t.para.list = Some(ParaList::bulleted(Bullet::Dot));
|
||||
let lay = layout(db(), &t);
|
||||
assert_eq!(lay.lines.len(), 2);
|
||||
let l0_glyphs = &lay.glyphs[lay.lines[0].glyph_start..lay.lines[0].glyph_end];
|
||||
let l1_glyphs = &lay.glyphs[lay.lines[1].glyph_start..lay.lines[1].glyph_end];
|
||||
assert!(l0_glyphs.iter().any(|g| g.len == 0), "line 0 has bullet marker");
|
||||
assert!(!l1_glyphs.iter().any(|g| g.len == 0), "continuation line does not have bullet marker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_soft_return_creates_empty_continuation_line() {
|
||||
let t = TextObject::point(Point::ZERO, "Line 1\u{2028}", style(12.0));
|
||||
let lay = layout(db(), &t);
|
||||
assert_eq!(lay.lines.len(), 2, "trailing soft return produces a second empty line");
|
||||
assert_eq!(lay.line_of(t.plain_text().len()), 1, "caret at end sits on the new line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_return_breaks_line_even_inside_no_break() {
|
||||
let mut st = style(12.0);
|
||||
st.no_break = true;
|
||||
let t = area("First\u{2028}Second", st, Rect::new(0.0, 0.0, 300.0, 200.0), Justify::Left);
|
||||
let lay = layout(db(), &t);
|
||||
assert_eq!(lay.lines.len(), 2, "soft return honors line break despite no_break");
|
||||
}
|
||||
|
||||
+64
-13
@@ -3,8 +3,9 @@
|
||||
//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the
|
||||
//! segment independently of its endpoint anchors, drag to move selected anchors (the one pressed on snaps to
|
||||
//! anchors, segments and Smart Guides, or with them off Snap to Point; Shift keeps the move at 45°
|
||||
//! steps; Alt on an anchor or a segment picks the whole path and Alt-drag copies it), drag a
|
||||
//! direction handle to
|
||||
//! steps; Alt on an anchor or a segment picks the whole path and Alt-drag copies it; an Alt-click
|
||||
//! on a path selected whole adds its group, and each further Alt-click adds the next group up, as
|
||||
//! Group Selection's clicks do), drag a direction handle to
|
||||
//! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap
|
||||
//! it), marquee to select anchors (Shift-drag toggles them: the selected ones inside are
|
||||
//! deselected, the others selected), drag a corner widget of any path (a star, a pen path) to round its corners
|
||||
@@ -37,7 +38,7 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{HitKind, compound_part_at, hit_test};
|
||||
use vectorcraft_doc::hit::{HitKind, compound_part_at, hit_test, selected_hidden_at};
|
||||
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, Rect, Vec2};
|
||||
|
||||
@@ -178,6 +179,9 @@ pub struct DirectSelectionTool {
|
||||
click: Option<(NodeId, usize, usize)>,
|
||||
/// A click on a whole path picks the segment on release; a drag still moves the whole path.
|
||||
click_segment: Option<(NodeId, usize, usize)>,
|
||||
/// An Alt-click on a path selected whole adds this enclosing group on release, as a Group
|
||||
/// Selection click does; an Alt-drag still copies the selection.
|
||||
click_group: Option<NodeId>,
|
||||
}
|
||||
|
||||
impl DirectSelectionTool {
|
||||
@@ -199,6 +203,7 @@ impl DirectSelectionTool {
|
||||
guide: GuideEdit::default(),
|
||||
click: None,
|
||||
click_segment: None,
|
||||
click_group: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +449,23 @@ struct SegmentHit {
|
||||
curved: bool,
|
||||
}
|
||||
|
||||
/// The group a Group Selection click on a selected object adds, given the object's `ancestry`
|
||||
/// (its layer and sublayers first, the object last). It walks up from the object and returns the
|
||||
/// first ancestor below the layer and its sublayers that is not yet selected. The walk stops at the
|
||||
/// isolated object, since the next group up is outside isolation mode, and at any layer or
|
||||
/// sublayer, including one inside a group.
|
||||
fn next_group(cx: &ToolContext, ancestry: &[NodeId]) -> Option<NodeId> {
|
||||
// Skip the layer and its sublayers: they are not objects.
|
||||
let layers = ancestry.iter().take_while(|id| cx.doc.node(**id).is_some_and(Node::is_layer)).count().max(1);
|
||||
ancestry
|
||||
.iter()
|
||||
.skip(layers)
|
||||
.rev()
|
||||
.copied()
|
||||
.take_while(|id| in_scope(cx.doc, cx.isolation, *id) && !cx.doc.node(*id).is_some_and(Node::is_layer))
|
||||
.find(|id| !cx.selection.contains(*id))
|
||||
}
|
||||
|
||||
/// The segment of path `id` nearest to `p`, where the hit test found its stroke.
|
||||
fn segment_at(cx: &ToolContext, id: NodeId, p: Point) -> Option<SegmentHit> {
|
||||
let pd = cx.doc.node(id)?.path_data()?;
|
||||
@@ -488,7 +510,7 @@ impl Tool for DirectSelectionTool {
|
||||
self.state = State::Idle;
|
||||
self.guides.clear();
|
||||
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
|
||||
(self.click, self.click_segment) = (None, None);
|
||||
(self.click, self.click_segment, self.click_group) = (None, None, None);
|
||||
vec![Action::Cancel]
|
||||
}
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
@@ -512,6 +534,7 @@ impl Tool for DirectSelectionTool {
|
||||
(PointerKind::Down, _) => {
|
||||
self.click = None;
|
||||
self.click_segment = None;
|
||||
self.click_group = None;
|
||||
if let Some(c) = CornerDrag::hit(cx, ev, true) {
|
||||
self.state = State::Corner(c);
|
||||
return vec![];
|
||||
@@ -563,6 +586,9 @@ impl Tool for DirectSelectionTool {
|
||||
if let Some(h) = hit_test(cx.doc, p, cx.selection_hit_options()) {
|
||||
return self.press_object(cx, ev, p, tol, h);
|
||||
}
|
||||
if let Some(out) = self.press_hidden(cx, ev, p) {
|
||||
return out;
|
||||
}
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false };
|
||||
vec![]
|
||||
}
|
||||
@@ -851,12 +877,26 @@ impl DirectSelectionTool {
|
||||
mod tests;
|
||||
|
||||
impl DirectSelectionTool {
|
||||
/// A press where a clipping mask hides the art of a selected object (picked in the Layers
|
||||
/// panel, say) grabs the selection by it, as a press on its visible part does; `None`
|
||||
/// elsewhere, and with Shift (a marquee then adds to the selection).
|
||||
fn press_hidden(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point) -> Option<Vec<Action>> {
|
||||
if ev.mods.shift {
|
||||
return None;
|
||||
}
|
||||
selected_hidden_at(cx.doc, p, cx.selection_hit_options(), &cx.selection.objects)?;
|
||||
self.state = State::MoveObject { start: p, grab: None, hold: p, began: false, deselect: None };
|
||||
Some(vec![])
|
||||
}
|
||||
fn press_group(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, tol: f64) -> Vec<Action> {
|
||||
if let Some(out) = self.guide.press(cx, ev) {
|
||||
self.state = State::Idle;
|
||||
return out;
|
||||
}
|
||||
let Some(mut h) = hit_test(cx.doc, p, cx.selection_hit_options()) else {
|
||||
if let Some(out) = self.press_hidden(cx, ev, p) {
|
||||
return out;
|
||||
}
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false };
|
||||
return vec![];
|
||||
};
|
||||
@@ -865,11 +905,7 @@ impl DirectSelectionTool {
|
||||
h.ancestry.push(part);
|
||||
h.leaf = part;
|
||||
}
|
||||
// Walk up from the leaf: select the first ancestor not yet selected (below the layer).
|
||||
// Stop at the isolated object: the next group up is outside isolation mode.
|
||||
let chain: Vec<NodeId> = h.ancestry.iter().skip(1).rev().copied().take_while(|id| in_scope(cx.doc, cx.isolation, *id)).collect();
|
||||
let target =
|
||||
if cx.selection.contains(h.leaf) { chain.iter().find(|id| !cx.selection.contains(**id)).copied().unwrap_or(h.leaf) } else { h.leaf };
|
||||
let target = if cx.selection.contains(h.leaf) { next_group(cx, &h.ancestry).unwrap_or(h.leaf) } else { h.leaf };
|
||||
let mut deselect = None;
|
||||
let mut out = vec![];
|
||||
if ev.mods.shift {
|
||||
@@ -902,19 +938,28 @@ impl DirectSelectionTool {
|
||||
return vec![];
|
||||
}
|
||||
// A double-click edits what is drawn: not a clipping path's outline.
|
||||
if hit_test(cx.doc, p, cx.hit_options()).and_then(|h| cx.doc.node(h.leaf)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) {
|
||||
let hit = hit_test(cx.doc, p, cx.hit_options());
|
||||
if hit.as_ref().and_then(|h| cx.doc.node(h.leaf)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) {
|
||||
return vec![Action::SwitchTool("type".into())];
|
||||
}
|
||||
// Outside the isolated art it leaves isolation mode, as with the Selection tool (#4466).
|
||||
if hit.is_none() && cx.isolation.is_some() {
|
||||
return vec![Action::Exec("object.exitIsolation".into(), json!({}))];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
fn press_anchor(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, (id, si, ai, grab): (NodeId, usize, usize, Point)) -> Vec<Action> {
|
||||
if cx.doc.node(id).is_some_and(|n| is_area_type(n) || is_path_type(n)) {
|
||||
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
|
||||
}
|
||||
// Alt picks the whole path (and Alt-drag copies it), as on a segment.
|
||||
// Alt picks the whole path (and Alt-drag copies it), as on a segment. On a path
|
||||
// already selected whole, an Alt-click adds the next enclosing group on release.
|
||||
if ev.mods.alt {
|
||||
self.state = State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None };
|
||||
let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none();
|
||||
if whole && !ev.mods.shift {
|
||||
self.click_group = cx.doc.ancestry(id).and_then(|a| next_group(cx, &a));
|
||||
}
|
||||
return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
|
||||
}
|
||||
// A path with all its anchors selected is stored whole (no partial entry).
|
||||
@@ -971,8 +1016,13 @@ impl DirectSelectionTool {
|
||||
return vec![Action::Exec("select.segments".into(), json!({"id": h.leaf.0, "segments": [[s.si, s.seg]]}))];
|
||||
}
|
||||
}
|
||||
// Clicking the fill selects the whole leaf path (all anchors).
|
||||
// Clicking the fill selects the whole leaf path (all anchors). With Alt, a click
|
||||
// on a path already selected whole adds the next enclosing group on release,
|
||||
// as Group Selection's click does; each further Alt-click climbs one level.
|
||||
self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect: None };
|
||||
if ev.mods.alt && whole && !ev.mods.shift {
|
||||
self.click_group = next_group(cx, &h.ancestry);
|
||||
}
|
||||
if ev.mods.shift {
|
||||
return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))];
|
||||
}
|
||||
@@ -1077,6 +1127,7 @@ impl DirectSelectionTool {
|
||||
self.state = State::Idle;
|
||||
self.guides.clear();
|
||||
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
|
||||
let group = self.click_group.take();
|
||||
match (began, deselect, self.click.take(), self.click_segment.take()) {
|
||||
(true, ..) => vec![Action::Commit],
|
||||
(false, Some(id), _, _) => vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))],
|
||||
@@ -1086,7 +1137,7 @@ impl DirectSelectionTool {
|
||||
(false, None, None, Some((id, si, seg))) => {
|
||||
vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[si, seg]]}))]
|
||||
}
|
||||
(false, None, None, None) => vec![],
|
||||
(false, None, None, None) => group.map(|g| Action::Exec("select.add".into(), json!({"ids": [g.0]}))).into_iter().collect(),
|
||||
}
|
||||
}
|
||||
fn release_marquee(&mut self, cx: &ToolContext, p: Point, (start, toggle, enclosed): (Point, bool, bool)) -> Vec<Action> {
|
||||
|
||||
@@ -2,6 +2,40 @@ use super::*;
|
||||
use crate::testutil::*;
|
||||
use vectorcraft_doc::Selection;
|
||||
|
||||
/// Direct Selection and Group Selection grab a selected clipped object (picked in the Layers
|
||||
/// panel) where its mask hides it, as on its visible part: a drag moves it. With nothing of it
|
||||
/// selected there, a press starts a marquee.
|
||||
#[test]
|
||||
fn a_press_where_a_mask_hides_a_selected_object_drags_it() {
|
||||
let (d, _, _, art) = doc_with_masked_square();
|
||||
let p = paint();
|
||||
let mut picked = Selection::default();
|
||||
picked.add(art);
|
||||
let none = Selection::default();
|
||||
// (370, 450): on the art, outside the mask (400..500).
|
||||
for group in [false, true] {
|
||||
let c = cx(&d, &picked, &p);
|
||||
let mut t = DirectSelectionTool::new(group);
|
||||
let down = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 370.0, 450.0));
|
||||
assert!(!down.iter().any(|a| matches!(a, Action::Exec(c, _) if c.starts_with("select."))), "{down:?} (group: {group})");
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 390.0, 470.0));
|
||||
assert!(a.iter().any(|a| matches!(a, Action::Begin(n) if n == "Move")), "{a:?} (group: {group})");
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 390.0, 470.0));
|
||||
// With Shift, a marquee.
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
let mut t = DirectSelectionTool::new(group);
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 370.0, 450.0).with_mods(shift));
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 390.0, 470.0).with_mods(shift));
|
||||
assert!(!a.iter().any(|a| matches!(a, Action::Begin(_))), "{a:?} (group: {group})");
|
||||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Marquee(_))), "a marquee (group: {group})");
|
||||
let c = cx(&d, &none, &p);
|
||||
let mut t = DirectSelectionTool::new(group);
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 370.0, 450.0));
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 390.0, 470.0));
|
||||
assert!(!a.iter().any(|a| matches!(a, Action::Begin(_))), "{a:?} (group: {group})");
|
||||
}
|
||||
}
|
||||
|
||||
/// #414: a ruler guide is picked over the art, but an anchor on it is picked first.
|
||||
#[test]
|
||||
fn direct_and_group_selection_pick_ruler_guides() {
|
||||
@@ -526,6 +560,141 @@ fn isolation_mode_leaves_art_outside_the_isolated_group() {
|
||||
assert!(a.is_empty(), "does not add the group outside isolation ({outer:?}): {a:?}");
|
||||
}
|
||||
|
||||
/// #4466: in isolation mode a Direct Selection double-click outside the isolated art (on the
|
||||
/// object beside it, or on nothing) leaves isolation mode, as the Selection tool's does; on the
|
||||
/// isolated art it doesn't.
|
||||
#[test]
|
||||
fn a_double_click_outside_isolation_leaves_it() {
|
||||
let (d, _, _, inner, _) = nested_group_doc();
|
||||
let p = paint();
|
||||
let none = Selection::default();
|
||||
let isolated = ToolContext { isolation: Some(inner), ..cx(&d, &none, &p) };
|
||||
let double = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&isolated, &PointerEvent::new(PointerKind::DoubleClick, x, y));
|
||||
let exit = vec![Action::Exec("object.exitIsolation".into(), json!({}))];
|
||||
assert_eq!(double(350.0, 350.0), exit, "on the object beside the isolated group");
|
||||
assert_eq!(double(700.0, 700.0), exit, "on nothing");
|
||||
assert!(double(150.0, 150.0).is_empty(), "on the isolated art");
|
||||
assert!(DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::DoubleClick, 700.0, 700.0)).is_empty());
|
||||
}
|
||||
|
||||
/// With Direct Selection, an Alt-click on a path selected whole adds its group on release, and
|
||||
/// each further Alt-click adds the next group up, as Group Selection's clicks do, on an anchor,
|
||||
/// a segment and the fill alike. The climb stops below the layer and at the isolated group.
|
||||
#[test]
|
||||
fn alt_clicks_on_a_path_selected_whole_add_its_groups() {
|
||||
let (d, inside, _, inner, outer) = nested_group_doc();
|
||||
let p = paint();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let add = |id: NodeId| vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))];
|
||||
let click = |cx: &ToolContext, x: f64, y: f64| {
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let down = t.pointer(cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(alt));
|
||||
assert!(down.is_empty(), "({x}, {y}): {down:?}");
|
||||
t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(alt))
|
||||
};
|
||||
for (x, y) in [(100.0, 100.0), (150.0, 100.0), (150.0, 150.0)] {
|
||||
let mut s = Selection::default();
|
||||
s.set([inside]);
|
||||
assert_eq!(click(&cx(&d, &s, &p), x, y), add(inner), "({x}, {y}): the path's group");
|
||||
s.set([inside, inner]);
|
||||
assert_eq!(click(&cx(&d, &s, &p), x, y), add(outer), "({x}, {y}): the group around it");
|
||||
s.set([inside, inner, outer]);
|
||||
assert!(click(&cx(&d, &s, &p), x, y).is_empty(), "({x}, {y}): the layer is not added");
|
||||
s.set([inside, inner]);
|
||||
let isolated = ToolContext { isolation: Some(inner), ..cx(&d, &s, &p) };
|
||||
assert!(click(&isolated, x, y).is_empty(), "({x}, {y}): not the group outside isolation");
|
||||
}
|
||||
}
|
||||
|
||||
/// In a sublayer, a Group Selection click and a Direct Selection Alt-click climb to the group
|
||||
/// and stop there: the sublayer is not added.
|
||||
#[test]
|
||||
fn group_clicks_in_a_sublayer_stop_below_the_sublayer() {
|
||||
use vectorcraft_doc::{Appearance, Document, LayerColor, Node};
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let (sub, g, a) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap();
|
||||
d.insert(Some(sub), 0, Node::group(g, vec![])).unwrap();
|
||||
d.insert(Some(g), 0, Node::path(a, vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), Appearance::default_art()))
|
||||
.unwrap();
|
||||
let p = paint();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let click = |s: &Selection, group: bool, mods: Mods| {
|
||||
let c = cx(&d, s, &p);
|
||||
let mut t = DirectSelectionTool::new(group);
|
||||
let down = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(mods));
|
||||
let up = t.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 150.0).with_mods(mods));
|
||||
[down, up].concat()
|
||||
};
|
||||
let add = |id: NodeId| vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))];
|
||||
let mut s = Selection::default();
|
||||
s.set([a]);
|
||||
assert_eq!(click(&s, true, Mods::default()), add(g), "Group Selection adds the group");
|
||||
assert_eq!(click(&s, false, alt), add(g), "an Alt-click adds the group");
|
||||
s.set([a, g]);
|
||||
assert!(click(&s, true, Mods::default()).is_empty(), "Group Selection does not add the sublayer");
|
||||
assert!(click(&s, false, alt).is_empty(), "an Alt-click does not add the sublayer");
|
||||
}
|
||||
|
||||
/// When a group holds a sublayer, the climb stops at the sublayer: it adds neither the sublayer
|
||||
/// nor the group above it.
|
||||
#[test]
|
||||
fn group_clicks_stop_at_a_sublayer_inside_a_group() {
|
||||
use vectorcraft_doc::{Appearance, Document, LayerColor, Node};
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let (outer, sub, g, a) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::group(outer, vec![])).unwrap();
|
||||
d.insert(Some(outer), 0, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap();
|
||||
d.insert(Some(sub), 0, Node::group(g, vec![])).unwrap();
|
||||
d.insert(Some(g), 0, Node::path(a, vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), Appearance::default_art()))
|
||||
.unwrap();
|
||||
let p = paint();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let click = |s: &Selection, group: bool, mods: Mods| {
|
||||
let c = cx(&d, s, &p);
|
||||
let mut t = DirectSelectionTool::new(group);
|
||||
let down = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(mods));
|
||||
let up = t.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 150.0).with_mods(mods));
|
||||
[down, up].concat()
|
||||
};
|
||||
let add = |id: NodeId| vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))];
|
||||
let mut s = Selection::default();
|
||||
s.set([a]);
|
||||
assert_eq!(click(&s, true, Mods::default()), add(g), "Group Selection adds the group");
|
||||
assert_eq!(click(&s, false, alt), add(g), "an Alt-click adds the group");
|
||||
s.set([a, g]);
|
||||
assert!(click(&s, true, Mods::default()).is_empty(), "Group Selection does not add the sublayer");
|
||||
assert!(click(&s, false, alt).is_empty(), "an Alt-click does not add the sublayer");
|
||||
}
|
||||
|
||||
/// An Alt-drag from a path selected whole inside a group copies the path and adds no group, and
|
||||
/// Alt+Shift-click on a segment or the fill still toggles the path, adding no group either.
|
||||
#[test]
|
||||
fn alt_drags_and_alt_shift_clicks_on_a_path_in_a_group_add_no_group() {
|
||||
let (d, inside, ..) = nested_group_doc();
|
||||
let p = paint();
|
||||
let mut s = Selection::default();
|
||||
s.set([inside]);
|
||||
let cx = cx(&d, &s, &p);
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
for (x, y) in [(100.0, 100.0), (150.0, 100.0), (150.0, 150.0)] {
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(alt)).is_empty());
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 20.0, y).with_mods(alt));
|
||||
assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}");
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + 20.0, y).with_mods(alt)), vec![Action::Commit]);
|
||||
}
|
||||
let alt_shift = Mods { alt: true, shift: true, ..Default::default() };
|
||||
for (x, y) in [(150.0, 100.0), (150.0, 150.0)] {
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(alt_shift));
|
||||
assert_eq!(a, vec![Action::Exec("select.toggle".into(), json!({"id": inside.0}))], "({x}, {y})");
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(alt_shift)).is_empty(), "({x}, {y})");
|
||||
}
|
||||
}
|
||||
|
||||
/// #927: a marquee across type selects the type object whole, beside the anchors it holds.
|
||||
#[test]
|
||||
fn marquee_selects_type_it_touches() {
|
||||
|
||||
@@ -27,8 +27,14 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
|
||||
/// radius while Alt switches it to the Smooth tool.
|
||||
const SMOOTH_RADIUS: f64 = 12.0;
|
||||
|
||||
/// The finest Fidelity (screen pixels) a Pencil or Paintbrush stroke is fitted at while it is drawn:
|
||||
/// a fit's cost grows as its Fidelity shrinks (at 0.01 pt a long pen stroke lagged, #4565), and
|
||||
/// a preview within half a pixel of the stroke looks the same. The release fits it at its Fidelity.
|
||||
const PREVIEW_FIDELITY_PX: f64 = 0.5;
|
||||
|
||||
pub struct GestureTool {
|
||||
/// How many points the last preview of the stroke had ([`crate::previews_at`]).
|
||||
/// How many points the last preview of the stroke had ([`crate::previews_at`]); 0 when it was
|
||||
/// fitted coarser than the stroke kept ([`PREVIEW_FIDELITY_PX`]).
|
||||
previewed: usize,
|
||||
id: &'static str,
|
||||
points: Vec<Point>,
|
||||
@@ -488,8 +494,11 @@ impl Tool for GestureTool {
|
||||
}
|
||||
// With the Pencil's Live preview off a thin line shows instead (`overlays`).
|
||||
if self.previews() && crate::previews_at(self.points.len()) {
|
||||
self.previewed = self.points.len();
|
||||
let mut v = self.freehand_params(false);
|
||||
let fidelity = self.fidelity.max(cx.tol(PREVIEW_FIDELITY_PX));
|
||||
v["fidelity"] = json!(fidelity);
|
||||
// A preview fitted coarser than the stroke kept is stale (`deactivate`).
|
||||
self.previewed = if fidelity > self.fidelity { 0 } else { self.points.len() };
|
||||
if !self.live_fit {
|
||||
// Live curve fitting off: the release fits the stroke (`finish`).
|
||||
v["fit"] = json!(false);
|
||||
|
||||
@@ -122,6 +122,40 @@ fn pencil_with_live_curve_fitting_off_previews_the_stroke_unfitted() {
|
||||
assert!(!drawn.is_empty() && drawn.iter().all(Option::is_none), "{drawn:?}");
|
||||
}
|
||||
|
||||
/// A stroke whose Fidelity is finer than half a screen pixel previews fitted at half a pixel, which
|
||||
/// looks the same at a fraction of the fit's cost (#4565); the release, and another tool chosen
|
||||
/// while it is drawn, fit it at its own Fidelity. A coarser Fidelity previews as it is kept.
|
||||
#[test]
|
||||
fn fine_fidelity_previews_at_half_a_pixel() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
let stroke = [(0.0, 0.0), (10.0, 5.0), (20.0, 0.0), (30.0, 5.0)];
|
||||
let fidelities = |acts: &[Action]| -> Vec<f64> {
|
||||
acts.iter().filter_map(|a| if let Action::Preview(_, v) = a { v["fidelity"].as_f64() } else { None }).collect()
|
||||
};
|
||||
for id in ["pencil", "paintbrush"] {
|
||||
for (zoom, fidelity, drawn) in [(1.0, 0.01, 0.5), (4.0, 0.01, 0.125), (1.0, 1.5, 1.5)] {
|
||||
cx.zoom = zoom;
|
||||
let mut t = GestureTool::new(id);
|
||||
t.set_option("fidelity", &json!(fidelity));
|
||||
let acts = drag(&mut t, &cx, &stroke, Mods::default());
|
||||
let (release, during) = acts.split_last().unwrap();
|
||||
assert_eq!(fidelities(&during.concat()), [drawn; 3], "{id} at {zoom}x, {fidelity} pt");
|
||||
assert_eq!(fidelities(release), [fidelity], "{id} at {zoom}x, {fidelity} pt: the release");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
|
||||
for &(x, y) in &stroke[1..] {
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y));
|
||||
}
|
||||
let acts = t.deactivate(&cx);
|
||||
let kept = if drawn > fidelity { [fidelity].to_vec() } else { vec![] };
|
||||
assert_eq!(fidelities(&acts), kept, "{id} at {zoom}x, {fidelity} pt: another tool chosen");
|
||||
assert_eq!(acts.last(), Some(&Action::Commit));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep selected is on by default, and every Pencil and Paintbrush preview sends it.
|
||||
#[test]
|
||||
fn pencil_and_paintbrush_send_keep_selected() {
|
||||
|
||||
+146
-9
@@ -1,9 +1,11 @@
|
||||
//! Flare, Reshape, Shaper and Graph tools.
|
||||
//!
|
||||
//! Flare: drag sets the centre and its size (click without dragging opens the Flare Tool Options),
|
||||
//! then a click sets the end point of the rings; the rest comes from the tool's options (the Flare
|
||||
//! Tool Options, which last). Reshape: drag a point of a selected path; the engine's
|
||||
//! `path.reshape` adds an anchor there if needed and moves the neighbourhood smoothly.
|
||||
//! Flare: drag sets the center and its size and turns the rays as the pointer moves up or down
|
||||
//! (Shift holds their turn; click without dragging opens the Flare Tool Options), then a click
|
||||
//! sets the end point of the rings; Space held during either drag moves the flare. The rest comes
|
||||
//! from the tool's options (the Flare Tool Options, which last). Reshape: drag a point of a
|
||||
//! selected path; the engine's `path.reshape` adds an anchor there if needed and moves the
|
||||
//! neighbourhood smoothly.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
@@ -51,9 +53,13 @@ pub struct FlareTool {
|
||||
/// Centre placed, waiting for the end-point click: (centre, diameter).
|
||||
placed: Option<(Point, f64)>,
|
||||
hover: Point,
|
||||
/// The pointer at the last press or drag, from which Space held moves the flare.
|
||||
last: Point,
|
||||
began: bool,
|
||||
/// Ray count changed with ↑/↓ during the gesture (None = the options' count).
|
||||
rays: Option<u64>,
|
||||
/// The rays' turn (`shape.flare`'s `rotation`, degrees) the center drag set.
|
||||
turn: f64,
|
||||
/// The options' values, in [`FLARE_OPTIONS`] order.
|
||||
values: [f64; 12],
|
||||
/// Whether the flare has rays and rings (the options' Rays and Rings checkboxes).
|
||||
@@ -67,8 +73,10 @@ impl Default for FlareTool {
|
||||
drag: None,
|
||||
placed: None,
|
||||
hover: Point::ZERO,
|
||||
last: Point::ZERO,
|
||||
began: false,
|
||||
rays: None,
|
||||
turn: 0.0,
|
||||
values: FLARE_OPTIONS.map(|(_, default, _, _)| default),
|
||||
rays_on: true,
|
||||
rings_on: true,
|
||||
@@ -101,12 +109,26 @@ pub fn flare_params(o: &Value, c: Point) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
/// The chosen rotation rate: one degree per document point of vertical pointer travel.
|
||||
/// This is independent of pointer event frequency and elapsed time.
|
||||
const RAY_DEGREES_PER_POINT: f64 = 1.0;
|
||||
|
||||
/// Accumulate travel rather than an event-count step. Frozen/moving samples still update
|
||||
/// the pointer baseline, so releasing a modifier never catches up skipped rotation.
|
||||
fn ray_turn(turn: f64, dy: f64, shift: bool) -> f64 {
|
||||
if shift || !dy.is_finite() {
|
||||
return turn;
|
||||
}
|
||||
(turn + dy * RAY_DEGREES_PER_POINT).rem_euclid(360.0)
|
||||
}
|
||||
|
||||
impl FlareTool {
|
||||
/// `shape.flare`'s params: the options, with the dragged centre and diameter, the ray count
|
||||
/// ↑/↓ chose and the rings' end point once clicked.
|
||||
/// `shape.flare`'s params: the options, with the dragged center and diameter, the rays' turn,
|
||||
/// the ray count ↑/↓ chose and the rings' end point once clicked.
|
||||
fn params(&self, c: Point, diameter: f64, end: Option<Point>) -> Value {
|
||||
let mut v = flare_params(&self.options(), c);
|
||||
v["diameter"] = json!(diameter.max(1.0));
|
||||
v["rotation"] = json!(self.turn);
|
||||
if let Some(r) = self.rays.filter(|_| self.rays_on) {
|
||||
v["rays"] = json!(r);
|
||||
}
|
||||
@@ -132,12 +154,20 @@ impl Tool for FlareTool {
|
||||
if self.placed.is_none() {
|
||||
self.drag = Some((ev.pos, ev.pos));
|
||||
self.rays = None;
|
||||
self.turn = 0.0;
|
||||
}
|
||||
self.last = ev.pos;
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
if let Some((c, _)) = self.drag {
|
||||
if let Some((mut c, _)) = self.drag {
|
||||
// Space held moves the center with the pointer, so the size and the turn stay.
|
||||
let dy = ev.pos.y - self.last.y;
|
||||
crate::shape::space_moves(&mut c, &mut self.last, ev, self.began);
|
||||
self.drag = Some((c, ev.pos));
|
||||
if !ev.mods.space {
|
||||
self.turn = ray_turn(self.turn, dy, ev.mods.shift);
|
||||
}
|
||||
if !self.began && ev.pos.distance(c) >= cx.tol(3.0) {
|
||||
self.began = true;
|
||||
return vec![
|
||||
@@ -148,13 +178,17 @@ impl Tool for FlareTool {
|
||||
if self.began {
|
||||
return vec![Action::Preview("shape.flare".into(), self.params(c, 2.0 * ev.pos.distance(c), None))];
|
||||
}
|
||||
} else if let Some((c, d)) = self.placed {
|
||||
} else if let Some((mut c, d)) = self.placed {
|
||||
// Space held moves the whole flare, rings' end point included.
|
||||
crate::shape::space_moves(&mut c, &mut self.last, ev, true);
|
||||
self.placed = Some((c, d));
|
||||
return vec![Action::Preview("shape.flare".into(), self.params(c, d, Some(ev.pos)))];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Up => {
|
||||
if let Some((c, d)) = self.placed.take() {
|
||||
if let Some((mut c, d)) = self.placed.take() {
|
||||
crate::shape::space_moves(&mut c, &mut self.last, ev, true);
|
||||
self.began = false;
|
||||
return vec![Action::Preview("shape.flare".into(), self.params(c, d, Some(ev.pos))), Action::Commit];
|
||||
}
|
||||
@@ -538,6 +572,109 @@ mod tests {
|
||||
assert_eq!(t.params(Point::ZERO, 1.0, None)["rays"].as_u64(), Some(0));
|
||||
}
|
||||
|
||||
/// The last `shape.flare` preview of `a`.
|
||||
fn flare_preview(a: &[Action]) -> Value {
|
||||
match a.iter().rev().find(|a| matches!(a, Action::Preview(..))) {
|
||||
Some(Action::Preview(c, v)) if c == "shape.flare" => v.clone(),
|
||||
other => panic!("no flare preview: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Equal vertical travel gives equal rotation, regardless of event partitioning.
|
||||
#[test]
|
||||
fn flare_drag_turns_the_rays_and_shift_holds_their_turn() {
|
||||
use crate::testutil::*;
|
||||
use vectorcraft_doc::Selection;
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let space = Mods { space: true, ..Default::default() };
|
||||
let mut results = vec![];
|
||||
for steps in [1, 8, 64] {
|
||||
let mut t = FlareTool::default();
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
|
||||
for i in 1..=steps {
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0 + 80.0 * i as f64 / steps as f64));
|
||||
}
|
||||
assert_eq!(t.turn, 80.0);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 180.0));
|
||||
assert_eq!(t.turn, 80.0, "horizontal movement doesn't turn");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 150.0));
|
||||
assert_eq!(t.turn, 50.0, "reverse travel subtracts its distance");
|
||||
let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 200.0).with_mods(shift)));
|
||||
assert_eq!(v["rotation"].as_f64(), Some(50.0));
|
||||
assert_eq!(v["diameter"].as_f64(), Some(200.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 220.0));
|
||||
assert_eq!(t.turn, 70.0, "release does not catch up frozen movement");
|
||||
t.key(&cx, ToolKey::Up, Mods::default());
|
||||
assert_eq!(t.turn, 70.0);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 220.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 200.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 260.0));
|
||||
assert_eq!(a.last(), Some(&Action::Commit));
|
||||
assert_eq!(flare_preview(&a)["rotation"].as_f64(), Some(70.0));
|
||||
results.push(t.turn);
|
||||
}
|
||||
assert!(results.windows(2).all(|p| p[0] == p[1]));
|
||||
// Space held before the first drag update freezes rotation too.
|
||||
let mut t = FlareTool::default();
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 140.0).with_mods(space));
|
||||
assert_eq!(t.turn, 0.0);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 150.0));
|
||||
assert_eq!(t.turn, 10.0);
|
||||
}
|
||||
|
||||
/// Space held while dragging out a flare moves it at its current size and turn, in the center
|
||||
/// drag and in the drag that places the rings' end point; let go, the drag goes on from there.
|
||||
#[test]
|
||||
fn space_moves_the_flare_being_drawn() {
|
||||
use crate::testutil::*;
|
||||
use vectorcraft_doc::Selection;
|
||||
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let space = Mods { space: true, ..Default::default() };
|
||||
let step = 30.0;
|
||||
let mut t = FlareTool::default();
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 130.0));
|
||||
// Space held: the pointer's y grows, but the flare moves without turning.
|
||||
let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 180.0).with_mods(space)));
|
||||
let at = |v: &Value| (v["cx"].as_f64(), v["cy"].as_f64(), v["diameter"].as_f64());
|
||||
let turned = |v: &Value, by: f64| v["rotation"].as_f64().is_some_and(|r| (r - by).abs() < 1e-9);
|
||||
assert_eq!(at(&v), (Some(150.0), Some(150.0), Some(60.0)));
|
||||
assert!(turned(&v, step));
|
||||
// Space let go: the size follows the pointer again, from the moved center, and so does the turn.
|
||||
let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 190.0)));
|
||||
assert_eq!(at(&v), (Some(150.0), Some(150.0), Some(80.0)));
|
||||
assert!(turned(&v, step + 10.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 190.0));
|
||||
// The second drag: Space held moves the flare, its rings' end point with it.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 300.0, 300.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
|
||||
let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 320.0, 310.0).with_mods(space)));
|
||||
assert_eq!((at(&v), v["x2"].as_f64(), v["y2"].as_f64()), ((Some(170.0), Some(160.0), Some(80.0)), Some(320.0), Some(310.0)));
|
||||
assert!(turned(&v, step + 10.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 340.0, 310.0));
|
||||
assert_eq!(a.last(), Some(&Action::Commit));
|
||||
let v = flare_preview(&a);
|
||||
assert_eq!((v["cx"].as_f64(), v["cy"].as_f64(), v["x2"].as_f64()), (Some(170.0), Some(160.0), Some(340.0)));
|
||||
// A release with Space held away from the last drag moves the flare there too.
|
||||
let mut t = FlareTool::default();
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 100.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 130.0, 100.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 320.0, 300.0));
|
||||
let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 340.0, 310.0).with_mods(space)));
|
||||
assert_eq!((v["cx"].as_f64(), v["cy"].as_f64(), v["x2"].as_f64()), (Some(120.0), Some(110.0), Some(340.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_drag_honors_measurement_labels() {
|
||||
use crate::testutil::*;
|
||||
|
||||
@@ -219,6 +219,11 @@ pub trait Tool: Send {
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
vec![]
|
||||
}
|
||||
/// The tool, already active, is chosen again (its toolbar button, its shortcut, `tool.select`).
|
||||
/// Most tools keep their state; the Blend tool finishes the blend it is adding to.
|
||||
fn chosen_again(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
vec![]
|
||||
}
|
||||
/// A command from outside the tool ran (a menu, a panel, an agent): finish work it ended (the
|
||||
/// Type tool stops editing text the command took out of the selection).
|
||||
fn after_command(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
|
||||
@@ -88,6 +88,11 @@ impl Tool for BlendTool {
|
||||
(t.first, t.chained) = (None, false);
|
||||
vec![Action::Dialog(BLEND_OPTIONS.into(), json!({}))]
|
||||
};
|
||||
// A first pick whose object is no longer in the document (deleted, or undone away) is
|
||||
// dropped, so the click picks the object it hits as the new first pick.
|
||||
if self.first.is_some_and(|(a, _)| cx.doc.node(a).is_none()) {
|
||||
(self.first, self.chained) = (None, false);
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::DoubleClick => options(self),
|
||||
PointerKind::Down if ev.mods.alt => options(self),
|
||||
@@ -148,6 +153,12 @@ impl Tool for BlendTool {
|
||||
(self.first, self.chained) = (None, false);
|
||||
vec![]
|
||||
}
|
||||
/// Choosing the Blend tool again finishes the blend: it drops the object picked first and
|
||||
/// stops adding to the blend just made, so the next click picks a new first object. The
|
||||
/// document and the selection stay as they are.
|
||||
fn chosen_again(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||||
self.deactivate(cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -296,6 +307,72 @@ mod tests {
|
||||
assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0]}))]);
|
||||
}
|
||||
|
||||
/// Choosing the Blend tool again drops the object picked first and stops adding to the blend
|
||||
/// just made: the next click is a new first pick. The Mesh tool keeps its state.
|
||||
#[test]
|
||||
fn choosing_the_blend_tool_again_finishes_the_blend() {
|
||||
let (mut d, a) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let (b, g) = (d.alloc_id(), d.alloc_id());
|
||||
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0));
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let (k1, k2) = (d.alloc_id(), d.alloc_id());
|
||||
let key = |id: NodeId, x: f64| {
|
||||
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0));
|
||||
std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art()))
|
||||
};
|
||||
let blend = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 0.0), key(k2, 100.0)], spec: Default::default() });
|
||||
d.insert(Some(l), 2, blend).unwrap();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let picks = |id: NodeId| vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))];
|
||||
let down = |x: f64, y: f64| PointerEvent::new(PointerKind::Down, x, y);
|
||||
let mut t = BlendTool::default();
|
||||
assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a));
|
||||
assert!(t.busy());
|
||||
assert!(t.chosen_again(&c).is_empty(), "choosing the tool again changes nothing in the document");
|
||||
assert!(!t.busy() && t.first(&c).is_none() && t.overlays(&c).is_empty(), "and drops the pick");
|
||||
assert_eq!(t.pointer(&c, &down(320.0, 120.0)), picks(b), "b is picked first");
|
||||
// After a blend is made, the next click no longer adds to it.
|
||||
let mut s = Selection::default();
|
||||
s.set([g]);
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = BlendTool { first: None, chained: true };
|
||||
assert!(t.first(&c).is_some());
|
||||
assert!(t.chosen_again(&c).is_empty());
|
||||
assert!(t.first(&c).is_none(), "choosing the tool again ends adding to the blend just made");
|
||||
assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a));
|
||||
// Other tools keep their state: the default does nothing.
|
||||
assert!(create("mesh").unwrap().chosen_again(&c).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_tool_picks_again_when_its_first_pick_is_gone() {
|
||||
let (mut d, a) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let (b, e) = (d.alloc_id(), d.alloc_id());
|
||||
for (id, y) in [(b, 100.0), (e, 300.0)] {
|
||||
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, y, 350.0, y + 50.0));
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
}
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let mut t = BlendTool::default();
|
||||
t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
|
||||
// The first pick is deleted (or undone away) while the tool waits for the second click.
|
||||
d.remove(a).unwrap();
|
||||
let c = cx(&d, &s, &p);
|
||||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 320.0, 120.0));
|
||||
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [b.0]}))], "the click picks b first");
|
||||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 320.0, 320.0));
|
||||
assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [b.0, e.0]}))]);
|
||||
// After a blend is made, a click while the selected blend is gone picks the object it hits.
|
||||
let mut s = Selection::default();
|
||||
s.set([NodeId(9999)]);
|
||||
let mut t = BlendTool { first: None, chained: true };
|
||||
let acts = t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 320.0, 120.0));
|
||||
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [b.0]}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_tool_anchor_clicks_and_options() {
|
||||
let (mut d, a) = doc_with_rect();
|
||||
|
||||
@@ -37,7 +37,7 @@ use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{Hit, HitKind, hit_test, marquee, objects_at};
|
||||
use vectorcraft_doc::hit::{Hit, HitKind, hit_test, marquee, objects_at, selected_hidden_at};
|
||||
use vectorcraft_doc::{NodeId, OrientedBox};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
@@ -740,7 +740,7 @@ impl Tool for SelectionTool {
|
||||
out
|
||||
}
|
||||
None => {
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false };
|
||||
self.press_nothing(cx, p, grab, m);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -1191,6 +1191,17 @@ impl SelectionTool {
|
||||
}
|
||||
|
||||
impl SelectionTool {
|
||||
/// A press at `p` that hits no object starts a marquee, except where a clipping mask hides
|
||||
/// the art of a selected object (picked in the Layers panel, say): there, without Shift, it
|
||||
/// grabs the selection by that art (at `grab`), as a press on its visible part does.
|
||||
fn press_nothing(&mut self, cx: &ToolContext, p: Point, grab: Point, m: Mods) {
|
||||
self.state = if !m.shift && selected_hidden_at(cx.doc, p, cx.selection_hit_options(), &cx.selection.objects).is_some() {
|
||||
State::Moving { start: grab, press: p, hold: grab, began: false, deselect: None, key: None }
|
||||
} else {
|
||||
State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false }
|
||||
};
|
||||
}
|
||||
|
||||
fn drag_moving(&mut self, cx: &ToolContext, p: Point, m: Mods, (start, press, hold, began): (Point, Point, Point, bool)) -> Vec<Action> {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
|
||||
@@ -79,6 +79,53 @@ fn a_press_on_a_masks_edge_selects_its_clip_group() {
|
||||
assert!(!a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "{a:?}");
|
||||
}
|
||||
|
||||
/// The art of a selected clipped object (picked in the Layers panel), pressed where its mask
|
||||
/// hides it, is grabbed: a drag moves the selection (the art, not its clip group) and a click
|
||||
/// keeps it selected. With nothing of it selected, or with Shift, a press there starts a marquee.
|
||||
#[test]
|
||||
fn a_press_where_a_mask_hides_a_selected_object_drags_it() {
|
||||
let (d, _, _, art) = doc_with_masked_square();
|
||||
let p = paint();
|
||||
let mut picked = Selection::default();
|
||||
picked.add(art);
|
||||
let shift = |kind, x, y| {
|
||||
let mut e = ev(kind, x, y);
|
||||
e.mods.shift = true;
|
||||
e
|
||||
};
|
||||
let changes_selection = |a: &[Action]| a.iter().any(|a| matches!(a, Action::Exec(c, _) if c.starts_with("select.")));
|
||||
// (370, 450): on the art, outside the mask (400..500).
|
||||
let mut t = SelectionTool::default();
|
||||
let c = cx(&d, &picked, &p);
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Down, 370.0, 450.0)).is_empty());
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 390.0, 470.0));
|
||||
assert!(a.iter().any(|a| matches!(a, Action::Begin(n) if n == "Move")), "{a:?}");
|
||||
assert!(a.iter().any(|a| matches!(a, Action::Preview(c, _) if c == "object.transform")), "{a:?}");
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 390.0, 470.0)), vec![Action::Commit]);
|
||||
// A click there keeps the selection.
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, 370.0, 450.0));
|
||||
assert!(!changes_selection(&t.pointer(&c, &ev(PointerKind::Up, 370.0, 450.0))));
|
||||
// Nothing selected: a marquee, and a click deselects.
|
||||
let none = Selection::default();
|
||||
let c = cx(&d, &none, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, 370.0, 450.0));
|
||||
assert!(!t.pointer(&c, &ev(PointerKind::Drag, 390.0, 470.0)).iter().any(|a| matches!(a, Action::Begin(_))));
|
||||
// Shift: a marquee that adds to the selection.
|
||||
let c = cx(&d, &picked, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&c, &shift(PointerKind::Down, 370.0, 450.0));
|
||||
assert!(!t.pointer(&c, &shift(PointerKind::Drag, 390.0, 470.0)).iter().any(|a| matches!(a, Action::Begin(_))));
|
||||
// An object a click hits there wins: a press on a square over that point selects it.
|
||||
let mut d = d;
|
||||
let (front, l) = (d.alloc_id(), d.layers[0].id);
|
||||
let square = vectorcraft_geom::shapes::rectangle(Rect::new(350.0, 430.0, 390.0, 470.0));
|
||||
d.insert(Some(l), 2, vectorcraft_doc::Node::path(front, square, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let a = SelectionTool::default().pointer(&cx(&d, &picked, &p), &ev(PointerKind::Down, 370.0, 450.0));
|
||||
assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([front.0]))), "{a:?}");
|
||||
}
|
||||
|
||||
/// #955: grabbed by an anchor and dropped near another object's anchor, the anchor lands
|
||||
/// exactly on it with Smart Guides, even where lining the bounds up would leave it beside it.
|
||||
#[test]
|
||||
|
||||
@@ -202,6 +202,17 @@ pub struct TypeTool {
|
||||
}
|
||||
|
||||
impl TypeTool {
|
||||
pub(super) fn step_attribute(&mut self, key: ToolKey, mods: Mods, a: usize, b: usize) -> Option<Vec<Action>> {
|
||||
if !mods.alt {
|
||||
return None;
|
||||
}
|
||||
let id = self.editing?;
|
||||
let (attribute, by) = plain::step_key(key, mods.shift, a == b)?;
|
||||
let mut out = self.commit();
|
||||
let by = if mods.cmd { by * 5.0 } else { by };
|
||||
out.push(Action::Exec("type.step".into(), json!({"id": id.0, "start": a, "end": b, "attribute": attribute, "by": by})));
|
||||
Some(out)
|
||||
}
|
||||
pub fn new(id: &str) -> Self {
|
||||
let mode = match id {
|
||||
"areaType" | "verticalAreaType" => Mode::Area,
|
||||
|
||||
@@ -1469,3 +1469,22 @@ fn ime_surrounding_reports_the_document_when_it_changed_under_the_session() {
|
||||
assert_eq!(stale, ImeSurrounding::Available { text: "XYZ".into(), cursor: 3, anchor: 3 });
|
||||
assert!(!tool.ime_session_matches_document(&cx(&d, &sel, &p)), "the commit starts a fresh session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_enter_inserts_soft_return() {
|
||||
let (mut d, id, mut tool) = editing("Hello World");
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
tool.caret = 5;
|
||||
tool.anchor = 5;
|
||||
let snap = d.clone();
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let acts = tool.key(&cx(&d, &sel, &p), ToolKey::Enter, shift);
|
||||
apply(&mut d, &snap, &acts);
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), "Hello\u{2028} World");
|
||||
// Plain Enter inserts standard newline
|
||||
let acts = tool.key(&cx(&d, &sel, &p), ToolKey::Enter, Mods::default());
|
||||
apply(&mut d, &snap, &acts);
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), "Hello\u{2028}\n World");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::plain::{
|
||||
char_range_to_bytes, ime_surrounding_window, plain_ceil_boundary, plain_chars_after, plain_chars_before, plain_floor_boundary, plain_is_boundary,
|
||||
plain_len, step_key,
|
||||
plain_len,
|
||||
};
|
||||
use super::ports::{ported, ports, ports_shown};
|
||||
use super::{
|
||||
@@ -175,13 +175,7 @@ impl Tool for TypeTool {
|
||||
self.anchor = self.anchor.min(len);
|
||||
let (a, b) = self.sel();
|
||||
// Alt+arrows step the type by the Preferences › Type increments (Cmd/Ctrl too: five steps).
|
||||
if mods.alt
|
||||
&& let Some(id) = self.editing
|
||||
&& let Some((attribute, by)) = step_key(key, mods.shift, a == b)
|
||||
{
|
||||
let mut out = self.commit();
|
||||
let by = if mods.cmd { by * 5.0 } else { by };
|
||||
out.push(Action::Exec("type.step".into(), json!({"id": id.0, "start": a, "end": b, "attribute": attribute, "by": by})));
|
||||
if let Some(out) = self.step_attribute(key, mods, a, b) {
|
||||
return out;
|
||||
}
|
||||
let word = mods.cmd || mods.alt;
|
||||
@@ -211,7 +205,7 @@ impl Tool for TypeTool {
|
||||
out.push(Action::SwitchTool(target));
|
||||
out
|
||||
}
|
||||
ToolKey::Enter => self.replace(cx, "\n", None),
|
||||
ToolKey::Enter => self.replace(cx, if mods.shift { "\u{2028}" } else { "\n" }, None),
|
||||
ToolKey::Tab => self.replace(cx, "\t", None),
|
||||
ToolKey::Backspace | ToolKey::Delete if a != b => self.replace(cx, "", None),
|
||||
ToolKey::Backspace if self.caret > 0 => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user