mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 10:01:58 +00:00
Merge upstream/main into fix/6865-ai-oversized-artboard
This commit is contained in:
@@ -128,6 +128,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
- 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged.
|
||||
- 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`).
|
||||
- 2026-11-11: Pattern libraries: Patterns › Grit Textures, a second built-in pattern library, holds eight seamless black textures (Fine Grain, Coarse Grit, Sand, Speckle, Stipple, Scratches, Sparse Flecks and Dense Flecks) on tiles of 18 to 36 pt, each drawn the same way every time; marks that reach past a tile edge continue on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged.
|
||||
- 2026-11-11: Pattern libraries: Patterns › Optical Effects, a third built-in pattern library, of VectorCraft's own designs, holds eight black wave, moiré and op-art tiles (Sine Bands, Ripple Lines, Interference Rings, Offset Rings, Warped Checks, Pinched Stripes, Chevron Pulse and Radiating Lenses) of 32 to 48 pt, each drawn the same way every time; each tile is one period of its design, and art that reaches past a tile edge continues on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged.
|
||||
- 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923).
|
||||
- 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922).
|
||||
- 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925).
|
||||
|
||||
@@ -381,6 +381,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
exists: Some(std::sync::Arc::new(|p: &str| std::path::Path::new(p).exists())),
|
||||
// Background Save and Export write from a worker thread.
|
||||
write_shared: Some(std::sync::Arc::new(write_file)),
|
||||
save_write: Some(std::sync::Arc::new(fileio::write_save_file)),
|
||||
save_stream: Some(std::sync::Arc::new(fileio::write_save_file_with)),
|
||||
// Native saves stream into the file as they are encoded.
|
||||
stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| {
|
||||
fileio::write_file_with(p, fill).map_err(|e| e.to_string())
|
||||
|
||||
@@ -102,13 +102,22 @@ fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Sav
|
||||
log::warn!("the portal's save dialog picked a file name that isn't UTF-8: nothing is saved");
|
||||
return Ok(None);
|
||||
};
|
||||
let filter = results.get("current_filter").and_then(|v| {
|
||||
let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
|
||||
Some(label)
|
||||
});
|
||||
let filter = results.get("current_filter").and_then(|v| filter_label(v));
|
||||
Ok(Some(Saved { path, filter }))
|
||||
}
|
||||
|
||||
/// The label of a file type as the portal sends it, a `(sa(us))` structure; `None` for a value of
|
||||
/// another shape.
|
||||
fn filter_label(value: &Value<'_>) -> Option<String> {
|
||||
match value {
|
||||
Value::Structure(s) => match s.fields() {
|
||||
[Value::Str(label), Value::Array(globs)] if globs.element_signature() == "(us)" => Some(label.to_string()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg` (its bytes); `None` for a file elsewhere
|
||||
/// (`file://host/`) or another scheme.
|
||||
fn file_uri_path(uri: &str) -> Option<Vec<u8>> {
|
||||
@@ -396,6 +405,36 @@ mod tests {
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster"]);
|
||||
}
|
||||
|
||||
/// A `current_filter` of another shape than `(sa(us))` (a structure with fewer, other or more
|
||||
/// fields, a string) gives the path alone; `uris` of another shape is an error.
|
||||
#[test]
|
||||
fn an_answer_of_another_shape_is_not_read() {
|
||||
let malformed = [
|
||||
Value::from(("SVG".to_string(),)),
|
||||
Value::from(("SVG".to_string(), vec![(0u32,)])),
|
||||
Value::from(("SVG".to_string(), vec![("*.svg".to_string(), 0u32)])),
|
||||
Value::from((1u32, vec![(0u32, "*.svg".to_string())])),
|
||||
Value::from(("SVG".to_string(), vec![(0u32, "*.svg".to_string())], 1u32)),
|
||||
Value::from("SVG"),
|
||||
];
|
||||
for filter in malformed {
|
||||
let mut r = results("file:///tmp/card", None);
|
||||
r.insert("current_filter".to_string(), OwnedValue::try_from(filter).unwrap());
|
||||
assert_eq!(answer(0, &r), Ok(Some(Saved { path: "/tmp/card".into(), filter: None })));
|
||||
}
|
||||
let malformed = [
|
||||
Value::from(vec![1u32]),
|
||||
Value::from(vec![("file:///tmp/card".to_string(),)]),
|
||||
Value::from(Vec::<String>::new()),
|
||||
Value::from("file:///tmp/card"),
|
||||
];
|
||||
for uris in malformed {
|
||||
let mut r = HashMap::new();
|
||||
r.insert("uris".to_string(), OwnedValue::try_from(uris).unwrap());
|
||||
assert!(answer(0, &r).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel (1) and a dialog closed another way (2) answer no file; an answer without a local file
|
||||
/// is an error, and rfd shows the dialog instead.
|
||||
#[test]
|
||||
@@ -486,10 +525,7 @@ mod tests {
|
||||
}
|
||||
let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap<String, OwnedValue>)>() else { break };
|
||||
let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok());
|
||||
let filter = options.get("current_filter").and_then(|v| {
|
||||
let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
|
||||
Some(label)
|
||||
});
|
||||
let filter = options.get("current_filter").and_then(|v| filter_label(v));
|
||||
let calls = log.lock().map(|mut l| {
|
||||
l.push((text("current_name"), filter));
|
||||
l.len()
|
||||
|
||||
+26
-4
@@ -133,6 +133,13 @@ pub(crate) fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Deco
|
||||
Some((d.colortype().ok()?, d))
|
||||
}
|
||||
|
||||
/// The Orientation tag of TIFF `bytes`' first image, of any unsigned type; `None` for other images
|
||||
/// and TIFFs without one. Reads only its header.
|
||||
pub fn tiff_orientation(bytes: &[u8]) -> Option<u16> {
|
||||
let (_, mut d) = tiff(bytes)?;
|
||||
d.find_tag_unsigned::<u16>(tiff::tags::Tag::Orientation).ok().flatten()
|
||||
}
|
||||
|
||||
/// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)?
|
||||
fn planar(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>) -> bool {
|
||||
d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1)
|
||||
@@ -309,10 +316,10 @@ fn tiff_inks(bytes: &[u8]) -> Option<Inks> {
|
||||
Inks::new(w, h, read_samples(&mut d, n, 4)?)
|
||||
}
|
||||
|
||||
/// CMYK TIFF `bytes` as Open and Place store them: as they are, or, when they store one plane per
|
||||
/// ink, which the image decoder doesn't read, their ink amounts written again by
|
||||
/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi`. `None` for other images and for CMYK
|
||||
/// TIFFs whose inks [`ImageBlob::cmyk`] doesn't read.
|
||||
/// CMYK TIFF `bytes` that no orientation turns ([`turned_cmyk`] stores the others) as Open and
|
||||
/// Place store them: as they are, or, when they store one plane per ink, which the image decoder
|
||||
/// doesn't read, their ink amounts written again by [`ImageBlob::cmyk_tiff_at`] with the resolution
|
||||
/// `ppi`. `None` for other images and for CMYK TIFFs whose inks [`ImageBlob::cmyk`] doesn't read.
|
||||
pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option<Vec<u8>> {
|
||||
let inks = tiff_inks(bytes)?;
|
||||
let (_, mut d) = tiff(bytes)?;
|
||||
@@ -322,6 +329,21 @@ pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option<Vec<u8>
|
||||
ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes))
|
||||
}
|
||||
|
||||
/// CMYK JPEG or TIFF `bytes` that `orientation` turns or flips, as Open and Place store them: their
|
||||
/// ink amounts ([`ImageBlob::cmyk`], 8 bits each) turned and written again by
|
||||
/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi` of the turned pixels, with the turned
|
||||
/// width and height. `None` for other images and for CMYK images whose inks [`ImageBlob::cmyk`]
|
||||
/// doesn't read.
|
||||
pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: Option<(f64, f64)>) -> Option<(Vec<u8>, (u32, u32))> {
|
||||
let inks = if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }?;
|
||||
// Four 8-bit inks to a pixel, turned as four 8-bit channels are.
|
||||
let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?);
|
||||
px.apply_orientation(orientation);
|
||||
let inks = Inks::new(px.width(), px.height(), px.into_bytes())?;
|
||||
let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?;
|
||||
Some((Arc::unwrap_or_clone(tiff.bytes), (inks.width, inks.height)))
|
||||
}
|
||||
|
||||
/// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at
|
||||
/// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink
|
||||
/// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB),
|
||||
|
||||
@@ -180,6 +180,24 @@ impl ObjectsOnPath {
|
||||
});
|
||||
Some((spacing, start))
|
||||
}
|
||||
/// Move the object `member` to the place on the path of the object `target`: the objects
|
||||
/// between them each move one place toward where `member` was, except the objects for which
|
||||
/// `fixed` is true, which keep their places and are passed over. Only the order changes, not the
|
||||
/// Space or the start. Returns `member`'s new index, or `None` when either one is not an object
|
||||
/// of the group.
|
||||
pub fn move_member(&mut self, member: NodeId, target: NodeId, fixed: impl Fn(NodeId) -> bool) -> Option<usize> {
|
||||
let moves = |id: NodeId| id == member || id == target || !fixed(id);
|
||||
let slots: Vec<usize> = (0..self.members.len()).filter(|&i| moves(self.members[i].id)).collect();
|
||||
let from = slots.iter().position(|&i| self.members[i].id == member)?;
|
||||
let to = slots.iter().position(|&i| self.members[i].id == target)?;
|
||||
let mut moving: Vec<_> = slots.iter().map(|&i| self.members[i]).collect();
|
||||
let m = moving.remove(from);
|
||||
moving.insert(to, m);
|
||||
for (&i, m) in slots.iter().zip(moving) {
|
||||
self.members[i] = m;
|
||||
}
|
||||
Some(slots[to])
|
||||
}
|
||||
/// The children given new ids (a copy of the group): the path and the objects by their new ids.
|
||||
pub fn rename(&mut self, ids: &HashMap<NodeId, NodeId>) {
|
||||
let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id);
|
||||
@@ -487,6 +505,36 @@ mod tests {
|
||||
assert_eq!(layout(400.0, true, 4, Some(300.0), 400.0), (0.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_an_object_shifts_the_ones_between_it_and_its_new_place() {
|
||||
let (a, b, c) =
|
||||
(rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0)), rect(4, Rect::new(40.0, 0.0, 50.0, 10.0)));
|
||||
let rec = ObjectsOnPath { spacing: Some(30.0), start: 5.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b, &c], CENTER, 15.0) };
|
||||
let order = |r: &ObjectsOnPath| r.members.iter().map(|m| m.id.0).collect::<Vec<_>>();
|
||||
let free = |_: NodeId| false;
|
||||
let mut fwd = rec.clone();
|
||||
assert_eq!(fwd.move_member(NodeId(2), NodeId(4), free), Some(2));
|
||||
assert_eq!(order(&fwd), [3, 4, 2], "the first to the last place");
|
||||
assert_eq!((fwd.spacing, fwd.start, fwd.pivot, fwd.rotate), (rec.spacing, rec.start, rec.pivot, rec.rotate));
|
||||
let mut back = rec.clone();
|
||||
assert_eq!(back.move_member(NodeId(4), NodeId(2), free), Some(0));
|
||||
assert_eq!(order(&back), [4, 2, 3], "the last to the first place");
|
||||
let mut same = rec.clone();
|
||||
assert_eq!(same.move_member(NodeId(3), NodeId(3), free), Some(1));
|
||||
assert_eq!(same, rec, "onto its own place");
|
||||
let mut other = rec.clone();
|
||||
assert_eq!(other.move_member(NodeId(1), NodeId(3), free), None, "the path is not one of the objects");
|
||||
assert_eq!(other.move_member(NodeId(3), NodeId(9), free), None);
|
||||
assert_eq!(other, rec);
|
||||
// A fixed object between them keeps its place; the others pass over it.
|
||||
let mut over = rec.clone();
|
||||
assert_eq!(over.move_member(NodeId(2), NodeId(4), |id| id == NodeId(3)), Some(2));
|
||||
assert_eq!(order(&over), [4, 3, 2], "the first and last trade places round the fixed one");
|
||||
let mut back_over = rec.clone();
|
||||
assert_eq!(back_over.move_member(NodeId(4), NodeId(2), |id| id == NodeId(3)), Some(0));
|
||||
assert_eq!(order(&back_over), [4, 3, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_saved_without_space_reads_as_auto() {
|
||||
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
|
||||
@@ -59,6 +59,11 @@ pub struct Selection {
|
||||
/// that object ([`crate::symbol_overrides`]). Any other selection change clears it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub symbol_part: Option<SymbolPart>,
|
||||
/// An object of an Objects on Path group, chosen with its Select/Move widget: the group is the
|
||||
/// one selected object, and the object is one of its objects on the path
|
||||
/// ([`crate::objects_on_path::ObjectsOnPath::members`]). Any other selection change clears it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_path_member: Option<NodeId>,
|
||||
}
|
||||
|
||||
/// See [`Selection::symbol_part`].
|
||||
@@ -94,6 +99,13 @@ impl Selection {
|
||||
self.guides.clear();
|
||||
self.key_guide = None;
|
||||
self.symbol_part = None;
|
||||
self.on_path_member = None;
|
||||
}
|
||||
/// Select only the Objects on Path group `group`, with its object `member` (see
|
||||
/// [`Selection::on_path_member`]).
|
||||
pub fn set_on_path_member(&mut self, group: NodeId, member: NodeId) {
|
||||
self.set([group]);
|
||||
self.on_path_member = Some(member);
|
||||
}
|
||||
/// Select only instance `instance`, with the object at `path` of its symbol's art (see
|
||||
/// [`Selection::symbol_part`]).
|
||||
@@ -115,6 +127,7 @@ impl Selection {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.symbol_part = None;
|
||||
self.on_path_member = None;
|
||||
if !self.objects.contains(&id) {
|
||||
self.objects.push(id);
|
||||
}
|
||||
@@ -123,6 +136,7 @@ impl Selection {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.symbol_part = None;
|
||||
self.on_path_member = None;
|
||||
self.objects.retain(|x| *x != id);
|
||||
self.anchors.remove(&id);
|
||||
self.segments.remove(&id);
|
||||
@@ -152,6 +166,7 @@ impl Selection {
|
||||
/// Shift-click on a guide).
|
||||
pub fn toggle_guide(&mut self, i: NodeId) {
|
||||
self.symbol_part = None;
|
||||
self.on_path_member = None;
|
||||
if let Some(k) = self.guides.iter().position(|g| *g == i) {
|
||||
self.guides.remove(k);
|
||||
if self.key_guide == Some(i) {
|
||||
@@ -258,6 +273,22 @@ impl Selection {
|
||||
if !part_kept {
|
||||
self.symbol_part = None;
|
||||
}
|
||||
// A chosen object on a path needs its Objects on Path group selected alone, with no anchors
|
||||
// or guides, and still one of the group's objects, neither hidden nor locked.
|
||||
let member_kept = self.on_path_member.is_some_and(|m| {
|
||||
let [g] = self.objects.as_slice() else { return false };
|
||||
self.anchors.is_empty()
|
||||
&& self.guides.is_empty()
|
||||
&& doc.is_editable(m)
|
||||
&& found
|
||||
.get(g)
|
||||
.and_then(|p| doc.node_at(p))
|
||||
.and_then(|n| n.extras().objects_on_path.as_deref().filter(|_| crate::objects_on_path::is_objects_on_path(n)))
|
||||
.is_some_and(|rec| rec.members.iter().any(|x| x.id == m))
|
||||
});
|
||||
if !member_kept {
|
||||
self.on_path_member = None;
|
||||
}
|
||||
}
|
||||
/// Deselect the objects that are hidden or locked, themselves or through a group or layer
|
||||
/// around them (and so the key object when it is one): hidden or locked art can't stay
|
||||
|
||||
@@ -179,6 +179,49 @@ fn selection_changes_clear_the_target() {
|
||||
assert_eq!(s.target, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chosen_object_on_a_path_needs_its_group_selected_alone() {
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (line, a, b, g, other) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
let line = Node::path(line, shapes::line(Point::new(0.0, 100.0), Point::new(200.0, 100.0)), Default::default());
|
||||
let a = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Default::default());
|
||||
let b = Node::path(b, shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), Default::default());
|
||||
let rec = crate::objects_on_path::ObjectsOnPath::new(line.id, &[&a, &b], crate::objects_on_path::CENTER, 0.0);
|
||||
let (line_id, a_id) = (line.id, a.id);
|
||||
let mut group = Node::new(g, NodeKind::Group { children: vec![Arc::new(line), Arc::new(a), Arc::new(b)], clip: false });
|
||||
group.extras_mut().objects_on_path = Some(Box::new(rec));
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
d.insert(Some(l), 1, Node::path(other, shapes::rectangle(Rect::new(150.0, 0.0, 160.0, 10.0)), Default::default())).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.set_on_path_member(g, a_id);
|
||||
s.prune(&d);
|
||||
assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], Some(a_id)));
|
||||
s.add(other);
|
||||
assert_eq!(s.on_path_member, None, "another object joins the selection");
|
||||
s.set_on_path_member(g, a_id);
|
||||
s.set([g]);
|
||||
assert_eq!(s.on_path_member, None, "a new selection");
|
||||
// The path is not one of the objects, nor is an object outside the group.
|
||||
for m in [line_id, other] {
|
||||
s.set_on_path_member(g, m);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.on_path_member, None, "{m:?}");
|
||||
}
|
||||
// A locked object is not chosen.
|
||||
s.set_on_path_member(g, a_id);
|
||||
d.node_mut(a_id).unwrap().locked = true;
|
||||
s.prune(&d);
|
||||
assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], None), "locked");
|
||||
d.node_mut(a_id).unwrap().locked = false;
|
||||
// Once the group is a plain group, no object of it is chosen.
|
||||
s.set_on_path_member(g, a_id);
|
||||
d.node_mut(g).unwrap().extras_mut().objects_on_path = None;
|
||||
s.prune(&d);
|
||||
assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_object_needs_two_or_more_selected_objects() {
|
||||
let mut s = Selection::default();
|
||||
|
||||
@@ -29,7 +29,7 @@ const MAX_PAINT_COPIES: usize = 256 << 20;
|
||||
|
||||
/// The most face and box tests a group's transparent members' faces take together to rejoin their
|
||||
/// members' last runs; past them each starts a run of its own.
|
||||
const MAX_OVERLAP_TESTS: usize = 1 << 22;
|
||||
pub(crate) const MAX_OVERLAP_TESTS: usize = 1 << 22;
|
||||
|
||||
/// The width of the stroke around each face that covers the seams between faces.
|
||||
const SEAM_WIDTH: f64 = 0.75;
|
||||
@@ -628,14 +628,14 @@ pub(crate) fn scoped<'a>(id: NodeId, surfaces: Vec<(&'a [Scope], Arc<Node>)>, le
|
||||
/// What a face can paint, to tell whether two faces overlap: the convex hull of the points and
|
||||
/// control points of its outline, which holds the outline, and the hull's box grown by half the
|
||||
/// seam stroke.
|
||||
struct Coverage {
|
||||
bounds: Rect,
|
||||
pub(crate) struct Coverage {
|
||||
pub(crate) bounds: Rect,
|
||||
hull: Vec<Point>,
|
||||
}
|
||||
|
||||
impl Coverage {
|
||||
/// `None` for a face with no outline and no box.
|
||||
fn of(face: &Node) -> Option<Coverage> {
|
||||
pub(crate) fn of(face: &Node) -> Option<Coverage> {
|
||||
let points: Vec<Point> = match face.path_data() {
|
||||
Some(path) => path
|
||||
.to_bezpath()
|
||||
@@ -663,7 +663,7 @@ impl Coverage {
|
||||
/// Do the two faces' hulls, each grown by half the seam stroke, overlap? A test of the
|
||||
/// separating axes of their edges and of the page's axes: `true` whenever they overlap, and
|
||||
/// sometimes near a corner when they don't.
|
||||
fn meets(&self, other: &Coverage) -> bool {
|
||||
pub(crate) fn meets(&self, other: &Coverage) -> bool {
|
||||
fn normals(hull: &[Point]) -> impl Iterator<Item = Vec2> + '_ {
|
||||
(0..hull.len()).filter_map(|i| {
|
||||
let (a, b) = (hull.get(i)?, hull.get((i + 1) % hull.len())?);
|
||||
@@ -710,7 +710,7 @@ fn convex_hull(mut points: Vec<Point>) -> Vec<Point> {
|
||||
}
|
||||
|
||||
/// Do boxes `a` and `b` overlap with some area?
|
||||
fn overlaps(a: Rect, b: Rect) -> bool {
|
||||
pub(crate) fn overlaps(a: Rect, b: Rect) -> bool {
|
||||
a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1
|
||||
}
|
||||
|
||||
|
||||
@@ -700,10 +700,11 @@ type ShadowAndSurfaces = (Vec<Arc<Node>>, Vec<Arc<Node>>);
|
||||
/// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a
|
||||
/// solid from the path for the top visible fill and one from the outline of each visible stroke,
|
||||
/// each in its own paint. When every one is opaque with normal blending and no colored light makes
|
||||
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
|
||||
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance, and with
|
||||
/// caps, a shape's fill walls before the faces of its covering strokes that overlap them:
|
||||
/// [`draw_order`]);
|
||||
/// a surface one composite, their faces are drawn in one back-to-front order (where depths tie, as
|
||||
/// for the caps turned toward the viewer, in the order of the appearance, and with caps, a shape's
|
||||
/// fill walls before the faces of its covering strokes that overlap them: [`draw_order`]), the
|
||||
/// faces of a solid in a gradient with transparent stops in runs that carry its opacity mask
|
||||
/// ([`in_runs`]);
|
||||
/// otherwise each solid is a surface of its own, in that order. A member's opacity mask and
|
||||
/// blending mode, and those of a group it is in, apply to the surfaces of the members it holds
|
||||
/// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it
|
||||
@@ -856,19 +857,21 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod
|
||||
return Some(masked(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect()));
|
||||
}
|
||||
// One order takes a plain group of faces from each solid, one face for each facet. With
|
||||
// colored lights a surface can instead be one clipped composite of all its facets, or carry an
|
||||
// opacity mask, and then it stays whole.
|
||||
let plain = surfaces.iter().all(|(depths, s, _)| {
|
||||
s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())
|
||||
});
|
||||
// colored lights a surface can instead be one clipped composite of all its facets. A composite
|
||||
// has no faces to order, so when any surface is one, every surface of the solid, masked ones
|
||||
// included, draws whole in member order.
|
||||
let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()));
|
||||
if !opaque || !plain {
|
||||
return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect());
|
||||
}
|
||||
// A surface in a gradient with transparent stops carries an opacity mask: its faces go in runs
|
||||
// ([`in_runs`]), and each run carries the mask.
|
||||
let masks: Vec<Option<Box<OpacityMask>>> = surfaces.iter().map(|(_, s, _)| s.extras().mask.clone()).collect();
|
||||
let (mut placed, mut faces) = (vec![], vec![]);
|
||||
for (depths, s, _) in surfaces {
|
||||
for (surface, (depths, s, _)) in surfaces.into_iter().enumerate() {
|
||||
if let NodeKind::Group { children, .. } = s.kind {
|
||||
placed.extend(depths);
|
||||
faces.extend(children.into_iter().map(Some));
|
||||
faces.extend(children.into_iter().map(|face| Some((surface, face))));
|
||||
}
|
||||
}
|
||||
let sealed = match fx.id.as_str() {
|
||||
@@ -877,7 +880,69 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod
|
||||
_ => extrude_options(&p).cap,
|
||||
};
|
||||
let order = draw_order(&placed, sealed);
|
||||
Some(vec![Arc::new(Node::group(n.id, order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect()))])
|
||||
let faces: Vec<(usize, Arc<Node>)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect();
|
||||
let children = if masks.iter().all(Option::is_none) { faces.into_iter().map(|(_, face)| face).collect() } else { in_runs(n.id, faces, &masks) };
|
||||
Some(vec![Arc::new(Node::group(n.id, children))])
|
||||
}
|
||||
|
||||
/// `faces` in their back-to-front order, each with the index of its surface, where some surfaces
|
||||
/// carry an opacity mask (`masks`, by surface). A face of a surface without one goes in the order
|
||||
/// on its own. The faces of a masked surface share a run, one group with the surface's mask. Such a
|
||||
/// face joins its surface's last run unless a face drawn after that run overlaps it: only then does
|
||||
/// the surface split, where one group can't be both behind and in front of another face.
|
||||
fn in_runs(id: NodeId, faces: Vec<(usize, Arc<Node>)>, masks: &[Option<Box<OpacityMask>>]) -> Vec<Arc<Node>> {
|
||||
use crate::revolve::{Coverage, overlaps};
|
||||
struct Run {
|
||||
surface: Option<usize>,
|
||||
bounds: Rect,
|
||||
faces: Vec<Arc<Node>>,
|
||||
cover: Vec<Coverage>,
|
||||
}
|
||||
let mut runs: Vec<Run> = vec![];
|
||||
let mut last_run = vec![None; masks.len()];
|
||||
// Past this many tests a face starts a run of its own, which never draws out of order.
|
||||
let mut budget = crate::revolve::MAX_OVERLAP_TESTS;
|
||||
for (surface, face) in faces {
|
||||
let Some(cover) = Coverage::of(&face) else {
|
||||
// A face with no outline and no box overlaps nothing.
|
||||
runs.push(Run { surface: None, bounds: Rect::ZERO, faces: vec![face], cover: vec![] });
|
||||
continue;
|
||||
};
|
||||
if masks.get(surface).is_none_or(Option::is_none) {
|
||||
runs.push(Run { surface: None, bounds: cover.bounds, faces: vec![face], cover: vec![cover] });
|
||||
continue;
|
||||
}
|
||||
let mut clear = |r: &Run| {
|
||||
budget = budget.saturating_sub(1);
|
||||
if !overlaps(r.bounds, cover.bounds) {
|
||||
return budget > 0;
|
||||
}
|
||||
budget = budget.saturating_sub(r.cover.len());
|
||||
budget > 0 && r.cover.iter().all(|c| !overlaps(c.bounds, cover.bounds) || !c.meets(&cover))
|
||||
};
|
||||
let joins = last_run.get(surface).copied().flatten().filter(|&i: &usize| runs.get(i + 1..).is_some_and(|later| later.iter().all(&mut clear)));
|
||||
match joins.and_then(|i| runs.get_mut(i)) {
|
||||
Some(run) => {
|
||||
run.bounds = run.bounds.union(cover.bounds);
|
||||
run.faces.push(face);
|
||||
run.cover.push(cover);
|
||||
}
|
||||
None => {
|
||||
if let Some(last) = last_run.get_mut(surface) {
|
||||
*last = Some(runs.len());
|
||||
}
|
||||
runs.push(Run { surface: Some(surface), bounds: cover.bounds, faces: vec![face], cover: vec![cover] });
|
||||
}
|
||||
}
|
||||
}
|
||||
runs.into_iter()
|
||||
.filter_map(|mut r| {
|
||||
let Some(mask) = r.surface.and_then(|s| masks.get(s)).cloned().flatten() else { return r.faces.pop() };
|
||||
let mut group = Node::group(id, r.faces);
|
||||
group.extras_mut().mask = Some(mask);
|
||||
Some(Arc::new(group))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
|
||||
@@ -415,15 +415,16 @@ fn a_translucent_stroke_is_a_surface_of_its_own() {
|
||||
}
|
||||
|
||||
/// With a light that is not white, a fill in a gradient with transparent stops is drawn with an
|
||||
/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their
|
||||
/// own, and the mask stays on the fill's. Under a white light the same object is one surface.
|
||||
/// opacity mask: its faces still take their places in the one order of the fill's and the
|
||||
/// stroke's faces, in runs that each carry the mask. Under a white light the faces carry the
|
||||
/// gradient itself and no mask.
|
||||
#[test]
|
||||
fn a_masked_surface_under_a_colored_light_stays_whole() {
|
||||
fn a_masked_surface_under_a_colored_light_keeps_the_shared_face_order() {
|
||||
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let mut half = GradientStop::new(1.0, RED);
|
||||
half.opacity = 0.5;
|
||||
let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), half]));
|
||||
for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] {
|
||||
for (lights, masked) in [(json!({"lights": [{"color": "#ff8080"}]}), true), (json!({}), false)] {
|
||||
let mut n = stroked_rect(100.0);
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
|
||||
f.paint = Paint::Gradient(Box::new(gradient.clone()));
|
||||
@@ -431,8 +432,10 @@ fn a_masked_surface_under_a_colored_light_stays_whole() {
|
||||
n.appearance.effects.push(fx(EXTRUDE, lights.clone()));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let drawn = art.children().unwrap();
|
||||
assert_eq!(drawn.len(), surfaces, "{lights}");
|
||||
assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}");
|
||||
assert_eq!(drawn.len(), 1, "{lights}: one order of faces");
|
||||
let runs: Vec<&Arc<Node>> = drawn[0].children().unwrap().iter().filter(|c| c.extras().mask.is_some()).collect();
|
||||
assert_eq!(!runs.is_empty(), masked, "{lights}");
|
||||
assert!(runs.iter().all(|r| r.children().unwrap().iter().all(|f| !blue(f))), "{lights}: the mask stays on the fill's faces");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -847,7 +850,9 @@ fn classic_shading_keeps_a_gradients_transparency() {
|
||||
}
|
||||
let art = solid_art(&n).unwrap();
|
||||
let surface = &art.children().unwrap()[0];
|
||||
assert_eq!(surface.extras().mask.is_some(), masked, "{end}");
|
||||
let mut has_mask = false;
|
||||
surface.walk(&mut |c| has_mask |= c.extras().mask.is_some());
|
||||
assert_eq!(has_mask, masked, "{end}");
|
||||
let mut opaque = true;
|
||||
surface.walk(&mut |c| {
|
||||
for item in &c.appearance.items {
|
||||
@@ -862,6 +867,67 @@ fn classic_shading_keeps_a_gradients_transparency() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A group member in a gradient with transparent stops keeps the shared back-to-front order of
|
||||
/// every member's faces: its faces go in runs that carry its opacity mask, and the other member's
|
||||
/// faces are drawn between them where they are in front of some and behind others. Here a square
|
||||
/// frame in the gradient holds a square in its hole: the frame's inner walls are behind the square
|
||||
/// and its front cap is in front of the square's walls.
|
||||
#[test]
|
||||
fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() {
|
||||
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let square = |b: &mut BezPath, x0: f64, x1: f64, clockwise: bool| {
|
||||
let corners = [(x0, x0), (x1, x0), (x1, x1), (x0, x1)];
|
||||
let mut points = corners.to_vec();
|
||||
if !clockwise {
|
||||
points.reverse();
|
||||
}
|
||||
b.move_to(points[0]);
|
||||
for p in &points[1..] {
|
||||
b.line_to(*p);
|
||||
}
|
||||
b.close_path();
|
||||
};
|
||||
let art = |id: &str, params: &Value, end_opacity: f32| {
|
||||
let mut end = GradientStop::new(1.0, Color::BLACK);
|
||||
end.opacity = end_opacity;
|
||||
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), end]))));
|
||||
let mut b = BezPath::new();
|
||||
square(&mut b, 100.0, 300.0, true);
|
||||
square(&mut b, 150.0, 250.0, false);
|
||||
let frame = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(gradient, Paint::None, 0.0));
|
||||
let mut b = BezPath::new();
|
||||
square(&mut b, 185.0, 215.0, true);
|
||||
let inner = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0));
|
||||
let mut g = Node::group(NodeId(3), vec![Arc::new(frame), Arc::new(inner)]);
|
||||
g.appearance.effects.push(fx(id, params.clone()));
|
||||
solid_art(&g).unwrap_or_else(|| panic!("{id}"))
|
||||
};
|
||||
// The drawing order: `M` for a group with an opacity mask, `F` for a face outside one.
|
||||
fn visit(n: &Node, order: &mut String) {
|
||||
if n.extras().mask.is_some() {
|
||||
order.push('M');
|
||||
return;
|
||||
}
|
||||
if n.path_data().is_some() {
|
||||
order.push('F');
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, order);
|
||||
}
|
||||
}
|
||||
let order = |art: &Node| {
|
||||
let mut order = String::new();
|
||||
visit(art, &mut order);
|
||||
order
|
||||
};
|
||||
for (id, params) in [(CLASSIC_EXTRUDE, json!({})), (EXTRUDE, json!({"lights": [{"color": "#ff8080"}]}))] {
|
||||
assert!(!order(&art(id, ¶ms, 1.0)).contains('M'), "{id}: an opaque gradient needs no mask");
|
||||
let masked = order(&art(id, ¶ms, 0.5));
|
||||
let (first, last) = (masked.find('M').unwrap(), masked.rfind('M').unwrap());
|
||||
assert!(masked.get(first..last).is_some_and(|between| between.contains('F')), "{id}: faces between the member's runs: {masked}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_parameters_are_checked() {
|
||||
let n = classic(json!({}));
|
||||
|
||||
@@ -176,6 +176,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[
|
||||
BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes },
|
||||
BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails },
|
||||
BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams },
|
||||
BuiltinBrushLibrary { id: "dry-media", name: "Dry Media", make: dry_media },
|
||||
];
|
||||
|
||||
/// Built-in library `id`, made once.
|
||||
@@ -474,6 +475,170 @@ fn energy_beams() -> BrushLibrary {
|
||||
built("Energy Beams", Document::new(100.0, 100.0), brushes)
|
||||
}
|
||||
|
||||
/// The fixed-seed number source of the Dry Media grain: integer steps and plain arithmetic, so the
|
||||
/// art is the same on every run and every platform.
|
||||
struct Grain(u64);
|
||||
|
||||
impl Grain {
|
||||
/// The next number in 0..1.
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
(self.0 >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
/// The next number in `a`..`b`.
|
||||
fn range(&mut self, a: f64, b: f64) -> f64 {
|
||||
a + (b - a) * self.next()
|
||||
}
|
||||
}
|
||||
|
||||
/// `floor` at x = `x0` and `x1`, rising smoothly to 1 within `ramp` pt of each.
|
||||
fn ends(x: f64, x0: f64, x1: f64, ramp: f64, floor: f64) -> f64 {
|
||||
let t = ((x - x0).min(x1 - x) / ramp).clamp(0.0, 1.0);
|
||||
floor + (1.0 - floor) * t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// A closed band from x = `x0` to `x1` in `n` steps, at most `half` pt either side of y = 0 times
|
||||
/// `taper(x)`; each edge point is pulled in by up to `rough` of that.
|
||||
fn rough_band(g: &mut Grain, (x0, x1): (f64, f64), n: usize, half: f64, rough: f64, taper: impl Fn(f64) -> f64) -> PathData {
|
||||
let xs: Vec<f64> = (0..=n).map(|i| x0 + (x1 - x0) * i as f64 / n as f64).collect();
|
||||
let mut pts: Vec<Point> = xs.iter().map(|&x| Point::new(x, -half * taper(x) * (1.0 - rough * g.next()))).collect();
|
||||
let bottom: Vec<Point> = xs.iter().rev().map(|&x| Point::new(x, half * taper(x) * (1.0 - rough * g.next()))).collect();
|
||||
pts.extend(bottom);
|
||||
PathData::single(SubPath::polyline(&pts, true))
|
||||
}
|
||||
|
||||
/// A small four-cornered fleck around (`cx`, `cy`), at most `w` × `h` pt either side; one corner
|
||||
/// in each direction, so its outline never crosses itself.
|
||||
fn fleck(g: &mut Grain, cx: f64, cy: f64, w: f64, h: f64) -> PathData {
|
||||
let mut side = |a: f64, b: f64| g.range(a, b);
|
||||
let pts = [
|
||||
(cx - w * side(0.6, 1.0), cy + h * side(-0.3, 0.3)),
|
||||
(cx + w * side(-0.3, 0.3), cy - h * side(0.6, 1.0)),
|
||||
(cx + w * side(0.6, 1.0), cy + h * side(-0.3, 0.3)),
|
||||
(cx + w * side(-0.3, 0.3), cy + h * side(0.6, 1.0)),
|
||||
];
|
||||
polygon(&pts)
|
||||
}
|
||||
|
||||
/// `body` with `holes` (inside it, apart from each other) cut out, even-odd, in gray `k`: what
|
||||
/// lies under the stroke shows through the holes.
|
||||
fn holed(body: PathData, holes: Vec<PathData>, k: f32) -> Node {
|
||||
let children = std::iter::once(body).chain(holes).map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect();
|
||||
let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd });
|
||||
n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::gray(k))))], ..Default::default() }.into();
|
||||
n
|
||||
}
|
||||
|
||||
/// Path `p` filled with gray `k` (1 is black).
|
||||
fn ink(p: PathData, k: f32) -> Node {
|
||||
filled(p, Paint::solid(Color::gray(k)))
|
||||
}
|
||||
|
||||
/// Slivers `w` × `h` pt (ranges) in `count` slots along x = 6..94 inside a band `half` pt wide
|
||||
/// times `taper`. Each stays within `reach` of the band's half width from y = 0, taken where the
|
||||
/// band is narrowest over the sliver and the 3 pt either side of it, so with `reach` below the
|
||||
/// band's roughness margin no sliver touches the band's edge or another sliver.
|
||||
fn slivers(g: &mut Grain, count: usize, half: f64, reach: f64, (w, h): ((f64, f64), (f64, f64)), taper: impl Fn(f64) -> f64) -> Vec<PathData> {
|
||||
let slot = 88.0 / count as f64;
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let cx = 6.0 + slot * (i as f64 + 0.5);
|
||||
let (w, h) = (g.range(w.0, w.1), g.range(h.0, h.1));
|
||||
let room = reach * half * taper(cx - w - 3.0).min(taper(cx + w + 3.0));
|
||||
let cy = g.range(-1.0, 1.0) * (room - h).max(0.0);
|
||||
fleck(g, cx, cy, w, h)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Dry Media: art brushes of grainy dry drawing marks, chalk, pencil, crayon and charcoal (original
|
||||
/// vector grain from fixed seeds; every brush 100 pt long, black and grays that take the stroke
|
||||
/// color as tints, gaps cut out so the paper shows).
|
||||
pub(crate) fn dry_media() -> BrushLibrary {
|
||||
// Chalk Grain, 100 × 8 pt: a soft band with narrow scratches and chips along its edges.
|
||||
let chalk = {
|
||||
let mut g = Grain(0x0c4a_1c0a);
|
||||
let taper = |x: f64| ends(x, 0.0, 100.0, 10.0, 0.35);
|
||||
let body = rough_band(&mut g, (0.0, 100.0), 36, 4.0, 0.25, taper);
|
||||
let scratches = slivers(&mut g, 14, 4.0, 0.7, ((1.2, 2.4), (0.15, 0.3)), taper);
|
||||
let mut parts = vec![holed(body, scratches, 0.8)];
|
||||
for i in 0..18 {
|
||||
let cx = 3.0 + 94.0 * (i as f64 + g.range(0.2, 0.8)) / 18.0;
|
||||
let side = if i % 2 == 0 { -1.0 } else { 1.0 };
|
||||
let cy = side * 4.0 * taper(cx) * g.range(0.7, 0.95);
|
||||
let (w, h) = (g.range(0.3, 0.7), g.range(0.25, 0.5));
|
||||
let f = fleck(&mut g, cx, cy, w, h);
|
||||
parts.push(ink(f, 1.0));
|
||||
}
|
||||
group(parts)
|
||||
};
|
||||
// Graphite Pencil, 100 × 2.5 pt: a solid tapered core with fine slanted hatching at its edges.
|
||||
let pencil = {
|
||||
let mut g = Grain(0x009e_4c11);
|
||||
let core = rough_band(&mut g, (0.0, 100.0), 40, 0.8, 0.12, |x| ends(x, 0.0, 100.0, 18.0, 0.25));
|
||||
let mut parts = vec![ink(core, 1.0)];
|
||||
for i in 0..24 {
|
||||
let x0 = 2.0 + 92.0 * (i as f64 + g.range(0.0, 0.4)) / 24.0;
|
||||
let len = g.range(1.5, 3.0);
|
||||
let t = g.range(0.12, 0.2);
|
||||
let slant = g.range(0.1, 0.3);
|
||||
let y0 = if i % 2 == 0 { -g.range(0.85, 1.05) } else { g.range(0.85, 1.25) - t - slant };
|
||||
parts.push(ink(polygon(&[(x0, y0 + slant), (x0 + len, y0), (x0 + len, y0 + t), (x0, y0 + slant + t)]), 0.6));
|
||||
}
|
||||
group(parts)
|
||||
};
|
||||
// Wax Crayon, 100 × 6 pt: a dense band with small gaps in its middle and short overlapping
|
||||
// marks along both edges.
|
||||
let crayon = {
|
||||
let mut g = Grain(0x00c0_a7e5);
|
||||
let taper = |x: f64| ends(x, 0.0, 100.0, 6.0, 0.5);
|
||||
let body = rough_band(&mut g, (0.0, 100.0), 30, 2.6, 0.2, taper);
|
||||
let gaps = slivers(&mut g, 16, 2.6, 0.34, ((0.6, 1.6), (0.15, 0.3)), taper);
|
||||
let mut parts = vec![holed(body, gaps, 0.9)];
|
||||
for i in 0..36 {
|
||||
let w = g.range(1.0, 2.2);
|
||||
let cx = (1.0 + w).max(98.0 * (i as f64 + g.range(0.0, 1.0)) / 36.0).min(99.0 - w);
|
||||
let side = if i % 2 == 0 { -1.0 } else { 1.0 };
|
||||
let h = g.range(0.4, 0.6);
|
||||
// Clear of the gaps, which stay within 0.9 pt of the middle.
|
||||
let inner = 0.9 + h;
|
||||
let cy = side * g.range(inner, (2.4 * taper(cx)).max(inner));
|
||||
let f = fleck(&mut g, cx, cy, w, h);
|
||||
parts.push(ink(f, 1.0));
|
||||
}
|
||||
group(parts)
|
||||
};
|
||||
// Charcoal Dust, 100 × 7 pt: a dark band broken into pieces, with flecks of dust either side.
|
||||
let dust = {
|
||||
let mut g = Grain(0xc4a2_c0a1);
|
||||
let mut parts = vec![];
|
||||
let mut x0 = 0.0;
|
||||
while x0 < 100.0 {
|
||||
let x1 = (x0 + g.range(14.0, 24.0)).min(100.0);
|
||||
let x1 = if 100.0 - x1 < 8.0 { 100.0 } else { x1 };
|
||||
let piece = rough_band(&mut g, (x0, x1), 10, 2.0, 0.3, |x| ends(x, 0.0, 100.0, 8.0, 0.4) * ends(x, x0, x1, 2.0, 0.3));
|
||||
parts.push(ink(piece, 1.0));
|
||||
x0 = x1 + g.range(1.5, 3.0);
|
||||
}
|
||||
for i in 0..30 {
|
||||
let cx = 2.0 + 96.0 * (i as f64 + g.range(0.2, 0.8)) / 30.0;
|
||||
let side = if g.next() < 0.5 { -1.0 } else { 1.0 };
|
||||
let s = g.range(0.15, 0.3);
|
||||
let cy = side * g.range(2.3, 3.5 - s);
|
||||
let f = fleck(&mut g, cx, cy, s, s);
|
||||
parts.push(ink(f, if i % 3 == 0 { 0.7 } else { 1.0 }));
|
||||
}
|
||||
group(parts)
|
||||
};
|
||||
let brushes = vec![
|
||||
art("Chalk Grain", chalk, Colorization::Tints),
|
||||
art("Graphite Pencil", pencil, Colorization::Tints),
|
||||
art("Wax Crayon", crayon, Colorization::Tints),
|
||||
art("Charcoal Dust", dust, Colorization::Tints),
|
||||
];
|
||||
built("Dry Media", Document::new(100.0, 100.0), brushes)
|
||||
}
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo {
|
||||
|
||||
@@ -92,9 +92,9 @@ pub use pdfimport::{LoadOptions, page_document};
|
||||
pub(crate) use save::job_for;
|
||||
pub(crate) use save::save as save_document;
|
||||
pub use save::{
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, leave_out_unreadable_save_paths,
|
||||
reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
|
||||
template_filters, templates_dialog_folder, templates_folder,
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder,
|
||||
leave_out_unreadable_save_paths, reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan,
|
||||
save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
|
||||
};
|
||||
pub(crate) use screens::{
|
||||
PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS,
|
||||
@@ -123,7 +123,10 @@ mod formats;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use access::list_dir;
|
||||
pub use access::write_file_with;
|
||||
pub use access::{write_file_with, write_save_file, write_save_file_with};
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests_readonly;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use access::{Placed, copy_new, file_metadata};
|
||||
pub(crate) use access::{
|
||||
|
||||
@@ -112,6 +112,31 @@ pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write)
|
||||
vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// The save commands' writer: preserves a filesystem location refusal as a typed error. Other
|
||||
/// writers keep their existing errors; an automation-root refusal is never reclassified.
|
||||
pub fn write_save_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
write_save_file_with(path, &mut |w| w.write_all(bytes))
|
||||
}
|
||||
|
||||
/// [`write_save_file`] for a streamed save.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
may_write(path)?;
|
||||
vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| save_write_error(path, e))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn save_write_error(path: &str, error: std::io::Error) -> EngineError {
|
||||
let reason = format!("{path}: {error}");
|
||||
if vectorcraft_format::read_only_location(&error) { EngineError::ReadOnlyLocation(reason) } else { EngineError::Other(reason) }
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
|
||||
write_file_with(path, fill)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn create_dir(path: &str) -> Result<()> {
|
||||
may_write(path)?;
|
||||
|
||||
@@ -825,6 +825,9 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option<String> {
|
||||
/// A TIFF with more strips or tiles than the decoder can count
|
||||
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are
|
||||
/// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in.
|
||||
/// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`exif_orientation`]) turns or flips it is
|
||||
/// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK JPEG
|
||||
/// or TIFF keeps its inks, 8 bits each, as a CMYK TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]).
|
||||
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let ppi = super::ppi::resolution(bytes);
|
||||
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
|
||||
@@ -863,7 +866,16 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
|
||||
// A photo whose EXIF orientation turns or flips it (a camera held upright) is stored as it is
|
||||
// seen, so it places the way it was taken (#4284); the others keep their own bytes.
|
||||
let turned = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff") && exif_orientation(bytes).is_some();
|
||||
let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten();
|
||||
let turned = orientation.is_some();
|
||||
// The resolution describes the stored pixels; a quarter turn swaps its axes.
|
||||
let quarter = orientation.is_some_and(|o| o.to_exif() >= 5);
|
||||
let shown_ppi = ppi.map(|(x, y)| if quarter { (y, x) } else { (x, y) });
|
||||
if let Some(orientation) = orientation.filter(|_| matches!(f.id, "jpg" | "tiff"))
|
||||
&& let Some((tiff, size)) = vectorcraft_doc::cmyk::turned_cmyk(bytes, orientation, shown_ppi)
|
||||
{
|
||||
return stored(tiff, "image/tiff", size, shown_ppi);
|
||||
}
|
||||
let kept = match f.id {
|
||||
_ if turned => None,
|
||||
"png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()),
|
||||
@@ -879,36 +891,39 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
|
||||
};
|
||||
let mut img = match (f.id == "tiff").then(extra_samples).flatten() {
|
||||
Some(img) => img,
|
||||
Some(img) => image::DynamicImage::ImageRgba8(img),
|
||||
None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() {
|
||||
Some(read) => read.map_err(err)?,
|
||||
Some(read) => image::DynamicImage::ImageRgba8(read.map_err(err)?),
|
||||
None => {
|
||||
use image::ImageDecoder as _;
|
||||
let mut decoder = reader.into_decoder().map_err(err)?;
|
||||
let decoder = reader.into_decoder().map_err(err)?;
|
||||
// The allowance counts the decoded samples and the RGBA pixels they become (#1717).
|
||||
let (w, h) = decoder.dimensions();
|
||||
if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC {
|
||||
return Err(err(format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
let orientation = decoder.orientation().unwrap_or(image::metadata::Orientation::NoTransforms);
|
||||
let mut img = image::DynamicImage::from_decoder(decoder).map_err(err)?;
|
||||
img.apply_orientation(orientation);
|
||||
img.to_rgba8()
|
||||
image::DynamicImage::from_decoder(decoder).map_err(err)?
|
||||
}
|
||||
},
|
||||
};
|
||||
img.apply_orientation(orientation.unwrap_or(image::metadata::Orientation::NoTransforms));
|
||||
let mut img = img.into_rgba8();
|
||||
if f.id == "tga" {
|
||||
super::tgaread::fix_alpha(bytes, &mut img);
|
||||
}
|
||||
as_png(img, None, ppi)
|
||||
as_png(img, None, shown_ppi)
|
||||
}
|
||||
|
||||
/// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when
|
||||
/// it leaves them as stored, or the image has none.
|
||||
/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with
|
||||
/// extra samples) has its Orientation tag read from its first image
|
||||
/// ([`vectorcraft_doc::cmyk::tiff_orientation`]).
|
||||
pub(crate) fn exif_orientation(bytes: &[u8]) -> Option<image::metadata::Orientation> {
|
||||
use image::ImageDecoder as _;
|
||||
let mut decoder = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
|
||||
decoder.orientation().ok().filter(|o| *o != image::metadata::Orientation::NoTransforms)
|
||||
use image::metadata::Orientation;
|
||||
let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok();
|
||||
let tagged = || vectorcraft_doc::cmyk::tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?));
|
||||
decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms)
|
||||
}
|
||||
|
||||
/// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its
|
||||
|
||||
@@ -157,6 +157,11 @@ fn documents_folder() -> Option<std::path::PathBuf> {
|
||||
Some(home.join("Documents"))
|
||||
}
|
||||
|
||||
/// A Save As after a refused Save starts in Documents, else home, never the refused folder.
|
||||
pub fn documents_dialog_folder() -> Option<String> {
|
||||
documents_folder().filter(|p| p.is_dir()).map(|p| p.to_string_lossy().into_owned()).or_else(home_folder)
|
||||
}
|
||||
|
||||
/// `XDG_DOCUMENTS_DIR` in a `user-dirs.dirs` file's `text`, `$HOME` being `home`. None when it isn't
|
||||
/// set, is relative, or is the home folder itself (the spec's way of turning it off).
|
||||
fn xdg_documents(text: &str, home: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
@@ -1043,7 +1048,7 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value>
|
||||
let plan = save_plan(s, mode, p)?;
|
||||
let job = save_job(s, plan)?;
|
||||
let uid = s.doc()?.uid;
|
||||
let mut out = job.write_streamed(&mut super::write_file_with)?;
|
||||
let mut out = job.write_streamed(&mut super::write_save_file_with)?;
|
||||
job.complete(s, uid);
|
||||
leave_out_unreadable_save_paths(&mut out, str_param(p, "path"));
|
||||
Ok(out)
|
||||
@@ -1170,7 +1175,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused",
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
||||
@@ -907,6 +907,254 @@ fn a_photo_places_the_way_its_exif_orientation_says() {
|
||||
assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "the left half is on top");
|
||||
}
|
||||
|
||||
/// Where the first stored pixel shows once EXIF (or TIFF) `orientation` turns the image, as
|
||||
/// (right, bottom): orientations 1 and 5 keep it at the top left, 2 and 6 put it at the top right,
|
||||
/// 3 and 7 at the bottom right, 4 and 8 at the bottom left. Values outside 1 to 8 leave it.
|
||||
fn first_pixel_at(orientation: u16) -> (bool, bool) {
|
||||
match orientation {
|
||||
2 | 6 => (true, false),
|
||||
3 | 7 => (true, true),
|
||||
4 | 8 => (false, true),
|
||||
_ => (false, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// A little-endian EXIF block (a TIFF header and directory, as a PNG `eXIf` chunk or a WebP
|
||||
/// `EXIF` chunk holds it) with an Orientation of `orientation` and an XResolution and YResolution
|
||||
/// of `x` and `y` pixels per inch.
|
||||
fn exif_block(orientation: u16, (x, y): (u32, u32)) -> Vec<u8> {
|
||||
let entry = |tag: u16, kind: u16, value: u32| [&tag.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &value.to_le_bytes()].concat();
|
||||
// Orientation, XResolution, YResolution, ResolutionUnit inches; the two rationals follow.
|
||||
[
|
||||
b"II".as_slice(),
|
||||
&[0x2a, 0, 8, 0, 0, 0],
|
||||
&4u16.to_le_bytes(),
|
||||
&entry(0x0112, 3, u32::from(orientation)),
|
||||
&entry(0x011a, 5, 62),
|
||||
&entry(0x011b, 5, 70),
|
||||
&entry(0x0128, 3, 2),
|
||||
&[0, 0, 0, 0],
|
||||
&[x.to_le_bytes(), 1u32.to_le_bytes(), y.to_le_bytes(), 1u32.to_le_bytes()].concat(),
|
||||
]
|
||||
.concat()
|
||||
}
|
||||
|
||||
/// A photo turned a quarter by its EXIF orientation keeps its physical size: the resolution's
|
||||
/// axes swap with the pixels', in what Place reports and in the stored image. Every orientation
|
||||
/// puts the first stored pixel in its corner once, and the stored image, read again, is not turned
|
||||
/// a second time. Values outside 1 to 8 leave the photo as stored.
|
||||
#[test]
|
||||
fn a_quarter_turn_swaps_the_resolution_axes() {
|
||||
// 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) red, the rest white.
|
||||
let rgb = image::RgbImage::from_fn(48, 32, |x, y| if x < 16 && y < 16 { image::Rgb([220, 0, 0]) } else { image::Rgb([255, 255, 255]) });
|
||||
let mut jpg = Vec::new();
|
||||
image::DynamicImage::ImageRgb8(rgb).write_to(&mut Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap();
|
||||
for orientation in [0u16, 1, 2, 3, 4, 5, 6, 7, 8, 9] {
|
||||
let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].concat();
|
||||
// Without a JFIF density the resolution comes from the EXIF block.
|
||||
let mut turned = jpg[..2].to_vec();
|
||||
turned.extend([0xff, 0xe1]);
|
||||
turned.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes());
|
||||
turned.extend(&exif);
|
||||
turned.extend(&jpg[2..]);
|
||||
let what = format!("orientation {orientation}");
|
||||
assert_eq!(super::ppi::resolution(&turned), Some((300.0, 150.0)), "{what}: as stored");
|
||||
let quarter = (5..=8).contains(&orientation);
|
||||
let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) };
|
||||
let img = raster_image(&turned).unwrap();
|
||||
assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}");
|
||||
assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image");
|
||||
let px = image::load_from_memory(&img.blob.bytes).unwrap().to_rgb8();
|
||||
let red = |x: u32, y: u32| px.get_pixel(x, y)[1] < 80;
|
||||
let (w, h) = size;
|
||||
let corners = [(false, false), (true, false), (true, true), (false, true)];
|
||||
let reds: Vec<_> =
|
||||
corners.into_iter().filter(|&(right, bottom)| red(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 })).collect();
|
||||
assert_eq!(reds, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner");
|
||||
let again = raster_image(&img.blob.bytes).unwrap();
|
||||
assert_eq!(((again.width, again.height), again.ppi), (size, Some(ppi)), "{what}: read again, it isn't turned again");
|
||||
}
|
||||
}
|
||||
|
||||
/// `tiff` (little-endian) with an Orientation tag (274) of `value`, a SHORT or, when `long`, a
|
||||
/// LONG, and, when `y_ppi` is given, a YResolution (283, which `tiff` must have) of that many
|
||||
/// pixels per inch: its first directory copied to the end of the file with the tag added in order,
|
||||
/// and the header pointing there, so that every other offset in the file still holds.
|
||||
fn tiff_oriented(tiff: &[u8], value: u16, long: bool, y_ppi: Option<u32>) -> Vec<u8> {
|
||||
let at = usize::try_from(u32::from_le_bytes(tiff[4..8].try_into().unwrap())).unwrap();
|
||||
let n = usize::from(u16::from_le_bytes([tiff[at], tiff[at + 1]]));
|
||||
let mut entries: Vec<Vec<u8>> = tiff[at + 2..at + 2 + 12 * n].chunks(12).map(<[u8]>::to_vec).collect();
|
||||
let i = entries.iter().position(|e| u16::from_le_bytes([e[0], e[1]]) > 274).unwrap_or(n);
|
||||
let (kind, v) = if long { (4u16, u32::from(value).to_le_bytes()) } else { (3, [value.to_le_bytes(), [0, 0]].concat().try_into().unwrap()) };
|
||||
entries.insert(i, [&274u16.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &v].concat());
|
||||
let mut out = tiff.to_vec();
|
||||
if out.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
if let Some(y) = y_ppi {
|
||||
let rational = u32::try_from(out.len()).unwrap();
|
||||
out.extend([y.to_le_bytes(), 1u32.to_le_bytes()].concat());
|
||||
let e = entries.iter_mut().find(|e| u16::from_le_bytes([e[0], e[1]]) == 283).unwrap();
|
||||
e[8..12].copy_from_slice(&rational.to_le_bytes());
|
||||
}
|
||||
let ifd = u32::try_from(out.len()).unwrap();
|
||||
out[4..8].copy_from_slice(&ifd.to_le_bytes());
|
||||
out.extend(u16::try_from(n + 1).unwrap().to_le_bytes());
|
||||
out.extend(entries.concat());
|
||||
out.extend([0; 4]);
|
||||
out
|
||||
}
|
||||
|
||||
/// TIFFs the image decoder can't read, grayscale with an alpha channel and 1-bit ones compressed
|
||||
/// with CCITT Group 3, turn and flip as their Orientation tag says, as the others do (#4284),
|
||||
/// whether the tag is a SHORT or a LONG. A quarter turn swaps the resolution's axes.
|
||||
#[test]
|
||||
fn tiffs_read_without_the_image_decoder_follow_their_orientation() {
|
||||
// 3 × 2 at 150 × 300 ppi, the first pixel black and the rest white, opaque.
|
||||
let gray = hand_tiff(1, 8, &[2], false, &[vec![0, 255, 255, 255, 255, 255], vec![255; 6]]);
|
||||
let rows: Vec<Vec<bool>> = (0..2).map(|y| (0..3).map(|x| x == 0 && y == 0).collect()).collect();
|
||||
let g = vectorcraft_testkit::tiff::Group3 {
|
||||
rows: &rows,
|
||||
compression: 2,
|
||||
t4_options: 0,
|
||||
photometric: 0,
|
||||
fill_order: 1,
|
||||
rows_per_strip: 2,
|
||||
tile: None,
|
||||
rtc: false,
|
||||
};
|
||||
let fax = vectorcraft_testkit::tiff::group3(&g);
|
||||
// The Group 3 file declares no resolution.
|
||||
for (kind, file, y_ppi) in [("Group 3", fax, None), ("gray with alpha", gray, Some(300))] {
|
||||
assert!(image::load_from_memory(&file).is_err(), "{kind}: the image decoder reads it now");
|
||||
for (orientation, long) in (1..=8).flat_map(|o| [(o, false), (o, true)]) {
|
||||
let what = format!("{kind}, orientation {orientation}, LONG {long}");
|
||||
let img = raster_image(&tiff_oriented(&file, orientation, long, y_ppi)).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
let quarter = orientation >= 5;
|
||||
let size = if quarter { (2, 3) } else { (3, 2) };
|
||||
let ppi = y_ppi.map(|y| if quarter { (f64::from(y), 150.0) } else { (150.0, f64::from(y)) });
|
||||
assert_eq!(((img.width, img.height), img.ppi), (size, ppi), "{what}");
|
||||
assert_eq!(super::ppi::resolution(&img.blob.bytes), ppi, "{what}: the stored image");
|
||||
let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8();
|
||||
let (right, bottom) = first_pixel_at(orientation);
|
||||
let (x, y) = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 });
|
||||
let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect();
|
||||
assert_eq!(black, [(x, y)], "{what}: the first stored pixel's corner");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A CMYK TIFF that its Orientation tag turns or flips keeps its inks, turned, whether it stores
|
||||
/// them together or one plane per ink, at 8 or 16 bits; a quarter turn swaps the resolution's axes.
|
||||
/// Read again, the stored image is not turned a second time.
|
||||
#[test]
|
||||
fn cmyk_tiffs_turned_by_their_orientation_keep_their_inks() {
|
||||
// 3 × 2 at 150 × 300 ppi: the first pixel full cyan, the others 30% black.
|
||||
let first: [u8; 4] = [255, 0, 0, 0];
|
||||
let other: [u8; 4] = [0, 0, 0, 77];
|
||||
let channel = |i: usize| -> Vec<u16> { (0..6).map(|p| u16::from(if p == 0 { first[i] } else { other[i] })).collect() };
|
||||
for (bits, planar) in [(8, false), (8, true), (16, false), (16, true)] {
|
||||
let channels: Vec<Vec<u16>> = (0..4).map(|i| channel(i).iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect();
|
||||
let file = hand_tiff(5, bits, &[], planar, &channels);
|
||||
for orientation in 1..=8 {
|
||||
let what = format!("{bits} bits, planar {planar}, orientation {orientation}");
|
||||
let img = raster_image(&tiff_oriented(&file, orientation, false, Some(300))).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
let quarter = orientation >= 5;
|
||||
let size = if quarter { (2, 3) } else { (3, 2) };
|
||||
let ppi = if quarter { (300.0, 150.0) } else { (150.0, 300.0) };
|
||||
assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), "image/tiff"), "{what}");
|
||||
assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image");
|
||||
let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost"));
|
||||
assert_eq!((inks.width, inks.height), size, "{what}: the inks' size");
|
||||
let (right, bottom) = first_pixel_at(orientation);
|
||||
let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 });
|
||||
for (i, px) in inks.data.chunks(4).enumerate() {
|
||||
let (x, y) = (u32::try_from(i).unwrap() % size.0, u32::try_from(i).unwrap() / size.0);
|
||||
assert_eq!(px, if (x, y) == at { first } else { other }, "{what}: the inks of pixel ({x}, {y})");
|
||||
}
|
||||
let again = raster_image(&img.blob.bytes).unwrap();
|
||||
assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A CMYK JPEG that its EXIF orientation turns or flips keeps its inks, turned, stored as a CMYK
|
||||
/// TIFF; a quarter turn swaps the resolution's axes. Orientation 1 keeps the JPEG as it is, and
|
||||
/// the stored image, read again, is not turned a second time.
|
||||
#[test]
|
||||
fn cmyk_jpegs_turned_by_their_orientation_keep_their_inks() {
|
||||
use vectorcraft_render::encode::jpeg::{self, ColorModel, JpegOptions};
|
||||
// 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) full cyan, the rest 30% black.
|
||||
let inks: Vec<u8> =
|
||||
(0..32u32).flat_map(|y| (0..48u32).flat_map(move |x| if x < 16 && y < 16 { [255, 0, 0, 0] } else { [0, 0, 0, 77] })).collect();
|
||||
let o = JpegOptions { color_model: ColorModel::Cmyk, embed_icc: false, ..Default::default() };
|
||||
let jpg = jpeg::encode(&inks, 48, 32, 100, None, &o).unwrap();
|
||||
for orientation in 1..=8u16 {
|
||||
let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].concat();
|
||||
let mut file = jpg[..2].to_vec();
|
||||
file.extend([0xff, 0xe1]);
|
||||
file.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes());
|
||||
file.extend(&exif);
|
||||
file.extend(&jpg[2..]);
|
||||
let what = format!("orientation {orientation}");
|
||||
let quarter = orientation >= 5;
|
||||
let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) };
|
||||
let mime = if orientation == 1 { "image/jpeg" } else { "image/tiff" };
|
||||
let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), mime), "{what}");
|
||||
assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image");
|
||||
let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost"));
|
||||
assert_eq!((inks.width, inks.height), size, "{what}: the inks' size");
|
||||
let (w, h) = size;
|
||||
let at = |x: u32, y: u32| inks.data[usize::try_from((y * w + x) * 4).unwrap()..][..4].to_vec();
|
||||
let cyan = |p: &[u8]| p[0] > 200 && p[3] < 40;
|
||||
let corners = [(false, false), (true, false), (true, true), (false, true)];
|
||||
let cyans: Vec<_> =
|
||||
corners.into_iter().filter(|&(right, bottom)| cyan(&at(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 }))).collect();
|
||||
assert_eq!(cyans, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner");
|
||||
let black = at(w / 2, h / 2);
|
||||
assert!(black[0] < 40 && (60..=95).contains(&black[3]), "{what}: the other inks, {black:?}");
|
||||
let again = raster_image(&img.blob.bytes).unwrap();
|
||||
assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again");
|
||||
}
|
||||
}
|
||||
|
||||
/// A PNG (its `eXIf` chunk and `pHYs` resolution), a WebP (its `EXIF` chunk) and an RGB TIFF read
|
||||
/// by the image decoder, turned a quarter by their orientation, swap their resolution's axes; a
|
||||
/// half turn keeps them.
|
||||
#[test]
|
||||
fn png_webp_and_rgb_tiff_turned_a_quarter_swap_their_resolution() {
|
||||
use image::ImageEncoder as _;
|
||||
let rgb = image::RgbImage::from_fn(3, 2, |x, y| image::Rgb(if x == 0 && y == 0 { [0, 0, 0] } else { [255, 255, 255] }));
|
||||
for orientation in [3u16, 6, 8] {
|
||||
let mut png = Vec::new();
|
||||
let mut enc = image::codecs::png::PngEncoder::new(&mut png);
|
||||
enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap();
|
||||
enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap();
|
||||
let png = super::ppi::with_png_resolution(&png, (150.0, 300.0));
|
||||
let mut webp = Vec::new();
|
||||
let mut enc = image::codecs::webp::WebPEncoder::new_lossless(&mut webp);
|
||||
enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap();
|
||||
enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap();
|
||||
let channel = |c: usize| -> Vec<u16> { rgb.pixels().map(|p| u16::from(p[c])).collect() };
|
||||
let tiff = tiff_oriented(&hand_tiff(2, 8, &[], false, &[channel(0), channel(1), channel(2)]), orientation, false, Some(300));
|
||||
for (kind, file) in [("PNG", png), ("WebP", webp), ("TIFF", tiff)] {
|
||||
let what = format!("{kind}, orientation {orientation}");
|
||||
assert_eq!(super::ppi::resolution(&file), Some((150.0, 300.0)), "{what}: as stored");
|
||||
let quarter = orientation >= 5;
|
||||
let (size, ppi) = if quarter { ((2, 3), (300.0, 150.0)) } else { ((3, 2), (150.0, 300.0)) };
|
||||
let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}");
|
||||
assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image");
|
||||
let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8();
|
||||
let (right, bottom) = first_pixel_at(orientation);
|
||||
let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 });
|
||||
let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect();
|
||||
assert_eq!(black, [at], "{what}: the first stored pixel's corner");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// With `importer` named, a file up to the importer's limit is read whole and a larger one is an
|
||||
/// error, whatever its content.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Save errors retain their type without changing the other filesystem writers (#6146).
|
||||
|
||||
use std::io::{self, ErrorKind};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::{SaveMode, access, save_plan, save_with, write_file_with, write_save_file_with};
|
||||
use crate::{EngineError, READ_ONLY_LOCATION, Session};
|
||||
|
||||
#[test]
|
||||
fn readonly_error_is_typed_and_other_writers_keep_their_error() {
|
||||
let dir = vectorcraft_testkit::temp_dir("save-refusal");
|
||||
let path = dir.join("Poster.vectorcraft").to_string_lossy().into_owned();
|
||||
for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem, ErrorKind::StorageFull, ErrorKind::NotFound, ErrorKind::Other] {
|
||||
let typed = write_save_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err();
|
||||
let ordinary = write_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err();
|
||||
let reason = format!("{path}: injected");
|
||||
assert!(matches!(&ordinary, EngineError::Other(_)));
|
||||
assert_eq!(ordinary.to_string(), reason);
|
||||
if matches!(kind, ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem) {
|
||||
assert!(matches!(&typed, EngineError::ReadOnlyLocation(_)));
|
||||
assert_eq!(typed.to_string(), format!("{READ_ONLY_LOCATION}: {reason}"));
|
||||
} else {
|
||||
assert!(matches!(&typed, EngineError::Other(_)));
|
||||
assert_eq!(typed.to_string(), reason);
|
||||
}
|
||||
assert!(!std::path::Path::new(&path).exists());
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_failed_save_keeps_document_and_options() {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({"width": 40, "height": 40})).unwrap();
|
||||
session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
|
||||
let st = session.doc_mut().unwrap();
|
||||
st.path = Some("Poster.svg".into());
|
||||
st.format = "svg";
|
||||
st.save_options = json!({"decimals": 3}).as_object().unwrap().clone();
|
||||
let before = serde_json::to_value(&*st.doc).unwrap();
|
||||
let options = st.save_options.clone();
|
||||
let plan = save_plan(&session, SaveMode::Save, &json!({"modified": null})).unwrap();
|
||||
let e = save_with(&mut session, plan, |path, _| Err(access::save_write_error(path, io::Error::from(ErrorKind::PermissionDenied)))).unwrap_err();
|
||||
assert!(matches!(e, EngineError::ReadOnlyLocation(_)));
|
||||
let st = session.doc().unwrap();
|
||||
assert_eq!(st.path.as_deref(), Some("Poster.svg"));
|
||||
assert_eq!(st.format, "svg");
|
||||
assert_eq!(st.save_options, options);
|
||||
assert!(st.is_dirty());
|
||||
assert_eq!(serde_json::to_value(&*st.doc).unwrap(), before);
|
||||
}
|
||||
@@ -730,7 +730,7 @@ fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_
|
||||
let settings = json!({"formats": [{"format": "png"}], "folder": parent});
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Parent", "settings": settings})).unwrap();
|
||||
let e = confine(confined.as_ref(), || s.execute("document.exportForScreens", &json!({"exportPreset": "Parent"}))).unwrap_err().to_string();
|
||||
let root = inside.display().to_string();
|
||||
let root = crate::cmd::findfiles::plain(inside.canonicalize().unwrap()).display().to_string();
|
||||
let name = tmp.0.file_name().unwrap().to_string_lossy().into_owned();
|
||||
assert!(e.contains(&format!("root {root}: {name}")), "{e}");
|
||||
}
|
||||
|
||||
@@ -436,7 +436,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a
|
||||
continue;
|
||||
};
|
||||
let preview = d.images.get(&key).map(|b| b.bytes.clone());
|
||||
// A file read as it is keeps its own type; one turned upright (EXIF orientation) is the PNG it became.
|
||||
// A file read as it is keeps its own type; one turned upright by its orientation is the PNG, or CMYK TIFF, it became.
|
||||
let mime = match fileio::format_for_name(g.link.name()) {
|
||||
Some(f) if img.blob.bytes.as_slice() == bytes.as_slice() => f.mime.to_string(),
|
||||
_ => img.blob.mime.clone(),
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
//! from the tool's options (Objects on Path Tool Options). `object.objectsOnPath.options` sets the
|
||||
//! group's Pivot, Rotate and Space (the distance between the objects along the path, and where the
|
||||
//! first one attaches), and `object.objectsOnPath.expand` makes it a plain group.
|
||||
//! `object.objectsOnPath.selectMember` chooses one object of a selected group (its Select/Move
|
||||
//! widget), and `object.objectsOnPath.moveMember` moves one object to another's place along the
|
||||
//! path, the objects between it and that place each moving one place toward where it was; hidden
|
||||
//! and locked objects keep their places.
|
||||
//!
|
||||
//! After every edit, [`refresh`] puts the objects of each Objects on Path group that changed back
|
||||
//! on its path: editing the path moves them, an object moved on its own goes back to its place,
|
||||
@@ -19,7 +23,7 @@
|
||||
//! Object › Objects on Path › Attach; the order on the path, the attachment point, the distribution,
|
||||
//! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Expand).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -28,7 +32,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::edit::{roots_of, selected_roots};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_selection};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_doc, has_selection};
|
||||
|
||||
/// The id of the Objects on Path tool.
|
||||
pub const TOOL: &str = "objectsOnPath";
|
||||
@@ -53,6 +57,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_objects_on_path,
|
||||
options
|
||||
),
|
||||
cmd!(
|
||||
"object.objectsOnPath.selectMember",
|
||||
"Select Object on Path",
|
||||
[],
|
||||
None,
|
||||
"{member: id of an object of an Objects on Path group} select the group alone with that object chosen, as a click on the object's Select/Move widget does; any other selection change clears the choice → {group, member}",
|
||||
has_doc,
|
||||
select_member
|
||||
),
|
||||
cmd!(
|
||||
"object.objectsOnPath.moveMember",
|
||||
"Move Object on Path",
|
||||
[],
|
||||
None,
|
||||
"{member: id of an object of an Objects on Path group, target: id of another object of the same group, group?: the group's id (when given, it must be the group of both); neither object nor the group may be hidden or locked} move the object to the target's place along the path; the objects between them each move one place toward where it was, except hidden and locked objects, which keep their places. Only the order along the path changes: Space, the start, Pivot, Rotate and the order in the Layers panel stay. A target that is the object itself changes nothing → {group, member, index}",
|
||||
has_doc,
|
||||
move_member
|
||||
),
|
||||
cmd!(
|
||||
"object.objectsOnPath.expand",
|
||||
"Expand",
|
||||
@@ -296,6 +318,71 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
// ---------- Select/Move ----------
|
||||
|
||||
/// The Objects on Path group whose object `key` of `p` names, checked: an object listed in the
|
||||
/// group's record (not its path), in a group that is not hidden or locked.
|
||||
fn member_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<(NodeId, NodeId)> {
|
||||
let v = p.get(key).filter(|v| !v.is_null()).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?;
|
||||
let id = checked_id(s, v, cmd)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let not_on_path = || bad(cmd, format!("`{key}` must name an object attached to the path of an Objects on Path group"));
|
||||
let g =
|
||||
d.ancestry(id).unwrap_or_default().into_iter().rev().nth(1).filter(|g| d.node(*g).is_some_and(is_objects_on_path)).ok_or_else(not_on_path)?;
|
||||
let listed = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref()).is_some_and(|rec| rec.members.iter().any(|m| m.id == id));
|
||||
if !listed {
|
||||
return Err(not_on_path());
|
||||
}
|
||||
if !d.is_editable(g) {
|
||||
return Err(bad(cmd, "the Objects on Path group is hidden or locked"));
|
||||
}
|
||||
if !d.is_editable(id) {
|
||||
return Err(bad(cmd, format!("`{key}` names an object that is hidden or locked")));
|
||||
}
|
||||
Ok((g, id))
|
||||
}
|
||||
|
||||
fn select_member(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (g, member) = member_param(s, p, "member", "object.objectsOnPath.selectMember")?;
|
||||
s.select(|_, sel| sel.set_on_path_member(g, member))?;
|
||||
Ok(json!({ "group": g.0, "member": member.0 }))
|
||||
}
|
||||
|
||||
fn move_member(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.objectsOnPath.moveMember";
|
||||
let (g, member) = member_param(s, p, "member", C)?;
|
||||
let (target_group, target) = member_param(s, p, "target", C)?;
|
||||
if target_group != g {
|
||||
return Err(bad(C, "`target` must be an object of the same Objects on Path group as `member`"));
|
||||
}
|
||||
if let Some(v) = p.get("group").filter(|v| !v.is_null())
|
||||
&& checked_id(s, v, C)? != g
|
||||
{
|
||||
return Err(bad(C, "`group` must be the Objects on Path group of `member` and `target`"));
|
||||
}
|
||||
let at = |s: &Session| -> Result<usize> {
|
||||
let rec = s.doc()?.doc.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?;
|
||||
rec.members.iter().position(|m| m.id == member).ok_or(EngineError::NoNode(member))
|
||||
};
|
||||
// Onto its own place: nothing changes, and no undo step is recorded.
|
||||
if target == member {
|
||||
return Ok(json!({ "group": g.0, "member": member.0, "index": at(s)? }));
|
||||
}
|
||||
// Hidden and locked objects between them keep their places.
|
||||
let index = s.edit("Move Object on Path", |d, _| {
|
||||
let mut rec = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?;
|
||||
let fixed: HashSet<NodeId> = {
|
||||
let editable = d.editable();
|
||||
rec.members.iter().map(|m| m.id).filter(|&id| !editable(id)).collect()
|
||||
};
|
||||
let index = rec.move_member(member, target, |id| fixed.contains(&id)).ok_or(EngineError::NoNode(member))?;
|
||||
let n = d.node_mut(g).ok_or(EngineError::NoNode(g))?;
|
||||
n.extras_mut().objects_on_path = Some(Box::new(rec));
|
||||
Ok(index)
|
||||
})?;
|
||||
Ok(json!({ "group": g.0, "member": member.0, "index": index }))
|
||||
}
|
||||
|
||||
// ---------- Expand ----------
|
||||
|
||||
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct BuiltinPatternLibrary {
|
||||
pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = &[
|
||||
BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns },
|
||||
BuiltinPatternLibrary { id: "grit-textures", name: "Grit Textures", make: grit_textures },
|
||||
BuiltinPatternLibrary { id: "optical-effects", name: "Optical Effects", make: optical_effects },
|
||||
];
|
||||
|
||||
/// The library category of the built-in pattern libraries.
|
||||
@@ -256,6 +257,162 @@ fn grit_textures(d: &mut Document) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `n` + 1 points of the curve `(t, f(t))` for `t` from `t0` to `t1`, evenly spaced.
|
||||
fn curve(t0: f64, t1: f64, n: u32, f: impl Fn(f64) -> f64) -> Vec<(f64, f64)> {
|
||||
(0..=n)
|
||||
.map(|i| {
|
||||
let t = t0 + (t1 - t0) * f64::from(i) / f64::from(n);
|
||||
(t, f(t))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The closed band between two curves drawn from the same side to the same side: `upper` forward,
|
||||
/// then `lower` back.
|
||||
fn band(upper: &[(f64, f64)], lower: &[(f64, f64)]) -> PathData {
|
||||
let points: Vec<(f64, f64)> = upper.iter().chain(lower.iter().rev()).copied().collect();
|
||||
polygon(&points)
|
||||
}
|
||||
|
||||
/// 72 corners of the circle of radius `r` about (`cx`, `cy`), turning the other way with `reverse`.
|
||||
fn circle(cx: f64, cy: f64, r: f64, reverse: bool) -> SubPath {
|
||||
const CORNERS: u32 = 72;
|
||||
let turn = if reverse { -std::f64::consts::TAU } else { std::f64::consts::TAU };
|
||||
let points: Vec<Point> = (0..CORNERS)
|
||||
.map(|i| {
|
||||
let a = turn * f64::from(i) / f64::from(CORNERS);
|
||||
Point::new(cx + r * a.cos(), cy + r * a.sin())
|
||||
})
|
||||
.collect();
|
||||
SubPath::polyline(&points, true)
|
||||
}
|
||||
|
||||
/// The ring from radius `inner` to `outer` about (`cx`, `cy`): the outer circle and the inner one
|
||||
/// turning the other way, so the middle stays clear under either fill rule. An `inner` of 0 gives
|
||||
/// a disk.
|
||||
fn ring(cx: f64, cy: f64, inner: f64, outer: f64) -> PathData {
|
||||
let mut subpaths = vec![circle(cx, cy, outer, false)];
|
||||
if inner > 0.0 {
|
||||
subpaths.push(circle(cx, cy, inner, true));
|
||||
}
|
||||
PathData::new(subpaths)
|
||||
}
|
||||
|
||||
/// A lens `2 × l` long and `2 × h` high about (`cx`, `cy`), lying along x: two half sine waves that
|
||||
/// meet at its tips, turning the other way with `reverse`.
|
||||
fn lens(cx: f64, cy: f64, l: f64, h: f64, reverse: bool) -> SubPath {
|
||||
const STEPS: u32 = 32;
|
||||
let at = |i: u32, side: f64| {
|
||||
let t = f64::from(i) / f64::from(STEPS);
|
||||
Point::new(cx - l + 2.0 * l * t, cy + side * h * (std::f64::consts::PI * t).sin())
|
||||
};
|
||||
let mut points: Vec<Point> = (0..=STEPS).map(|i| at(i, -1.0)).chain((1..STEPS).rev().map(|i| at(i, 1.0))).collect();
|
||||
if reverse {
|
||||
points.reverse();
|
||||
}
|
||||
SubPath::polyline(&points, true)
|
||||
}
|
||||
|
||||
/// Optical Effects: black waves, rings, warped checks, pinched stripes, chevrons and lenses on a
|
||||
/// clear background. Each tile is one period of its design, so a band that leaves one edge comes
|
||||
/// back at the same place on the opposite edge. Art that reaches past an edge is kept whole: the
|
||||
/// tiling draws the part outside the tile on the next tile.
|
||||
fn optical_effects(d: &mut Document) {
|
||||
use std::f64::consts::TAU;
|
||||
// Three bands 5 pt wide, 12 pt apart, each one sine wave across the tile.
|
||||
let bands = (0..3u32)
|
||||
.map(|k| {
|
||||
let mid = move |x: f64| 6.0 + 12.0 * f64::from(k) + 3.0 * (TAU * x / 36.0).sin();
|
||||
band(&curve(0.0, 36.0, 48, |x| mid(x) - 2.5), &curve(0.0, 36.0, 48, |x| mid(x) + 2.5))
|
||||
})
|
||||
.collect();
|
||||
add(d, "Sine Bands", 36.0, 36.0, TileType::Grid, bands);
|
||||
// Nine thin waves 4 pt apart, two waves across the tile, each a ninth of a wave on from the one
|
||||
// above it, so the ninth leads into the first wave of the next tile down.
|
||||
let lines = (0..9u32)
|
||||
.map(|k| {
|
||||
let (y, phase) = (2.0 + 4.0 * f64::from(k), TAU * f64::from(k) / 9.0);
|
||||
let mid = move |x: f64| y + 2.5 * (TAU * x / 18.0 + phase).sin();
|
||||
band(&curve(0.0, 36.0, 72, |x| mid(x) - 0.6), &curve(0.0, 36.0, 72, |x| mid(x) + 0.6))
|
||||
})
|
||||
.collect();
|
||||
add(d, "Ripple Lines", 36.0, 36.0, TileType::Grid, lines);
|
||||
// Thin rings 3 pt apart about the center and rings 3.3 pt apart about a point 4 pt to its
|
||||
// right. The two sets cross, and the outer rings reach into the next tile across.
|
||||
let rings = (1..=7u32)
|
||||
.map(|k| (24.0, 3.0 * f64::from(k)))
|
||||
.chain((1..=7u32).map(|k| (28.0, 3.3 * f64::from(k))))
|
||||
.map(|(cx, r)| ring(cx, 24.0, r - 0.6, r + 0.6))
|
||||
.collect();
|
||||
add(d, "Interference Rings", 48.0, 48.0, TileType::Grid, rings);
|
||||
// Rings 2 pt and 1 pt wide by turns about the tile's corner and about its center, the center's
|
||||
// starting with the narrower one. The corner's rings are the same rings at all four corners.
|
||||
let mut rings = vec![];
|
||||
for (c, first) in [(0.0, 0), (20.0, 1)] {
|
||||
let mut r = 0.0;
|
||||
for k in 0..5 {
|
||||
let width = if (k + first) % 2 == 0 { 2.0 } else { 1.0 };
|
||||
rings.push(ring(c, c, r, r + width));
|
||||
r += width + 1.2;
|
||||
}
|
||||
}
|
||||
add(d, "Offset Rings", 40.0, 40.0, TileType::Grid, rings);
|
||||
// An 8 × 8 checkerboard of 4 pt cells whose columns are bent by a sine wave down the tile and
|
||||
// whose rows by one across it, each edge drawn through five points.
|
||||
let warp = |x: f64, y: f64| (x + 2.0 * (TAU * y / 32.0).sin(), y + 2.0 * (TAU * x / 32.0).sin());
|
||||
let cells = (0..8u32)
|
||||
.flat_map(|row| (0..8u32).map(move |col| (col, row)))
|
||||
.filter(|(col, row)| (col + row) % 2 == 0)
|
||||
.map(|(col, row)| {
|
||||
let (x, y) = (4.0 * f64::from(col), 4.0 * f64::from(row));
|
||||
let corners = [(x, y), (x + 4.0, y), (x + 4.0, y + 4.0), (x, y + 4.0), (x, y)];
|
||||
let points: Vec<(f64, f64)> = corners
|
||||
.windows(2)
|
||||
.flat_map(|e| {
|
||||
let (a, b) = (e[0], e[1]);
|
||||
(0..4u32).map(move |i| {
|
||||
let t = f64::from(i) / 4.0;
|
||||
warp(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
polygon(&points)
|
||||
})
|
||||
.collect();
|
||||
add(d, "Warped Checks", 32.0, 32.0, TileType::Grid, cells);
|
||||
// Six upright stripes 6 pt apart that widen from 0.8 pt to 5.6 pt and narrow again down the tile,
|
||||
// each a sixth of a wave on from the one to its left.
|
||||
let stripes = (0..6u32)
|
||||
.map(|k| {
|
||||
let (x, phase) = (3.0 + 6.0 * f64::from(k), TAU * f64::from(k) / 6.0);
|
||||
let half = move |y: f64| 1.6 + 1.2 * (TAU * y / 36.0 + phase).cos();
|
||||
let side = |s: f64| -> Vec<(f64, f64)> { curve(0.0, 36.0, 48, |y| x + s * half(y)).into_iter().map(|(y, at)| (at, y)).collect() };
|
||||
band(&side(-1.0), &side(1.0))
|
||||
})
|
||||
.collect();
|
||||
add(d, "Pinched Stripes", 36.0, 36.0, TileType::Grid, stripes);
|
||||
// Four zigzag bands 8 pt apart, two zigzags across the tile; the bend and the width of the bands
|
||||
// rise and fall from band to band.
|
||||
let chevrons = [(0.0, 3.0, 4.0), (8.0, 4.5, 2.5), (16.0, 3.0, 1.0), (24.0, 1.5, 2.5)]
|
||||
.into_iter()
|
||||
.map(|(y, bend, width)| {
|
||||
let upper: Vec<(f64, f64)> = (0..=4u32).map(|i| (8.0 * f64::from(i), if i % 2 == 1 { y + bend } else { y })).collect();
|
||||
let lower: Vec<(f64, f64)> = upper.iter().map(|&(x, y)| (x, y + width)).collect();
|
||||
band(&upper, &lower)
|
||||
})
|
||||
.collect();
|
||||
add(d, "Chevron Pulse", 32.0, 32.0, TileType::Grid, chevrons);
|
||||
// Five nested lens outlines 1.5 pt wide about the center, 14 pt to 78 pt long. The longer ones
|
||||
// reach across the side edges and cross the next tiles' lenses.
|
||||
let lenses = (0..5u32)
|
||||
.map(|k| {
|
||||
let (l, h) = (7.0 + 8.0 * f64::from(k), 4.0 + 4.0 * f64::from(k));
|
||||
PathData::new(vec![lens(24.0, 24.0, l, h, false), lens(24.0, 24.0, l - 1.5, h - 1.5, true)])
|
||||
})
|
||||
.collect();
|
||||
add(d, "Radiating Lenses", 48.0, 48.0, TileType::Grid, lenses);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -326,4 +483,101 @@ mod tests {
|
||||
}
|
||||
assert!(crossing > 0, "no mark reaches past an edge");
|
||||
}
|
||||
|
||||
const OPTICAL: [&str; 8] =
|
||||
["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"];
|
||||
|
||||
fn optical_doc() -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
optical_effects(&mut d);
|
||||
d
|
||||
}
|
||||
|
||||
/// The paths of pattern `p`'s art, each with its fill rule.
|
||||
fn paths(p: &PatternDef) -> Vec<(&PathData, vectorcraft_geom::FillRule)> {
|
||||
p.art
|
||||
.iter()
|
||||
.map(|n| match &n.kind {
|
||||
vectorcraft_doc::NodeKind::Path { path, rule, .. } => (path, *rule),
|
||||
_ => panic!("{}: art that is not a path", p.name),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optical_effects_is_a_pattern_library_of_eight_bounded_tiles() {
|
||||
let b = PATTERN_LIBRARIES.iter().find(|b| b.id == "optical-effects").expect("Optical Effects is listed");
|
||||
assert_eq!(b.name, "Optical Effects");
|
||||
let names: Vec<String> = library_doc(b.id).unwrap().patterns.iter().map(|p| p.name.clone()).collect();
|
||||
assert_eq!(names, OPTICAL);
|
||||
// Made twice, the art is the same.
|
||||
let (a, again) = (optical_doc(), optical_doc());
|
||||
assert_eq!(a.patterns, again.patterns);
|
||||
for p in &a.patterns {
|
||||
let (w, h) = (p.width(), p.height());
|
||||
assert!((32.0..=48.0).contains(&w) && w == h && p.tile.x0 == 0.0 && p.tile.y0 == 0.0, "{}", p.name);
|
||||
assert_eq!(p.tile_type, TileType::Grid, "{}", p.name);
|
||||
let paths = paths(p);
|
||||
let segments: usize = paths.iter().flat_map(|(path, _)| &path.subpaths).map(SubPath::segment_count).sum();
|
||||
assert!(!paths.is_empty() && paths.len() <= 600 && segments <= 20_000, "{}: {} paths, {segments} segments", p.name, paths.len());
|
||||
for (path, _) in &paths {
|
||||
let b = path.bounds().unwrap_or_else(|| panic!("{}: an empty path", p.name));
|
||||
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() > 0.0 && b.height() > 0.0, "{}: {b:?}", p.name);
|
||||
assert!(b.x1 > 0.0 && b.y1 > 0.0 && b.x0 < w && b.y0 < h, "{}: {b:?} does not touch the tile", p.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Just either side of a tile's right and bottom edges, the tiling paints the same points: a band
|
||||
/// cut off at an edge, or one that comes back at another height or width, paints one side only.
|
||||
/// No path is another moved by a whole tile, which the tiling would draw twice in one place.
|
||||
#[test]
|
||||
fn optical_tiles_join_without_a_seam() {
|
||||
const SAMPLES: u32 = 400;
|
||||
const STEP: f64 = 1e-4;
|
||||
for p in &optical_doc().patterns {
|
||||
let (w, h) = (p.width(), p.height());
|
||||
let shapes: Vec<(BezPathFill, Rect)> =
|
||||
paths(p).into_iter().map(|(path, rule)| ((path.to_bezpath(), rule), path.bounds().unwrap_or(Rect::ZERO))).collect();
|
||||
let paints = |q: Point| {
|
||||
let near = Rect::new(q.x - STEP, q.y - STEP, q.x + STEP, q.y + STEP);
|
||||
p.offsets_covering(near).into_iter().any(|o| {
|
||||
let at = q - o;
|
||||
shapes.iter().any(|((bp, rule), b)| {
|
||||
let inside = b.x0 <= at.x && at.x <= b.x1 && b.y0 <= at.y && at.y <= b.y1;
|
||||
let turns = if inside { kurbo::Shape::winding(bp, at) } else { 0 };
|
||||
match rule {
|
||||
vectorcraft_geom::FillRule::EvenOdd => turns % 2 != 0,
|
||||
vectorcraft_geom::FillRule::NonZero => turns != 0,
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
let (mut differ, mut painted) = (0, 0);
|
||||
for i in 0..SAMPLES {
|
||||
let t = (f64::from(i) + 0.37) / f64::from(SAMPLES);
|
||||
let (right, bottom) = (Point::new(w, t * h), Point::new(t * w, h));
|
||||
let (dx, dy) = (kurbo::Vec2::new(STEP, 0.0), kurbo::Vec2::new(0.0, STEP));
|
||||
for (a, b) in [(right - dx, right + dx), (bottom - dy, bottom + dy)] {
|
||||
let (pa, pb) = (paints(a), paints(b));
|
||||
differ += usize::from(pa != pb);
|
||||
painted += usize::from(pa);
|
||||
}
|
||||
}
|
||||
assert!(differ <= 2, "{}: {differ} of {} points across the edges differ", p.name, 2 * SAMPLES);
|
||||
assert!(painted > 0, "{}: nothing crosses an edge", p.name);
|
||||
let bounds: Vec<Rect> = shapes.iter().map(|(_, b)| *b).collect();
|
||||
let same = |a: &Rect, b: Rect| {
|
||||
(a.x0 - b.x0).abs() < 1e-9 && (a.y0 - b.y0).abs() < 1e-9 && (a.x1 - b.x1).abs() < 1e-9 && (a.y1 - b.y1).abs() < 1e-9
|
||||
};
|
||||
for b in &bounds {
|
||||
for (dx, dy) in [(w, 0.0), (-w, 0.0), (0.0, h), (0.0, -h), (w, h), (w, -h), (-w, h), (-w, -h)] {
|
||||
let moved = *b + kurbo::Vec2::new(dx, dy);
|
||||
assert!(!bounds.iter().any(|o| same(o, moved)), "{}: {b:?} is drawn twice by the tiling", p.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type BezPathFill = (vectorcraft_geom::BezPath, vectorcraft_geom::FillRule);
|
||||
}
|
||||
|
||||
@@ -62,13 +62,28 @@ fn err(e: vectorcraft_plugins::Error) -> EngineError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Under automation read roots, a reply names a file or folder outside them by its name alone.
|
||||
fn shown_file(path: &str) -> String {
|
||||
super::fileio::shown_path(path).into_owned()
|
||||
}
|
||||
|
||||
/// A plug-in turned off for the next launch, or the one a launch stopped at, as a reply shows it: a
|
||||
/// file outside the automation read roots is named by its file name.
|
||||
fn shown_item(item: &safe_mode::Item) -> Value {
|
||||
let mut v = item.to_json();
|
||||
if let Some(file) = item.file.as_deref() {
|
||||
v["file"] = json!(shown_file(file));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// JSON description of an installed plug-in.
|
||||
pub fn describe(p: &Plugin) -> Value {
|
||||
let m = p.manifest();
|
||||
let mut v = json!({
|
||||
"id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description,
|
||||
"params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()),
|
||||
"size": p.size(), "source": p.source(),
|
||||
"size": p.size(), "source": p.source().map(shown_file),
|
||||
"permissions": permissions::names(&m.permissions()), "hash": p.hash(), "api": m.api,
|
||||
});
|
||||
match m.kind {
|
||||
@@ -932,9 +947,46 @@ fn describe_installed(p: &Plugin) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
/// The folder load `report` as a reply shows it: under automation read roots, a folder outside them
|
||||
/// is `null` and a failed file outside them is named by its file name, in the errors too.
|
||||
fn shown_report(mut report: Value) -> Value {
|
||||
let mut hidden: Vec<(String, String)> = vec![];
|
||||
if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) {
|
||||
for row in failed {
|
||||
let Some(file) = row.get("file").and_then(Value::as_str).map(str::to_string) else { continue };
|
||||
let shown = shown_file(&file);
|
||||
if shown != file {
|
||||
row["file"] = json!(shown);
|
||||
hidden.push((file, shown));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(folder) = report.get("folder").and_then(Value::as_str).map(str::to_string) {
|
||||
let shown = shown_file(&folder);
|
||||
if shown != folder {
|
||||
report["folder"] = Value::Null;
|
||||
hidden.push((folder, shown));
|
||||
}
|
||||
}
|
||||
// A failed file's path holds its folder's: the longer paths are put back first.
|
||||
hidden.sort_by_key(|(path, _)| std::cmp::Reverse(path.len()));
|
||||
let unnamed = |text: &str| hidden.iter().fold(text.to_string(), |t, (path, name)| t.replace(path.as_str(), name));
|
||||
if let Some(e) = report.get("error").and_then(Value::as_str).map(unnamed) {
|
||||
report["error"] = json!(e);
|
||||
}
|
||||
if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) {
|
||||
for row in failed {
|
||||
if let Some(e) = row.get("error").and_then(Value::as_str).map(unnamed) {
|
||||
row["error"] = json!(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
fn list(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone());
|
||||
let disabled: Vec<Value> = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect();
|
||||
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| shown_report(r.clone()));
|
||||
let disabled: Vec<Value> = safe_mode::disabled().iter().map(shown_item).collect();
|
||||
Ok(
|
||||
json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::<Vec<_>>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}),
|
||||
)
|
||||
@@ -946,11 +998,33 @@ fn safe_mode_state(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({
|
||||
"active": safe_mode::active(),
|
||||
"source": safe_mode::source().map(safe_mode::Source::name),
|
||||
"disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::<Vec<_>>(),
|
||||
"pendingItem": safe_mode::previous().map(|i| i.to_json()),
|
||||
"disabled": safe_mode::disabled().iter().map(shown_item).collect::<Vec<_>>(),
|
||||
"pendingItem": safe_mode::previous().map(|i| shown_item(&i)),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The file of a plug-in `plugin.safeMode` lists as `file`, or `plugin.list` as `source`: one they name
|
||||
/// by its file name (outside the automation read roots) is found by that name, when no other listed
|
||||
/// file has it.
|
||||
fn listed_file(file: String) -> String {
|
||||
let mut listed: Vec<String> = safe_mode::disabled()
|
||||
.into_iter()
|
||||
.chain(safe_mode::previous())
|
||||
.filter_map(|i| i.file)
|
||||
.chain(registry::list().iter().filter_map(|p| p.source().map(str::to_string)))
|
||||
.collect();
|
||||
listed.sort();
|
||||
listed.dedup();
|
||||
if listed.contains(&file) {
|
||||
return file;
|
||||
}
|
||||
let mut named = listed.into_iter().filter(|f| shown_file(f) == file);
|
||||
match (named.next(), named.next()) {
|
||||
(Some(f), None) => f,
|
||||
_ => file,
|
||||
}
|
||||
}
|
||||
|
||||
/// `plugin.setEnabled`: turns a plug-in on or off for the next launch, by `id` or by `file` (a
|
||||
/// plug-in that stopped a launch while loading may be known by its file alone). It neither loads
|
||||
/// nor unloads anything now, and uninstalls nothing.
|
||||
@@ -972,7 +1046,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let stored = if enabled { s.stored_plugin_file(&id).map(safe_mode::Item::file) } else { None };
|
||||
(registry::get(&id).map(|p| safe_mode::Item::of(&p)).unwrap_or(safe_mode::Item { id: Some(id), ..Default::default() }), stored)
|
||||
}
|
||||
(None, Some(file)) => (safe_mode::Item::file(file), None),
|
||||
(None, Some(file)) => (safe_mode::Item::file(listed_file(file)), None),
|
||||
(None, None) => return Err(bad(C, "give `id` or `file` (see plugin.safeMode)")),
|
||||
};
|
||||
let items: Vec<&safe_mode::Item> = std::iter::once(&item).chain(stored.as_ref()).collect();
|
||||
@@ -986,7 +1060,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Without a plug-in folder (a session confined to automation roots), or when the list there
|
||||
// can't take the change, the change stays in this process and goes with the next change that saves.
|
||||
let saved = !changed || s.store_disabled_plugins();
|
||||
Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::<Vec<_>>()}))
|
||||
Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(shown_item).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1055,7 +1129,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"List Plug-ins",
|
||||
[],
|
||||
None,
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null (under automation read roots, a folder outside them is null, and a failed file and a plug-in's source outside them are named by their file name), safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
@@ -1127,7 +1201,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Safe Mode",
|
||||
[],
|
||||
None,
|
||||
"{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command",
|
||||
"{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file; under automation read roots, a file outside them is named by its file name here and in pendingItem, and plugin.setEnabled takes that name), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command",
|
||||
always,
|
||||
safe_mode_state
|
||||
),
|
||||
@@ -1136,7 +1210,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Enable on Re-launch",
|
||||
[],
|
||||
None,
|
||||
"{id | file: a plug-in as plugin.safeMode's disabled lists it, enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}",
|
||||
"{id | file: a plug-in as plugin.safeMode's disabled lists it or as plugin.list's source (under automation read roots, a file outside them by the file name shown, when no other listed file has it), enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}",
|
||||
always,
|
||||
set_enabled
|
||||
),
|
||||
|
||||
@@ -307,7 +307,9 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"object.nudge",
|
||||
"object.objectsOnPath.attach",
|
||||
"object.objectsOnPath.expand",
|
||||
"object.objectsOnPath.moveMember",
|
||||
"object.objectsOnPath.options",
|
||||
"object.objectsOnPath.selectMember",
|
||||
"object.path.addAnchorPoints",
|
||||
"object.path.cleanUp",
|
||||
"object.path.divideObjectsBelow",
|
||||
|
||||
@@ -97,6 +97,10 @@ pub trait RecoveryStore: Send + Sync {
|
||||
fn remove(&self, name: &str) -> std::result::Result<(), String>;
|
||||
/// Where the entries are, as the user knows it (a folder, "browser storage").
|
||||
fn location(&self) -> String;
|
||||
/// Is [`RecoveryStore::location`] a folder on this computer (default: no)?
|
||||
fn in_folder(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// The areas in the store (default: those with entries).
|
||||
fn areas(&self) -> std::result::Result<Vec<String>, String> {
|
||||
let names = self.list()?;
|
||||
@@ -477,6 +481,9 @@ impl RecoveryStore for FolderStore {
|
||||
fn location(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
}
|
||||
fn in_folder(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn areas(&self) -> std::result::Result<Vec<String>, String> {
|
||||
Self::names(&self.0, true)
|
||||
}
|
||||
@@ -938,6 +945,21 @@ fn sweep(store: &dyn RecoveryStore, area: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's file `path` as a reply names it: under automation read roots, only inside them.
|
||||
fn shown_path(path: Option<String>) -> Option<String> {
|
||||
path.filter(|p| crate::file_access::check_read(p).is_ok())
|
||||
}
|
||||
|
||||
/// Where `store` keeps its copies, as a reply names it: a recovery folder outside the automation
|
||||
/// read roots is `null`.
|
||||
fn shown_location(store: &dyn RecoveryStore) -> Value {
|
||||
let location = store.location();
|
||||
if store.in_folder() && crate::file_access::check_read(&location).is_err() {
|
||||
return Value::Null;
|
||||
}
|
||||
json!(location)
|
||||
}
|
||||
|
||||
fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta {
|
||||
store.read(&format!("{file}{META_EXT}")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
}
|
||||
@@ -1235,7 +1257,7 @@ fn save(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
forget_clean(s);
|
||||
heartbeat(s);
|
||||
Ok(json!({ "saved": saved, "skipped": skipped, "location": store.location() }))
|
||||
Ok(json!({ "saved": saved, "skipped": skipped, "location": shown_location(store.as_ref()) }))
|
||||
}
|
||||
|
||||
/// Who an area of the store belongs to.
|
||||
@@ -1361,7 +1383,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let meta = read_meta(store.as_ref(), f);
|
||||
let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title };
|
||||
rows.push(json!({
|
||||
"file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved,
|
||||
"file": f, "title": title, "path": shown_path(meta.path), "format": meta.format, "saved": meta.saved,
|
||||
"open": (who == Owner::Mine && open.contains(f)) || left.contains(f), "running": who == Owner::Running,
|
||||
}));
|
||||
}
|
||||
@@ -1370,7 +1392,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
tidy(store.as_ref(), &area, hold);
|
||||
}
|
||||
}
|
||||
Ok(json!({ "copies": rows, "location": store.location() }))
|
||||
Ok(json!({ "copies": rows, "location": shown_location(store.as_ref()) }))
|
||||
}
|
||||
|
||||
/// A gone app's area to act on: its name, the copies to act on, and its lock.
|
||||
@@ -1483,7 +1505,10 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
|
||||
// The copy stays until the document is saved or closed.
|
||||
st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() });
|
||||
let title = st.title();
|
||||
Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path, "warnings": warnings }), links.to_json()))
|
||||
Ok(super::fileio::merge(
|
||||
json!({ "index": index, "title": title, "file": mine, "path": shown_path(meta.path), "warnings": warnings }),
|
||||
links.to_json(),
|
||||
))
|
||||
}
|
||||
|
||||
fn restore(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1527,7 +1552,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Recovery Data",
|
||||
[],
|
||||
None,
|
||||
"{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)",
|
||||
"{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web; null under automation read roots when the folder is outside them)",
|
||||
has_store,
|
||||
save
|
||||
),
|
||||
@@ -1536,7 +1561,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Recovery Data",
|
||||
[],
|
||||
None,
|
||||
"{} the recovery copies in the store → {copies: [{file (\"<area>/<name>\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash",
|
||||
"{} the recovery copies in the store → {copies: [{file (\"<area>/<name>\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash; under automation read roots, path is null when the document's file is outside them, and location when the recovery folder is",
|
||||
has_store,
|
||||
list
|
||||
),
|
||||
@@ -1545,7 +1570,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Restore Recovered Documents",
|
||||
[],
|
||||
None,
|
||||
"{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \"<name> [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed",
|
||||
"{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \"<name> [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}; under automation read roots, path is null when the original file is outside them. The copy moves to this app's area and stays until the document is saved or closed",
|
||||
has_store,
|
||||
restore
|
||||
),
|
||||
|
||||
@@ -233,6 +233,43 @@ impl Session {
|
||||
self.scripts_dir.as_deref().filter(|_| !cfg!(target_arch = "wasm32"))
|
||||
}
|
||||
|
||||
/// The last run's report ([`Session::script_report`]) as a reply shows it: under automation read
|
||||
/// roots, a file the run wrote outside them (`written`) and the file a failed run stopped in
|
||||
/// (`file`, also in the run's error line in `console`) are named by their file name. What the
|
||||
/// script wrote itself (`log`, its output lines) stays as written.
|
||||
pub fn shown_script_report(&self) -> Option<Value> {
|
||||
let mut report = self.script_report()?.clone();
|
||||
if let Some(written) = report.get_mut("written").and_then(Value::as_array_mut) {
|
||||
for path in written.iter_mut() {
|
||||
if let Some(name) = path.as_str().and_then(hidden_name) {
|
||||
*path = json!(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(file) = report.get("file").and_then(Value::as_str).map(str::to_string)
|
||||
&& let Some(name) = hidden_name(&file)
|
||||
{
|
||||
// The run's error lines come after every line the script wrote. They are written again
|
||||
// from the report with the file's name, which also covers a line the console cut inside
|
||||
// the path. A console that dropped its oldest lines may hold only the last of them.
|
||||
let before = script::error_lines(&report);
|
||||
report["file"] = json!(name);
|
||||
if let Some(message) = report.get("message").and_then(Value::as_str) {
|
||||
report["message"] = json!(message.replace(&file, &name));
|
||||
}
|
||||
let after = script::error_lines(&report);
|
||||
if let Some(lines) = report.pointer_mut("/console/lines").and_then(Value::as_array_mut) {
|
||||
let kept = (1..=before.len().min(lines.len()))
|
||||
.rev()
|
||||
.find(|&n| lines.iter().rev().take(n).map(Value::as_str).eq(before.iter().rev().take(n).map(|l| Some(l.as_str()))))
|
||||
.unwrap_or(0);
|
||||
lines.truncate(lines.len() - kept);
|
||||
lines.extend(after.iter().skip(after.len().saturating_sub(kept)).map(|l| json!(l)));
|
||||
}
|
||||
}
|
||||
Some(report)
|
||||
}
|
||||
|
||||
/// The text of the script at `path`, which a listing of the Scripts folder gave
|
||||
/// ([`script::folder::read`]), checked as `script.run` checks a file's.
|
||||
pub fn installed_script(&self, path: &str) -> Result<String> {
|
||||
@@ -248,6 +285,13 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name a reply names `path` by when the file is outside the automation read roots; `None`
|
||||
/// when a reply may name it whole.
|
||||
fn hidden_name(path: &str) -> Option<String> {
|
||||
let shown = super::fileio::shown_path(path);
|
||||
(shown != path).then(|| shown.into_owned())
|
||||
}
|
||||
|
||||
fn list_installed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(root) = s.scripts_dir() else {
|
||||
return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false}));
|
||||
|
||||
@@ -184,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Not Aligned to Pixel Grid",
|
||||
["Select", "Object"],
|
||||
None,
|
||||
"{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups but not into compound paths → {count}",
|
||||
"{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups, clip groups, blends, envelopes, repeats and Intertwine groups but not into compound paths → {count}",
|
||||
has_doc,
|
||||
not_pixel_aligned
|
||||
),
|
||||
|
||||
@@ -1365,6 +1365,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod area_tests;
|
||||
mod block;
|
||||
pub(crate) use block::needed_room;
|
||||
#[cfg(test)]
|
||||
mod block_tests;
|
||||
|
||||
|
||||
@@ -89,6 +89,39 @@ fn line_widths(db: &vectorcraft_text::FontDb, t: &TextObject) -> std::collection
|
||||
out
|
||||
}
|
||||
|
||||
/// The room along its lines that a frame of `t`'s text needs for Block to keep the widest line as
|
||||
/// typed at its present size: that line's width plus its paragraph's indents, as [`block_fit`]
|
||||
/// measures them, and a margin of under half a size step so that the line is not made a step
|
||||
/// smaller; with the widest line's first byte and its paragraph's index. A line whose negative
|
||||
/// indents take more than its width needs its own width. None when no line has a width.
|
||||
pub(crate) fn needed_room(t: &TextObject) -> Option<(f64, usize, usize)> {
|
||||
let points = t.style_scale().map(|s| s.points).filter(|p| p.is_finite() && *p > 1e-12)?;
|
||||
let text = t.plain_text();
|
||||
let lines = typed_lines(&text);
|
||||
let widths = line_widths(vectorcraft_text::FontDb::global(), t);
|
||||
let sizes = first_sizes(&t.runs, &lines);
|
||||
lines
|
||||
.iter()
|
||||
.zip(&sizes)
|
||||
.filter_map(|(l, size)| {
|
||||
let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?;
|
||||
let size = size * points;
|
||||
if l.content == l.start || !(size.is_finite() && size > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let para = t.para_at(l.para);
|
||||
let first = if l.para_start { para.first_line_indent } else { 0.0 };
|
||||
let indents = para.laid_out_left_indent() + para.right_indent + first;
|
||||
// Negative indents that take more than the line's width leave the line no room at its
|
||||
// size; the frame is then as wide as the line itself, and Block makes it larger.
|
||||
let room = if width + indents > 1e-9 { width + indents } else { width };
|
||||
// A tenth of a point more size widens the line by 0.1 * width / size.
|
||||
Some((room + 0.04 * width / size, l.start, l.para))
|
||||
})
|
||||
.filter(|r| r.0.is_finite() && r.0 > 1e-9)
|
||||
.reduce(|a, b| if b.0 > a.0 { b } else { a })
|
||||
}
|
||||
|
||||
/// The size a line set at `size` points and `width` wide takes to fill `avail`, its width growing
|
||||
/// by `slope` a point of size (`width / size` when the whole line follows its size): rounded down
|
||||
/// to tenths and kept within [`SIZES`]. A line that fits and would not fit a tenth larger keeps its
|
||||
@@ -152,7 +185,7 @@ pub(crate) fn block_fit(t: &mut TextObject) -> bool {
|
||||
let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?;
|
||||
let para = t.para_at(l.para);
|
||||
let first = if l.para_start { para.first_line_indent } else { 0.0 };
|
||||
let avail = cell - para.left_indent - para.right_indent - first;
|
||||
let avail = cell - para.laid_out_left_indent() - para.right_indent - first;
|
||||
let size = size * points;
|
||||
if !(size.is_finite() && size > 0.0) {
|
||||
return None;
|
||||
|
||||
@@ -188,13 +188,25 @@ fn bad_params_and_other_type_are_refused() {
|
||||
assert!(!text(&s, id).dynamic_text);
|
||||
let point = s.execute("text.create", &json!({"x": 10, "y": 500, "text": "point"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [point]})).unwrap();
|
||||
// Off leaves point type as it is, with no undo step, and a gap needs it turned on.
|
||||
let n = undo_len(&s);
|
||||
assert_eq!(s.execute("type.dynamicText", &json!({"on": false})).unwrap()["ids"], json!([point]));
|
||||
let e = s.execute("type.dynamicText", &json!({"on": false, "wordGap": 200})).unwrap_err().to_string();
|
||||
assert!(e.contains("need area type"), "{e}");
|
||||
assert_eq!(undo_len(&s), n);
|
||||
assert!(matches!(text(&s, point).kind, TextKind::Point));
|
||||
let shape = s.execute("shape.rectangle", &json!({"x": 500, "y": 500, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [shape]})).unwrap();
|
||||
let e = s.execute("type.dynamicText", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("select type on a path or area type"), "{e}");
|
||||
// With point type selected too, only the area type changes.
|
||||
// With point type selected too, both turn on, the point type as area type with Block on.
|
||||
s.execute("select.set", &json!({"ids": [id, point]})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("type.dynamicText", &json!({})).unwrap();
|
||||
assert_eq!(r["ids"], json!([id]));
|
||||
assert!(text(&s, id).dynamic_text && !text(&s, point).dynamic_text);
|
||||
assert_eq!(r["ids"], json!([id, point]));
|
||||
assert!(text(&s, id).dynamic_text && text(&s, point).dynamic_text);
|
||||
assert!(matches!(text(&s, point).kind, TextKind::Area { .. }));
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
// A gap with only type on a path selected is an error, and nothing changes.
|
||||
let path = s.execute("path.create", &json!({"d": "M60 550 L340 550"})).unwrap()["id"].as_u64().unwrap();
|
||||
let along = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Along"})).unwrap()["id"].as_u64().unwrap();
|
||||
@@ -438,6 +450,228 @@ fn threaded_frames_are_refused_and_threading_turns_block_off() {
|
||||
assert!(matches!(text(&s, a).kind, TextKind::Area { .. }));
|
||||
}
|
||||
|
||||
/// 12 pt point type at (300, 100) with `extra` params for `text.create`, selected.
|
||||
fn point(s: &mut Session, text: &str, extra: Value) -> u64 {
|
||||
let mut p = json!({"x": 300, "y": 100, "size": 12, "text": text});
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
let id = s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Where the first glyph of the laid-out line starting at byte `start` is in the document.
|
||||
fn line_start(t: &TextObject, start: usize) -> vectorcraft_geom::Point {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let l = lay.lines.iter().find(|l| l.start == start).expect("a line starts there");
|
||||
t.xf * lay.glyphs[l.glyph_start].origin
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_type_becomes_area_type_with_block_at_its_widest_line() {
|
||||
for justify in ["left", "center", "right"] {
|
||||
let mut s = session();
|
||||
let id = point(&mut s, "Short\nA much longer line", json!({}));
|
||||
s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap();
|
||||
let before = line_start(&text(&s, id), 6);
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("type.dynamicText", &json!({})).unwrap();
|
||||
assert_eq!((r["on"].clone(), r["ids"].clone()), (json!(true), json!([id])));
|
||||
assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(100.0), json!(0.0)), "the reply gives the new frame's gaps");
|
||||
assert_eq!(undo_len(&s), n + 1, "{justify}: one undo step");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Dynamic Text");
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}");
|
||||
assert_eq!(t.area.fit, AreaFit::None);
|
||||
assert_block(&t);
|
||||
assert_eq!(size_at(&t, 6), 12.0, "{justify}: the widest line keeps its size");
|
||||
assert!(size_at(&t, 0) > 24.0, "{justify}: the short line grows to the frame's width: {}", size_at(&t, 0));
|
||||
// Across, the widest line stays where it was; the short line above it grew, so it moved down.
|
||||
let after = line_start(&t, 6);
|
||||
assert!((after.x - before.x).abs() < 0.05, "{justify}: the widest line stays where it was across: {before:?} → {after:?}");
|
||||
assert!(after.y > before.y + 12.0, "{justify}");
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}: the frame holds every line");
|
||||
// Edits fit it again as area type; undo brings the point type back as it was.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let back = text(&s, id);
|
||||
assert!(matches!(back.kind, TextKind::Point) && !back.dynamic_text && size_at(&back, 0) == 12.0, "{justify}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_type_with_gaps_indents_and_vertical_type_turns_into_block() {
|
||||
let mut s = session();
|
||||
// The gaps apply to the frame point type becomes.
|
||||
let id = point(&mut s, "one\ntwo words here", json!({}));
|
||||
let r = s.execute("type.dynamicText", &json!({"wordGap": 300, "lineGap": 4})).unwrap();
|
||||
assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(300.0), json!(4.0)));
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && (t.area.word_gap, t.area.line_gap) == (300.0, 4.0));
|
||||
assert_block(&t);
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
|
||||
// Indents: the frame takes them in, so the widest line keeps its size.
|
||||
let id = point(&mut s, "Tiny\nThe widest line of all", json!({"y": 300}));
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": 20, "firstLineIndent": 10})).unwrap();
|
||||
let before = line_start(&text(&s, id), 5);
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_block(&t);
|
||||
assert_eq!(size_at(&t, 5), 12.0);
|
||||
assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5));
|
||||
// A nested list item: the frame takes in the level's indent too.
|
||||
let id = point(&mut s, "Tiny\nThe widest item of all", json!({"y": 200}));
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "listLevel": 3})).unwrap();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!(size_at(&t, 5), 12.0, "nested list");
|
||||
assert!(size_at(&t, 0) > 12.0);
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
|
||||
// Mixed alignments: the widest line keeps its size and its place.
|
||||
let id = point(&mut s, "Left\nThe widest line, right aligned", json!({"y": 400}));
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) {
|
||||
let right = vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Right, ..t.para.clone() };
|
||||
t.set_paragraph_styles(vec![t.para.clone(), right]);
|
||||
}
|
||||
let before = line_start(&text(&s, id), 5);
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!(size_at(&t, 5), 12.0, "mixed alignments");
|
||||
assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5));
|
||||
// A right-to-left list, whose indents the layout mirrors.
|
||||
let id = point(&mut s, "קצר\nשורה ארוכה בהרבה מהראשונה", json!({"y": 450}));
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "rightIndent": 9})).unwrap();
|
||||
let widest = "קצר\n".len();
|
||||
let before = line_start(&text(&s, id), widest);
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!(size_at(&t, widest), 12.0, "right-to-left list");
|
||||
assert!((line_start(&t, widest).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, widest));
|
||||
// Point type that was area type with its own options: the new frame takes the defaults but the
|
||||
// gaps.
|
||||
let id = point(&mut s, "Was\nonce a frame of two columns", json!({"y": 500}));
|
||||
s.execute("type.convertToAreaType", &json!({"ids": [id]})).unwrap();
|
||||
s.execute(
|
||||
"text.areaOptions",
|
||||
&json!({"columns": 2, "gutter": 12, "inset": 6, "verticalAlign": "bottom", "firstBaselineMin": 40, "wordGap": 150}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap();
|
||||
let before = line_start(&text(&s, id), 4);
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
let defaults = vectorcraft_doc::AreaOptions { word_gap: 150.0, ..Default::default() };
|
||||
assert_eq!(t.area, defaults);
|
||||
assert_eq!(size_at(&t, 4), 12.0, "options from an earlier frame");
|
||||
assert!((line_start(&t, 4).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 4));
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
|
||||
// Vertical type: the frame is as tall as the longest column and grows to the left.
|
||||
let id = point(&mut s, "縦書き\nもっと長い縦書きの行", json!({"x": 600, "y": 40, "vertical": true}));
|
||||
let before = line_start(&text(&s, id), "縦書き\n".len());
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert!(t.vertical && t.dynamic_text && matches!(t.kind, TextKind::Area { .. }));
|
||||
assert_block(&t);
|
||||
assert_eq!(size_at(&t, "縦書き\n".len()), 12.0);
|
||||
let after = line_start(&t, "縦書き\n".len());
|
||||
assert!((after.y - before.y).abs() < 0.05, "the longest column stays where it was down the page: {before:?} → {after:?}");
|
||||
assert!(after.x < before.x - 12.0, "the short column before it grew, so it moved left: {before:?} → {after:?}");
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_type_with_negative_indents_wider_than_its_line_turns_into_block() {
|
||||
// Block makes the line larger; it keeps its start, center or end by its alignment.
|
||||
let ends = |t: &TextObject| {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let l = &lay.lines[0];
|
||||
((t.xf * vectorcraft_geom::Point::new(l.x0, l.baseline)).x, (t.xf * vectorcraft_geom::Point::new(l.x1 - l.trailing, l.baseline)).x)
|
||||
};
|
||||
for justify in ["left", "center", "right"] {
|
||||
let mut s = session();
|
||||
let id = point(&mut s, "Short", json!({}));
|
||||
s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": -200})).unwrap();
|
||||
let (b0, b1) = ends(&text(&s, id));
|
||||
let n = undo_len(&s);
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "{justify}");
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}: the frame is as wide as the line");
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}");
|
||||
assert!(size_at(&t, 0) > 12.0, "{justify}: Block makes the line larger");
|
||||
let (a0, a1) = ends(&t);
|
||||
let kept = match justify {
|
||||
"left" => (a0, b0),
|
||||
"center" => ((a0 + a1) / 2.0, (b0 + b1) / 2.0),
|
||||
_ => (a1, b1),
|
||||
};
|
||||
assert!((kept.0 - kept.1).abs() < 0.05, "{justify}: the line keeps its aligned point: {b0},{b1} → {a0},{a1}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_type_under_a_text_wrap_object_turns_into_block_that_holds_every_line() {
|
||||
let mut s = session();
|
||||
let id = point(&mut s, "One\nTwo\nThree\nA much longer line than the rest", json!({}));
|
||||
// A wrap object above the type, across the top of the frame it becomes.
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 250, "y": 80, "width": 400, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("object.textWrap.make", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }));
|
||||
assert!(!t.wrap.is_empty(), "the new area type takes the wrap object");
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame holds every line around the wrap");
|
||||
// A wrap object deeper than any size Block sets, across the whole frame: the lines go below it.
|
||||
let id = point(&mut s, "Below", json!({"x": 1000, "y": 100}));
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 900, "y": 80, "width": 400, "height": 9000})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("object.textWrap.make", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && !t.wrap.is_empty());
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches past a deep wrap object");
|
||||
// An inverted wrap object far below the type holds the lines inside it: the frame reaches it.
|
||||
let id = point(&mut s, "Inside", json!({"x": 1600, "y": 100}));
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 1550, "y": 9000, "width": 400, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("object.textWrap.make", &json!({})).unwrap();
|
||||
s.execute("object.textWrap.options", &json!({"invert": true})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && !t.wrap.is_empty());
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches a distant inverted wrap object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_type_with_nothing_to_fit_is_left_as_it_is() {
|
||||
let mut s = session();
|
||||
for blank in ["", " ", "\n\n"] {
|
||||
let id = point(&mut s, blank, json!({}));
|
||||
let before = text(&s, id);
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("type.dynamicText", &json!({})).unwrap();
|
||||
assert_eq!(r["on"], json!(true));
|
||||
assert_eq!(undo_len(&s), n, "{blank:?}: no undo step");
|
||||
assert_eq!(text(&s, id), before, "{blank:?}");
|
||||
// A gap needs type it can apply to.
|
||||
let e = s.execute("type.dynamicText", &json!({"wordGap": 200})).unwrap_err().to_string();
|
||||
assert!(e.contains("need area type"), "{blank:?}: {e}");
|
||||
}
|
||||
// A line far shorter than the widest one stops at the size limit, and the frame still holds it.
|
||||
let id = point(&mut s, &format!(".\n{}", "w".repeat(400)), json!({"size": 6}));
|
||||
s.execute("type.dynamicText", &json!({})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text);
|
||||
assert_eq!(size_at(&t, 0), 1296.0);
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_text_fits_in_a_bounded_number_of_layouts() {
|
||||
let mut s = session();
|
||||
@@ -451,4 +685,16 @@ fn long_text_fits_in_a_bounded_number_of_layouts() {
|
||||
// pass and the caches), plus the reply's own.
|
||||
let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 4;
|
||||
assert!(used <= bound, "{used} layouts");
|
||||
// Long point type: measured, fitted in its frame and fitted again at its depth, with the
|
||||
// layouts that place and measure the frame.
|
||||
let id = point(&mut s, &story, json!({}));
|
||||
let before = vectorcraft_text::test_fonts::layouts_on_this_thread();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let used = vectorcraft_text::test_fonts::layouts_on_this_thread() - before;
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }));
|
||||
assert_block(&t);
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
|
||||
let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 8;
|
||||
assert!(used <= bound, "point type: {used} layouts");
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use vectorcraft_doc::{AreaFit, CharStyle, Document, Justify, LockedArtboards, No
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::object::choice;
|
||||
use super::typecmd::{area_text, block_gaps, refresh_bounds};
|
||||
use super::typecmd::{area_text, block_gaps, needed_room, refresh_bounds};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or,
|
||||
switch_or, targets, text_param,
|
||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Dynamic Text",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type (on omitted: the opposite of the first object's setting, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Point type in the selection is left as it is; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}",
|
||||
"{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type, or turn it on for point type (on omitted: the opposite of the first object's setting, point type counting as off, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection, and no point type it turns into area type, is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Turning it on turns point type into area type with Block on, with the default Area Type Options but the gaps (options kept from an earlier frame do not carry over): the frame is as wide (along the lines) as the widest line as typed with its paragraph's indents, so that line keeps its size and sits where it was (by its own alignment), the other lines are set to that width (when negative indents take more than that line's width, the frame is as wide as the line, and Block makes it larger), and the frame then grows in depth (down, or to the left for vertical type) to hold every fitted line, around any Text Wrap objects above it; the gaps apply to it as to area type. Point type with no line to fit (empty, or only spaces and breaks) is left as it is, and so is point type when it is turned off; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}",
|
||||
has_dynamic_type,
|
||||
dynamic_text
|
||||
),
|
||||
@@ -346,13 +346,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A little slack on the right so the text doesn't rewrap.
|
||||
let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0);
|
||||
let slack = size * 0.5 + trailing;
|
||||
// Slack goes where the alignment leaves room, so the text doesn't move or rewrap.
|
||||
let (sl, sr) = match t.para_at(0).justify {
|
||||
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
|
||||
Justify::Right | Justify::JustifyRight => (slack, 0.0),
|
||||
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0),
|
||||
_ => (0.0, slack),
|
||||
};
|
||||
let (sl, sr) = slack_sides(t.para_at(0).justify, lay.lines.first().is_some_and(|l| l.rtl), slack);
|
||||
let frame = lay.line_xf.transform_rect_bbox(Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0)));
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(frame) };
|
||||
refresh_bounds(t);
|
||||
@@ -362,6 +356,107 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
/// How `slack` added to point type's lines in a frame is shared before and after them: where the
|
||||
/// alignment leaves room, so the text doesn't move or rewrap.
|
||||
fn slack_sides(justify: Justify, rtl: bool, slack: f64) -> (f64, f64) {
|
||||
match justify {
|
||||
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
|
||||
Justify::Right | Justify::JustifyRight => (slack, 0.0),
|
||||
Justify::Auto if rtl => (slack, 0.0),
|
||||
_ => (0.0, slack),
|
||||
}
|
||||
}
|
||||
|
||||
/// Point type `t` turned into area type with Dynamic Text's Block on, with the default Area Type
|
||||
/// Options but its gaps: the frame takes the room along the lines that keeps the widest line as
|
||||
/// typed at its size ([`needed_room`]), placed so that line stays where it is by its own
|
||||
/// alignment, and after the fit it takes the depth that holds every line, so nothing overflows.
|
||||
/// Lines run in line space (for vertical type, down the page), and the frame is mapped to the
|
||||
/// type's own space by the layout's `line_xf`. `wrap` is the Text Wrap shapes the new area type
|
||||
/// takes, in the type's own space, which the fit and the depth follow. When negative indents take
|
||||
/// more than the widest line's width, the frame is as wide as that line and placed so that the
|
||||
/// line keeps its aligned point. None when no line has a
|
||||
/// width (empty text, or only spaces and breaks), which Block leaves at its size.
|
||||
fn point_to_block(t: &TextObject, wrap: &[vectorcraft_doc::WrapShape]) -> Option<TextObject> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// Options point type kept from a frame it had before (columns, inset, alignment, first
|
||||
// baseline) do not carry over to the new frame.
|
||||
let mut t = t.clone();
|
||||
let (word_gap, line_gap) = (t.area.word_gap, t.area.line_gap);
|
||||
t.area = vectorcraft_doc::AreaOptions { word_gap, line_gap, ..Default::default() };
|
||||
t.wrap = wrap.to_vec();
|
||||
let t = &t;
|
||||
// The room is measured as Block sets the lines in a frame, with the word gap.
|
||||
let as_block = TextObject { dynamic_text: true, kind: TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)) }, ..t.clone() };
|
||||
let (room, widest, para_index) = needed_room(&as_block)?;
|
||||
let lay = vectorcraft_text::layout(db, t);
|
||||
let first = lay.lines.first()?;
|
||||
let line = lay.lines.iter().find(|l| l.start == widest)?;
|
||||
// Across, the widest line as it is aligned, with its paragraph's indents, which the frame holds.
|
||||
let para = t.para_at(para_index);
|
||||
let starts = widest == 0 || t.plain_text().as_bytes().get(widest - 1) == Some(&b'\n');
|
||||
let start_indent = para.laid_out_left_indent() + if starts { para.first_line_indent } else { 0.0 };
|
||||
// A horizontal right-to-left list paragraph has its indents mirrored, as the layout sets them.
|
||||
let mirror = line.rtl && !t.vertical && para.list.is_some();
|
||||
let (left, right) = if mirror { (para.right_indent, start_indent) } else { (start_indent, para.right_indent) };
|
||||
let (lo, hi) = (line.x0 - left, line.x1 - line.trailing + right);
|
||||
let top = lay.lines.iter().map(|l| l.baseline - l.ascent).fold(first.baseline - first.ascent, f64::min);
|
||||
let bottom = lay.lines.iter().map(|l| l.baseline + l.descent).fold(top, f64::max);
|
||||
if ![lo, hi, top, bottom, room].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// Negative indents can take more than the line's width (hi below lo); the frame is then wider
|
||||
// than the line with its indents, and the same split of the difference keeps the line's
|
||||
// aligned point (its start, center or end) where it was when Block makes the line fill it.
|
||||
let (sl, _) = slack_sides(para.justify, line.rtl, room - (hi - lo));
|
||||
let frame = |depth: f64| lay.line_xf.transform_rect_bbox(Rect::new(lo - sl, top, lo - sl + room, top + depth.max(1.0)));
|
||||
let start = frame(bottom - top);
|
||||
let mut out = t.clone();
|
||||
out.kind = TextKind::Area { frame: shapes::rectangle(start) };
|
||||
out.dynamic_text = true;
|
||||
refresh_bounds(&mut out);
|
||||
// The lines fitted in one cell from the frame's top deep enough for all of them (every line at
|
||||
// the largest size Block sets), around any wrap shapes, give the depth the frame takes.
|
||||
let mut probe = out.clone();
|
||||
let points = t.style_scale().map_or(1.0, |s| s.points).max(1e-12);
|
||||
let deep = (bottom - top) + (lay.lines.len() as f64 + 1.0) * 3.0 * (1296.0 + out.area.line_gap.abs()) / points;
|
||||
// Wrap shapes push lines on past them (an inverted one holds them inside it), so the cell also
|
||||
// reaches as far again as the farthest one reaches past the frame's top.
|
||||
let inv = lay.line_xf.inverse();
|
||||
let deep = deep
|
||||
+ wrap
|
||||
.iter()
|
||||
.filter_map(|w| w.path.bounds().map(|r| inv.transform_rect_bbox(r)).map(|r| (r.y1 - top).max(0.0) + 2.0 * w.wrap.offset.abs()))
|
||||
.filter(|d| d.is_finite())
|
||||
.fold(0.0, f64::max);
|
||||
probe.kind = TextKind::Area { frame: shapes::rectangle(frame(deep)) };
|
||||
if !wrap.is_empty() {
|
||||
refresh_bounds(&mut probe);
|
||||
}
|
||||
let laid = vectorcraft_text::layout(db, &probe);
|
||||
if laid.overflow {
|
||||
return Some(out);
|
||||
}
|
||||
let mut grown = start;
|
||||
for l in &laid.lines {
|
||||
for y in [l.baseline - l.ascent, l.baseline + l.descent] {
|
||||
grown = grown.union_pt(laid.line_xf * vectorcraft_geom::Point::new(l.x0, y));
|
||||
}
|
||||
}
|
||||
// Only the depth grows (for vertical type, the columns run on to the left), with a hundredth
|
||||
// of a point to spare.
|
||||
if t.vertical {
|
||||
(grown.y0, grown.y1, grown.x0) = (start.y0, start.y1, grown.x0 - 0.01);
|
||||
} else {
|
||||
(grown.x0, grown.x1, grown.y1) = (start.x0, start.x1, grown.y1 + 0.01);
|
||||
}
|
||||
if grown.is_finite() && grown != start {
|
||||
out.kind = TextKind::Area { frame: shapes::rectangle(grown) };
|
||||
refresh_bounds(&mut out);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// (run, byte within the run) for a byte offset into the plain text.
|
||||
fn locate(runs: &[TextRun], byte: usize) -> Option<(usize, usize)> {
|
||||
let mut off = 0;
|
||||
@@ -698,14 +793,23 @@ fn dynamic_type(n: Option<&Node>) -> Option<&TextObject> {
|
||||
path_text(n).or_else(|| area_text(n))
|
||||
}
|
||||
|
||||
/// `type.dynamicText` is enabled for a selection holding type on a path or area type, none of it
|
||||
/// in a thread.
|
||||
/// Type `type.dynamicText` takes: type on a path, area type, and point type, which it turns into
|
||||
/// area type with Block on.
|
||||
fn dynamic_target(n: Option<&Node>) -> Option<&TextObject> {
|
||||
match n.map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Point) => Some(t),
|
||||
_ => dynamic_type(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// `type.dynamicText` is enabled for a selection holding type on a path, area type or point type,
|
||||
/// none of it in a thread.
|
||||
fn has_dynamic_type(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
let d = &st.doc;
|
||||
let selected: Vec<NodeId> = st.selection.objects.to_vec();
|
||||
let types: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| dynamic_type(d.node(*id)).is_some()).collect();
|
||||
let types: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| dynamic_target(d.node(*id)).is_some()).collect();
|
||||
if types.is_empty() {
|
||||
return Err("select type on a path or area type".into());
|
||||
}
|
||||
@@ -731,32 +835,46 @@ fn threaded_ids(d: &Document, ids: &[NodeId]) -> String {
|
||||
|
||||
/// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting, or on
|
||||
/// when a gap is given) on the selected type on a path and area type (Block, with its gaps), in
|
||||
/// one undo step, and none when nothing changes. Turning it off keeps the sizes the text was drawn
|
||||
/// at ([`bake_dynamic_text`]).
|
||||
/// one undo step, and none when nothing changes. Turning it on also turns the selected point type
|
||||
/// into area type with Block on ([`point_to_block`]). Turning it off keeps the sizes the text was
|
||||
/// drawn at ([`bake_dynamic_text`]).
|
||||
fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.dynamicText";
|
||||
let (word_gap, line_gap) = block_gaps(p, C)?;
|
||||
let (ids, on, planned) = {
|
||||
let (ids, on, planned, converted) = {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = texts(s, p, C)?.into_iter().filter(|i| dynamic_type(d.node(*i)).is_some()).collect();
|
||||
let ids: Vec<NodeId> = texts(s, p, C)?.into_iter().filter(|i| dynamic_target(d.node(*i)).is_some()).collect();
|
||||
let first = *ids.first().ok_or_else(|| bad(C, "select type on a path or area type"))?;
|
||||
if let Some(why) = threaded_refusal(d, &ids) {
|
||||
return Err(bad(C, why));
|
||||
}
|
||||
let gap_given = word_gap.is_some() || line_gap.is_some();
|
||||
if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some()) {
|
||||
let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?;
|
||||
// Point type with a line to fit, which turning it on makes area type.
|
||||
let converts = |id: &NodeId| on && dynamic_target(d.node(*id)).is_some_and(|t| matches!(t.kind, TextKind::Point) && needed_room(t).is_some());
|
||||
if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some() || converts(id)) {
|
||||
return Err(bad(C, "wordGap and lineGap need area type in the selection"));
|
||||
}
|
||||
let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?;
|
||||
// Each object as the command leaves it, and only those it changes.
|
||||
let mut planned: Vec<(NodeId, TextObject)> = vec![];
|
||||
// The point type that becomes area type, with the gaps applied, by id.
|
||||
let mut converted: Vec<(NodeId, TextObject)> = vec![];
|
||||
for id in &ids {
|
||||
let Some(t) = dynamic_type(d.node(*id)) else { continue };
|
||||
let Some(t) = dynamic_target(d.node(*id)) else { continue };
|
||||
let mut after = t.clone();
|
||||
if matches!(t.kind, TextKind::Area { .. }) {
|
||||
if matches!(t.kind, TextKind::Area { .. } | TextKind::Point) {
|
||||
after.area.word_gap = word_gap.unwrap_or(t.area.word_gap);
|
||||
after.area.line_gap = line_gap.unwrap_or(t.area.line_gap);
|
||||
}
|
||||
if matches!(t.kind, TextKind::Point) {
|
||||
// Point type has no Dynamic Text of its own: on, it becomes area type with Block
|
||||
// on; off, or with no line to fit, it stays as it is.
|
||||
if let Some(block) = on.then(|| point_to_block(&after, &[])).flatten() {
|
||||
planned.push((*id, block));
|
||||
converted.push((*id, after));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match (on, t.dynamic_text) {
|
||||
(true, true) => refresh_bounds(&mut after),
|
||||
(true, false) => {
|
||||
@@ -776,7 +894,7 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
planned.push((*id, after));
|
||||
}
|
||||
}
|
||||
(ids, on, planned)
|
||||
(ids, on, planned, converted)
|
||||
};
|
||||
if !planned.is_empty() {
|
||||
s.edit("Dynamic Text", |d, _| {
|
||||
@@ -785,6 +903,19 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
**t = after.clone();
|
||||
}
|
||||
}
|
||||
if converted.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// The new area type takes the Text Wrap shapes above it; one that has any is fitted
|
||||
// again around them, with the depth that holds every line.
|
||||
for id in vectorcraft_doc::text::resolve_wrap(None, d) {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue };
|
||||
let block = converted.iter().find(|(c, _)| *c == id).and_then(|(_, src)| point_to_block(src, &t.wrap));
|
||||
match block {
|
||||
Some(block) => **t = block,
|
||||
None => refresh_bounds(t),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ use super::{NodeId, cancel};
|
||||
/// The text of [`EngineError::Busy`], which ends every error a frontend reports for it.
|
||||
pub const SCRIPT_BUSY: &str = "a script is running: stop it or wait for it to finish";
|
||||
|
||||
/// Stable prefix for a save refused by the filesystem: choose another destination.
|
||||
pub const READ_ONLY_LOCATION: &str = "READ_ONLY_LOCATION";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
#[error("unknown command `{0}`")]
|
||||
@@ -20,6 +23,10 @@ pub enum EngineError {
|
||||
NoNode(NodeId),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
/// The save's terminal filesystem error refuses writes here. The reason keeps the path and
|
||||
/// system message. This never means an automation-root refusal or a recovery-copy failure.
|
||||
#[error("{code}: {0}", code = READ_ONLY_LOCATION)]
|
||||
ReadOnlyLocation(String),
|
||||
/// The caller asked for the command to stop ([`cancel`]). It changed nothing.
|
||||
#[error("cancelled; nothing was changed")]
|
||||
Cancelled,
|
||||
|
||||
@@ -382,7 +382,7 @@ pub(crate) use command_sets::SELECTS_GIVEN_IDS;
|
||||
use command_sets::given_ids;
|
||||
pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit};
|
||||
pub use doc_state::{DocState, OpenRows, RecalledSelection};
|
||||
pub use error::{EngineError, Result, SCRIPT_BUSY};
|
||||
pub use error::{EngineError, READ_ONLY_LOCATION, Result, SCRIPT_BUSY};
|
||||
#[cfg(test)]
|
||||
use history::history_budget_for;
|
||||
pub use history::{History, HistoryEntry, NamedStep, default_history_budget};
|
||||
|
||||
@@ -2116,6 +2116,27 @@ fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) {
|
||||
host.console.borrow_mut().push(line, cut);
|
||||
}
|
||||
|
||||
/// The error of the failed run `report` describes, as its console lines give it; `name` is the
|
||||
/// run's name in those lines ([`console::line_name`]).
|
||||
fn error_text(name: &str, report: &Value) -> String {
|
||||
let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0));
|
||||
let message = report["message"].as_str().unwrap_or_default();
|
||||
match report["file"].as_str() {
|
||||
Some(file) if line > 0 => format!("{name}: {file}:{line}:{column}: {message}"),
|
||||
_ if line > 0 => format!("{name}:{line}:{column}: {message}"),
|
||||
_ => format!("{name}: location unavailable: {message}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The console lines of the error of the failed run `report` describes, each as the console keeps
|
||||
/// it, cut to its limit.
|
||||
pub(crate) fn error_lines(report: &Value) -> Vec<String> {
|
||||
let name = console::line_name(report["name"].as_str().unwrap_or_default());
|
||||
let mut lines = vec![];
|
||||
each_line(&error_text(&name, report), |line, _| lines.push(line));
|
||||
lines
|
||||
}
|
||||
|
||||
/// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run
|
||||
/// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report
|
||||
/// line in the session's console. Adds the run's time, host calls by kind, slowest commands,
|
||||
@@ -2130,14 +2151,7 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::
|
||||
}
|
||||
}
|
||||
if report["ok"] == false {
|
||||
let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0));
|
||||
let message = report["message"].as_str().unwrap_or_default();
|
||||
let text = match report["file"].as_str() {
|
||||
Some(file) if line > 0 => format!("{}: {file}:{line}:{column}: {message}", host.name),
|
||||
_ if line > 0 => format!("{}:{line}:{column}: {message}", host.name),
|
||||
_ => format!("{}: location unavailable: {message}", host.name),
|
||||
};
|
||||
each_line(&text, |line, cut| line_to(s, host, console::Entry::Error(line), cut));
|
||||
each_line(&error_text(&host.name, report), |line, cut| line_to(s, host, console::Entry::Error(line), cut));
|
||||
}
|
||||
let timing = clock().is_some();
|
||||
let ms = millis(host.run_nanos.get());
|
||||
|
||||
@@ -104,7 +104,10 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
let list = run(&mut s, "brush.libraries", json!({}));
|
||||
let libs = list["libraries"].as_array().unwrap();
|
||||
let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "bristle-brushes", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]);
|
||||
assert_eq!(
|
||||
ids,
|
||||
["art-ribbons", "calligraphic-nibs", "bristle-brushes", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams", "dry-media"]
|
||||
);
|
||||
assert!(libs.iter().all(|l| l["category"] == "builtIn"));
|
||||
let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"}));
|
||||
assert_eq!(
|
||||
@@ -124,6 +127,12 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"]
|
||||
);
|
||||
assert_eq!(libs[6]["count"].as_u64(), Some(4));
|
||||
let dry = run(&mut s, "brush.library", json!({"library": "Dry Media"}));
|
||||
assert_eq!(
|
||||
dry["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect::<Vec<_>>(),
|
||||
[("Chalk Grain", "art"), ("Graphite Pencil", "art"), ("Wax Crayon", "art"), ("Charcoal Dust", "art")]
|
||||
);
|
||||
assert_eq!((libs[7]["name"].as_str(), libs[7]["count"].as_u64()), (Some("Dry Media"), Some(4)));
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
||||
let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap();
|
||||
let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]}));
|
||||
@@ -137,6 +146,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
("scatter-shapes", "scatter"),
|
||||
("game-trails", "art"),
|
||||
("energy-beams", "art"),
|
||||
("dry-media", "art"),
|
||||
] {
|
||||
let (_, lib) = cmd::brushlib::library(&s, id).unwrap();
|
||||
assert!(lib.len() >= 4, "{id}");
|
||||
@@ -166,6 +176,137 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err());
|
||||
}
|
||||
|
||||
/// Whether `p` lies inside the polygon through `pts` (even-odd ray cast).
|
||||
fn inside(p: vectorcraft_geom::Point, pts: &[vectorcraft_geom::Point]) -> bool {
|
||||
let mut odd = false;
|
||||
for (i, a) in pts.iter().enumerate() {
|
||||
let b = pts[(i + 1) % pts.len()];
|
||||
if (a.y > p.y) != (b.y > p.y) && p.x < a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) {
|
||||
odd = !odd;
|
||||
}
|
||||
}
|
||||
odd
|
||||
}
|
||||
|
||||
/// Every anchor of the paths in `nodes` and their descendants.
|
||||
fn anchors(nodes: &[&vectorcraft_doc::Node]) -> Vec<vectorcraft_geom::Point> {
|
||||
let mut out = vec![];
|
||||
for n in nodes {
|
||||
n.walk(&mut |c| out.extend(c.path_data().into_iter().flat_map(|p| p.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)))));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Dry Media: four art brushes of grainy dry marks (chalk, pencil, crayon, charcoal) with names
|
||||
/// of their own. Each one's art is a bounded set of filled vector paths 100 pt long that takes the
|
||||
/// stroke color as tints, with real even-odd gaps in the chalk and the crayon; it draws finite art
|
||||
/// on curved and short paths, is the same every time it is made, and goes into a document in one
|
||||
/// undo step, once, applied, and through a saved library unchanged.
|
||||
#[test]
|
||||
fn the_dry_media_library_holds_grainy_art_brushes_with_bounded_art() {
|
||||
use vectorcraft_brush::{ArtScale, Colorization};
|
||||
use vectorcraft_geom::FillRule;
|
||||
let mut s = session();
|
||||
let (info, lib) = cmd::brushlib::library(&s, "dry-media").unwrap();
|
||||
assert_eq!((info.name.as_str(), info.category, lib.name.as_str()), ("Dry Media", "builtIn", "Dry Media"));
|
||||
let names: Vec<String> = lib.brushes.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names, ["Chalk Grain", "Graphite Pencil", "Wax Crayon", "Charcoal Dust"]);
|
||||
let mut total = 0;
|
||||
for (b, (height, gaps)) in lib.brushes.iter().zip([(8.0, true), (2.5, false), (6.0, true), (7.0, false)]) {
|
||||
let BrushKind::Art(a) = &b.kind else { panic!("{} is not an art brush", b.name) };
|
||||
assert_eq!((a.colorization, a.scale, a.width), (Colorization::Tints, ArtScale::Stretch, 100.0), "{}", b.name);
|
||||
let (mut paths, mut holes) = (0, 0);
|
||||
a.art.walk(&mut |n| match &n.kind {
|
||||
NodeKind::Path { path, .. } => {
|
||||
paths += 1;
|
||||
assert!(path.subpaths.iter().all(|sp| sp.closed && sp.anchors.len() >= 4), "{}", b.name);
|
||||
}
|
||||
NodeKind::Compound { children, rule } => {
|
||||
assert_eq!(*rule, FillRule::EvenOdd, "{}", b.name);
|
||||
// The gaps lie inside the band and apart from each other, so each one is a hole.
|
||||
let body = anchors(&[&*children[0]]);
|
||||
let boxes: Vec<vectorcraft_geom::Rect> = children[1..].iter().map(|c| c.path_data().unwrap().bounds().unwrap()).collect();
|
||||
for (i, c) in children[1..].iter().enumerate() {
|
||||
assert!(anchors(&[&**c]).iter().all(|p| inside(*p, &body)), "{}: gap {i} leaves the band", b.name);
|
||||
let r = boxes[i];
|
||||
assert!(boxes[..i].iter().all(|o| r.x0 >= o.x1 || o.x0 >= r.x1 || r.y0 >= o.y1 || o.y0 >= r.y1), "{}: gap {i} overlaps", b.name);
|
||||
}
|
||||
holes += children.len() - 1;
|
||||
}
|
||||
NodeKind::Group { .. } => {}
|
||||
other => panic!("{}: unexpected {other:?}", b.name),
|
||||
});
|
||||
let pts = anchors(&[&a.art]);
|
||||
assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
|
||||
assert!(paths <= 80 && pts.len() <= 500, "{}: {paths} paths, {} anchors", b.name, pts.len());
|
||||
assert_eq!(holes > 0, gaps, "{}: {holes} gaps", b.name);
|
||||
let r = vectorcraft_geom::Rect::from_points(pts[0], pts[0]);
|
||||
let r = pts.iter().fold(r, |r, p| r.union_pt(*p));
|
||||
assert!(r.x0 >= 0.0 && r.x0 < 0.5 && r.x1 <= 100.0 && r.x1 > 99.5, "{}: {r:?}", b.name);
|
||||
assert!((r.height() - height).abs() <= height * 0.25 && r.y0 < 0.0 && r.y1 > 0.0, "{}: {r:?}", b.name);
|
||||
total += pts.len();
|
||||
// Finite art on a curve and on a 2 pt path.
|
||||
for (line, width) in [(vec![(0.0, 0.0), (60.0, -40.0), (140.0, 40.0), (200.0, 0.0)], 1.0), (vec![(0.0, 0.0), (2.0, 0.0)], 3.0)] {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
bp.move_to(line[0]);
|
||||
if line.len() == 4 {
|
||||
bp.curve_to(line[1], line[2], line[3]);
|
||||
} else {
|
||||
bp.line_to(line[1]);
|
||||
}
|
||||
let stroke = vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), width);
|
||||
let pieces = vectorcraft_brush::stroke_pieces(&lib.doc, b, &bp, &stroke);
|
||||
let drawn = anchors(&pieces.iter().collect::<Vec<_>>());
|
||||
assert!(!drawn.is_empty() && drawn.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{} on {line:?}", b.name);
|
||||
}
|
||||
}
|
||||
assert!(total <= 1200, "{total} anchors");
|
||||
// Made again, the same bytes; the checksum pins them across runs and platforms.
|
||||
let bytes = serde_json::to_string(&cmd::brushlib::dry_media().brushes).unwrap();
|
||||
assert_eq!(bytes, serde_json::to_string(&lib.brushes).unwrap());
|
||||
let fnv = bytes.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3));
|
||||
assert_eq!(fnv, 12_859_086_488_513_909_464, "the Dry Media art changed");
|
||||
// Names of their own: no default brush's, no other built-in library's.
|
||||
let defaults: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).collect();
|
||||
let others: Vec<String> = cmd::brushlib::BUILTIN
|
||||
.iter()
|
||||
.filter(|b| b.id != "dry-media")
|
||||
.flat_map(|b| cmd::brushlib::library(&s, b.id).unwrap().1.brushes.iter().map(|x| x.name.clone()).collect::<Vec<_>>())
|
||||
.collect();
|
||||
assert!(names.iter().all(|n| !defaults.contains(&n.as_str()) && !others.contains(n)), "{names:?}");
|
||||
|
||||
// Added and applied to a path in one undo step, which undo takes back.
|
||||
let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60}))["id"].as_u64().unwrap();
|
||||
let stroke_brush = |s: &Session| doc(s).node(NodeId(path)).unwrap().appearance.stroke().and_then(|st| st.brush.clone());
|
||||
let before = (undo_len(&s), brushes(&s));
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "Dry Media", "name": "Wax Crayon", "apply": true, "ids": [path]}));
|
||||
assert_eq!((strs(&r["added"]), r["applied"].clone()), (vec!["Wax Crayon"], json!([path])));
|
||||
assert_eq!((undo_len(&s), stroke_brush(&s).as_deref()), (before.0 + 1, Some("Wax Crayon")));
|
||||
assert_eq!(&brush(&s, "Wax Crayon"), lib.brush("Wax Crayon").unwrap());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((brushes(&s), stroke_brush(&s)), (before.1.clone(), None));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
// The rest in one more step; then nothing new to add.
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "dry-media"}));
|
||||
assert_eq!((strs(&r["added"]), strs(&r["existing"])), (vec!["Chalk Grain", "Graphite Pencil", "Charcoal Dust"], vec!["Wax Crayon"]));
|
||||
assert_eq!(undo_len(&s), before.0 + 2);
|
||||
assert_eq!(
|
||||
brushes(&s),
|
||||
[before.1.clone(), vec!["Wax Crayon".into(), "Chalk Grain".into(), "Graphite Pencil".into(), "Charcoal Dust".into()]].concat()
|
||||
);
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "dry-media"}));
|
||||
assert_eq!((strs(&r["added"]).len(), undo_len(&s)), (0, before.0 + 2));
|
||||
// Saved as a library and loaded back: the same brushes, in the document's order.
|
||||
let saved = run(&mut s, "brush.saveLibrary", json!({ "names": names, "name": "Dry" }));
|
||||
let b64 = saved["dataBase64"].as_str().unwrap().to_string();
|
||||
let r = run(&mut s, "brush.loadLibrary", json!({"dataBase64": b64, "name": "Dry.vectorcraft"}));
|
||||
let (_, reopened) = cmd::brushlib::library(&s, r["library"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(reopened.len(), lib.len());
|
||||
for b in &lib.brushes {
|
||||
assert_eq!(reopened.brush(&b.name), Some(b), "{}", b.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bristle Brushes: one brush per tip shape, every value inside the range the brush keeps (a value
|
||||
/// outside would be clamped without notice), names no other library or default brush uses; added
|
||||
/// in one step, not twice, and saved as a library they reopen unchanged.
|
||||
|
||||
@@ -88,19 +88,17 @@ fn dynamic_text_refuses_bad_params_and_other_type() {
|
||||
assert!(err(s.execute("type.dynamicText", &json!({"on": 1}))).contains("on"));
|
||||
assert_eq!(undo_len(&s), before);
|
||||
assert!(!text(&s, id).dynamic_text);
|
||||
// Point type alone: refused (area type takes Block, `typecmd::block_tests`).
|
||||
let point = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [point]})).unwrap();
|
||||
assert!(err(s.execute("type.dynamicText", &json!({}))).contains("select type on a path or area type"));
|
||||
let empty = s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [empty]})).unwrap();
|
||||
assert!(s.execute("type.dynamicText", &json!({})).is_err());
|
||||
// With point type selected too, only the type on a path changes.
|
||||
// With point type selected too, both change in one undo step: the point type becomes area
|
||||
// type with Block on (`typecmd::block_tests`).
|
||||
let before = undo_len(&s);
|
||||
s.execute("select.set", &json!({"ids": [point, id.0]})).unwrap();
|
||||
let r = s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
assert_eq!(r["ids"], json!([id.0]));
|
||||
assert!(!text(&s, NodeId(point)).dynamic_text);
|
||||
assert_eq!(r["ids"], json!([point, id.0]));
|
||||
assert!(text(&s, NodeId(point)).dynamic_text && text(&s, id).dynamic_text);
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -531,6 +531,139 @@ fn a_space_that_is_not_usable_is_refused_and_one_too_large_is_kept_to_the_path()
|
||||
assert_eq!(space(&s, g), (Some(100.0), 0.0));
|
||||
}
|
||||
|
||||
/// The ids of group `g`'s objects in their order along the path, and of its children in the
|
||||
/// Layers panel's order.
|
||||
fn orders(s: &Session, g: NodeId) -> (Vec<NodeId>, Vec<NodeId>) {
|
||||
let n = s.doc().unwrap().doc.node(g).unwrap();
|
||||
let path = n.extras().objects_on_path.as_deref().unwrap().members.iter().map(|m| m.id).collect();
|
||||
(path, n.children().unwrap().iter().map(|c| c.id).collect())
|
||||
}
|
||||
|
||||
/// Three objects of different widths (20, 40 and 10 pt) attached to a 300 pt line from (50, 300),
|
||||
/// in that order, with a Space of 100 pt from 0, Pivot 4 and Rotate 0.
|
||||
fn three_on_a_line(s: &mut Session) -> (NodeId, [NodeId; 3], NodeId) {
|
||||
let a = rect(s, 0.0, 0.0, 20.0, 20.0);
|
||||
let b = rect(s, 100.0, 0.0, 40.0, 20.0);
|
||||
let c = rect(s, 200.0, 0.0, 10.0, 20.0);
|
||||
let path = line(s, (50.0, 300.0), (350.0, 300.0));
|
||||
let g = attach(s, path, &[a, b, c], json!({}));
|
||||
s.execute("object.objectsOnPath.options", &json!({"spacing": 100})).unwrap();
|
||||
(g, [a, b, c], path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_member_takes_the_target_s_place_and_shifts_the_objects_between() {
|
||||
let mut s = session();
|
||||
let (g, [a, b, c], path) = three_on_a_line(&mut s);
|
||||
let (_, layers) = orders(&s, g);
|
||||
let before = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": c.0})).unwrap();
|
||||
assert_eq!(v, json!({"group": g.0, "member": a.0, "index": 2}));
|
||||
assert_eq!(orders(&s, g), (vec![b, c, a], layers.clone()), "the order along the path changes, the Layers order does not");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
|
||||
assert_eq!(center(&s, b), Point::new(50.0, 300.0));
|
||||
assert_eq!(center(&s, c), Point::new(150.0, 300.0));
|
||||
assert_eq!(center(&s, a), Point::new(250.0, 300.0));
|
||||
let after = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
|
||||
assert_eq!(
|
||||
(after.path, after.pivot, after.rotate, after.spacing, after.start),
|
||||
(path, before.pivot, before.rotate, before.spacing, before.start)
|
||||
);
|
||||
let w = bounds(&s, a);
|
||||
assert!((w.width() - 20.0).abs() < 1e-9, "the object keeps its art: {w:?}");
|
||||
// The last one back to the first place, with its group named.
|
||||
let v = s.execute("object.objectsOnPath.moveMember", &json!({"group": g.0, "member": a.0, "target": b.0})).unwrap();
|
||||
assert_eq!(v["index"], 0);
|
||||
assert_eq!(orders(&s, g).0, [a, b, c]);
|
||||
assert_eq!(center(&s, a), Point::new(50.0, 300.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(orders(&s, g).0, [b, c, a]);
|
||||
assert_eq!(center(&s, a), Point::new(250.0, 300.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(orders(&s, g).0, [a, b, c]);
|
||||
assert_eq!(center(&s, c), Point::new(250.0, 300.0));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(orders(&s, g).0, [b, c, a]);
|
||||
// Onto its own place: nothing changes and no step is recorded.
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": c.0, "target": c.0})).unwrap();
|
||||
assert_eq!(v["index"], 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
// The order saves and reopens.
|
||||
let data = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone();
|
||||
let mut o = Session::new();
|
||||
o.execute("document.open", &json!({"name": "objects.vectorcraft", "dataBase64": data})).unwrap();
|
||||
assert_eq!(orders(&o, g), (vec![b, c, a], layers));
|
||||
assert_eq!(center(&o, a), Point::new(250.0, 300.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_member_refuses_what_is_not_an_object_on_the_same_path() {
|
||||
let mut s = session();
|
||||
let (g, [a, b, c], path) = three_on_a_line(&mut s);
|
||||
let loose = rect(&mut s, 300.0, 20.0, 10.0, 10.0);
|
||||
let x = rect(&mut s, 0.0, 200.0, 10.0, 10.0);
|
||||
let other_line = line(&mut s, (50.0, 380.0), (350.0, 380.0));
|
||||
let h = attach(&mut s, other_line, &[x], json!({}));
|
||||
let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.moveMember", &p).unwrap_err().to_string();
|
||||
assert!(err(&mut s, json!({"member": path.0, "target": a.0})).contains("`member` must name an object attached"), "the path");
|
||||
assert!(err(&mut s, json!({"member": a.0, "target": path.0})).contains("`target` must name"), "the path as the target");
|
||||
assert!(err(&mut s, json!({"member": loose.0, "target": a.0})).contains("`member` must name"), "an object on no path");
|
||||
assert!(err(&mut s, json!({"member": g.0, "target": a.0})).contains("`member` must name"), "the group itself");
|
||||
assert!(err(&mut s, json!({"member": a.0, "target": x.0})).contains("same Objects on Path group"), "another group's object");
|
||||
assert!(err(&mut s, json!({"member": a.0, "target": b.0, "group": h.0})).contains("`group` must be"), "another group named");
|
||||
assert!(err(&mut s, json!({"target": b.0})).contains("missing `member`"));
|
||||
assert!(err(&mut s, json!({"member": a.0})).contains("missing `target`"));
|
||||
assert!(err(&mut s, json!({"member": 99999, "target": b.0})).contains("no such object"));
|
||||
assert!(err(&mut s, json!({"member": "a", "target": b.0})).contains("invalid object id"));
|
||||
// A locked object neither moves nor gives its place, and is not chosen.
|
||||
select(&mut s, &[c]);
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
assert!(err(&mut s, json!({"member": c.0, "target": a.0})).contains("`member` names an object that is hidden or locked"));
|
||||
assert!(err(&mut s, json!({"member": a.0, "target": c.0})).contains("`target` names an object that is hidden or locked"));
|
||||
assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": c.0})).is_err());
|
||||
s.execute("object.unlockAll", &json!({})).unwrap();
|
||||
// A locked object between the two keeps its place, and the others pass over it.
|
||||
select(&mut s, &[b]);
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": c.0})).unwrap();
|
||||
assert_eq!(v["index"], 2);
|
||||
assert_eq!(orders(&s, g).0, [c, b, a]);
|
||||
assert_eq!(center(&s, b), Point::new(150.0, 300.0), "the locked object stays where it was");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("object.unlockAll", &json!({})).unwrap();
|
||||
select(&mut s, &[g]);
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(g).unwrap().locked);
|
||||
let e = err(&mut s, json!({"member": a.0, "target": b.0}));
|
||||
assert!(e.contains("hidden or locked"), "a locked group: {e}");
|
||||
assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": a.0})).is_err(), "a locked group's object is not chosen");
|
||||
assert_eq!(orders(&s, g).0, [a, b, c], "nothing refused changed the order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_member_selects_the_group_with_the_object_chosen() {
|
||||
let mut s = session();
|
||||
let (g, [a, b, _], path) = three_on_a_line(&mut s);
|
||||
let v = s.execute("object.objectsOnPath.selectMember", &json!({"member": b.0})).unwrap();
|
||||
assert_eq!(v, json!({"group": g.0, "member": b.0}));
|
||||
let sel = &s.doc().unwrap().selection;
|
||||
assert_eq!((sel.objects.as_slice(), sel.on_path_member), (&[g][..], Some(b)));
|
||||
// Moving the chosen object keeps it chosen.
|
||||
s.execute("object.objectsOnPath.moveMember", &json!({"member": b.0, "target": a.0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.on_path_member, Some(b));
|
||||
assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": path.0})).is_err());
|
||||
select(&mut s, &[g]);
|
||||
assert_eq!(s.doc().unwrap().selection.on_path_member, None, "another selection clears it");
|
||||
// Both take their objects without a selection.
|
||||
select(&mut s, &[]);
|
||||
s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": b.0})).unwrap();
|
||||
assert_eq!(orders(&s, g).0[..2], [a, b]);
|
||||
s.execute("object.objectsOnPath.selectMember", &json!({"member": a.0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.on_path_member, Some(a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_space_saves_and_reopens_and_goes_with_a_copy() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -959,3 +959,202 @@ fn not_aligned_to_pixel_grid_leaves_out_what_cant_be_selected() {
|
||||
assert_eq!(s.execute("select.object.notAlignedToPixelGrid", &json!({})).unwrap()["count"], 1);
|
||||
assert_eq!(selected_ids(&s), vec![inside]);
|
||||
}
|
||||
|
||||
/// Run Not Aligned to Pixel Grid with `before` selected, check that it makes no undo step and that
|
||||
/// its count is the number of objects it selected → the selected ids, sorted.
|
||||
fn not_aligned(s: &mut Session, before: &[u64]) -> Vec<u64> {
|
||||
s.execute("select.set", &json!({ "ids": before })).unwrap();
|
||||
let steps = undo_len(s);
|
||||
let r = s.execute("select.object.notAlignedToPixelGrid", &json!({})).unwrap();
|
||||
let ids = selected_ids(s);
|
||||
assert_eq!(r["count"], json!(ids.len()), "{r}");
|
||||
assert_eq!(undo_len(s), steps);
|
||||
ids
|
||||
}
|
||||
|
||||
fn sorted(mut ids: Vec<u64>) -> Vec<u64> {
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
}
|
||||
|
||||
/// What Select All selects, sorted.
|
||||
fn all_ids(s: &mut Session) -> Vec<u64> {
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
selected_ids(s)
|
||||
}
|
||||
|
||||
fn kind(s: &Session, id: u64) -> NodeKind {
|
||||
s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind.clone()
|
||||
}
|
||||
|
||||
/// Images, symbol instances, point type, area type and type on a path cannot take the property, so
|
||||
/// Not Aligned to Pixel Grid leaves them out though Select All selects them; the path a symbol was
|
||||
/// made from is the symbol's art, not an object of the document.
|
||||
#[test]
|
||||
fn not_aligned_to_pixel_grid_leaves_out_images_symbol_instances_and_type() {
|
||||
let mut s = new_doc();
|
||||
let p = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 1.0);
|
||||
let png = crate::tests_links::png(4, 4, [255, 0, 0]);
|
||||
s.execute("file.place", &json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap();
|
||||
let image = s.doc().unwrap().selection.objects[0].0;
|
||||
let art = path(&mut s, "M60.5 60 L80 60 L80 80 L60.5 80 Z", 1.0);
|
||||
let made = s.execute("symbol.new", &json!({"name": "Mark", "ids": [art]})).unwrap()["id"].as_u64().unwrap();
|
||||
let placed = s.execute("symbol.place", &json!({"name": "Mark", "x": 200.3, "y": 200.3})).unwrap()["id"].as_u64().unwrap();
|
||||
let point = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "point"})).unwrap()["id"].as_u64().unwrap();
|
||||
let frame = json!({"width": 80.5, "height": 40.5});
|
||||
let area = s.execute("text.create", &json!({"x": 100, "y": 150, "text": "area", "area": frame})).unwrap()["id"].as_u64().unwrap();
|
||||
let on_path = path(&mut s, "M10.5 300 C100 250 200 350 300.5 300", 1.0);
|
||||
let on_path = s.execute("text.createInPath", &json!({"path": on_path, "mode": "onPath", "text": "on a path"})).unwrap()["id"].as_u64().unwrap();
|
||||
let in_path = path(&mut s, "M250.5 20.5 L350 20.5 L350 90 L250.5 90 Z", 1.0);
|
||||
let in_path = s.execute("text.createInPath", &json!({"path": in_path, "mode": "area", "text": "in a path"})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(matches!(kind(&s, image), NodeKind::Image(_)));
|
||||
for id in [made, placed] {
|
||||
assert!(matches!(kind(&s, id), NodeKind::SymbolInstance { .. }), "{id}");
|
||||
}
|
||||
for id in [point, area, on_path, in_path] {
|
||||
assert!(matches!(kind(&s, id), NodeKind::Text(_)), "{id}");
|
||||
}
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(art)).is_none());
|
||||
assert_eq!(all_ids(&mut s), sorted(vec![p, image, made, placed, point, area, on_path, in_path]));
|
||||
assert_eq!(not_aligned(&mut s, &[image, made, point]), vec![p]);
|
||||
// With the path aligned, nothing is left to select.
|
||||
s.execute("object.setPixelAligned", &json!({"ids": [p]})).unwrap();
|
||||
assert_eq!(not_aligned(&mut s, &[placed]), Vec::<u64>::new());
|
||||
}
|
||||
|
||||
/// Paths on a template layer, in a hidden or locked sublayer or on a locked artboard are left out,
|
||||
/// as Select All leaves them out; paths in an unlocked visible sublayer are selected.
|
||||
#[test]
|
||||
fn not_aligned_to_pixel_grid_leaves_out_template_layers_locked_sublayers_and_locked_artboards() {
|
||||
let mut s = new_doc();
|
||||
s.execute("artboard.new", &json!({"x": 500, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
let main = s.doc().unwrap().doc.layers[0].id.0;
|
||||
let free = path(&mut s, "M10 10 L20 10 L20 20 L10 20 Z", 1.0);
|
||||
let on_locked_board = path(&mut s, "M510 10 L520 10 L520 20 L510 20 Z", 1.0);
|
||||
let template = s.execute("layer.new", &json!({"name": "Trace"})).unwrap()["id"].as_u64().unwrap();
|
||||
let traced = path(&mut s, "M30 10 L40 10 L40 20 L30 20 Z", 1.0);
|
||||
s.execute("layer.setProps", &json!({"id": template, "template": true})).unwrap();
|
||||
let sub = |s: &mut Session, name: &str, d: &str| {
|
||||
let l = s.execute("layer.newSublayer", &json!({"parent": main, "name": name})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.setCurrent", &json!({"id": l})).unwrap();
|
||||
(l, path(s, d, 1.0))
|
||||
};
|
||||
let (_, in_sublayer) = sub(&mut s, "Open", "M50 10 L60 10 L60 20 L50 20 Z");
|
||||
let (hidden, in_hidden) = sub(&mut s, "Hidden", "M70 10 L80 10 L80 20 L70 20 Z");
|
||||
let (locked, in_locked) = sub(&mut s, "Locked", "M90 10 L100 10 L100 20 L90 20 Z");
|
||||
s.execute("layer.setProps", &json!({"id": hidden, "visible": false})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": locked, "locked": true})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
|
||||
let all = all_ids(&mut s);
|
||||
for id in [traced, in_hidden, in_locked, on_locked_board] {
|
||||
assert!(!all.contains(&id), "{id}");
|
||||
}
|
||||
assert_eq!(not_aligned(&mut s, &[free]), sorted(vec![free, in_sublayer]));
|
||||
// Unlocked, the artboard's path is selected again.
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "locked": false})).unwrap();
|
||||
assert_eq!(not_aligned(&mut s, &[free]), sorted(vec![free, in_sublayer, on_locked_board]));
|
||||
}
|
||||
|
||||
/// A clipping path and the paths it clips are selected, not their clip group; a path with a drop
|
||||
/// shadow or a Gaussian Blur is selected as the path it is, and its effects are not objects.
|
||||
#[test]
|
||||
fn not_aligned_to_pixel_grid_selects_clipping_paths_and_paths_with_effects() {
|
||||
let mut s = new_doc();
|
||||
let clipped = path(&mut s, "M10.5 10 L60 10 L60 60 L10.5 60 Z", 1.0);
|
||||
let clip = path(&mut s, "M20.5 20 L50 20 L50 50 L20.5 50 Z", 1.0);
|
||||
s.execute("select.set", &json!({"ids": [clipped, clip]})).unwrap();
|
||||
let group = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let shadowed = path(&mut s, "M100.5 10 L150 10 L150 60 L100.5 60 Z", 1.0);
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [shadowed]})).unwrap();
|
||||
let blurred = path(&mut s, "M200.5 10 L250 10 L250 60 L200.5 60 Z", 1.0);
|
||||
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "params": {"radius": 3}, "ids": [blurred]})).unwrap();
|
||||
assert!(matches!(kind(&s, group), NodeKind::Group { .. }));
|
||||
for id in [shadowed, blurred] {
|
||||
assert!(!s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects.is_empty(), "{id}");
|
||||
}
|
||||
let want = sorted(vec![clipped, clip, shadowed, blurred]);
|
||||
assert_eq!(not_aligned(&mut s, &[group]), want);
|
||||
// Object › Align to Pixel Grid takes each object selected; then nothing is left to select.
|
||||
s.execute("object.setPixelAligned", &json!({"ids": want})).unwrap();
|
||||
assert_eq!(not_aligned(&mut s, &[group]), Vec::<u64>::new());
|
||||
}
|
||||
|
||||
/// The filter looks into blends, envelopes, repeats and Intertwine groups and selects the paths
|
||||
/// they are made of, not the objects that hold them; Align to Pixel Grid takes that selection, and
|
||||
/// then nothing is left to select.
|
||||
#[test]
|
||||
fn not_aligned_to_pixel_grid_selects_the_paths_of_blends_envelopes_repeats_and_intertwines() {
|
||||
let mut s = new_doc();
|
||||
let b1 = path(&mut s, "M10.5 10 L30 10 L30 30 L10.5 30 Z", 1.0);
|
||||
let b2 = path(&mut s, "M80.5 10 L100 10 L100 30 L80.5 30 Z", 1.0);
|
||||
s.execute("select.set", &json!({"ids": [b1, b2]})).unwrap();
|
||||
let blend = s.execute("object.blend.make", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let warped = path(&mut s, "M10.5 100 L100 100 L100 150 L10.5 150 Z", 1.0);
|
||||
let envelope = s.execute("object.envelope.makeWithWarp", &json!({"ids": [warped]})).unwrap()["id"].as_u64().unwrap();
|
||||
let tile = path(&mut s, "M200.5 10 L220 10 L220 30 L200.5 30 Z", 1.0);
|
||||
let repeat = s.execute("object.repeat.grid", &json!({"ids": [tile], "rows": 2, "cols": 2})).unwrap()["id"].as_u64().unwrap();
|
||||
let i1 = path(&mut s, "M200.5 200 L260 200 L260 230 L200.5 230 Z", 1.0);
|
||||
let i2 = path(&mut s, "M220.5 180 L240 180 L240 250 L220.5 250 Z", 1.0);
|
||||
let intertwine = s.execute("object.intertwine.make", &json!({"ids": [i1, i2]})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(matches!(kind(&s, blend), NodeKind::Blend { .. }));
|
||||
assert!(matches!(kind(&s, envelope), NodeKind::Envelope { .. }));
|
||||
assert!(matches!(kind(&s, repeat), NodeKind::Repeat(_)));
|
||||
assert!(matches!(kind(&s, intertwine), NodeKind::Intertwine { .. }));
|
||||
let want = sorted(vec![b1, b2, warped, tile, i1, i2]);
|
||||
assert_eq!(not_aligned(&mut s, &[blend, envelope]), want);
|
||||
s.execute("object.setPixelAligned", &json!({})).unwrap();
|
||||
for id in &want {
|
||||
assert!(aligned(&s, *id), "{id}");
|
||||
}
|
||||
assert_eq!(not_aligned(&mut s, &[repeat, intertwine]), Vec::<u64>::new());
|
||||
}
|
||||
|
||||
/// Gradient meshes, placed documents and ruler guides are left out; a live shape is a path and is
|
||||
/// selected.
|
||||
#[test]
|
||||
fn not_aligned_to_pixel_grid_leaves_out_meshes_placed_documents_and_guides_but_takes_live_shapes() {
|
||||
let mut s = new_doc();
|
||||
let filled = path(&mut s, "M10.5 10 L60 10 L60 60 L10.5 60 Z", 1.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#3366cc"})).unwrap();
|
||||
let mesh = s.execute("object.mesh.create", &json!({"ids": [filled], "rows": 2, "cols": 2})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
let dir = crate::tests_links::Folder::new("pixelalign-placed");
|
||||
let file = dir.file("placed.vectorcraft");
|
||||
let mut d = vectorcraft_doc::Document::new(50.0, 50.0);
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.5, 0.5, 40.0, 40.0));
|
||||
let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(20, 20, 230));
|
||||
let look = vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0);
|
||||
d.insert(Some(layer), 0, vectorcraft_doc::Node::path(id, rect, look)).unwrap();
|
||||
crate::tests_links::write(&file, &vectorcraft_format::save_file(&d));
|
||||
let placed = s.execute("file.place", &json!({"path": file, "at": [200, 200]})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
let guide = path(&mut s, "M0 300.5 L400 300.5", 1.0);
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
let live = s.execute("shape.rectangle", &json!({"x": 100.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(matches!(kind(&s, mesh), NodeKind::Mesh(_)));
|
||||
assert!(matches!(kind(&s, placed), NodeKind::PlacedDocument(_)));
|
||||
assert!(matches!(kind(&s, guide), NodeKind::Path { guide: true, .. }));
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(live)).unwrap().live_shape().is_some());
|
||||
let all = all_ids(&mut s);
|
||||
assert!(all.contains(&mesh) && all.contains(&placed), "{all:?}");
|
||||
assert_eq!(not_aligned(&mut s, &[mesh, placed]), vec![live]);
|
||||
}
|
||||
|
||||
/// A compound path goes by its own property, whatever the property of the paths in it (a file can
|
||||
/// hold paths with the property on inside a compound path): off, it is selected as one object and
|
||||
/// the paths in it are not; on, it is not selected, though a path in it has the property off.
|
||||
#[test]
|
||||
fn not_aligned_to_pixel_grid_takes_a_compound_path_by_its_own_property() {
|
||||
let mut s = new_doc();
|
||||
let on = path(&mut s, "M10.5 10 L30 10 L30 30 L10.5 30 Z", 1.0);
|
||||
let off = path(&mut s, "M50.5 10 L70 10 L70 30 L50.5 30 Z", 1.0);
|
||||
s.execute("select.set", &json!({"ids": [on, off]})).unwrap();
|
||||
let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(on)).unwrap().extras_mut().pixel_aligned = true;
|
||||
assert!(aligned(&s, on) && !aligned(&s, off) && !aligned(&s, compound));
|
||||
let other = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 1.0);
|
||||
assert_eq!(not_aligned(&mut s, &[other]), sorted(vec![compound, other]));
|
||||
s.execute("object.setPixelAligned", &json!({"ids": [compound]})).unwrap();
|
||||
assert!(!aligned(&s, off));
|
||||
assert_eq!(not_aligned(&mut s, &[compound]), vec![other]);
|
||||
}
|
||||
|
||||
@@ -1433,6 +1433,92 @@ fn plugins_folder_set_again_after_clearing_installs_its_plugins() {
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// Under automation read roots, `plugin.list` names the plug-in folder last loaded, its failed
|
||||
/// files and a plug-in's source file only inside them: a folder outside is `null`, and a file
|
||||
/// outside is named by its file name.
|
||||
#[test]
|
||||
fn plugin_list_leaves_out_paths_outside_the_read_root() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let base = crate::tests_links::Folder::new("plugin-list-read-root");
|
||||
let (inside, outside) = (base.0.join("in"), base.0.join("out"));
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let id = "test.engine.listroots";
|
||||
let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#);
|
||||
std::fs::write(outside.join("hidden.wasm"), wat::parse_str(src).unwrap()).unwrap();
|
||||
std::fs::write(outside.join("broken.wasm"), b"\0asm?").unwrap();
|
||||
let folder = outside.to_string_lossy().to_string();
|
||||
let mut s = session();
|
||||
let r = run(&mut s, "plugin.reload", json!({"path": folder}));
|
||||
assert_eq!(r["loaded"], json!([id]), "{r}");
|
||||
let broken = outside.join("broken.wasm").to_string_lossy().to_string();
|
||||
let source = outside.join("hidden.wasm").to_string_lossy().to_string();
|
||||
let entry = |list: &Value| list["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).cloned().unwrap();
|
||||
|
||||
// The person sees the paths.
|
||||
let person = run(&mut s, "plugin.list", json!({}));
|
||||
assert_eq!((&person["folder"]["folder"], &person["folder"]["failed"][0]["file"]), (&json!(folder), &json!(broken)), "{person}");
|
||||
assert_eq!(entry(&person)["source"], json!(source));
|
||||
|
||||
// The client sees their names.
|
||||
let roots = AutomationRoots::new(&[&inside], &[]).unwrap();
|
||||
let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap();
|
||||
let report = &listed["folder"];
|
||||
assert_eq!((&report["folder"], &report["loaded"], &report["failed"][0]["file"]), (&Value::Null, &json!([id]), &json!("broken.wasm")), "{listed}");
|
||||
assert_eq!(entry(&listed)["source"], "hidden.wasm");
|
||||
let info = confine(roots.as_ref(), || s.execute("plugin.info", &json!({"id": id}))).unwrap();
|
||||
assert_eq!(info["source"], "hidden.wasm");
|
||||
let outside_text = json!(folder).to_string();
|
||||
for reply in [&listed, &info] {
|
||||
assert!(!reply.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {reply}");
|
||||
}
|
||||
|
||||
// A read root holding the folder shows it whole.
|
||||
let wider = AutomationRoots::new(&[&outside], &[]).unwrap();
|
||||
let listed = confine(wider.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap();
|
||||
assert_eq!(listed["folder"]["folder"], json!(folder), "{listed}");
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// Under automation read roots, a plug-in turned off for the next launch is listed with a file
|
||||
/// outside them named by its file name, and `plugin.setEnabled` turns it on by that name.
|
||||
#[test]
|
||||
fn disabled_plugins_name_files_outside_the_read_root_by_their_names() {
|
||||
if !in_own_process("disabled_plugins_name_files_outside_the_read_root_by_their_names") {
|
||||
return;
|
||||
}
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
let base = crate::tests_links::Folder::new("plugin-disabled-read-root");
|
||||
let (inside, outside) = (base.0.join("in"), base.0.join("out"));
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let id = "test.engine.disabledroots";
|
||||
let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#);
|
||||
let file = outside.join("off.wasm");
|
||||
std::fs::write(&file, wat::parse_str(src).unwrap()).unwrap();
|
||||
let file = file.to_string_lossy().to_string();
|
||||
let mut s = session();
|
||||
run(&mut s, "plugin.install", json!({"path": file}));
|
||||
let roots = AutomationRoots::new(&[&inside], &[]).unwrap();
|
||||
// The client turns it off by the source plugin.list shows.
|
||||
let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap();
|
||||
let source = listed["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).unwrap()["source"].clone();
|
||||
assert_eq!(source, "off.wasm", "{listed}");
|
||||
confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": source, "enabled": false}))).unwrap();
|
||||
assert_eq!(run(&mut s, "plugin.safeMode", json!({}))["disabled"][0]["file"], json!(file), "the person sees the path");
|
||||
|
||||
let outside_text = json!(outside.to_string_lossy()).to_string();
|
||||
for cmd in ["plugin.list", "plugin.safeMode"] {
|
||||
let r = confine(roots.as_ref(), || s.execute(cmd, &json!({}))).unwrap();
|
||||
assert_eq!(r["disabled"][0]["file"], "off.wasm", "{cmd}: {r}");
|
||||
assert!(!r.to_string().contains(outside_text.trim_matches('"')), "{cmd}: no path outside the root: {r}");
|
||||
}
|
||||
let r = confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": "off.wasm", "enabled": true}))).unwrap();
|
||||
assert_eq!((&r["changed"], &r["disabled"]), (&json!(true), &json!([])), "{r}");
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// #2541: a plug-in installed in a session with a plug-in store is installed again in the next
|
||||
/// session that attaches the store, and Remove Plug-in takes it out of the store.
|
||||
#[test]
|
||||
|
||||
@@ -1545,6 +1545,71 @@ impl RecoveryStore for DiesAfterDocument {
|
||||
}
|
||||
}
|
||||
|
||||
/// Under automation read roots, `file.recovery.save`, `file.recovery.list` and
|
||||
/// `file.recovery.restore` name a document's file and the recovery folder only inside them.
|
||||
#[test]
|
||||
fn recovery_replies_leave_out_paths_outside_the_read_root() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
use serde_json::Value;
|
||||
let base = crate::tests_links::Folder::new("recovery-read-root");
|
||||
let (inside, outside) = (base.0.join("in"), base.0.join("out"));
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let store: Arc<dyn RecoveryStore> = Arc::new(FolderStore::new(outside.join("recovery")));
|
||||
let location = outside.join("recovery").to_string_lossy().to_string();
|
||||
let kept = inside.join("kept.vectorcraft").to_string_lossy().to_string();
|
||||
let hidden = outside.join("hidden.vectorcraft").to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
s.recovery.set_store(store.clone());
|
||||
for path in [&kept, &hidden] {
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": path})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
}
|
||||
let roots = AutomationRoots::new(&[&inside], &[]).unwrap();
|
||||
let saved = confine(roots.as_ref(), || s.execute("file.recovery.save", &json!({}))).unwrap();
|
||||
assert_eq!(saved["location"], Value::Null, "{saved}");
|
||||
s.execute("shape.ellipse", &json!({"x": 80, "y": 20, "width": 20, "height": 20})).unwrap();
|
||||
assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["location"], json!(location), "the person sees the folder");
|
||||
|
||||
let sorted = |v: &Value, key: &str| -> Vec<String> {
|
||||
let mut out: Vec<String> = v[key].as_array().unwrap().iter().map(|c| c["path"].to_string()).collect();
|
||||
out.sort();
|
||||
out
|
||||
};
|
||||
let both = |first: Value| {
|
||||
let mut out = vec![first.to_string(), json!(kept).to_string()];
|
||||
out.sort();
|
||||
out
|
||||
};
|
||||
assert_eq!(sorted(&s.execute("file.recovery.list", &json!({})).unwrap(), "copies"), both(json!(hidden)));
|
||||
let listed = confine(roots.as_ref(), || s.execute("file.recovery.list", &json!({}))).unwrap();
|
||||
assert_eq!((sorted(&listed, "copies"), &listed["location"]), (both(Value::Null), &Value::Null), "{listed}");
|
||||
let outside_text = json!(outside.to_string_lossy()).to_string();
|
||||
assert!(!listed.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {listed}");
|
||||
// A relative recovery folder is checked as the file system reads it; a store that is no folder
|
||||
// keeps its name.
|
||||
let mut relative = Session::new();
|
||||
relative.recovery.set_store(Arc::new(FolderStore::new("vc-recovery-relative-folder")));
|
||||
let l = confine(roots.as_ref(), || relative.execute("file.recovery.list", &json!({}))).unwrap();
|
||||
assert_eq!(l["location"], Value::Null, "{l}");
|
||||
let (mut memory, _) = session();
|
||||
let l = confine(roots.as_ref(), || memory.execute("file.recovery.list", &json!({}))).unwrap();
|
||||
assert_eq!(l["location"], "memory", "{l}");
|
||||
|
||||
// After a crash, the copies restored.
|
||||
drop(s);
|
||||
let mut s = Session::new();
|
||||
s.recovery.set_store(store.clone());
|
||||
let r = confine(roots.as_ref(), || s.execute("file.recovery.restore", &json!({}))).unwrap();
|
||||
assert_eq!(sorted(&r, "restored"), both(Value::Null), "{r}");
|
||||
let mut opened: Vec<Option<String>> = s.documents().iter().map(|d| d.path.clone()).collect();
|
||||
opened.sort();
|
||||
let mut files = vec![Some(hidden), Some(kept)];
|
||||
files.sort();
|
||||
assert_eq!(opened, files, "the documents keep their files");
|
||||
}
|
||||
|
||||
/// A crash between writing a copy's document and its details restores the new document with
|
||||
/// every image, the one placed since the last copy too (and the first copy likewise).
|
||||
#[test]
|
||||
|
||||
@@ -265,3 +265,31 @@ fn the_slowest_commands_are_the_longest_in_all_then_by_id() {
|
||||
assert_eq!(ids, ["b.two", "a.one", "c.three", "f", "e"]);
|
||||
assert_eq!((slow[0].calls, slow[0].total_ms, slow[0].max_ms), (3, 9, 4));
|
||||
}
|
||||
|
||||
/// Under automation read roots, the error lines of a failed run name the included file it stopped
|
||||
/// in by its file name also when the console cut a line inside the file's path.
|
||||
#[test]
|
||||
fn a_cut_error_line_leaves_out_an_included_files_folder_outside_the_read_root() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
let base = crate::tests_links::Folder::new("script-report-cut-line");
|
||||
let inside = base.0.join("in");
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
// A path longer than a console line, as the report of a failed run names it.
|
||||
let file = base.0.join("out").join("d".repeat(MAX_LINE_BYTES)).join("bad.jsxinc").to_string_lossy().to_string();
|
||||
let mut report = json!({"ok": false, "name": "t.js", "file": file, "line": 2, "column": 7, "message": format!("Error: stopped\nin {file}")});
|
||||
let error = script::error_lines(&report);
|
||||
assert!(error.len() == 2 && error.iter().all(|l| l.len() <= MAX_LINE_BYTES && l.ends_with(CUT_MARK)), "both lines are cut: {error:?}");
|
||||
let written: Vec<String> = std::iter::once("written by the script".to_string()).chain(error).collect();
|
||||
report["console"] = json!({"lines": written, "droppedLines": 0, "truncatedLines": 2});
|
||||
let mut s = Session::new();
|
||||
s.script_report = Some(report.clone());
|
||||
|
||||
let roots = AutomationRoots::new(&[&inside], &[]).unwrap();
|
||||
let shown = confine(roots.as_ref(), || s.shown_script_report()).unwrap();
|
||||
assert_eq!((&shown["file"], &shown["message"]), (&json!("bad.jsxinc"), &json!("Error: stopped\nin bad.jsxinc")), "{shown}");
|
||||
assert_eq!(shown["console"]["lines"], json!(["written by the script", "t.js: bad.jsxinc:2:7: Error: stopped", "in bad.jsxinc"]), "{shown}");
|
||||
let outside = json!(base.0.join("out").to_string_lossy()).to_string();
|
||||
assert!(!shown.to_string().contains(outside.trim_matches('"')), "no path outside the root: {shown}");
|
||||
// The person sees the report as it is.
|
||||
assert_eq!(s.shown_script_report(), Some(report));
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ fn script_file_link_escape_refused() {
|
||||
/// not matter, and outside it where case matters, whether or not the file is there.
|
||||
#[test]
|
||||
fn script_file_case_variant_folder() {
|
||||
let base = folder("case");
|
||||
let base = crate::cmd::findfiles::plain(folder("case").canonicalize().unwrap());
|
||||
write(&base.join("main/own.txt"), b"own");
|
||||
let upper = base.join("MAIN");
|
||||
let source = |name: &str| {
|
||||
|
||||
@@ -844,7 +844,7 @@ fn a_grit_texture_adds_once_undoes_and_saves() {
|
||||
let list = run(&mut s, "swatch.library.list", json!({}));
|
||||
let ids: Vec<&str> =
|
||||
list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["geometric-patterns", "grit-textures"]);
|
||||
assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]);
|
||||
let got = run(&mut s, "swatch.library.get", json!({"library": "Grit Textures"}));
|
||||
let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Fine Grain", "Coarse Grit", "Sand", "Speckle", "Stipple", "Scratches", "Sparse Flecks", "Dense Flecks"]);
|
||||
@@ -869,6 +869,49 @@ fn a_grit_texture_adds_once_undoes_and_saves() {
|
||||
assert_eq!(back.swatch("Stipple").map(|w| &w.paint), doc(&s).swatch("Stipple").map(|w| &w.paint));
|
||||
}
|
||||
|
||||
/// Optical Effects is listed after Grit Textures, and reading it adds nothing. Clicking one of
|
||||
/// its patterns brings it with its swatch and fills the selection with it, once, as one undo step
|
||||
/// that also takes both away, and both stay after a save and reopen.
|
||||
#[test]
|
||||
fn an_optical_effect_adds_once_fills_undoes_and_saves() {
|
||||
let mut s = session();
|
||||
let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count());
|
||||
let empty = counts(doc(&s));
|
||||
let list = run(&mut s, "swatch.library.list", json!({}));
|
||||
let ids: Vec<&str> =
|
||||
list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]);
|
||||
let got = run(&mut s, "swatch.library.get", json!({"library": "Optical Effects"}));
|
||||
let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"]
|
||||
);
|
||||
assert_eq!(counts(doc(&s)), empty, "reading a library adds no pattern or swatch");
|
||||
let id = vectorcraft_doc::NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap());
|
||||
let undo = undo_len(&s);
|
||||
let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"], "apply": "fill"}));
|
||||
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Warped Checks"]), json!("Warped Checks")));
|
||||
assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1));
|
||||
let def = doc(&s).pattern("Warped Checks").expect("the pattern comes with its swatch").clone();
|
||||
assert_eq!(def.art.len(), 32, "the black cells of an 8 × 8 checkerboard");
|
||||
assert!(matches!(doc(&s).swatch("Warped Checks").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Warped Checks"));
|
||||
let filled = |d: &vectorcraft_doc::Document| matches!(d.node(id).unwrap().appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Warped Checks");
|
||||
assert!(filled(doc(&s)));
|
||||
let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"]}));
|
||||
assert_eq!((r["added"].clone(), r["existing"].clone()), (json!([]), json!(["Warped Checks"])));
|
||||
assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1), "added once");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(counts(doc(&s)), empty);
|
||||
assert!(doc(&s).pattern("Warped Checks").is_none() && doc(&s).swatch("Warped Checks").is_none() && !filled(doc(&s)));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(filled(doc(&s)));
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap();
|
||||
assert_eq!(back.pattern("Warped Checks"), Some(&def));
|
||||
assert_eq!(back.swatch("Warped Checks").map(|w| &w.paint), doc(&s).swatch("Warped Checks").map(|w| &w.paint));
|
||||
assert!(filled(&back));
|
||||
}
|
||||
|
||||
/// A device name is saved with a `-` after it, which can be another library's own name: neither
|
||||
/// library replaces the other, and saving either again replaces only itself.
|
||||
#[test]
|
||||
|
||||
@@ -18,6 +18,25 @@ use std::io::{self, Write as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Whether the returned write error refuses this location. Recovery errors must still be shown:
|
||||
/// their message names copies the user may need, even when their kind is PermissionDenied.
|
||||
pub fn read_only_location(error: &io::Error) -> bool {
|
||||
matches!(error.kind(), io::ErrorKind::ReadOnlyFilesystem | io::ErrorKind::PermissionDenied)
|
||||
&& !error.get_ref().is_some_and(|e| e.is::<RecoveryError>())
|
||||
}
|
||||
|
||||
/// Marks the existing recovery message without changing its text or the returned error kind.
|
||||
#[derive(Debug)]
|
||||
struct RecoveryError(String);
|
||||
|
||||
impl std::fmt::Display for RecoveryError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RecoveryError {}
|
||||
|
||||
/// Write `bytes` to `path` atomically (see the module docs).
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
write_atomic_with(path, |f| f.write_all(bytes))
|
||||
@@ -102,7 +121,7 @@ fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> {
|
||||
let _ = std::fs::remove_file(old);
|
||||
let why =
|
||||
format!("{refused}, and the file can't be copied aside before it is overwritten ({e}); the new content is kept in {}", tmp.display());
|
||||
return Err(io::Error::new(refused.kind(), why));
|
||||
return Err(io::Error::new(refused.kind(), RecoveryError(why)));
|
||||
}
|
||||
let copied = to.set_len(0).and_then(|()| io::copy(&mut from, &mut to)).and_then(|_| sync_file(&to));
|
||||
drop((from, to));
|
||||
@@ -115,10 +134,11 @@ fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
(Err(e), Some(old)) => {
|
||||
Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {}, the file as it was in {})", tmp.display(), old.display())))
|
||||
}
|
||||
(Err(e), None) => Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {})", tmp.display()))),
|
||||
(Err(e), Some(old)) => Err(io::Error::new(
|
||||
e.kind(),
|
||||
RecoveryError(format!("{e} (the new content is kept in {}, the file as it was in {})", tmp.display(), old.display())),
|
||||
)),
|
||||
(Err(e), None) => Err(io::Error::new(e.kind(), RecoveryError(format!("{e} (the new content is kept in {})", tmp.display())))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,6 +338,24 @@ fn keep_permissions(_: &Path, _: &Path) -> io::Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn readonly_classification_uses_the_terminal_kind_and_excludes_recovery() {
|
||||
for (kind, expected) in [
|
||||
(io::ErrorKind::PermissionDenied, true),
|
||||
(io::ErrorKind::ReadOnlyFilesystem, true),
|
||||
(io::ErrorKind::StorageFull, false),
|
||||
(io::ErrorKind::QuotaExceeded, false),
|
||||
(io::ErrorKind::NotFound, false),
|
||||
(io::ErrorKind::Other, false),
|
||||
] {
|
||||
assert_eq!(read_only_location(&io::Error::new(kind, "injected")), expected, "{kind:?}");
|
||||
let recovery = io::Error::new(kind, RecoveryError("the new content is kept in a.tmp".into()));
|
||||
assert!(!read_only_location(&recovery), "recovery: {kind:?}");
|
||||
assert_eq!(recovery.kind(), kind);
|
||||
assert_eq!(recovery.to_string(), "the new content is kept in a.tmp");
|
||||
}
|
||||
}
|
||||
|
||||
fn dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-atomic-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
|
||||
@@ -55,7 +55,7 @@ use serde::{Deserializer, Serialize};
|
||||
use vectorcraft_doc::{Document, ImageBlob};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use atomic::{sync_file, write_atomic, write_atomic_with, write_new_with};
|
||||
pub use atomic::{read_only_location, sync_file, write_atomic, write_atomic_with, write_new_with};
|
||||
pub use craft_vectorcraft_format::base64::{decode as base64_decode, decoded_len as base64_decoded_len, encode as base64_encode};
|
||||
pub use craft_vectorcraft_format::{
|
||||
BLOB_MAGIC, COMPRESSED_SINCE, EXTENSION, ICC_MIME, INLINE_MAX, LEGACY_EXTENSION, MIN_VERSION, PREVIEW_MAX, VERSION, is_compressed, json_part,
|
||||
|
||||
@@ -358,6 +358,29 @@ fn save_file_waits_for_the_file_in_the_app() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_save_file_keeps_the_stable_error_prefix() {
|
||||
struct Refused(bool);
|
||||
impl Backend for Refused {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
assert_eq!(method, "app.save");
|
||||
assert_eq!(params.get("wait").and_then(Value::as_bool), self.0.then_some(true));
|
||||
Err(vectorcraft_engine::EngineError::ReadOnlyLocation("Poster.svg: injected refusal".into()).to_string())
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
self.0
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"injected save refusal".into()
|
||||
}
|
||||
}
|
||||
for desktop in [false, true] {
|
||||
let r = call_tool(&mut Refused(desktop), "save_file", &json!({}));
|
||||
assert!(r.is_error);
|
||||
assert_eq!(r.content[0]["text"], "save_file: READ_ONLY_LOCATION: Poster.svg: injected refusal");
|
||||
}
|
||||
}
|
||||
|
||||
fn pdf_pages(pdf: &[u8]) -> usize {
|
||||
let mut h = Headless::new();
|
||||
h.call("engine.execute", json!({"command": "document.open", "params": {"name": "p.pdf", "dataBase64": vectorcraft_format::base64_encode(pdf)}}))
|
||||
|
||||
@@ -39,7 +39,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"file.save",
|
||||
"Save",
|
||||
"Cmd+S",
|
||||
"{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first",
|
||||
"{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first; ordinary Save to the document's existing file refused by filesystem permissions or a read-only volume opens Save As in Documents with the same name, format and options; cancelling keeps the original path and modified state. Explicit destinations and Close/Quit keep their existing error dialog. Automation gets READ_ONLY_LOCATION: followed by the reason and no panel",
|
||||
),
|
||||
(
|
||||
"file.place",
|
||||
|
||||
@@ -505,17 +505,31 @@ fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option<Rect> {
|
||||
|
||||
/// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual
|
||||
/// bounds: for each object in it, how far the effects in a symbol instance's art paint past that
|
||||
/// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the
|
||||
/// farthest side. 0 for any other kind of object.
|
||||
/// art ([`art_effects_reach`]), how far brush art can paint ([`crate::brush_fx::pad`]) and how far
|
||||
/// a path's own effects paint past it ([`path_effects_reach`]), on the farthest side. 0 for any
|
||||
/// other kind of object.
|
||||
fn live_content_reach(symbols: &SymbolBoxes, n: &Node) -> f64 {
|
||||
if !vectorcraft_doc::live::is_live(n) {
|
||||
return 0.0;
|
||||
}
|
||||
let mut reach = 0.0f64;
|
||||
n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c) + crate::brush_fx::pad(symbols, c)));
|
||||
n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c).max(path_effects_reach(symbols, c)) + crate::brush_fx::pad(symbols, c)));
|
||||
reach
|
||||
}
|
||||
|
||||
/// How far the effects of path or compound path `n` (a drop shadow, a glow, a geometry effect)
|
||||
/// paint past its visual bounds, on its farthest side ([`visual_bounds`]): 0 when it has no
|
||||
/// visible effects, and for any other kind of object.
|
||||
fn path_effects_reach(symbols: &SymbolBoxes, n: &Node) -> f64 {
|
||||
if !has_fx(n) {
|
||||
return 0.0;
|
||||
}
|
||||
match (visual_bounds(symbols, n), n.visual_bounds_with(symbols)) {
|
||||
(Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the effects in the art of symbol instance `n` (and in the art of the symbols the
|
||||
/// instances in it show) paint past that art's visual bounds, on its farthest side, measured on the
|
||||
/// art as the instance places it before its effects run: 0 when the art has no visible effects, and
|
||||
|
||||
@@ -1260,6 +1260,45 @@ fn undecodable_image_in_nested_opacity_mask_leaves_out_outer_object() {
|
||||
assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "the incomplete inner mask invalidates the outer mask");
|
||||
}
|
||||
|
||||
/// The drop shadows of a blend's steps draw under an opacity mask with Clip off, where the mask
|
||||
/// art leaves them uncovered, as they draw without the mask; with Clip on they are hidden.
|
||||
#[test]
|
||||
fn blend_step_shadows_draw_under_an_unclipped_opacity_mask() {
|
||||
use vectorcraft_doc::live::{BlendSpacing, BlendSpec};
|
||||
use vectorcraft_doc::{NodeKind, OpacityMask};
|
||||
|
||||
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 15, "y": 15, "blur": 0}), visible: true };
|
||||
let blend = |d: &mut Document, mask: Option<bool>| {
|
||||
let end = |d: &mut Document, x: f64| {
|
||||
let mut n = rect_node(d, Rect::new(x, 10.0, x + 10.0, 20.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
n.appearance.effects.push(shadow.clone());
|
||||
Arc::new(n)
|
||||
};
|
||||
let ends = vec![end(d, 10.0), end(d, 70.0)];
|
||||
let spec = BlendSpec { spacing: BlendSpacing::Steps(2), ..Default::default() };
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Blend { children: ends, spec: Box::new(spec) });
|
||||
if let Some(clip) = mask {
|
||||
let white = rect_node(d, Rect::new(0.0, 0.0, 5.0, 5.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
|
||||
n.extras_mut().mask = Some(Box::new(OpacityMask::new(white, clip)));
|
||||
}
|
||||
n
|
||||
};
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let draw = |mask: Option<bool>| {
|
||||
let mut d = Document::new(120.0, 60.0);
|
||||
let n = blend(&mut d, mask);
|
||||
d.insert(Some(d.layers[0].id), 0, n).unwrap();
|
||||
Renderer::new().render(&d, 120, 60, Affine::IDENTITY, &opts)
|
||||
};
|
||||
let (plain, unclipped, clipped) = (draw(None), draw(Some(false)), draw(Some(true)));
|
||||
for (x, y) in [(30, 30), (90, 32)] {
|
||||
assert!(plain.pixel(x, y)[0] < 128, "the shadow draws at ({x}, {y}) without a mask: {:?}", plain.pixel(x, y));
|
||||
assert_eq!(unclipped.pixel(x, y), plain.pixel(x, y), "the shadow draws at ({x}, {y}) under the unclipped mask");
|
||||
assert_eq!(clipped.pixel(x, y), [255, 255, 255, 255], "the clipped mask hides the shadow at ({x}, {y})");
|
||||
}
|
||||
assert_eq!(unclipped.pixel(15, 15), plain.pixel(15, 15), "the first end draws under the unclipped mask");
|
||||
}
|
||||
|
||||
/// #3580: each opacity mask is rendered over its object's pixels, not over the whole frame. 300
|
||||
/// masked 4 × 4 pt squares on a 1000 × 1000 page draw offscreen in proportion to their bounds, and
|
||||
/// still draw where their masks show them.
|
||||
|
||||
+304
-39
@@ -247,8 +247,9 @@ pub(super) fn prepare<'s>(
|
||||
&& n.tag_name().namespace() == Some(SVG_NS)
|
||||
&& !n.ancestors().any(|a| a.has_tag_name("style"))
|
||||
};
|
||||
// The slot of each text, by element, for the `<use>` elements in clip paths below.
|
||||
// The slot of each text, by element, for the `<use>` elements below.
|
||||
let mut slot_of = HashMap::new();
|
||||
let mut inst = Instances::default();
|
||||
for (t, style_t) in xml.descendants().filter(is_text).zip(style_xml.descendants().filter(is_text)) {
|
||||
let label = t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'"));
|
||||
// CSS Masking Module Level 1 §6.1 (https://www.w3.org/TR/css-masking-1/#ClipPathElement):
|
||||
@@ -275,20 +276,22 @@ pub(super) fn prepare<'s>(
|
||||
// A `<use>` elsewhere can still show the text by its id: the clip path leaves out this
|
||||
// group (a group isn't clip path content), but the `<use>` finds it.
|
||||
if t.attribute("id").is_some() {
|
||||
placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints);
|
||||
placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints, true);
|
||||
}
|
||||
} else {
|
||||
placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints);
|
||||
placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints, true);
|
||||
}
|
||||
copied = r.end;
|
||||
edits.push((r, out));
|
||||
slot_of.insert(t.id(), index);
|
||||
slots.texts.push(text);
|
||||
inst.paints.push(paints);
|
||||
}
|
||||
if slots.texts.is_empty() {
|
||||
return (Cow::Borrowed(svg), slots);
|
||||
}
|
||||
clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut edits, warnings);
|
||||
clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut inst, &mut edits, warnings);
|
||||
text_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut inst, &mut edits, warnings);
|
||||
edits.sort_by_key(|(r, _)| r.start);
|
||||
let mut out = String::with_capacity(svg.len());
|
||||
let mut copied = 0;
|
||||
@@ -332,6 +335,7 @@ fn clip_uses(
|
||||
cx: &Ctx,
|
||||
slot_of: &HashMap<roxmltree::NodeId, usize>,
|
||||
slots: &mut TextSlots,
|
||||
inst: &mut Instances,
|
||||
edits: &mut Vec<(Range<usize>, String)>,
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
@@ -343,18 +347,16 @@ fn clip_uses(
|
||||
for (k, (u, style_u)) in xml.descendants().filter(is_use).zip(style_xml.descendants().filter(is_use)).enumerate() {
|
||||
let Some(id) = href(u).and_then(|h| h.strip_prefix('#')) else { continue };
|
||||
let Some(t) = ids.get(id) else { continue };
|
||||
let Some(&i) = slot_of.get(&t.id()) else { continue };
|
||||
let Some(marker) = slots.texts.get(i).map(|text| text.marker) else { continue };
|
||||
let Some(&shared) = slot_of.get(&t.id()) else { continue };
|
||||
let Some(q) = qualifier(svg, u) else { continue };
|
||||
// An undisplayed `<use>` adds nothing to the clip (usvg leaves it out as it is).
|
||||
if cx.css.own(style_u, "display").is_some_and(|d| d.trim() == "none") {
|
||||
continue;
|
||||
}
|
||||
let style_t = style_ids.get(id).copied().filter(|s| s.tag_name() == t.tag_name());
|
||||
let em = cx.font_size(style_u);
|
||||
let length = |a: &str, pct: f64| u.attribute(a).and_then(|v| cx.length(v, em, pct)).unwrap_or(0.0);
|
||||
let (x, y) = (length("x", cx.viewport.0), length("y", cx.viewport.1));
|
||||
let placed = format!("{} translate({x} {y})", own_transform(&cx.css, style_u));
|
||||
let i = style_t.map_or(shared, |s| use_slot(cx, style_xml, slots, inst, shared, s, style_u, warnings));
|
||||
let Some(marker) = slots.texts.get(i).map(|text| text.marker) else { continue };
|
||||
let placed = use_placement(cx, u, style_u);
|
||||
let text_xf = match style_t {
|
||||
Some(s) => own_transform(&cx.css, s),
|
||||
None => t.attribute("transform").unwrap_or("").to_string(),
|
||||
@@ -386,6 +388,186 @@ fn clip_uses(
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace each `<use>` outside clip paths that shows a text it passes other properties on to by a
|
||||
/// group holding that text's placeholder as the `<use>` shows it ([`use_slot`]). Every other
|
||||
/// `<use>` of a text shows the text's own placeholder.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn text_uses(
|
||||
svg: &str,
|
||||
xml: &roxmltree::Document,
|
||||
style_xml: &roxmltree::Document,
|
||||
cx: &Ctx,
|
||||
slot_of: &HashMap<roxmltree::NodeId, usize>,
|
||||
slots: &mut TextSlots,
|
||||
inst: &mut Instances,
|
||||
edits: &mut Vec<(Range<usize>, String)>,
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
let is_use = |n: &XNode| {
|
||||
n.is_element()
|
||||
&& n.tag_name().name() == "use"
|
||||
&& n.tag_name().namespace() == Some(SVG_NS)
|
||||
&& !n.ancestors().any(|a| a.is_element() && a.tag_name().name() == "clipPath")
|
||||
};
|
||||
if !xml.descendants().any(|n| is_use(&n)) {
|
||||
return;
|
||||
}
|
||||
let (ids, style_ids) = (first_ids(xml), first_ids(style_xml));
|
||||
for (u, style_u) in xml.descendants().filter(is_use).zip(style_xml.descendants().filter(is_use)) {
|
||||
let Some(id) = href(u).and_then(|h| h.strip_prefix('#')) else { continue };
|
||||
let Some(t) = ids.get(id) else { continue };
|
||||
let Some(&shared) = slot_of.get(&t.id()) else { continue };
|
||||
let Some(style_t) = style_ids.get(id).copied().filter(|s| s.tag_name() == t.tag_name()) else { continue };
|
||||
let Some(q) = qualifier(svg, u) else { continue };
|
||||
let i = use_slot(cx, style_xml, slots, inst, shared, style_t, style_u, warnings);
|
||||
let (Some(text), Some(paints)) = (slots.texts.get(i), inst.paints.get(i)) else { continue };
|
||||
if i == shared {
|
||||
continue;
|
||||
}
|
||||
let mut out = String::new();
|
||||
use_group(&mut out, q, cx, u, style_u);
|
||||
placeholder(&mut out, q, &cx.css, *t, style_t, &slots.prefix, i, text.marker, paints, false);
|
||||
let _ = write!(out, "</{q}g>");
|
||||
edits.push((u.range(), out));
|
||||
}
|
||||
}
|
||||
|
||||
/// A text's slot, the parent of a `<use>` showing it and the properties that `<use>` sets itself.
|
||||
type UseKey = (usize, Option<roxmltree::NodeId>, Vec<(String, String)>);
|
||||
|
||||
/// The slots made for texts as `<use>` elements show them ([`use_slot`]).
|
||||
#[derive(Default)]
|
||||
struct Instances {
|
||||
/// The `url(#…)` paints of each slot, by index.
|
||||
paints: Vec<Vec<String>>,
|
||||
/// The slot made for each [`UseKey`]; `None` where the text's own slot serves.
|
||||
memo: HashMap<UseKey, Option<usize>>,
|
||||
/// The nodes, text bytes and text path elements of the texts built so far, up to
|
||||
/// [`MAX_INSTANCE_WORK`].
|
||||
work: usize,
|
||||
}
|
||||
|
||||
/// The most nodes, text bytes and elements of the paths `<textPath>`s follow that [`use_slot`]
|
||||
/// builds texts from, all `<use>` elements together (the file is untrusted). Past it, a `<use>`
|
||||
/// shows its text with the text's own properties.
|
||||
const MAX_INSTANCE_WORK: usize = 1 << 20;
|
||||
|
||||
/// The slot of text `style_t` (slot `shared`) as `<use>` `style_u` shows it. The text inherits
|
||||
/// from the `<use>` there ([`Ctx::instance`]): when that changes it, it gets a new slot; otherwise
|
||||
/// the result is `shared`. `<use>` elements with one parent that set the same properties share one.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn use_slot(
|
||||
cx: &Ctx,
|
||||
style_xml: &roxmltree::Document,
|
||||
slots: &mut TextSlots,
|
||||
inst: &mut Instances,
|
||||
shared: usize,
|
||||
style_t: XNode,
|
||||
style_u: XNode,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> usize {
|
||||
// A `<use>` inside the text it shows would make the text inherit from itself.
|
||||
if style_u.ancestors().any(|a| a == style_t) {
|
||||
return shared;
|
||||
}
|
||||
let placing = |name: &str| matches!(name, "id" | "x" | "y" | "width" | "height" | "href" | "transform" | "style");
|
||||
let mut own: Vec<(String, String)> = style_u
|
||||
.attributes()
|
||||
.filter(|a| a.namespace().is_none() && !placing(a.name()))
|
||||
.map(|a| (a.name().to_string(), a.value().to_string()))
|
||||
.collect();
|
||||
own.extend(cx.css.declared(style_u).iter().filter(|(k, _)| k.as_str() != "transform").map(|(k, v)| (format!("style {k}"), v.clone())));
|
||||
own.sort();
|
||||
let key = (shared, style_u.parent_element().map(|p| p.id()), own);
|
||||
if let Some(slot) = inst.memo.get(&key) {
|
||||
return slot.unwrap_or(shared);
|
||||
}
|
||||
let label = style_t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'"));
|
||||
let work = if inst.work < MAX_INSTANCE_WORK {
|
||||
// Each build lays the text out along the path its `<textPath>` follows, so the path counts too.
|
||||
style_t
|
||||
.descendants()
|
||||
.map(|n| if n.is_text() { n.text().map_or(0, str::len) } else { cx.path_elements(n).saturating_add(1) })
|
||||
.fold(0, usize::saturating_add)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
inst.work = inst.work.saturating_add(work);
|
||||
if work == 0 || inst.work > MAX_INSTANCE_WORK {
|
||||
inst.work = MAX_INSTANCE_WORK;
|
||||
let w = format!(
|
||||
"more than {} KiB of text and text paths shown through <use> elements: the rest show their text without the properties the <use> passes on",
|
||||
MAX_INSTANCE_WORK >> 10
|
||||
);
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
return shared;
|
||||
}
|
||||
let shown = cx.instance(style_t, style_u);
|
||||
let mut found = vec![];
|
||||
let built = if style_t.tag_name().name() == "flowRoot" {
|
||||
shown.build_flow(style_t, &label, &mut found)
|
||||
} else {
|
||||
shown.build(style_xml, style_t, &label, &mut found)
|
||||
};
|
||||
let differs = |(text, paints): &(PendingText, Vec<String>)| {
|
||||
slots.texts.get(shared).is_none_or(|old| old.obj != text.obj || old.servers != text.servers || old.under != text.under)
|
||||
|| inst.paints.get(shared) != Some(paints)
|
||||
};
|
||||
let slot = built.filter(differs).map(|(text, paints)| {
|
||||
for w in found {
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
slots.texts.push(text);
|
||||
inst.paints.push(paints);
|
||||
slots.texts.len() - 1
|
||||
});
|
||||
inst.memo.insert(key, slot);
|
||||
slot.unwrap_or(shared)
|
||||
}
|
||||
|
||||
/// The transform `<use>` `u` places what it shows with: its own, then its `x`/`y` offset (in its
|
||||
/// font size). `style_u` supplies its styles.
|
||||
fn use_placement(cx: &Ctx, u: XNode, style_u: XNode) -> String {
|
||||
let em = cx.font_size(style_u);
|
||||
let length = |a: &str, pct: f64| u.attribute(a).and_then(|v| cx.length(v, em, pct)).unwrap_or(0.0);
|
||||
let (x, y) = (length("x", cx.viewport.0), length("y", cx.viewport.1));
|
||||
format!("{} translate({x} {y})", own_transform(&cx.css, style_u))
|
||||
}
|
||||
|
||||
/// Write the start tag of the group that takes `<use>` `u`'s place: its attributes in no namespace
|
||||
/// other than its link, place and size, and as its `style` the properties rules and its `style` set
|
||||
/// on it (from `style_u`), then its placement ([`use_placement`]) and its non-inherited properties
|
||||
/// and `visibility`, so no rule that matches the group changes it.
|
||||
fn use_group(out: &mut String, q: &str, cx: &Ctx, u: XNode, style_u: XNode) {
|
||||
let _ = write!(out, "<{q}g");
|
||||
let placing = |name: &str| matches!(name, "style" | "href" | "x" | "y" | "width" | "height" | "transform");
|
||||
for a in u.attributes().filter(|a| a.namespace().is_none() && !placing(a.name())) {
|
||||
let _ = write!(out, " {}=\"{}\"", a.name(), xml_escape(a.value()));
|
||||
}
|
||||
let declared = cx.css.declared(style_u);
|
||||
let mut props: Vec<_> = declared.iter().filter(|(k, _)| k.as_str() != "transform").collect();
|
||||
props.sort();
|
||||
let mut style: String = props.into_iter().map(|(k, v)| format!("{k}:{v};")).collect();
|
||||
let _ = write!(style, "transform:{};", use_placement(cx, u, style_u));
|
||||
for (p, initial) in [
|
||||
("opacity", "1"),
|
||||
("clip-path", "none"),
|
||||
("mask", "none"),
|
||||
("filter", "none"),
|
||||
("mix-blend-mode", "normal"),
|
||||
("isolation", "auto"),
|
||||
("display", "inline"),
|
||||
] {
|
||||
let _ = write!(style, "{p}:{};", cx.css.own(style_u, p).as_deref().unwrap_or(initial));
|
||||
}
|
||||
let _ = write!(style, "visibility:{}", cx.css.prop(style_u, "visibility").as_deref().unwrap_or("visible"));
|
||||
let _ = write!(out, " style=\"{}\">", xml_escape(&style));
|
||||
}
|
||||
|
||||
/// Write a copy of `<use>` `u` in a `<defs>`: its attributes in no namespace, its link as `href`,
|
||||
/// and the properties set on it by rules and its `style` (from `style_u`) as its `style`.
|
||||
fn use_copy(out: &mut String, q: &str, css: &Styles, u: XNode, style_u: XNode) {
|
||||
@@ -455,11 +637,23 @@ fn clip_placeholder(out: &mut String, q: &str, id: &str, transform: &str, props:
|
||||
let _ = write!(out, " d=\"M{} {}H{}V{}H{}Z\"/>", b.x0, b.y0, b.x1, b.y1, b.x0);
|
||||
}
|
||||
|
||||
/// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`.
|
||||
/// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`, with the text's
|
||||
/// id when `with_id`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode, prefix: &str, index: usize, b: Rect, paints: &[String]) {
|
||||
fn placeholder(
|
||||
out: &mut String,
|
||||
q: &str,
|
||||
css: &Styles,
|
||||
t: XNode,
|
||||
style_t: XNode,
|
||||
prefix: &str,
|
||||
index: usize,
|
||||
b: Rect,
|
||||
paints: &[String],
|
||||
with_id: bool,
|
||||
) {
|
||||
let _ = write!(out, "<{q}g");
|
||||
for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"] {
|
||||
for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"].into_iter().skip(usize::from(!with_id)) {
|
||||
if let Some(v) = t.attribute(a) {
|
||||
let _ = write!(out, " {a}=\"{}\"", xml_escape(v));
|
||||
}
|
||||
@@ -488,25 +682,41 @@ fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode
|
||||
|
||||
/// Computed text properties of the document's elements.
|
||||
struct Ctx {
|
||||
css: Styles,
|
||||
css: Rc<Styles>,
|
||||
/// Pixels per inch absolute lengths convert at ([`super::RootUnits::dpi`]).
|
||||
dpi: f64,
|
||||
/// Viewport size in user units (the base of `x`/`y` percentages).
|
||||
viewport: (f64, f64),
|
||||
/// For the styles of one `<use>` instance: the element the `<use>` shows and the `<use>`. The
|
||||
/// shown element inherits from the `<use>` rather than from its parent (SVG 2 §5.6,
|
||||
/// https://www.w3.org/TR/SVG2/struct.html#UseElement).
|
||||
host: Option<(roxmltree::NodeId, roxmltree::NodeId)>,
|
||||
sizes: RefCell<HashMap<roxmltree::NodeId, f64>>,
|
||||
weights: RefCell<HashMap<roxmltree::NodeId, u16>>,
|
||||
elems: RefCell<HashMap<roxmltree::NodeId, Rc<ElemStyle>>>,
|
||||
/// The path each `<textPath>` read so far follows ([`Self::text_path`]); `None` where it has
|
||||
/// no usable one. The styles of a `<use>` instance share it, so each path is found and parsed
|
||||
/// once however many `<use>` elements show its text.
|
||||
paths: Rc<RefCell<HashMap<roxmltree::NodeId, Option<Rc<BezPath>>>>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many times [`Ctx::read_text_path`] has read a `<textPath>`'s path on this thread.
|
||||
static TEXT_PATH_READS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
fn new(xml: &roxmltree::Document, dpi: f64) -> Self {
|
||||
let mut cx = Self {
|
||||
css: Styles::new(xml),
|
||||
css: Rc::new(Styles::new(xml)),
|
||||
dpi,
|
||||
viewport: (100.0, 100.0),
|
||||
host: None,
|
||||
sizes: RefCell::default(),
|
||||
weights: RefCell::default(),
|
||||
elems: RefCell::default(),
|
||||
paths: Rc::default(),
|
||||
};
|
||||
let root = xml.root_element();
|
||||
let dim = |a: &str| root.attribute(a).and_then(|v| cx.length(v, DEFAULT_FONT_SIZE, 0.0)).filter(|v| *v > 0.0);
|
||||
@@ -517,6 +727,38 @@ impl Ctx {
|
||||
cx
|
||||
}
|
||||
|
||||
/// The styles of element `shown` as `<use>` `u` shows it.
|
||||
fn instance(&self, shown: XNode, u: XNode) -> Self {
|
||||
Self {
|
||||
css: self.css.clone(),
|
||||
dpi: self.dpi,
|
||||
viewport: self.viewport,
|
||||
host: Some((shown.id(), u.id())),
|
||||
sizes: RefCell::default(),
|
||||
weights: RefCell::default(),
|
||||
elems: RefCell::default(),
|
||||
paths: self.paths.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The element `n` inherits from: its parent, or the `<use>` for the element it shows.
|
||||
fn parent<'a, 'i>(&self, n: XNode<'a, 'i>) -> Option<XNode<'a, 'i>> {
|
||||
match self.host {
|
||||
Some((shown, u)) if n.id() == shown => n.document().get_node(u),
|
||||
_ => n.parent_element(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Element `n` and the elements it inherits from, nearest first.
|
||||
fn lineage<'a, 'i>(&self, n: XNode<'a, 'i>) -> impl Iterator<Item = XNode<'a, 'i>> {
|
||||
std::iter::successors(Some(n), |a| self.parent(*a))
|
||||
}
|
||||
|
||||
/// An inherited property: the nearest specified value other than `inherit`.
|
||||
fn prop(&self, n: XNode, name: &str) -> Option<String> {
|
||||
self.lineage(n).find_map(|a| self.css.own(a, name).filter(|v| v != "inherit"))
|
||||
}
|
||||
|
||||
/// A length in user units: absolute units convert at [`Self::dpi`], `em` and `ex` are relative
|
||||
/// to `em` (the font size) and percentages to `pct`.
|
||||
fn length(&self, v: &str, em: f64, pct: f64) -> Option<f64> {
|
||||
@@ -559,7 +801,7 @@ impl Ctx {
|
||||
if let Some(s) = self.sizes.borrow().get(&n.id()) {
|
||||
return *s;
|
||||
}
|
||||
let parent = n.parent_element().map_or(DEFAULT_FONT_SIZE, |p| self.font_size(p));
|
||||
let parent = self.parent(n).map_or(DEFAULT_FONT_SIZE, |p| self.font_size(p));
|
||||
let s = self.css.own(n, "font-size").and_then(|v| self.parse_font_size(&v, parent)).unwrap_or(parent);
|
||||
self.sizes.borrow_mut().insert(n.id(), s);
|
||||
s
|
||||
@@ -570,7 +812,7 @@ impl Ctx {
|
||||
if let Some(w) = self.weights.borrow().get(&n.id()) {
|
||||
return *w;
|
||||
}
|
||||
let parent = n.parent_element().map_or(400, |p| self.weight(p));
|
||||
let parent = self.parent(n).map_or(400, |p| self.weight(p));
|
||||
let w = match self.css.own(n, "font-weight").as_deref() {
|
||||
Some("normal") => 400,
|
||||
Some("bold") => 700,
|
||||
@@ -594,12 +836,12 @@ impl Ctx {
|
||||
/// A fill or stroke: a ready paint, or a `url(#…)` paint for usvg to resolve.
|
||||
fn paint(&self, n: XNode, prop: &str) -> (Paint, Option<String>) {
|
||||
let initial = if prop == "fill" { Paint::solid(Color::BLACK) } else { Paint::None };
|
||||
let Some(v) = self.css.prop(n, prop) else { return (initial, None) };
|
||||
let Some(v) = self.prop(n, prop) else { return (initial, None) };
|
||||
match svgtypes::Paint::from_str(&v) {
|
||||
Ok(svgtypes::Paint::None) => (Paint::None, None),
|
||||
Ok(svgtypes::Paint::Color(c)) => (solid(c), None),
|
||||
Ok(svgtypes::Paint::CurrentColor) => {
|
||||
(self.css.prop(n, "color").and_then(|c| svgtypes::Color::from_str(&c).ok()).map_or(Paint::solid(Color::BLACK), solid), None)
|
||||
(self.prop(n, "color").and_then(|c| svgtypes::Color::from_str(&c).ok()).map_or(Paint::solid(Color::BLACK), solid), None)
|
||||
}
|
||||
Ok(svgtypes::Paint::FuncIRI(..)) => (Paint::None, Some(v)),
|
||||
_ => (initial, None),
|
||||
@@ -613,7 +855,7 @@ impl Ctx {
|
||||
}
|
||||
let css = &self.css;
|
||||
let mut st = CharStyle::default();
|
||||
if let Some(f) = css.prop(n, "font-family") {
|
||||
if let Some(f) = self.prop(n, "font-family") {
|
||||
let fam = f.split(',').next().unwrap_or("").trim().trim_matches(|c| c == '\'' || c == '"').to_string();
|
||||
if !fam.is_empty() {
|
||||
st.font_family = fam;
|
||||
@@ -621,45 +863,46 @@ impl Ctx {
|
||||
}
|
||||
let size = self.font_size(n);
|
||||
st.size = size;
|
||||
let italic = css.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique");
|
||||
let italic = self.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique");
|
||||
st.font_style = style_name(self.weight(n), italic);
|
||||
// Inherited: `inherit`, `unset`, `revert` and a value CSS ignores leave the one inherited.
|
||||
let inherits = |v: &String| ["inherit", "unset", "revert", "revert-layer"].iter().any(|k| v.trim().eq_ignore_ascii_case(k));
|
||||
let own = |a: XNode| css.own(a, "font-variation-settings").filter(|v| !inherits(v)).and_then(|v| variation_settings(&v));
|
||||
st.font_axes = n.ancestors().filter(|a| a.is_element()).find_map(own).unwrap_or_default();
|
||||
st.font_axes = self.lineage(n).find_map(own).unwrap_or_default();
|
||||
let (fill, mut fill_url) = self.paint(n, "fill");
|
||||
st.fill = fill;
|
||||
let (stroke, mut stroke_url) = self.paint(n, "stroke");
|
||||
if !stroke.is_none() || stroke_url.is_some() {
|
||||
st.stroke = stroke;
|
||||
st.stroke_width = css.prop(n, "stroke-width").and_then(|w| self.length(&w, size, size)).unwrap_or(1.0);
|
||||
st.stroke_cap = match css.prop(n, "stroke-linecap").as_deref() {
|
||||
st.stroke_width = self.prop(n, "stroke-width").and_then(|w| self.length(&w, size, size)).unwrap_or(1.0);
|
||||
st.stroke_cap = match self.prop(n, "stroke-linecap").as_deref() {
|
||||
Some("round") => LineCap::Round,
|
||||
Some("square") => LineCap::Square,
|
||||
_ => LineCap::Butt,
|
||||
};
|
||||
st.stroke_join = match css.prop(n, "stroke-linejoin").as_deref() {
|
||||
st.stroke_join = match self.prop(n, "stroke-linejoin").as_deref() {
|
||||
Some("round") => LineJoin::Round,
|
||||
Some("bevel") => LineJoin::Bevel,
|
||||
_ => LineJoin::Miter,
|
||||
};
|
||||
st.stroke_miter_limit =
|
||||
css.prop(n, "stroke-miterlimit").and_then(|m| m.trim().parse::<f64>().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0));
|
||||
let pattern: Vec<f64> = css.prop(n, "stroke-dasharray").map_or(vec![], |d| list(&d).filter_map(|v| self.length(v, size, size)).collect());
|
||||
let offset = css.prop(n, "stroke-dashoffset").and_then(|o| self.length(&o, size, size)).unwrap_or(0.0);
|
||||
self.prop(n, "stroke-miterlimit").and_then(|m| m.trim().parse::<f64>().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0));
|
||||
let pattern: Vec<f64> =
|
||||
self.prop(n, "stroke-dasharray").map_or(vec![], |d| list(&d).filter_map(|v| self.length(v, size, size)).collect());
|
||||
let offset = self.prop(n, "stroke-dashoffset").and_then(|o| self.length(&o, size, size)).unwrap_or(0.0);
|
||||
st.stroke_dash = Some(Dash { pattern, offset, align_corners: false }).filter(Dash::is_dashed);
|
||||
}
|
||||
if let Some(v) = css.prop(n, "letter-spacing").and_then(|v| self.length(&v, size, size))
|
||||
if let Some(v) = self.prop(n, "letter-spacing").and_then(|v| self.length(&v, size, size))
|
||||
&& size > 0.0
|
||||
{
|
||||
st.tracking = v / size * 1000.0;
|
||||
}
|
||||
if let Some(v) = css.prop(n, "kerning").and_then(|v| self.length(&v, size, size))
|
||||
if let Some(v) = self.prop(n, "kerning").and_then(|v| self.length(&v, size, size))
|
||||
&& size > 0.0
|
||||
{
|
||||
st.kerning = Some(v / size * 1000.0);
|
||||
}
|
||||
if let Some(d) = css.prop(n, "text-decoration") {
|
||||
if let Some(d) = self.prop(n, "text-decoration") {
|
||||
st.underline = d.contains("underline");
|
||||
st.strikethrough = d.contains("line-through");
|
||||
}
|
||||
@@ -672,11 +915,11 @@ impl Ctx {
|
||||
};
|
||||
let parent = if n.tag_name().name() == "text" { 0.0 } else { n.parent_element().map_or(0.0, |p| self.elem(p).style.baseline_shift) };
|
||||
st.baseline_shift = parent + own;
|
||||
if css.prop(n, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") {
|
||||
if self.prop(n, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") {
|
||||
(st.fill, st.stroke, fill_url, stroke_url) = (Paint::None, Paint::None, None, None);
|
||||
}
|
||||
let word_spacing = css.prop(n, "word-spacing").and_then(|v| self.length(&v, size, size)).unwrap_or(0.0);
|
||||
let order = css.prop(n, "paint-order").and_then(|v| svgtypes::PaintOrder::from_str(&v).ok()).unwrap_or_default().order;
|
||||
let word_spacing = self.prop(n, "word-spacing").and_then(|v| self.length(&v, size, size)).unwrap_or(0.0);
|
||||
let order = self.prop(n, "paint-order").and_then(|v| svgtypes::PaintOrder::from_str(&v).ok()).unwrap_or_default().order;
|
||||
let at = |k| order.iter().position(|o| *o == k);
|
||||
let stroke_first = at(svgtypes::PaintOrderKind::Stroke) < at(svgtypes::PaintOrderKind::Fill);
|
||||
let e = Rc::new(ElemStyle { style: st, fill: fill_url, stroke: stroke_url, word_spacing, stroke_first });
|
||||
@@ -761,8 +1004,27 @@ impl Ctx {
|
||||
}
|
||||
}
|
||||
|
||||
/// The path a `<textPath>` follows, in the text's user space.
|
||||
fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option<BezPath> {
|
||||
/// The path a `<textPath>` follows, in the text's user space. It is read from the file once
|
||||
/// ([`Self::paths`]).
|
||||
fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option<Rc<BezPath>> {
|
||||
if let Some(known) = self.paths.borrow().get(&tp.id()) {
|
||||
return known.clone();
|
||||
}
|
||||
let path = self.read_text_path(xml, tp).map(Rc::new);
|
||||
self.paths.borrow_mut().insert(tp.id(), path.clone());
|
||||
path
|
||||
}
|
||||
|
||||
/// How many elements the path of `<textPath>` `tp` has, when a text built so far follows it
|
||||
/// ([`Self::text_path`]); 0 for any other node.
|
||||
fn path_elements(&self, tp: XNode) -> usize {
|
||||
self.paths.borrow().get(&tp.id()).and_then(|p| p.as_deref()).map_or(0, |p| p.elements().len())
|
||||
}
|
||||
|
||||
/// Find and parse the path `<textPath>` `tp` follows.
|
||||
fn read_text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option<BezPath> {
|
||||
#[cfg(test)]
|
||||
TEXT_PATH_READS.set(TEXT_PATH_READS.get() + 1);
|
||||
let mut bp = match tp.attribute("path") {
|
||||
Some(d) => BezPath::from_svg(d).ok()?,
|
||||
None => {
|
||||
@@ -974,7 +1236,7 @@ impl Ctx {
|
||||
.collect();
|
||||
let under = stroke_under(&mut cells, label, warnings);
|
||||
|
||||
let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.css.prop(c.1, "text-anchor")).as_deref() {
|
||||
let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.prop(c.1, "text-anchor")).as_deref() {
|
||||
Some("middle") => 0.5,
|
||||
Some("end") => 1.0,
|
||||
_ => 0.0,
|
||||
@@ -987,7 +1249,7 @@ impl Ctx {
|
||||
None
|
||||
}
|
||||
});
|
||||
let vertical = self.css.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical"));
|
||||
let vertical = self.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical"));
|
||||
self.fit_lengths(&cs.spans, &mut cells, vertical, label, warnings);
|
||||
let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default());
|
||||
let mut breaks: Vec<Break> = vec![];
|
||||
@@ -1059,7 +1321,7 @@ impl Ctx {
|
||||
}
|
||||
// SVG text runs left to right unless `direction: rtl` says otherwise, whatever its first
|
||||
// strong character: pinned (on every paragraph) when that would read as right to left.
|
||||
if self.css.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") {
|
||||
if self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") {
|
||||
obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::RightToLeft));
|
||||
} else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) {
|
||||
obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight));
|
||||
@@ -1400,3 +1662,6 @@ fn assemble(cells: &[Cell], breaks: &[Break]) -> (Runs, Vec<usize>) {
|
||||
}
|
||||
(out, bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
use super::*;
|
||||
|
||||
/// The texts [`prepare`] builds from `body` and how many times it read a `<textPath>`'s path.
|
||||
fn prepared(body: &str) -> (usize, u64, Vec<String>) {
|
||||
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400">{body}</svg>"#);
|
||||
let xml = roxmltree::Document::parse(&svg).unwrap();
|
||||
let mut warnings = vec![];
|
||||
let before = TEXT_PATH_READS.get();
|
||||
let (_, slots) = prepare(&svg, &xml, None, 96.0, &mut warnings);
|
||||
(slots.texts.len(), TEXT_PATH_READS.get() - before, warnings)
|
||||
}
|
||||
|
||||
/// Every `<use>` of a text on a path follows the path the text's own build read.
|
||||
#[test]
|
||||
fn uses_of_a_text_on_a_path_read_the_path_once() {
|
||||
let uses: String = (101..=150).map(|i| format!(r##"<use href="#t" font-size="{i}"/>"##)).collect();
|
||||
let (texts, reads, warnings) =
|
||||
prepared(&format!(r##"<defs><path id="p" d="M0 20L150 20"/><text id="t"><textPath href="#p">Path</textPath></text></defs>{uses}"##));
|
||||
assert_eq!(texts, 51, "the text and one per <use>: {warnings:?}");
|
||||
assert_eq!(reads, 1);
|
||||
// A path with no usable data is not read again either.
|
||||
let (texts, reads, _) = prepared(&format!(r##"<defs><text id="t"><textPath href="#none">Path</textPath></text></defs>{uses}"##));
|
||||
assert_eq!((texts, reads), (51, 1));
|
||||
}
|
||||
@@ -183,6 +183,20 @@ fn a_use_of_a_text_in_a_clip_path_clips_where_the_use_places_it() {
|
||||
assert!(glyphs.x0 >= 200.0 - 1e-6 && glyphs.y1 <= 100.0 + 1e-6 && glyphs.y0 > 40.0, "{glyphs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_use_of_a_text_in_a_clip_path_passes_its_properties_on_to_the_text() {
|
||||
let d = open(
|
||||
r##"<defs><text id="t" x="0" y="0">HI</text></defs>
|
||||
<clipPath id="c" font-weight="bold"><use href="#t" x="20" y="150" font-size="100"/></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) };
|
||||
assert!(near(t.runs[0].style.size, 100.0, 1e-9), "{}", t.runs[0].style.size);
|
||||
assert_eq!(t.runs[0].style.font_style, "Bold");
|
||||
assert!(clip_bounds(clip).height() > 50.0, "{:?}", clip_bounds(clip));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_path_on_a_use_of_a_text_is_in_the_use_s_user_space() {
|
||||
// The text's transform moves its glyphs to x 200…; the `<use>`'s clip keeps x < 250 of its own
|
||||
|
||||
@@ -191,6 +191,89 @@ fn hidden_text_is_skipped_and_use_instances_are_kept() {
|
||||
assert!(t[1].xf.translation().to_point().distance(Point::new(50.0, 30.0)) < 1e-9);
|
||||
}
|
||||
|
||||
/// SVG 2 §5.6 (https://www.w3.org/TR/SVG2/struct.html#UseElement): the element a `<use>` shows
|
||||
/// inherits from the `<use>`, not from where it is defined.
|
||||
#[test]
|
||||
fn a_use_of_a_text_passes_its_properties_on_to_the_text() {
|
||||
let d = open(
|
||||
r##"<defs font-size="30"><text id="t" y="50">HI</text></defs>
|
||||
<use href="#t" font-size="100" fill="red"/><g font-weight="bold"><use href="#t" x="200"/></g>
|
||||
<use href="#t" y="200" opacity="0.5" style="font-size:20px"/>"##,
|
||||
);
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 3, "{t:?}");
|
||||
let s: Vec<CharStyle> = t.iter().map(|t| t.first_style()).collect();
|
||||
assert!(close(s[0].size, 100.0, 1e-9), "{}", s[0].size);
|
||||
assert_eq!(s[0].fill, Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0)));
|
||||
assert!(close(s[1].size, 12.0, 1e-9), "the defaults, not the <defs>'s 30: {}", s[1].size);
|
||||
assert_eq!(s[1].font_style, "Bold");
|
||||
assert_eq!(s[1].fill, Paint::solid(vectorcraft_color::Color::BLACK));
|
||||
assert!(close(s[2].size, 20.0, 1e-9), "{}", s[2].size);
|
||||
assert!(t[0].xf.translation().to_point().distance(Point::new(0.0, 50.0)) < 1e-9, "{:?}", t[0].xf);
|
||||
assert!(t[1].xf.translation().to_point().distance(Point::new(200.0, 50.0)) < 1e-9, "{:?}", t[1].xf);
|
||||
assert!(t[2].xf.translation().to_point().distance(Point::new(0.0, 250.0)) < 1e-9, "{:?}", t[2].xf);
|
||||
// The `<use>`'s opacity stays on the object it becomes.
|
||||
let faded: Vec<&Node> = art(&d).into_iter().filter(|n| (n.opacity - 0.5).abs() < 1e-6).collect();
|
||||
assert_eq!(faded.len(), 1, "{faded:?}");
|
||||
// The text's own properties win over the `<use>`'s.
|
||||
let d = open(r##"<defs><text id="t" y="50" font-size="20" fill="blue">HI</text></defs><use href="#t" font-size="100" fill="red"/>"##);
|
||||
let s = only_text(&d).first_style();
|
||||
assert!(close(s.size, 20.0, 1e-9), "{}", s.size);
|
||||
assert_eq!(s.fill, Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 255)));
|
||||
}
|
||||
|
||||
/// A `<use>` inside the text it shows does not make the text inherit from itself.
|
||||
#[test]
|
||||
fn a_use_inside_the_text_it_shows_opens() {
|
||||
let d = open(r##"<text id="t" y="50" font-size="20">HI<use href="#t" font-size="100"/><tspan><use href="#t"/></tspan></text>"##);
|
||||
assert!(texts(&d).iter().all(|t| close(t.first_style().size, 20.0, 1e-9)), "{:?}", texts(&d));
|
||||
}
|
||||
|
||||
/// Past 1 MiB of text built for `<use>` elements, the rest show their text with its own properties.
|
||||
#[test]
|
||||
fn uses_of_a_long_text_stop_taking_their_properties_past_the_limit() {
|
||||
let long = "a".repeat(350_000);
|
||||
let uses: String = (1..=4).map(|i| format!(r##"<use href="#t" font-size="{}"/>"##, i * 10)).collect();
|
||||
let (d, w) = import_with_report(&svg(&format!(r##"<defs><text id="t" y="50">{long}</text></defs>{uses}"##))).unwrap();
|
||||
let sizes: Vec<f64> = texts(&d).iter().map(|t| t.first_style().size).collect();
|
||||
assert_eq!(sizes.len(), 4, "{sizes:?}");
|
||||
assert!(close(sizes[0], 10.0, 1e-9) && close(sizes[1], 20.0, 1e-9), "{sizes:?}");
|
||||
assert!(close(sizes[2], 12.0, 1e-9) && close(sizes[3], 12.0, 1e-9), "{sizes:?}");
|
||||
assert_eq!(w.iter().filter(|w| w.contains("KiB of text and text paths shown through <use> elements")).count(), 1, "{w:?}");
|
||||
}
|
||||
|
||||
/// The path a text follows counts toward that limit: each `<use>` of the text lays it out along
|
||||
/// the whole path again.
|
||||
#[test]
|
||||
fn uses_of_a_text_on_a_long_path_stop_taking_their_properties_past_the_limit() {
|
||||
let d = format!("M0 0{}", "h1".repeat(350_000));
|
||||
let uses: String = (1..=4).map(|i| format!(r##"<use href="#t" font-size="{}"/>"##, i * 10)).collect();
|
||||
let body = format!(r##"<defs><path id="p" d="{d}"/><text id="t"><textPath href="#p">HI</textPath></text></defs>{uses}"##);
|
||||
let (d, w) = import_with_report(&svg(&body)).unwrap();
|
||||
let sizes: Vec<f64> = texts(&d).iter().map(|t| t.first_style().size).collect();
|
||||
assert_eq!(sizes.len(), 4, "{sizes:?}");
|
||||
assert!(close(sizes[0], 10.0, 1e-9) && close(sizes[1], 20.0, 1e-9), "{sizes:?}");
|
||||
assert!(close(sizes[2], 12.0, 1e-9) && close(sizes[3], 12.0, 1e-9), "{sizes:?}");
|
||||
assert_eq!(w.iter().filter(|w| w.contains("KiB of text and text paths shown through <use> elements")).count(), 1, "{w:?}");
|
||||
// The same text on a short path is within the limit however many `<use>` elements show it.
|
||||
let uses: String = (1..=300).map(|i| format!(r##"<use href="#t" font-size="{i}"/>"##)).collect();
|
||||
let body = format!(r##"<defs><path id="p" d="M0 20L150 20"/><text id="t"><textPath href="#p">HI</textPath></text></defs>{uses}"##);
|
||||
let (d, w) = import_with_report(&svg(&body)).unwrap();
|
||||
assert!(texts(&d).iter().enumerate().all(|(i, t)| close(t.first_style().size, (i + 1) as f64, 1e-9)), "{w:?}");
|
||||
assert!(!w.iter().any(|w| w.contains("shown through <use> elements")), "{w:?}");
|
||||
}
|
||||
|
||||
/// Many `<use>` elements of one text that inherit the same properties each get it.
|
||||
#[test]
|
||||
fn many_uses_of_a_text_in_one_group_all_take_its_properties() {
|
||||
let uses: String = (0..200).map(|i| format!(r##"<use href="#t" x="{i}"/>"##)).collect();
|
||||
let d = open(&format!(r##"<defs><text id="t" y="50">HI</text></defs><g font-size="40">{uses}</g>"##));
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 200);
|
||||
assert!(t.iter().all(|t| close(t.first_style().size, 40.0, 1e-9)));
|
||||
assert!(t[199].xf.translation().to_point().distance(Point::new(199.0, 50.0)) < 1e-9, "{:?}", t[199].xf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_path_becomes_type_on_a_path() {
|
||||
let d = open(
|
||||
|
||||
@@ -29,12 +29,24 @@
|
||||
//! distance along the path shows next to the pointer in the document's units, the drag is
|
||||
//! previewed and kept as one undo step on release, and Escape cancels it.
|
||||
//!
|
||||
//! The Select/Move widgets: with one Objects on Path group selected, the same tools show a small
|
||||
//! ring on each object's attachment point, highlighted under the pointer. A click on one chooses
|
||||
//! that object (`object.objectsOnPath.selectMember`), and its ring is filled; the group stays
|
||||
//! selected, and a click on the object's art still selects the group. Dragging a ring along the
|
||||
//! path moves its object to the place of the object nearest the pointer, and the objects between
|
||||
//! move one place toward where it was (`object.objectsOnPath.moveMember`): only their order along
|
||||
//! the path changes, and hidden and locked objects keep their places. On an open path a pointer beyond an end takes the place at that end; on a
|
||||
//! closed path the drag goes on round across the first anchor. The drag is previewed and kept as
|
||||
//! one undo step on release, and Escape cancels it.
|
||||
//!
|
||||
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html>,
|
||||
//! <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html>.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::blend::Spine;
|
||||
use vectorcraft_doc::objects_on_path::{CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop};
|
||||
use vectorcraft_doc::objects_on_path::{
|
||||
CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop, stops,
|
||||
};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, Selection};
|
||||
use vectorcraft_geom::{BezPath, Point, Vec2};
|
||||
|
||||
@@ -68,6 +80,11 @@ pub const MOVE_ALL_HALF_PX: f32 = 4.5;
|
||||
/// How near (screen px) the pointer must be to hit the Move All widget.
|
||||
const MOVE_ALL_HIT_PX: f64 = 6.0;
|
||||
|
||||
/// The radius (screen px) of the Select/Move widget's ring, as the canvas draws it.
|
||||
pub const MEMBER_RADIUS_PX: f32 = 4.5;
|
||||
/// How near (screen px) the pointer must be to hit a Select/Move widget.
|
||||
const MEMBER_HIT_PX: f64 = 6.0;
|
||||
|
||||
/// The Objects on Path group the widgets show for at `zoom` (screen px per document point), with
|
||||
/// its record, its children and its path: only when the selection is that one editable group, on
|
||||
/// a path with length, and no anchors are Direct-Selected.
|
||||
@@ -567,6 +584,189 @@ impl MoveAllDrag {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Select/Move widget of one object of a selected Objects on Path group: a small ring on the
|
||||
/// object's attachment point.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MemberWidget {
|
||||
pub group: NodeId,
|
||||
/// The object.
|
||||
pub member: NodeId,
|
||||
/// The object's place along the path (0: the first).
|
||||
pub index: usize,
|
||||
/// The object's attachment point, in the document: the widget's center.
|
||||
pub at: Point,
|
||||
/// Is the object the chosen one ([`Selection::on_path_member`])?
|
||||
pub chosen: bool,
|
||||
}
|
||||
|
||||
impl MemberWidget {
|
||||
/// The widgets of the selection at `zoom` (screen px per document point), one per object in
|
||||
/// its order along the path that is neither hidden nor locked: only when the selection is one
|
||||
/// editable Objects on Path group on a path with length, and no anchors are Direct-Selected.
|
||||
pub fn all(doc: &Document, selection: &Selection, zoom: f64) -> Vec<Self> {
|
||||
let Some((group, rec, _, path)) = one_group(doc, selection, zoom) else { return vec![] };
|
||||
let Some(at) = stops(path, rec.members.len(), rec.spacing, rec.start) else { return vec![] };
|
||||
let editable = doc.editable();
|
||||
rec.members
|
||||
.iter()
|
||||
.zip(at)
|
||||
.enumerate()
|
||||
.filter(|(_, (m, (p, _)))| p.x.is_finite() && p.y.is_finite() && editable(m.id))
|
||||
.map(|(index, (m, (at, _)))| Self { group, member: m.id, index, at, chosen: selection.on_path_member == Some(m.id) })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The widget of `cx` nearest `p`, when `p` is within reach of it; of widgets as near, the
|
||||
/// chosen one, else the first along the path.
|
||||
fn at_point(cx: &ToolContext, p: Point) -> Option<Self> {
|
||||
let reach = cx.tol(MEMBER_HIT_PX);
|
||||
// Every attachment point lies on the path: a pointer beyond reach of its bounds is over none.
|
||||
let (_, _, _, path) = one_group(cx.doc, cx.selection, cx.zoom)?;
|
||||
if !path.control_bounds()?.inflate(reach, reach).contains(p) {
|
||||
return None;
|
||||
}
|
||||
let mut best: Option<(f64, Self)> = None;
|
||||
for w in Self::all(cx.doc, cx.selection, cx.zoom) {
|
||||
let d = w.at.distance(p);
|
||||
let nearer = best.is_none_or(|(b, bw)| d < b - 1e-9 || (d <= b + 1e-9 && w.chosen && !bw.chosen));
|
||||
if d <= reach && nearer {
|
||||
best = Some((d, w));
|
||||
}
|
||||
}
|
||||
best.map(|(_, w)| w)
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `p` over a Select/Move widget?
|
||||
pub fn over_member(cx: &ToolContext, p: Point) -> bool {
|
||||
MemberWidget::at_point(cx, p).is_some()
|
||||
}
|
||||
|
||||
/// The highlight of the Select/Move widget under `p`, if any: a ring around it.
|
||||
pub fn member_hover(cx: &ToolContext, p: Point) -> Option<Overlay> {
|
||||
let w = MemberWidget::at_point(cx, p)?;
|
||||
let r = cx.tol(f64::from(MEMBER_RADIUS_PX) + 2.0);
|
||||
let ring = vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(w.at.x - r, w.at.y - r, w.at.x + r, w.at.y + r));
|
||||
Some(Overlay::Path { path: ring.to_bezpath(), color: HIGHLIGHT, width: 1.5, dashed: false })
|
||||
}
|
||||
|
||||
/// Dragging a Select/Move widget: the object goes to the place along the path nearest the
|
||||
/// pointer, and the objects between it and its place move one place toward where it was, except
|
||||
/// hidden and locked ones (`object.objectsOnPath.moveMember`). On an open path a pointer beyond
|
||||
/// an end takes the place at that end; on a closed path the pointer goes on round across the first
|
||||
/// anchor. Where the path passes as near the pointer more than once, as where it runs back over
|
||||
/// itself, the drag follows the part nearest along the path to where it was. Where two places are
|
||||
/// as near, the one the drag is at stays. A click without a drag, and
|
||||
/// the release of a drag, choose the object (`object.objectsOnPath.selectMember`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MemberDrag {
|
||||
widget: MemberWidget,
|
||||
/// The objects in their order along the path when the press began.
|
||||
order: Vec<NodeId>,
|
||||
/// The distance along the path to each place (points), in that order.
|
||||
places: Vec<f64>,
|
||||
/// Can the drag take each place? Not the place of a hidden or locked object.
|
||||
open: Vec<bool>,
|
||||
/// The length of the path (points).
|
||||
len: f64,
|
||||
/// Is the path closed?
|
||||
closed: bool,
|
||||
start: Point,
|
||||
began: bool,
|
||||
/// The distance along the path the drag was last at (points).
|
||||
last: f64,
|
||||
/// The place the drag puts the object at.
|
||||
target: usize,
|
||||
}
|
||||
|
||||
impl MemberDrag {
|
||||
/// The drag has left the click: the object is moving.
|
||||
pub fn adjusting(&self) -> bool {
|
||||
self.began
|
||||
}
|
||||
|
||||
/// Start a drag when the press `ev` is on a Select/Move widget.
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||||
let widget = MemberWidget::at_point(cx, ev.pos)?;
|
||||
let n = cx.doc.node(widget.group)?;
|
||||
let rec = n.extras().objects_on_path.as_deref()?;
|
||||
let (len, closed) = path_length(attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?)?;
|
||||
let count = rec.members.len();
|
||||
let (s0, gap) = layout(len, closed, count, rec.spacing, rec.start);
|
||||
let places: Vec<f64> = (0..count).map(|i| nth_length(len, closed, s0, gap, i)).collect();
|
||||
let last = *places.get(widget.index)?;
|
||||
let editable = cx.doc.editable();
|
||||
let open = rec.members.iter().map(|m| editable(m.id)).collect();
|
||||
let order = rec.members.iter().map(|m| m.id).collect();
|
||||
Some(Self { widget, order, places, open, len, closed, start: ev.pos, began: false, last, target: widget.index })
|
||||
}
|
||||
|
||||
/// How far apart along the path the distances `a` and `b` are: the shorter way round a closed
|
||||
/// path.
|
||||
fn apart(&self, a: f64, b: f64) -> f64 {
|
||||
let d = (a - b).abs();
|
||||
if self.closed { d.rem_euclid(self.len).min(self.len - d.rem_euclid(self.len)) } else { d }
|
||||
}
|
||||
|
||||
/// The place nearest the distance `t` along the path, of the places the drag can take; the
|
||||
/// current one where another is as near.
|
||||
fn nearest_place(&self, t: f64) -> usize {
|
||||
let t = if self.closed { t } else { t.clamp(0.0, self.len) };
|
||||
let now = self.places.get(self.target).map_or(f64::INFINITY, |s| self.apart(t, *s));
|
||||
let mut best = (self.target, now);
|
||||
for (i, s) in self.places.iter().enumerate().filter(|(i, _)| self.open.get(*i).copied().unwrap_or(false)) {
|
||||
let d = self.apart(t, *s);
|
||||
if d.is_finite() && d < best.1 - 1e-9 {
|
||||
best = (i, d);
|
||||
}
|
||||
}
|
||||
best.0
|
||||
}
|
||||
|
||||
/// The pointer moved to `p`.
|
||||
pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
|
||||
let mut out = vec![];
|
||||
if !self.began {
|
||||
if p.distance(self.start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
self.began = true;
|
||||
out.push(Action::Begin("Move Object on Path".into()));
|
||||
}
|
||||
let w = &self.widget;
|
||||
let Some(spine) = group_spine(cx.doc, w.group) else { return out };
|
||||
let Some((near, t)) = spine.nearest(p, None) else { return out };
|
||||
// Where the path passes as near the pointer more than once, the point nearest along the path
|
||||
// to where the drag was counts.
|
||||
let t = match spine.nearest(p, Some(self.last)) {
|
||||
Some((d, s)) if d - spine.apart(s, self.last) / 2.0 - near <= 1e-9 * (1.0 + near) => s,
|
||||
_ => t,
|
||||
};
|
||||
if !t.is_finite() {
|
||||
return out;
|
||||
}
|
||||
self.last = t;
|
||||
let target = self.nearest_place(t);
|
||||
if target != self.target {
|
||||
self.target = target;
|
||||
let to = self.order.get(target).copied().unwrap_or(w.member);
|
||||
out.push(Action::Preview("object.objectsOnPath.moveMember".into(), json!({ "group": w.group.0, "member": w.member.0, "target": to.0 })));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The release: a drag that moved the object keeps it as one undo step, one that ends where it
|
||||
/// began keeps nothing, and either way the object is chosen.
|
||||
pub fn finish(self) -> Vec<Action> {
|
||||
let choose = Action::Exec("object.objectsOnPath.selectMember".into(), json!({ "member": self.widget.member.0 }));
|
||||
match (self.began, self.target == self.widget.index) {
|
||||
(false, _) => vec![choose],
|
||||
(true, true) => vec![Action::Cancel, choose],
|
||||
(true, false) => vec![Action::Commit, choose],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The path of the Objects on Path group `g` of `doc`, to measure along.
|
||||
fn group_spine(doc: &Document, g: NodeId) -> Option<Spine> {
|
||||
let n = doc.node(g)?;
|
||||
@@ -602,6 +802,10 @@ pub struct ObjectsOnPathTool {
|
||||
spacing: Option<SpaceDrag>,
|
||||
/// The Move All widget drag, while the button is down after a press on the widget.
|
||||
moving: Option<MoveAllDrag>,
|
||||
/// The Select/Move widget drag, while the button is down after a press on a widget.
|
||||
member: Option<MemberDrag>,
|
||||
/// Where the pointer is while no button is down, for the Select/Move widget it is over.
|
||||
pointer: Option<Point>,
|
||||
/// The reference point of each object attached next (0-8, row by row from the top left).
|
||||
pivot: u8,
|
||||
/// The angle of each object attached next, in degrees counter-clockwise from upright on the path.
|
||||
@@ -610,7 +814,7 @@ pub struct ObjectsOnPathTool {
|
||||
|
||||
impl Default for ObjectsOnPathTool {
|
||||
fn default() -> Self {
|
||||
Self { hover: None, press: None, rotating: None, spacing: None, moving: None, pivot: CENTER, rotate: 0.0 }
|
||||
Self { hover: None, press: None, rotating: None, spacing: None, moving: None, member: None, pointer: None, pivot: CENTER, rotate: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,9 +846,18 @@ impl Tool for ObjectsOnPathTool {
|
||||
_ => vec![],
|
||||
};
|
||||
}
|
||||
if let Some(d) = &mut self.member {
|
||||
return match ev.kind {
|
||||
PointerKind::Drag => d.drag(cx, p),
|
||||
PointerKind::Up => self.member.take().map(MemberDrag::finish).unwrap_or_default(),
|
||||
_ => vec![],
|
||||
};
|
||||
}
|
||||
self.pointer = (ev.kind == PointerKind::Move).then_some(p);
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
self.hover = if over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) { None } else { target(cx, p) };
|
||||
let widget = over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) || over_member(cx, p);
|
||||
self.hover = if widget { None } else { target(cx, p) };
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
@@ -663,6 +876,11 @@ impl Tool for ObjectsOnPathTool {
|
||||
self.hover = None;
|
||||
return vec![];
|
||||
}
|
||||
if let Some(d) = MemberDrag::hit(cx, ev) {
|
||||
self.member = Some(d);
|
||||
self.hover = None;
|
||||
return vec![];
|
||||
}
|
||||
self.press = Some(p);
|
||||
vec![]
|
||||
}
|
||||
@@ -689,16 +907,22 @@ impl Tool for ObjectsOnPathTool {
|
||||
if let Some(d) = &self.moving {
|
||||
o.extend(d.overlays(cx));
|
||||
}
|
||||
if let Some(p) = self.pointer.filter(|_| !self.busy()) {
|
||||
o.extend(member_hover(cx, p));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
|
||||
if self.member.is_some() {
|
||||
return Cursor::Move;
|
||||
}
|
||||
let idle = self.spacing.is_none() && self.moving.is_none();
|
||||
if self.rotating.is_some() || (idle && over_rotate_all(cx, p)) {
|
||||
Cursor::Rotate
|
||||
} else if self.spacing.is_some() || (self.moving.is_none() && over_space(cx, p)) {
|
||||
Cursor::ArrowHollow
|
||||
} else if self.moving.is_some() || over_move_all(cx, p) {
|
||||
} else if self.moving.is_some() || over_move_all(cx, p) || over_member(cx, p) {
|
||||
Cursor::Move
|
||||
} else if target(cx, p).is_some() {
|
||||
Cursor::ObjectsOnPath
|
||||
@@ -708,8 +932,8 @@ impl Tool for ObjectsOnPathTool {
|
||||
}
|
||||
|
||||
/// Escape while the button is down cancels the click, so the release attaches nothing, or the
|
||||
/// Rotate All, Space or Move All drag, so the objects go back to the Rotate, Space or place
|
||||
/// they had.
|
||||
/// Rotate All, Space, Move All or Select/Move drag, so the objects go back to the Rotate, Space,
|
||||
/// place or order they had.
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape {
|
||||
self.press = None;
|
||||
@@ -717,7 +941,8 @@ impl Tool for ObjectsOnPathTool {
|
||||
let rotated = self.rotating.take().is_some_and(|d| d.adjusting());
|
||||
let spaced = self.spacing.take().is_some_and(|d| d.adjusting());
|
||||
let moved = self.moving.take().is_some_and(|d| d.adjusting());
|
||||
if rotated || spaced || moved {
|
||||
let ordered = self.member.take().is_some_and(|d| d.adjusting());
|
||||
if rotated || spaced || moved || ordered {
|
||||
return vec![Action::Cancel];
|
||||
}
|
||||
}
|
||||
@@ -747,13 +972,14 @@ impl Tool for ObjectsOnPathTool {
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() || self.moving.is_some()
|
||||
self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() || self.moving.is_some() || self.member.is_some()
|
||||
}
|
||||
|
||||
fn transforming(&self) -> bool {
|
||||
self.rotating.as_ref().is_some_and(RotateAllDrag::adjusting)
|
||||
|| self.spacing.as_ref().is_some_and(SpaceDrag::adjusting)
|
||||
|| self.moving.as_ref().is_some_and(MoveAllDrag::adjusting)
|
||||
|| self.member.as_ref().is_some_and(MemberDrag::adjusting)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1469,4 +1695,267 @@ mod tests {
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)).is_empty(), "{}: the release keeps nothing", t.id());
|
||||
}
|
||||
}
|
||||
|
||||
/// A document holding an Objects on Path group of `n` 20 × 20 squares attached by their centers
|
||||
/// to `path`, with the group's Space `spacing`, a selection of the group, and the squares' ids
|
||||
/// in their order along the path.
|
||||
fn members_doc(path: vectorcraft_geom::PathData, n: usize, spacing: Option<f64>) -> (Document, NodeId, Selection, Vec<NodeId>) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let (line, g) = (d.alloc_id(), d.alloc_id());
|
||||
let line = Node::path(line, path, Appearance::default_art());
|
||||
let squares: Vec<Node> = (0..n)
|
||||
.map(|i| {
|
||||
let x = 50.0 * i as f64;
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 50.0, x + 20.0, 70.0)), Appearance::default_art())
|
||||
})
|
||||
.collect();
|
||||
let ids: Vec<NodeId> = squares.iter().map(|s| s.id).collect();
|
||||
let rec = vectorcraft_doc::objects_on_path::ObjectsOnPath {
|
||||
spacing,
|
||||
..vectorcraft_doc::objects_on_path::ObjectsOnPath::new(line.id, &squares.iter().collect::<Vec<_>>(), CENTER, 0.0)
|
||||
};
|
||||
let mut children = vec![Arc::new(line)];
|
||||
children.extend(squares.into_iter().map(Arc::new));
|
||||
let mut group = Node::new(g, NodeKind::Group { children, clip: false });
|
||||
group.extras_mut().objects_on_path = Some(Box::new(rec));
|
||||
vectorcraft_doc::objects_on_path::place(&mut group, &Document::new(1.0, 1.0).symbol_boxes());
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
let mut sel = Selection::default();
|
||||
sel.set([g]);
|
||||
(d, g, sel, ids)
|
||||
}
|
||||
|
||||
/// Three squares on the line from (0, 300) to (400, 300): at 0, 200 and 400.
|
||||
fn three_doc() -> (Document, NodeId, Selection, Vec<NodeId>) {
|
||||
members_doc(shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), 3, None)
|
||||
}
|
||||
|
||||
/// The `target` a drag's actions preview for `object.objectsOnPath.moveMember`, if any.
|
||||
fn previewed_target(acts: &[Action]) -> Option<u64> {
|
||||
acts.iter().rev().find_map(|a| match a {
|
||||
Action::Preview(cmd, p) if cmd == "object.objectsOnPath.moveMember" => p["target"].as_u64(),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn choose(id: NodeId) -> Action {
|
||||
Action::Exec("object.objectsOnPath.selectMember".into(), json!({ "member": id.0 }))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_select_move_widget_sits_on_each_attachment_point() {
|
||||
let (d, g, sel, ids) = three_doc();
|
||||
let ws = MemberWidget::all(&d, &sel, 1.0);
|
||||
let at: Vec<(NodeId, usize, Point, bool)> = ws.iter().map(|w| (w.member, w.index, w.at, w.chosen)).collect();
|
||||
assert!(ws.iter().all(|w| w.group == g));
|
||||
assert_eq!(at.len(), 3);
|
||||
for (i, x) in [0.0, 200.0, 400.0].into_iter().enumerate() {
|
||||
assert!(at[i].0 == ids[i] && at[i].1 == i && near(at[i].2, Point::new(x, 300.0)) && !at[i].3, "{at:?}");
|
||||
}
|
||||
let mut chosen = sel.clone();
|
||||
chosen.on_path_member = Some(ids[1]);
|
||||
assert_eq!(MemberWidget::all(&d, &chosen, 1.0).iter().map(|w| w.chosen).collect::<Vec<_>>(), [false, true, false]);
|
||||
// None for an object in the group, with anchors Direct-Selected, or for a locked group.
|
||||
let mut one = Selection::default();
|
||||
one.set([ids[0]]);
|
||||
assert!(MemberWidget::all(&d, &one, 1.0).is_empty());
|
||||
let mut anchors = sel.clone();
|
||||
anchors.anchors.insert(ids[0], [(0, 0)].into());
|
||||
assert!(MemberWidget::all(&d, &anchors, 1.0).is_empty());
|
||||
let (mut locked, lg, lsel, _) = three_doc();
|
||||
locked.node_mut(lg).unwrap().locked = true;
|
||||
assert!(MemberWidget::all(&locked, &lsel, 1.0).is_empty());
|
||||
}
|
||||
|
||||
/// A click on a widget chooses its object without moving it, with the Selection tool and the
|
||||
/// Objects on Path tool; a click on the object's art away from the widget does not.
|
||||
#[test]
|
||||
fn a_click_on_a_select_move_widget_chooses_its_object() {
|
||||
let (d, _, sel, ids) = three_doc();
|
||||
let paint = paint();
|
||||
let c = cx(&d, &sel, &paint);
|
||||
let tools: [Box<dyn Tool>; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())];
|
||||
for mut t in tools {
|
||||
t.pointer(&c, &ev(PointerKind::Move, 202.0, 301.0));
|
||||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Path { color, .. } if *color == HIGHLIGHT)), "{}: highlighted", t.id());
|
||||
assert_eq!(t.cursor(&c, Point::new(202.0, 301.0), Mods::default()), Cursor::Move, "{}", t.id());
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Down, 202.0, 301.0)).is_empty());
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Drag, 203.0, 301.0)).is_empty(), "{}: within the click", t.id());
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 203.0, 301.0)), vec![choose(ids[1])], "{}", t.id());
|
||||
assert!(!t.busy());
|
||||
// On the art, 8 px from the attachment point: not the widget.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 208.0, 300.0));
|
||||
let acts = t.pointer(&c, &ev(PointerKind::Up, 208.0, 300.0));
|
||||
assert!(!acts.contains(&choose(ids[1])), "{}: {acts:?}", t.id());
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget at an end of the path is within reach from beyond the path's bounds too, up to
|
||||
/// 6 px away.
|
||||
#[test]
|
||||
fn a_select_move_widget_at_an_end_is_hit_from_beyond_the_path() {
|
||||
let (d, _, sel, _) = three_doc();
|
||||
let paint = paint();
|
||||
let c = cx(&d, &sel, &paint);
|
||||
assert!(over_member(&c, Point::new(-5.0, 300.0)), "before the start");
|
||||
assert!(over_member(&c, Point::new(405.0, 300.0)), "after the end");
|
||||
assert!(over_member(&c, Point::new(0.0, 305.5)), "beside the line");
|
||||
assert!(!over_member(&c, Point::new(0.0, 306.5)), "out of reach");
|
||||
assert!(!over_member(&c, Point::new(-6.5, 300.0)), "out of reach before the start");
|
||||
}
|
||||
|
||||
/// Dragging the middle object's widget past the last one moves it to the last place; dragging
|
||||
/// the last one's beyond the start of the line moves it to the first place. Each drag is one
|
||||
/// undo step, and the release chooses the object.
|
||||
#[test]
|
||||
fn dragging_a_select_move_widget_moves_its_object_to_the_nearest_place() {
|
||||
let (d, g, sel, ids) = three_doc();
|
||||
let paint = paint();
|
||||
let c = cx(&d, &sel, &paint);
|
||||
let tools: [Box<dyn Tool>; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())];
|
||||
for mut t in tools {
|
||||
t.pointer(&c, &ev(PointerKind::Down, 200.0, 300.0));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 280.0, 310.0));
|
||||
assert_eq!(a, vec![Action::Begin("Move Object on Path".into())], "{}: still nearest its own place", t.id());
|
||||
assert!(t.transforming() && t.cursor(&c, Point::new(280.0, 310.0), Mods::default()) == Cursor::Move);
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 330.0, 300.0));
|
||||
assert!(
|
||||
matches!(a.as_slice(), [Action::Preview(cmd, p)] if cmd == "object.objectsOnPath.moveMember" && *p == json!({ "group": g.0, "member": ids[1].0, "target": ids[2].0 })),
|
||||
"{}: {a:?}",
|
||||
t.id()
|
||||
);
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Drag, 600.0, 300.0)).is_empty(), "{}: beyond the end, the last place", t.id());
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 600.0, 300.0)), vec![Action::Commit, choose(ids[1])], "{}", t.id());
|
||||
assert!(!t.busy() && !t.transforming());
|
||||
// The last one, back before the start of the line.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 400.0, 300.0));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 250.0, 300.0));
|
||||
assert_eq!(previewed_target(&a), Some(ids[1].0), "{}: {a:?}", t.id());
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, -80.0, 300.0));
|
||||
assert_eq!(previewed_target(&a), Some(ids[0].0), "{}: {a:?}", t.id());
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, -80.0, 300.0)), vec![Action::Commit, choose(ids[2])], "{}", t.id());
|
||||
}
|
||||
}
|
||||
|
||||
/// A drag that comes back to its own place previews the object back there and keeps nothing on
|
||||
/// release; Escape cancels a drag.
|
||||
#[test]
|
||||
fn a_select_move_drag_back_to_its_place_or_escaped_keeps_nothing() {
|
||||
let (d, _, sel, ids) = three_doc();
|
||||
let paint = paint();
|
||||
let c = cx(&d, &sel, &paint);
|
||||
let tools: [Box<dyn Tool>; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())];
|
||||
for mut t in tools {
|
||||
t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0));
|
||||
assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 190.0, 300.0))), Some(ids[1].0));
|
||||
assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 20.0, 300.0))), Some(ids[0].0), "{}: back onto itself", t.id());
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 20.0, 300.0)), vec![Action::Cancel, choose(ids[0])], "{}", t.id());
|
||||
t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0));
|
||||
assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 390.0, 300.0))).is_some());
|
||||
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{}", t.id());
|
||||
assert!(!t.busy() && !t.transforming(), "{}", t.id());
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Drag, 0.0, 300.0)).is_empty(), "{}", t.id());
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)).is_empty(), "{}: the release keeps nothing", t.id());
|
||||
}
|
||||
}
|
||||
|
||||
/// Round a closed path the drag goes on across the first anchor: the first object, dragged back
|
||||
/// past it, takes the last place.
|
||||
#[test]
|
||||
fn a_select_move_drag_goes_round_a_closed_path_across_its_first_anchor() {
|
||||
// The square's outline is 400 pt round, from (100, 100) to the right and on clockwise: the
|
||||
// four objects sit on its corners.
|
||||
let (d, _, sel, ids) = members_doc(shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), 4, None);
|
||||
let paint = paint();
|
||||
let c = cx(&d, &sel, &paint);
|
||||
let mut t = ObjectsOnPathTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0));
|
||||
assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 100.0, 130.0))).is_none(), "nearer its own place");
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 100.0, 180.0));
|
||||
assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}");
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 100.0, 180.0)), vec![Action::Commit, choose(ids[0])]);
|
||||
// Forward from the last one across the first anchor: the first place.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 100.0, 200.0));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 100.0, 120.0));
|
||||
assert_eq!(previewed_target(&a), Some(ids[0].0), "{a:?}");
|
||||
}
|
||||
|
||||
/// On an open path that turns back on itself, a drag takes the place nearest the pointer even
|
||||
/// when that place is far along the path from where the drag is.
|
||||
#[test]
|
||||
fn a_select_move_drag_takes_the_place_under_the_pointer_on_a_path_that_turns_back() {
|
||||
// The path runs 210 pt from (0, 0) right, down and back left to (0, 10): the four objects
|
||||
// sit at 0, 70, 140 and 210 pt along it, the last one 10 pt below the first.
|
||||
let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 10.0), Point::new(0.0, 10.0)];
|
||||
let path = vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&corners, false)]);
|
||||
let (d, _, sel, ids) = members_doc(path, 4, None);
|
||||
let paint = paint();
|
||||
let c = cx(&d, &sel, &paint);
|
||||
let mut t = ObjectsOnPathTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, 0.0, 0.0));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 0.0, 9.0));
|
||||
assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}");
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 9.0)), vec![Action::Commit, choose(ids[0])]);
|
||||
}
|
||||
|
||||
/// Where an open path runs back over itself, a drag along the part that runs back stays on that
|
||||
/// part.
|
||||
#[test]
|
||||
fn a_select_move_drag_follows_the_part_it_is_on_where_a_path_runs_back_over_itself() {
|
||||
// The path runs 540 pt from (0, 0) right to (300, 0) and back left to (60, 0): the four
|
||||
// objects sit at 0, 180, 360 and 540 pt along it, at x 0, 180, 240 and 60.
|
||||
let corners = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(60.0, 0.0)];
|
||||
let path = vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&corners, false)]);
|
||||
let (d, _, sel, ids) = members_doc(path, 4, None);
|
||||
let paint = paint();
|
||||
let c = cx(&d, &sel, &paint);
|
||||
let mut t = ObjectsOnPathTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, 240.0, 0.0));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 220.0, 0.0));
|
||||
assert!(previewed_target(&a).is_none(), "nearer its own place: {a:?}");
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 80.0, 0.0));
|
||||
assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}");
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 80.0, 0.0)), vec![Action::Commit, choose(ids[2])]);
|
||||
}
|
||||
|
||||
/// A hidden or locked object has no widget, and a drag does not take its place: the first
|
||||
/// object, dragged onto the locked last one's place, stays at the nearest place it can take.
|
||||
#[test]
|
||||
fn a_locked_object_has_no_select_move_widget_and_keeps_its_place() {
|
||||
let (mut d, _, sel, ids) = three_doc();
|
||||
d.node_mut(ids[2]).unwrap().locked = true;
|
||||
assert_eq!(MemberWidget::all(&d, &sel, 1.0).iter().map(|w| w.member).collect::<Vec<_>>(), ids[..2]);
|
||||
let paint = paint();
|
||||
let c = cx(&d, &sel, &paint);
|
||||
let mut t = ObjectsOnPathTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, 400.0, 300.0));
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Up, 400.0, 300.0)).iter().all(|a| *a != choose(ids[2])), "no widget to press");
|
||||
t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0));
|
||||
assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 390.0, 300.0))), Some(ids[1].0));
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 390.0, 300.0)), vec![Action::Commit, choose(ids[0])]);
|
||||
}
|
||||
|
||||
/// Of widgets on the same point (no Space between the objects), a press takes the chosen
|
||||
/// object's, else the first one's; between places as near, a drag keeps the place it is at.
|
||||
#[test]
|
||||
fn stacked_select_move_widgets_take_the_chosen_one_and_ties_keep_the_place() {
|
||||
let (d, _, mut sel, ids) = members_doc(shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), 3, Some(0.0));
|
||||
let paint = paint();
|
||||
{
|
||||
let c = cx(&d, &sel, &paint);
|
||||
let mut t = ObjectsOnPathTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0));
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)), vec![choose(ids[0])]);
|
||||
// Every place is as far as the others: the drag stays where it is.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0));
|
||||
assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 200.0, 300.0))).is_none());
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 200.0, 300.0)), vec![Action::Cancel, choose(ids[0])]);
|
||||
}
|
||||
sel.on_path_member = Some(ids[2]);
|
||||
let c = cx(&d, &sel, &paint);
|
||||
let mut t = ObjectsOnPathTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0));
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)), vec![choose(ids[2])], "the chosen one");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,9 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path};
|
||||
use crate::objects_on_path::{MoveAllDrag, RotateAllDrag, SpaceDrag, over_move_all, over_rotate_all, over_space};
|
||||
use crate::objects_on_path::{
|
||||
MemberDrag, MoveAllDrag, RotateAllDrag, SpaceDrag, member_hover, over_member, over_move_all, over_rotate_all, over_space,
|
||||
};
|
||||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||||
use crate::pie::{self, PieDrag};
|
||||
use crate::rulerguide::GuideEdit;
|
||||
@@ -135,6 +137,8 @@ enum State {
|
||||
Space(SpaceDrag),
|
||||
/// Dragging an Objects on Path group's Move All widget.
|
||||
MoveAll(MoveAllDrag),
|
||||
/// Pressing or dragging the Select/Move widget of an object of an Objects on Path group.
|
||||
Member(MemberDrag),
|
||||
/// Move or rotate a live mirror axis.
|
||||
Mirror(crate::mirror::MirrorDrag),
|
||||
/// Drag a grid repeat's row, column or corner handle or a spacing slider.
|
||||
@@ -753,7 +757,7 @@ impl Tool for SelectionTool {
|
||||
|
||||
/// E during a marquee drag switches the marquee between the objects it touches and only the
|
||||
/// objects inside it. Escape during a drag (a move or an Alt-drag copy, a bounding-box scale or
|
||||
/// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space or Move All widget, the brackets of type on a path, a mirror
|
||||
/// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space, Move All or Select/Move widget, the brackets of type on a path, a mirror
|
||||
/// axis, a grid repeat's handles, a ruler guide) cancels it: what it changed goes back, no copy
|
||||
/// is left, and the tool waits for the next press. With the pointer loaded with text, Escape
|
||||
/// drops the text.
|
||||
@@ -845,6 +849,7 @@ impl Tool for SelectionTool {
|
||||
State::RotateAll(d) => d.adjusting(),
|
||||
State::Space(d) => d.adjusting(),
|
||||
State::MoveAll(d) => d.adjusting(),
|
||||
State::Member(d) => d.adjusting(),
|
||||
State::Bracket(b) => b.adjusting(),
|
||||
State::Mirror(d) => d.adjusting(),
|
||||
State::GridRepeat(d) => d.adjusting(),
|
||||
@@ -1042,6 +1047,15 @@ impl Tool for SelectionTool {
|
||||
self.state = State::Idle;
|
||||
d.finish()
|
||||
}
|
||||
(PointerKind::Drag, State::Member(mut d)) => {
|
||||
let out = d.drag(cx, p);
|
||||
self.state = State::Member(d);
|
||||
out
|
||||
}
|
||||
(PointerKind::Up, State::Member(d)) => {
|
||||
self.state = State::Idle;
|
||||
d.finish()
|
||||
}
|
||||
(PointerKind::Drag, State::Bracket(mut b)) => {
|
||||
let out = b.drag(cx, p, m.cmd);
|
||||
self.state = State::Bracket(b);
|
||||
@@ -1110,6 +1124,9 @@ impl Tool for SelectionTool {
|
||||
State::Loaded { press: Some((start, cur)), .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))),
|
||||
_ => {}
|
||||
}
|
||||
if let (Some(p), State::Idle) = (self.bracket_hover, &self.state) {
|
||||
o.extend(member_hover(cx, p));
|
||||
}
|
||||
o.extend(self.guides.iter().cloned());
|
||||
o.extend(self.guide.overlays(cx));
|
||||
if let (Some((q, label)), State::Idle, true) = (self.hover, &self.state, cx.smart_guides) {
|
||||
@@ -1158,7 +1175,7 @@ impl Tool for SelectionTool {
|
||||
State::Pie(_) => return Cursor::ArrowHollow,
|
||||
State::RotateAll(_) => return Cursor::Rotate,
|
||||
State::Space(_) => return Cursor::ArrowHollow,
|
||||
State::MoveAll(_) => return Cursor::Move,
|
||||
State::MoveAll(_) | State::Member(_) => return Cursor::Move,
|
||||
State::Bracket(_) => return Cursor::PathBracket,
|
||||
State::Loaded { .. } => return Cursor::LoadedText,
|
||||
_ => {}
|
||||
@@ -1184,7 +1201,7 @@ impl Tool for SelectionTool {
|
||||
if over_space(cx, p) {
|
||||
return Cursor::ArrowHollow;
|
||||
}
|
||||
if over_move_all(cx, p) {
|
||||
if over_move_all(cx, p) || over_member(cx, p) {
|
||||
return Cursor::Move;
|
||||
}
|
||||
if over_bracket(cx, p) {
|
||||
@@ -1536,7 +1553,7 @@ impl SelectionTool {
|
||||
self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None };
|
||||
return Some(vec![]);
|
||||
}
|
||||
// 1. Live Corners widgets, pie widgets, the Rotate All, Space and Move All widgets and type on a path's brackets, then the
|
||||
// 1. Live Corners widgets, pie widgets, the Rotate All, Space, Move All and Select/Move widgets and type on a path's brackets, then the
|
||||
// bounding-box handles of the current selection.
|
||||
if let Some(c) = CornerDrag::hit(cx, ev, false) {
|
||||
self.state = State::Corner(c);
|
||||
@@ -1558,6 +1575,10 @@ impl SelectionTool {
|
||||
self.state = State::MoveAll(d);
|
||||
return Some(vec![]);
|
||||
}
|
||||
if let Some(d) = MemberDrag::hit(cx, ev) {
|
||||
self.state = State::Member(d);
|
||||
return Some(vec![]);
|
||||
}
|
||||
if let Some(b) = BracketDrag::hit(cx, ev) {
|
||||
self.state = State::Bracket(b);
|
||||
return Some(vec![]);
|
||||
|
||||
@@ -238,14 +238,15 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
|
||||
rotate_all_widget(p, xf, tool, &st.doc, &st.selection);
|
||||
}
|
||||
|
||||
/// The Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one Objects on Path
|
||||
/// group selected). The Rotate All widget is joined by a line to the first object's attachment
|
||||
/// point. It stays while dragged.
|
||||
/// The Select/Move, Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one
|
||||
/// Objects on Path group selected). The Rotate All widget is joined by a line to the first object's
|
||||
/// attachment point. It stays while dragged.
|
||||
fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) {
|
||||
use vectorcraft_tools::objects_on_path::{ROTATE_ALL_RADIUS_PX, RotateAllWidget};
|
||||
if !matches!(tool, "selection" | "objectsOnPath") {
|
||||
return;
|
||||
}
|
||||
member_widgets(p, xf, doc, selection);
|
||||
if let Some(w) = RotateAllWidget::of(doc, selection, xf.zoom) {
|
||||
let (on, c, color) = (xf.to_screen(w.pivot), xf.to_screen(w.at), c32(doc.layer_color(w.group)));
|
||||
p.line_segment([on, c], Stroke::new(1.0, color));
|
||||
@@ -256,6 +257,23 @@ fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_d
|
||||
move_all_widget(p, xf, doc, selection);
|
||||
}
|
||||
|
||||
/// The Select/Move widgets: a ring on each object's attachment point, filled blue for the chosen
|
||||
/// object. They are drawn under the other widgets, which a press takes first.
|
||||
fn member_widgets(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) {
|
||||
use vectorcraft_tools::objects_on_path::{MEMBER_RADIUS_PX, MemberWidget};
|
||||
for w in MemberWidget::all(doc, selection, xf.zoom) {
|
||||
let c = xf.to_screen(w.at);
|
||||
if w.chosen {
|
||||
p.circle(c, MEMBER_RADIUS_PX, MEMBER_CHOSEN, Stroke::new(1.0, Color32::WHITE));
|
||||
} else {
|
||||
p.circle(c, MEMBER_RADIUS_PX, Color32::TRANSPARENT, Stroke::new(1.5, c32(doc.layer_color(w.group))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The fill of the chosen object's Select/Move widget.
|
||||
pub(super) const MEMBER_CHOSEN: Color32 = Color32::from_rgb(0x1f, 0x6f, 0xeb);
|
||||
|
||||
/// The Space widget (beside the Rotate All widget, with two or more objects on the path): a square
|
||||
/// joined by a line to the last object's attachment point. It stays while dragged.
|
||||
fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) {
|
||||
|
||||
@@ -158,8 +158,8 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
center_on_path_items(app, &mut items);
|
||||
}
|
||||
}
|
||||
// Dynamic Text turns on or off for type on a path and area type (Block), as the Type menu's item does;
|
||||
// left out for point type and type in a thread, which is VectorCraft's own rule
|
||||
// Dynamic Text turns on or off for type on a path and area type (Block), and turns point type into area
|
||||
// type with Block on, as the Type menu's item does; left out for type in a thread
|
||||
// (<https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/adjust-formatting-and-resize-text-with-dynamic-text.html>).
|
||||
if crate::menus::enabled(app, "type.dynamicText") {
|
||||
items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none()));
|
||||
|
||||
@@ -4898,6 +4898,38 @@ fn a_selected_objects_on_path_group_shows_its_move_all_widget_when_it_has_room()
|
||||
assert_eq!(widgets(&mut app), 1, "one object");
|
||||
}
|
||||
|
||||
/// A selected Objects on Path group shows a Select/Move widget on each object's attachment point
|
||||
/// with the Selection and Objects on Path tools, the chosen object's filled blue.
|
||||
#[test]
|
||||
fn a_selected_objects_on_path_group_shows_a_select_move_widget_per_object() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let id = |v: serde_json::Value| v["id"].as_u64().unwrap();
|
||||
let line = id(app.session.execute("shape.line", &json!({"x1": 20, "y1": 200, "x2": 380, "y2": 200})).unwrap());
|
||||
let a = id(app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap());
|
||||
let b = id(app.session.execute("shape.rectangle", &json!({"x": 80, "y": 40, "width": 20, "height": 20})).unwrap());
|
||||
let c = id(app.session.execute("shape.rectangle", &json!({"x": 120, "y": 40, "width": 20, "height": 20})).unwrap());
|
||||
app.run("select.set", json!({"ids": [a, b, c]})).unwrap();
|
||||
app.session.execute("object.objectsOnPath.attach", &json!({"path": line})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let r = vectorcraft_tools::objects_on_path::MEMBER_RADIUS_PX;
|
||||
let widgets = |app: &mut VectorcraftApp, fill: Color32| {
|
||||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(k) if k.radius == r && k.fill == fill)).count()
|
||||
};
|
||||
let (ring, chosen) = (Color32::TRANSPARENT, super::overlays::MEMBER_CHOSEN);
|
||||
app.select_tool("selection");
|
||||
assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (3, 0));
|
||||
app.run("object.objectsOnPath.selectMember", json!({"member": b})).unwrap();
|
||||
assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (2, 1), "the chosen one is filled");
|
||||
app.select_tool("objectsOnPath");
|
||||
assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (2, 1));
|
||||
app.select_tool("directSelection");
|
||||
assert_eq!(widgets(&mut app, ring) + widgets(&mut app, chosen), 0);
|
||||
app.select_tool("selection");
|
||||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||||
assert_eq!(widgets(&mut app, ring) + widgets(&mut app, chosen), 0, "an object in the group, not the group");
|
||||
}
|
||||
|
||||
/// The shapes Type → Show Hidden Characters draws in one frame, seen through `xf`.
|
||||
fn hidden_chars(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) -> Vec<Shape> {
|
||||
let raw = egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() };
|
||||
|
||||
@@ -253,7 +253,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
|
||||
}
|
||||
let mut inspected = json!({
|
||||
"scriptRunning": app.session.script_running(),
|
||||
"scriptReport": app.session.script_report(),
|
||||
"scriptReport": app.session.shown_script_report(),
|
||||
"tool": app.session.tool_id(),
|
||||
"toolOptions": app.session.tool_options(),
|
||||
"ui": ui,
|
||||
|
||||
@@ -21,12 +21,13 @@ fn the_thai_filter_lists_fonts_with_thai() {
|
||||
/// stay clear of it.
|
||||
#[test]
|
||||
fn the_scroll_bar_leaves_the_stars_clear() {
|
||||
// Every family starred, so each row draws its star.
|
||||
// Every family starred, so each row draws its star. Tests running alongside add fonts and
|
||||
// composite fonts to the process-wide database, which the menu lists too: the ★ filter keeps
|
||||
// the rows to the families starred here.
|
||||
let all = vectorcraft_text::FontDb::global().families();
|
||||
let families = all.len();
|
||||
let ctx = egui::Context::default();
|
||||
let area = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(300.0, 160.0));
|
||||
let mut st = super::MenuState::default();
|
||||
let mut st = super::MenuState { favorites_only: true, ..Default::default() };
|
||||
let look = super::MenuLook { samples: false, favorites: &all, ..Default::default() };
|
||||
let mut stars = vec![];
|
||||
for _ in 0..3 {
|
||||
@@ -45,6 +46,7 @@ fn the_scroll_bar_leaves_the_stars_clear() {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let families = st.rows.len();
|
||||
assert!(!stars.is_empty() || families == 0, "{families} families, no row stars drawn");
|
||||
// The rows don't fill the list without enough families to scroll, and then there is no bar.
|
||||
if families as f32 * look.row > area.height() {
|
||||
@@ -62,6 +64,13 @@ fn family(name: &str) -> Row {
|
||||
Row::Family(name.to_string())
|
||||
}
|
||||
|
||||
/// The families the font menu lists that stay listed, in its order: the menu's list without the
|
||||
/// composite fonts at its top, which tests running alongside define and remove in the process-wide
|
||||
/// database.
|
||||
fn lasting_families() -> Vec<String> {
|
||||
vectorcraft_text::FontDb::global().family_list().iter().filter(|f| !f.starts_with('.')).cloned().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_fonts_are_first_available_unique_and_expandable() {
|
||||
let names: Vec<_> = ["Alpha", "Bravo", "Charlie"].map(str::to_string).into();
|
||||
@@ -86,7 +95,7 @@ fn recent_fonts_are_first_available_unique_and_expandable() {
|
||||
|
||||
#[test]
|
||||
fn recent_section_rebuilds_and_arrows_skip_its_headings() {
|
||||
let names = vectorcraft_text::FontDb::global().menu_family_list();
|
||||
let names = lasting_families();
|
||||
if names.len() < 3 {
|
||||
return;
|
||||
}
|
||||
@@ -135,7 +144,7 @@ fn recent_section_rebuilds_and_arrows_skip_its_headings() {
|
||||
|
||||
#[test]
|
||||
fn recent_fonts_obey_search_and_favorite_filters() {
|
||||
let names = vectorcraft_text::FontDb::global().menu_family_list();
|
||||
let names = lasting_families();
|
||||
if names.len() < 3 {
|
||||
return;
|
||||
}
|
||||
@@ -176,7 +185,7 @@ fn recent_fonts_obey_search_and_favorite_filters() {
|
||||
|
||||
#[test]
|
||||
fn opening_on_another_font_keeps_the_recent_section_visible() {
|
||||
let names = vectorcraft_text::FontDb::global().menu_family_list();
|
||||
let names = lasting_families();
|
||||
let Some(current) = names.last() else { return };
|
||||
let recent = vec![names[0].clone()];
|
||||
let ctx = egui::Context::default();
|
||||
@@ -244,7 +253,7 @@ fn the_wheel_steps_one_family_per_notch_and_stops_at_the_ends() {
|
||||
/// others in the database's order, under the favourites (and other) filters.
|
||||
#[test]
|
||||
fn the_wheel_follows_the_menus_order_and_filters() {
|
||||
let names = vectorcraft_text::FontDb::global().menu_family_list();
|
||||
let names = lasting_families();
|
||||
if names.len() < 3 {
|
||||
return;
|
||||
}
|
||||
@@ -770,8 +779,9 @@ fn the_variable_filter_lists_only_variable_families() {
|
||||
assert_eq!(frame(&mut st, vec![], &favorites).0, [TWO_AXIS_FAMILY]);
|
||||
st.favorites_only = false;
|
||||
// Off again: every family is listed.
|
||||
let lasting = lasting_families();
|
||||
let again = click(&mut st, &favorites);
|
||||
assert!(!st.variable_only);
|
||||
assert_eq!(again, frame(&mut st, vec![], &[]).0);
|
||||
assert!(lasting.iter().all(|f| has(&again, f)), "{again:?}");
|
||||
assert!(has(&again, TWO_AXIS_FAMILY) && has(&again, "Source Sans 3") && has(&again, "JetBrains Mono"), "{again:?}");
|
||||
}
|
||||
|
||||
@@ -5860,6 +5860,7 @@ theme Appearance Външен вид
|
||||
Ruby… Руби…
|
||||
Ruby Руби
|
||||
Geometric Guides Геометрични водачи
|
||||
Optical Effects Оптични ефекти
|
||||
Geometric Patterns Геометрични мостри
|
||||
Grit Textures Зърнести текстури
|
||||
Ease In Плавно начало
|
||||
@@ -6074,6 +6075,11 @@ axis Y Axis Y ос
|
||||
Grass Fan Ветрило за трева
|
||||
Pattern Borders Бордюри с мостри
|
||||
Scatter Shapes Разсеяни фигури
|
||||
Dry Media Сухи материали
|
||||
Chalk Grain Тебеширено зърно
|
||||
Graphite Pencil Графитен молив
|
||||
Wax Crayon Восъчен пастел
|
||||
Charcoal Dust Въглен прах
|
||||
Save Brush Library… Запази библиотека с четки…
|
||||
@msg no empty artboards to delete няма празни работни области за изтриване
|
||||
Scale Presets Пресети за мащаб
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5877,6 +5877,7 @@ mark-style Japanese Japanski
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometrijske vodilice
|
||||
Optical Effects Optički efekti
|
||||
Geometric Patterns Geometrijski uzorci
|
||||
Grit Textures Zrnaste teksture
|
||||
Ease In Ublaži ulaz
|
||||
@@ -6125,6 +6126,11 @@ axis Y Axis Y osa
|
||||
Grass Fan Lepeza za travu
|
||||
Pattern Borders Ivice od uzoraka
|
||||
Scatter Shapes Raspršeni oblici
|
||||
Dry Media Suhi mediji
|
||||
Chalk Grain Zrno krede
|
||||
Graphite Pencil Grafitna olovka
|
||||
Wax Crayon Voštana bojica
|
||||
Charcoal Dust Ugljena prašina
|
||||
Save Brush Library… Sačuvaj biblioteku četkica…
|
||||
@msg `{_1}` has no brushes to apply `{_1}` nema četkica za primjenu
|
||||
@msg missing `library` (see brush.libraries) nedostaje `library` (pogledajte brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2927,6 +2927,7 @@ digits Malayalam Malaiàlam
|
||||
Opens network connections Obre connexions de xarxa
|
||||
Open… Obre…
|
||||
Optical Òptic
|
||||
Optical Effects Efectes òptics
|
||||
Optical Margin Alignment Alineació òptica de marges
|
||||
Dynamic Text Text dinàmic
|
||||
Dynamic Text Presets Valors predefinits de text dinàmic
|
||||
@@ -3867,6 +3868,11 @@ digits Malayalam Malaiàlam
|
||||
Scatter Brush Options Opcions del pinzell de dispersió
|
||||
Scatter Graph Tool Eina de gràfic de dispersió
|
||||
Scatter Shapes Formes de dispersió
|
||||
Dry Media Tècniques seques
|
||||
Chalk Grain Gra de guix
|
||||
Graphite Pencil Llapis de grafit
|
||||
Wax Crayon Cera de colors
|
||||
Charcoal Dust Pols de carbó
|
||||
Scatter: Dispersió:
|
||||
Scissors Tisores
|
||||
Scissors Tool Eina de tisores
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6046,6 +6046,7 @@ mark-style Japanese Japonské
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometrická vodítka
|
||||
Optical Effects Optické efekty
|
||||
Geometric Patterns Geometrické vzorky
|
||||
Grit Textures Zrnité textury
|
||||
Ease In Pozvolný začátek
|
||||
@@ -6337,6 +6338,11 @@ axis Y Axis osa Y
|
||||
Grass Fan Vějíř na trávu
|
||||
Pattern Borders Vzorkové okraje
|
||||
Scatter Shapes Rozptýlené tvary
|
||||
Dry Media Suché techniky
|
||||
Chalk Grain Křídové zrno
|
||||
Graphite Pencil Grafitová tužka
|
||||
Wax Crayon Vosková pastelka
|
||||
Charcoal Dust Uhlový prach
|
||||
Save Brush Library… Uložit knihovnu štětců…
|
||||
@msg `{_1}` has no brushes to apply `{_1}` nemá žádné štětce k použití
|
||||
@msg missing `library` (see brush.libraries) chybí `library` (viz brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5875,6 +5875,7 @@ swatch Bright Violet Kraftig violet
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometriske hjælpelinjer
|
||||
Optical Effects Optiske effekter
|
||||
Geometric Patterns Geometriske mønstre
|
||||
Grit Textures Kornede teksturer
|
||||
Ease In Langsom start
|
||||
@@ -6089,6 +6090,11 @@ axis Y Axis Y-akse
|
||||
Grass Fan Græsvifte
|
||||
Pattern Borders Mønsterborter
|
||||
Scatter Shapes Spredte former
|
||||
Dry Media Tørre medier
|
||||
Chalk Grain Kridtkorn
|
||||
Graphite Pencil Grafitblyant
|
||||
Wax Crayon Vokskridt
|
||||
Charcoal Dust Kulstøv
|
||||
Save Brush Library… Gem penselbibliotek…
|
||||
@msg no empty artboards to delete ingen tomme tegneflader at slette
|
||||
Scale Presets Skaleringsforudindstillinger
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5635,6 +5635,7 @@ effect Soft Weich
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometrische Hilfslinien
|
||||
Optical Effects Optische Effekte
|
||||
Geometric Patterns Geometrische Muster
|
||||
Grit Textures Körnige Texturen
|
||||
Ease In Langsam starten
|
||||
@@ -5928,6 +5929,11 @@ axis Y Axis Y-Achse
|
||||
Grass Fan Grasfächer
|
||||
Pattern Borders Musterbordüren
|
||||
Scatter Shapes Streuformen
|
||||
Dry Media Trockene Medien
|
||||
Chalk Grain Kreidekorn
|
||||
Graphite Pencil Graphitstift
|
||||
Wax Crayon Wachsmalstift
|
||||
Charcoal Dust Kohlestaub
|
||||
Save Brush Library… Pinsel-Bibliothek speichern…
|
||||
@msg `{_1}` has no brushes to apply `{_1}` hat keine Pinsel zum Anwenden
|
||||
@msg missing `library` (see brush.libraries) `library` fehlt (siehe brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5997,6 +5997,7 @@ effect Points Σημεία
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Γεωμετρικοί οδηγοί
|
||||
Optical Effects Οπτικά εφέ
|
||||
Geometric Patterns Γεωμετρικά μοτίβα
|
||||
Grit Textures Κοκκώδεις υφές
|
||||
Ease In Ομαλή αρχή
|
||||
@@ -6288,6 +6289,11 @@ axis Y Axis Άξονας Y
|
||||
Grass Fan Βεντάλια για γρασίδι
|
||||
Pattern Borders Περιθώρια μοτίβου
|
||||
Scatter Shapes Σχήματα διασποράς
|
||||
Dry Media Ξηρά μέσα
|
||||
Chalk Grain Κόκκος κιμωλίας
|
||||
Graphite Pencil Μολύβι γραφίτη
|
||||
Wax Crayon Κηρομπογιά
|
||||
Charcoal Dust Σκόνη κάρβουνου
|
||||
Save Brush Library… Αποθήκευση βιβλιοθήκης πινέλων…
|
||||
@msg `{_1}` has no brushes to apply Η `{_1}` δεν έχει πινέλα για εφαρμογή
|
||||
@msg missing `library` (see brush.libraries) λείπει το `library` (δείτε brush.libraries)
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5988,6 +5988,7 @@ mark-style Japanese Japonesas
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Guías geométricas
|
||||
Optical Effects Efectos ópticos
|
||||
Geometric Patterns Motivos geométricos
|
||||
Grit Textures Texturas granuladas
|
||||
Ease In Entrada suave
|
||||
@@ -6279,6 +6280,11 @@ axis Y Axis Eje Y
|
||||
Grass Fan Abanico de hierba
|
||||
Pattern Borders Bordes de motivo
|
||||
Scatter Shapes Formas de dispersión
|
||||
Dry Media Técnicas secas
|
||||
Chalk Grain Grano de tiza
|
||||
Graphite Pencil Lápiz de grafito
|
||||
Wax Crayon Cera de colores
|
||||
Charcoal Dust Polvo de carboncillo
|
||||
Save Brush Library… Guardar biblioteca de pinceles…
|
||||
@msg `{_1}` has no brushes to apply `{_1}` no tiene pinceles para aplicar
|
||||
@msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6076,6 +6076,7 @@ swatch Bright Violet Kirkas violetti
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometriset apuviivat
|
||||
Optical Effects Optiset tehosteet
|
||||
Geometric Patterns Geometriset kuviot
|
||||
Grit Textures Rakeiset tekstuurit
|
||||
Ease In Hidas alku
|
||||
@@ -6367,6 +6368,11 @@ axis Y Axis Y-akseli
|
||||
Grass Fan Ruohoviuhka
|
||||
Pattern Borders Kuvioreunukset
|
||||
Scatter Shapes Sirontamuodot
|
||||
Dry Media Kuivat välineet
|
||||
Chalk Grain Liitujyvä
|
||||
Graphite Pencil Grafiittikynä
|
||||
Wax Crayon Vahaliitu
|
||||
Charcoal Dust Hiilipöly
|
||||
Save Brush Library… Tallenna sivellinkirjasto…
|
||||
@msg `{_1}` has no brushes to apply Kohteessa `{_1}` ei ole käytettäviä siveltimiä
|
||||
@msg missing `library` (see brush.libraries) `library` puuttuu (katso brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6041,6 +6041,7 @@ theme Appearance Apparence
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Repères géométriques
|
||||
Optical Effects Effets optiques
|
||||
Geometric Patterns Motifs géométriques
|
||||
Grit Textures Textures granuleuses
|
||||
Ease In Accélération progressive
|
||||
@@ -6332,6 +6333,11 @@ axis Y Axis Axe Y
|
||||
Grass Fan Éventail à herbe
|
||||
Pattern Borders Bordures de motif
|
||||
Scatter Shapes Formes de dispersion
|
||||
Dry Media Techniques sèches
|
||||
Chalk Grain Grain de craie
|
||||
Graphite Pencil Crayon graphite
|
||||
Wax Crayon Craie grasse
|
||||
Charcoal Dust Poussière de fusain
|
||||
Save Brush Library… Enregistrer la bibliothèque de formes…
|
||||
@msg `{_1}` has no brushes to apply `{_1}` n'a aucune forme à appliquer
|
||||
@msg missing `library` (see brush.libraries) `library` manquant (voir brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -434,6 +434,11 @@ antiAlias Sharp शार्प
|
||||
Plasma Arc प्लाज़्मा चाप
|
||||
Charge Wave चार्ज तरंग
|
||||
Spark Trail चिंगारी पथ
|
||||
Dry Media सूखे माध्यम
|
||||
Chalk Grain चॉक के दाने
|
||||
Graphite Pencil ग्रेफाइट पेंसिल
|
||||
Wax Crayon मोम क्रेयॉन
|
||||
Charcoal Dust चारकोल धूल
|
||||
Art using a deleted global swatch keeps its colour. हटाए गए ग्लोबल स्वॉच वाला आर्टवर्क अपना रंग बनाए रखता है।
|
||||
Artboard आर्टबोर्ड
|
||||
Artboard {n} of {total} {total} में से आर्टबोर्ड {n}
|
||||
@@ -3183,6 +3188,7 @@ antiAlias Sharp शार्प
|
||||
Opens network connections नेटवर्क कनेक्शन ओपन करें
|
||||
OpenType Features ओपन टाइप सुविधाएँ
|
||||
Optical ऑप्टिकल
|
||||
Optical Effects ऑप्टिकल इफ़ेक्ट्स
|
||||
Optical Margin Alignment ऑप्टिकल मार्जिन संरेखण
|
||||
Dynamic Text डायनैमिक टेक्स्ट
|
||||
Dynamic Text Presets डायनैमिक टेक्स्ट प्रीसेट
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6029,6 +6029,7 @@ imageTrace Outline Körvonal
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometriai segédvonalak
|
||||
Optical Effects Optikai effektusok
|
||||
Geometric Patterns Geometriai mintázatok
|
||||
Grit Textures Szemcsés textúrák
|
||||
Ease In Lassú kezdés
|
||||
@@ -6320,6 +6321,11 @@ axis Y Axis Y tengely
|
||||
Grass Fan Fűlegyező
|
||||
Pattern Borders Mintázatszegélyek
|
||||
Scatter Shapes Szórt alakzatok
|
||||
Dry Media Száraz technikák
|
||||
Chalk Grain Krétaszemcse
|
||||
Graphite Pencil Grafitceruza
|
||||
Wax Crayon Zsírkréta
|
||||
Charcoal Dust Szénpor
|
||||
Save Brush Library… Ecsetkönyvtár mentése…
|
||||
@msg `{_1}` has no brushes to apply A(z) `{_1}` nem tartalmaz alkalmazható ecsetet
|
||||
@msg missing `library` (see brush.libraries) hiányzik a `library` (lásd: brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5875,6 +5875,7 @@ swatch Bright Violet Violet cerah
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Panduan Geometris
|
||||
Optical Effects Efek Optis
|
||||
Geometric Patterns Pola Geometris
|
||||
Grit Textures Tekstur Butiran
|
||||
Ease In Awal Lambat
|
||||
@@ -6089,6 +6090,11 @@ axis Y Axis Sumbu Y
|
||||
Grass Fan Kipas rumput
|
||||
Pattern Borders Bingkai pola
|
||||
Scatter Shapes Bentuk sebar
|
||||
Dry Media Media kering
|
||||
Chalk Grain Butiran kapur
|
||||
Graphite Pencil Pensil grafit
|
||||
Wax Crayon Krayon lilin
|
||||
Charcoal Dust Debu arang
|
||||
Save Brush Library… Simpan pustaka kuas…
|
||||
@msg no empty artboards to delete tidak ada artboard kosong untuk dihapus
|
||||
Scale Presets Preset skala
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2388 and column 5.
|
@@ -5669,6 +5669,7 @@ lighting Light Luce
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Guide geometriche
|
||||
Optical Effects Effetti ottici
|
||||
Geometric Patterns Motivi geometrici
|
||||
Grit Textures Texture granulose
|
||||
Ease In Inizio graduale
|
||||
@@ -5962,6 +5963,11 @@ axis Y Axis Asse Y
|
||||
Grass Fan Ventaglio per erba
|
||||
Pattern Borders Bordi a motivo
|
||||
Scatter Shapes Forme a dispersione
|
||||
Dry Media Tecniche secche
|
||||
Chalk Grain Grana di gesso
|
||||
Graphite Pencil Matita di grafite
|
||||
Wax Crayon Pastello a cera
|
||||
Charcoal Dust Polvere di carboncino
|
||||
Save Brush Library… Salva libreria pennelli…
|
||||
@msg `{_1}` has no brushes to apply `{_1}` non ha pennelli da applicare
|
||||
@msg missing `library` (see brush.libraries) manca `library` (vedi brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5719,6 +5719,7 @@ lighting Light ライト
|
||||
Ruby… ルビ…
|
||||
Ruby ルビ
|
||||
Geometric Guides 幾何ガイド
|
||||
Optical Effects オプティカル効果
|
||||
Geometric Patterns 幾何パターン
|
||||
Grit Textures 粒状テクスチャ
|
||||
Ease In イーズイン
|
||||
@@ -6027,6 +6028,11 @@ axis Y Axis Y 軸
|
||||
Grass Fan 草描き扇筆
|
||||
Pattern Borders パターンボーダー
|
||||
Scatter Shapes 散布シェイプ
|
||||
Dry Media ドライメディア
|
||||
Chalk Grain チョークの粒子
|
||||
Graphite Pencil 黒鉛鉛筆
|
||||
Wax Crayon ワックスクレヨン
|
||||
Charcoal Dust 木炭の粉
|
||||
Save Brush Library… ブラシライブラリを保存…
|
||||
@msg `{_1}` has no brushes to apply `{_1}` に適用できるブラシがありません
|
||||
@msg missing `library` (see brush.libraries) `library` がありません (brush.libraries を参照)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5986,6 +5986,7 @@ imageTrace Outline კონტური
|
||||
Ruby… რუბი…
|
||||
Ruby რუბი
|
||||
Geometric Guides გეომეტრიული მიმმართველები
|
||||
Optical Effects ოპტიკური ეფექტები
|
||||
Geometric Patterns გეომეტრიული პატერნები
|
||||
Grit Textures მარცვლოვანი ტექსტურები
|
||||
Ease In ნელი დასაწყისი
|
||||
@@ -6288,6 +6289,11 @@ axis Y Axis Y ღერძი
|
||||
Grass Fan ბალახის მარაო
|
||||
Pattern Borders პატერნის ბორდიურები
|
||||
Scatter Shapes გაბნეული ფიგურები
|
||||
Dry Media მშრალი მასალები
|
||||
Chalk Grain ცარცის მარცვალი
|
||||
Graphite Pencil გრაფიტის ფანქარი
|
||||
Wax Crayon ცვილის ფანქარი
|
||||
Charcoal Dust ნახშირის მტვერი
|
||||
Save Brush Library… ფუნჯების ბიბლიოთეკის შენახვა…
|
||||
@msg `{_1}` has no brushes to apply `{_1}`-ში გამოსაყენებელი ფუნჯები არ არის
|
||||
@msg missing `library` (see brush.libraries) `library` აკლია (იხ. brush.libraries)
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5630,6 +5630,7 @@ lighting Light 광원
|
||||
Ruby… 루비…
|
||||
Ruby 루비
|
||||
Geometric Guides 기하학적 안내선
|
||||
Optical Effects 광학 효과
|
||||
Geometric Patterns 기하학적 패턴
|
||||
Grit Textures 입자 텍스처
|
||||
Ease In 서서히 시작
|
||||
@@ -5923,6 +5924,11 @@ axis Y Axis Y축
|
||||
Grass Fan 잔디 부채붓
|
||||
Pattern Borders 패턴 테두리
|
||||
Scatter Shapes 흩뿌리기 모양
|
||||
Dry Media 건식 재료
|
||||
Chalk Grain 분필 입자
|
||||
Graphite Pencil 흑연 연필
|
||||
Wax Crayon 왁스 크레용
|
||||
Charcoal Dust 목탄 가루
|
||||
Save Brush Library… 브러시 라이브러리 저장…
|
||||
@msg `{_1}` has no brushes to apply `{_1}`에 적용할 브러시가 없습니다
|
||||
@msg missing `library` (see brush.libraries) `library`가 없습니다(brush.libraries 참조)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5926,6 +5926,7 @@ swatch Bright Violet Felviolet
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometrische hulplijnen
|
||||
Optical Effects Optische effecten
|
||||
Geometric Patterns Geometrische patronen
|
||||
Grit Textures Korrelige texturen
|
||||
Ease In Langzaam beginnen
|
||||
@@ -6140,6 +6141,11 @@ axis Y Axis Y-as
|
||||
Grass Fan Graswaaier
|
||||
Pattern Borders Patroonranden
|
||||
Scatter Shapes Strooivormen
|
||||
Dry Media Droge media
|
||||
Chalk Grain Krijtkorrel
|
||||
Graphite Pencil Grafietpotlood
|
||||
Wax Crayon Waskrijt
|
||||
Charcoal Dust Houtskoolstof
|
||||
Save Brush Library… Penseelbibliotheek opslaan…
|
||||
@msg no empty artboards to delete geen lege tekengebieden om te verwijderen
|
||||
Scale Presets Schaalvoorinstellingen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5900,6 +5900,7 @@ mark-style Japanese Japońskie
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometryczne linie pomocnicze
|
||||
Optical Effects Efekty optyczne
|
||||
Geometric Patterns Wzorki geometryczne
|
||||
Grit Textures Tekstury ziarniste
|
||||
Ease In Łagodny początek
|
||||
@@ -6191,6 +6192,11 @@ axis Y Axis Oś Y
|
||||
Grass Fan Wachlarz do trawy
|
||||
Pattern Borders Obramowania wzorków
|
||||
Scatter Shapes Kształty rozproszone
|
||||
Dry Media Techniki suche
|
||||
Chalk Grain Ziarno kredy
|
||||
Graphite Pencil Ołówek grafitowy
|
||||
Wax Crayon Kredka woskowa
|
||||
Charcoal Dust Pył węglowy
|
||||
Save Brush Library… Zapisz bibliotekę pędzli…
|
||||
@msg `{_1}` has no brushes to apply `{_1}` nie ma pędzli do zastosowania
|
||||
@msg missing `library` (see brush.libraries) brak `library` (zobacz brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5961,6 +5961,7 @@ mark-style Japanese Japonesas
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Guias geométricas
|
||||
Optical Effects Efeitos ópticos
|
||||
Geometric Patterns Padrões geométricos
|
||||
Grit Textures Texturas granuladas
|
||||
Ease In Entrada suave
|
||||
@@ -6252,6 +6253,11 @@ axis Y Axis Eixo Y
|
||||
Grass Fan Leque de Grama
|
||||
Pattern Borders Bordas de Padrão
|
||||
Scatter Shapes Formas de Dispersão
|
||||
Dry Media Técnicas Secas
|
||||
Chalk Grain Grão de Giz
|
||||
Graphite Pencil Lápis de Grafite
|
||||
Wax Crayon Giz de Cera
|
||||
Charcoal Dust Pó de Carvão
|
||||
Save Brush Library… Salvar Biblioteca de Pincéis…
|
||||
@msg `{_1}` has no brushes to apply `{_1}` não tem pincéis para aplicar
|
||||
@msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -5655,6 +5655,7 @@ lighting Light Свет
|
||||
Ruby… Руби…
|
||||
Ruby Руби
|
||||
Geometric Guides Геометрические направляющие
|
||||
Optical Effects Оптические эффекты
|
||||
Geometric Patterns Геометрические узоры
|
||||
Grit Textures Зернистые текстуры
|
||||
Ease In Плавное начало
|
||||
@@ -5948,6 +5949,11 @@ axis Y Axis Ось Y
|
||||
Grass Fan Веер для травы
|
||||
Pattern Borders Узорные каймы
|
||||
Scatter Shapes Рассеянные фигуры
|
||||
Dry Media Сухие материалы
|
||||
Chalk Grain Меловое зерно
|
||||
Graphite Pencil Графитный карандаш
|
||||
Wax Crayon Восковой мелок
|
||||
Charcoal Dust Угольная пыль
|
||||
Save Brush Library… Сохранить библиотеку кистей…
|
||||
@msg `{_1}` has no brushes to apply В `{_1}` нет кистей для применения
|
||||
@msg missing `library` (see brush.libraries) не указан `library` (см. brush.libraries)
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6068,6 +6068,7 @@ font-script Japanese Japanska
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometriska stödlinjer
|
||||
Optical Effects Optiska effekter
|
||||
Geometric Patterns Geometriska mönster
|
||||
Grit Textures Korniga texturer
|
||||
Ease In Långsam start
|
||||
@@ -6354,6 +6355,11 @@ axis Y Axis Y-axel
|
||||
Grass Fan Grässolfjäder
|
||||
Pattern Borders Mönsterbårder
|
||||
Scatter Shapes Spridningsformer
|
||||
Dry Media Torra medier
|
||||
Chalk Grain Kritkorn
|
||||
Graphite Pencil Grafitpenna
|
||||
Wax Crayon Vaxkrita
|
||||
Charcoal Dust Koldamm
|
||||
Save Brush Library… Spara penselbibliotek…
|
||||
@msg `{_1}` has no brushes to apply `{_1}` har inga penslar att använda
|
||||
@msg missing `library` (see brush.libraries) `library` saknas (se brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -184,6 +184,20 @@ fn font_height_reference_labels_are_in_complete_catalogs() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Window › Brush Libraries menu, built from the built-in library table, names each library.
|
||||
#[test]
|
||||
fn built_in_brush_library_names_are_in_complete_catalogs() {
|
||||
let mut bad = vec![];
|
||||
for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) {
|
||||
let missing: Vec<&str> =
|
||||
vectorcraft_engine::cmd::brushlib::BUILTIN.iter().map(|b| b.name).filter(|l| lang.catalog().plain(l).is_none()).collect();
|
||||
if !missing.is_empty() {
|
||||
bad.push(format!("{} is missing {missing:?}", lang.code));
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "{}", bad.join("\n"));
|
||||
}
|
||||
|
||||
/// Preference choices and the marquee's E hint share the same translated labels.
|
||||
#[test]
|
||||
fn marquee_selection_labels_are_in_complete_catalogs() {
|
||||
|
||||
@@ -6041,6 +6041,7 @@ axis Y Axis Y ekseni
|
||||
Snap to Tangent Teğete Yasla
|
||||
Snap to Perpendicular Dikeye Yasla
|
||||
Geometric Guides Geometrik Kılavuzlar
|
||||
Optical Effects Optik Efektler
|
||||
Geometric Patterns Geometrik Desenler
|
||||
Grit Textures Grenli Dokular
|
||||
@msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır
|
||||
@@ -6293,6 +6294,11 @@ axis Y Axis Y ekseni
|
||||
Grass Fan Çim Yelpazesi
|
||||
Pattern Borders Desen Kenarlıkları
|
||||
Scatter Shapes Dağılım Şekilleri
|
||||
Dry Media Kuru Teknikler
|
||||
Chalk Grain Tebeşir Dokusu
|
||||
Graphite Pencil Grafit Kalem
|
||||
Wax Crayon Mum Boya
|
||||
Charcoal Dust Kömür Tozu
|
||||
Save Brush Library… Fırça Kitaplığını Kaydet…
|
||||
@msg `{_1}` has no brushes to apply `{_1}` içinde uygulanacak fırça yok
|
||||
@msg missing `library` (see brush.libraries) `library` eksik (bkz. brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -5649,6 +5649,7 @@ lighting Light Світло
|
||||
Ruby… Рубі…
|
||||
Ruby Рубі
|
||||
Geometric Guides Геометричні напрямні
|
||||
Optical Effects Оптичні ефекти
|
||||
Geometric Patterns Геометричні візерунки
|
||||
Grit Textures Зернисті текстури
|
||||
Ease In Плавний початок
|
||||
@@ -5942,6 +5943,11 @@ axis Y Axis Вісь Y
|
||||
Grass Fan Віяло для трави
|
||||
Pattern Borders Візерункові облямівки
|
||||
Scatter Shapes Розсіяні фігури
|
||||
Dry Media Сухі матеріали
|
||||
Chalk Grain Крейдяне зерно
|
||||
Graphite Pencil Графітний олівець
|
||||
Wax Crayon Восковий олівець
|
||||
Charcoal Dust Вугільний пил
|
||||
Save Brush Library… Зберегти бібліотеку пензлів…
|
||||
@msg `{_1}` has no brushes to apply У `{_1}` немає пензлів для застосування
|
||||
@msg missing `library` (see brush.libraries) не вказано `library` (див. brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -5650,6 +5650,7 @@ theme Appearance Giao diện
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Geometric Guides Đường dẫn hình học
|
||||
Optical Effects Hiệu ứng quang học
|
||||
Geometric Patterns Họa tiết hình học
|
||||
Grit Textures Kết cấu hạt
|
||||
Ease In Chậm lúc đầu
|
||||
@@ -5943,6 +5944,11 @@ axis Y Axis Trục Y
|
||||
Grass Fan Cọ quạt vẽ cỏ
|
||||
Pattern Borders Viền họa tiết
|
||||
Scatter Shapes Hình phân tán
|
||||
Dry Media Chất liệu khô
|
||||
Chalk Grain Hạt phấn
|
||||
Graphite Pencil Bút chì than chì
|
||||
Wax Crayon Bút sáp màu
|
||||
Charcoal Dust Bụi than
|
||||
Save Brush Library… Lưu thư viện cọ…
|
||||
@msg `{_1}` has no brushes to apply `{_1}` không có cọ nào để áp dụng
|
||||
@msg missing `library` (see brush.libraries) thiếu `library` (xem brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -5883,6 +5883,7 @@ lighting Light 光源
|
||||
Ruby… 注音…
|
||||
Ruby 注音
|
||||
Geometric Guides 几何参考线
|
||||
Optical Effects 光学效果
|
||||
Geometric Patterns 几何图案
|
||||
Grit Textures 颗粒纹理
|
||||
Ease In 缓入
|
||||
@@ -6175,6 +6176,11 @@ axis Y Axis Y 轴
|
||||
Grass Fan 画草扇形笔
|
||||
Pattern Borders 图案边框
|
||||
Scatter Shapes 散布形状
|
||||
Dry Media 干性画材
|
||||
Chalk Grain 粉笔颗粒
|
||||
Graphite Pencil 石墨铅笔
|
||||
Wax Crayon 蜡笔
|
||||
Charcoal Dust 炭粉
|
||||
Save Brush Library… 保存画笔库…
|
||||
@msg `{_1}` has no brushes to apply `{_1}` 中没有可应用的画笔
|
||||
@msg missing `library` (see brush.libraries) 缺少 `library`(请参阅 brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5687,6 +5687,7 @@ lighting Light 光源
|
||||
Ruby… 注音…
|
||||
Ruby 注音
|
||||
Geometric Guides 幾何參考線
|
||||
Optical Effects 光學效果
|
||||
Geometric Patterns 幾何圖樣
|
||||
Grit Textures 顆粒紋理
|
||||
Ease In 緩入
|
||||
@@ -5980,6 +5981,11 @@ axis Y Axis Y 軸
|
||||
Grass Fan 畫草扇形筆
|
||||
Pattern Borders 圖樣邊框
|
||||
Scatter Shapes 散佈形狀
|
||||
Dry Media 乾性畫材
|
||||
Chalk Grain 粉筆顆粒
|
||||
Graphite Pencil 石墨鉛筆
|
||||
Wax Crayon 蠟筆
|
||||
Charcoal Dust 炭粉
|
||||
Save Brush Library… 儲存筆刷資料庫…
|
||||
@msg `{_1}` has no brushes to apply `{_1}` 中沒有可套用的筆刷
|
||||
@msg missing `library` (see brush.libraries) 未提供 `library`(請參閱 brush.libraries)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -15,8 +15,12 @@ use crate::{FilePick, Services, VectorcraftApp, dialogs};
|
||||
|
||||
mod export;
|
||||
mod open;
|
||||
mod read_only_save;
|
||||
mod save;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_read_only_save;
|
||||
|
||||
pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, export_pdf, hide_exports, reveal, save_command_output};
|
||||
pub(crate) use export::{
|
||||
export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named,
|
||||
@@ -29,6 +33,7 @@ pub use open::{
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use open::{open_file, opened_with_notes};
|
||||
pub(crate) use save::save_current;
|
||||
pub use save::{ask_format_options, ask_pdf_options, remember_active_artboard, remember_view, save, save_pdf};
|
||||
pub(crate) use save::{export_path, is_web, notes_of, report_saved, show_with_notes, target_path, write_named, write_reporting, write_to};
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! The continuation of ordinary File > Save when its own location refuses writes (#6146).
|
||||
//! It reuses Save As, including extension and overwrite checks, under its own picker entry.
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::fileio::{self, SaveMode, SavePlan};
|
||||
|
||||
use crate::picks::{self, Entry};
|
||||
use crate::{FilePick, VectorcraftApp};
|
||||
|
||||
pub(super) struct Retry {
|
||||
path: Option<String>,
|
||||
pick: FilePick,
|
||||
params: Value,
|
||||
}
|
||||
|
||||
impl Retry {
|
||||
pub(super) fn new(plan: &SavePlan, folder: Option<String>) -> Self {
|
||||
let name = plan.path.as_deref().map(fileio::file_name).unwrap_or_else(|| plan.name.clone());
|
||||
let mut params = json!({ "format": plan.format.id, "options": plan.options });
|
||||
if let Some(modified) = plan.modified {
|
||||
params["modified"] = json!(modified);
|
||||
}
|
||||
Self { path: plan.path.clone(), pick: FilePick { name, folder, filters: vec![] }, params }
|
||||
}
|
||||
|
||||
/// None when this continuation is stale or another picker prevents it. The existing failed-save
|
||||
/// dialog then stays in charge; there is no queue of deferred Save As requests.
|
||||
pub(super) fn instead(self, app: &mut VectorcraftApp, uid: u64) -> Option<Result<Value, String>> {
|
||||
if app.automation_asked() || app.services.pick_save.is_none() || app.picks.is_waiting() || super::save::is_web(app) {
|
||||
return None;
|
||||
}
|
||||
let index = app.session.documents().iter().position(|st| st.uid == uid && st.path == self.path)?;
|
||||
if app.session.active_index() != Some(index) && app.run("document.activate", json!({ "index": index })).is_err() {
|
||||
return None;
|
||||
}
|
||||
let name = &self.pick.name;
|
||||
let status = format!("Cannot write to the location of “{name}”; choose where to save it");
|
||||
app.status(status.clone());
|
||||
let save = Rc::new(move |app: &mut VectorcraftApp| {
|
||||
// An agent can retarget this document while the sheet is open. Its answer must not
|
||||
// replace that later choice. The picker itself already checks the document's uid.
|
||||
if !app.session.active().is_some_and(|st| st.uid == uid && st.path == self.path) {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
super::save::save_suggesting(app, SaveMode::SaveAs, &self.params, false, Some(&self.pick))
|
||||
});
|
||||
let again = save.clone();
|
||||
let entry = Entry::Call(Rc::new(move |app| {
|
||||
if let Err(e) = again(app) {
|
||||
app.status(e);
|
||||
}
|
||||
}));
|
||||
let r = picks::as_own_entry(app, entry, |app| save(app));
|
||||
Some(match r {
|
||||
Err(e) if e == "cancelled" && app.picks.is_waiting() => {
|
||||
app.status(status);
|
||||
Ok(json!({ "pending": "fileDialog" }))
|
||||
}
|
||||
r => r,
|
||||
})
|
||||
}
|
||||
}
|
||||
+102
-19
@@ -241,6 +241,31 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result<SavePlan, Str
|
||||
fileio::save_plan(&app.session, mode, p).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Ordinary File > Save alone offers another destination when its own location refuses writes.
|
||||
/// Save Changes during Close/Quit continues to use [`save`].
|
||||
pub(crate) fn save_current(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
save_current_in(app, p, fileio::documents_dialog_folder())
|
||||
}
|
||||
|
||||
/// The same entry with a resolved Documents folder (also permits deterministic host-free tests).
|
||||
pub(super) fn save_current_in(app: &mut VectorcraftApp, p: &Value, folder: Option<String>) -> Result<Value, String> {
|
||||
remember_view(app);
|
||||
let first = plan(app, SaveMode::Save, &acknowledged(p))?;
|
||||
let own = !app.automation_asked()
|
||||
&& !is_web(app)
|
||||
&& p.get("path").is_none_or(Value::is_null)
|
||||
&& first.path.is_some()
|
||||
&& app.session.active().is_some_and(|st| !st.converted && !st.recovered && st.path == first.path && st.format == first.format.id);
|
||||
if !own {
|
||||
return save(app, SaveMode::Save, p, true);
|
||||
}
|
||||
if let Some(r) = first.path.as_deref().and_then(|path| ask_before_losing(app, path, "file.save", p)) {
|
||||
return Ok(r);
|
||||
}
|
||||
let retry = super::read_only_save::Retry::new(&first, folder);
|
||||
write_plan(app, first, ("file.save", p.clone()), Some(retry))
|
||||
}
|
||||
|
||||
/// File → Save, Save As…, Save a Copy…, Save as Template… and the options dialogs' OK. `p` is the
|
||||
/// engine command's `{path?, format?, options?, svg?}`.
|
||||
///
|
||||
@@ -253,6 +278,17 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result<SavePlan, Str
|
||||
/// → `{path, format, warnings…}` once written, or `{pending: <dialog kind>,
|
||||
/// path?}` while a dialog is open.
|
||||
pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result<Value, String> {
|
||||
save_suggesting(app, mode, p, ask_options, None)
|
||||
}
|
||||
|
||||
/// Reuse Save As with a captured name and folder, without changing the document before writing.
|
||||
pub(super) fn save_suggesting(
|
||||
app: &mut VectorcraftApp,
|
||||
mode: SaveMode,
|
||||
p: &Value,
|
||||
ask_options: bool,
|
||||
suggestion: Option<&FilePick>,
|
||||
) -> Result<Value, String> {
|
||||
remember_view(app);
|
||||
// Writing over the file the document was read from is asked about here, not refused.
|
||||
let first = plan(app, mode, &acknowledged(p))?;
|
||||
@@ -264,7 +300,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo
|
||||
if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") {
|
||||
return ask_format_options(app, mode, first.format, &path);
|
||||
}
|
||||
return write_plan(app, first, (save_command(mode), with_path(p, &path)));
|
||||
return write_plan(app, first, (save_command(mode), with_path(p, &path)), None);
|
||||
}
|
||||
if is_web(app) && ask_options {
|
||||
return Ok(open_options(app, mode.command(), first.format, &first.name, true));
|
||||
@@ -273,25 +309,35 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo
|
||||
SaveMode::Template => fileio::template_filters(first.format.id),
|
||||
_ => fileio::save_filters(first.format.id),
|
||||
});
|
||||
let pick = FilePick { name: first.name.clone(), folder: first.folder.clone(), filters };
|
||||
let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &first.name, file_type, first.format, mode))?;
|
||||
let pick = FilePick {
|
||||
name: suggestion.map_or_else(|| first.name.clone(), |s| s.name.clone()),
|
||||
folder: suggestion.map_or_else(|| first.folder.clone(), |s| s.folder.clone()),
|
||||
filters,
|
||||
};
|
||||
let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &pick.name, file_type, first.format, mode))?;
|
||||
// Only the read-only retry carries options for the original format through Save As.
|
||||
// A different format must use its own normal options flow instead.
|
||||
let changed_retry_format = suggestion.is_some() && fileio::format_for_name(&picked).is_some_and(|f| f.id != first.format.id);
|
||||
// The picked file type wins over a `format` param.
|
||||
let mut q = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("format");
|
||||
if changed_retry_format {
|
||||
o.remove("options");
|
||||
}
|
||||
o.insert("path".into(), json!(picked));
|
||||
}
|
||||
let chosen = plan(app, mode, &acknowledged(&q))?;
|
||||
if let Some(r) = chosen.path.as_deref().and_then(|path| ask_before_losing(app, path, save_command(mode), &q)) {
|
||||
return Ok(r);
|
||||
}
|
||||
if ask && asks_options(mode, chosen.format) {
|
||||
if (ask || changed_retry_format) && asks_options(mode, chosen.format) {
|
||||
let path = chosen.path.clone().unwrap_or_default();
|
||||
let r = ask_format_options(app, mode, chosen.format, &path);
|
||||
crate::dialogs::opened_by_panel(app);
|
||||
return r;
|
||||
}
|
||||
write_plan(app, chosen, (save_command(mode), q))
|
||||
write_plan(app, chosen, (save_command(mode), q), None)
|
||||
}
|
||||
|
||||
/// `p` (an object, else none) with `path`.
|
||||
@@ -471,7 +517,12 @@ pub(crate) fn saved_status(path: &str, r: &Value) -> String {
|
||||
/// A file it writes beside the one at its path that is there already, and that the document's last
|
||||
/// save did not write, is asked about before anything is written; `again` (a command and its params)
|
||||
/// runs the save again once the question's OK allows it ([`ask_to_replace_files`]).
|
||||
fn write_plan(app: &mut VectorcraftApp, plan: SavePlan, again: (&'static str, Value)) -> Result<Value, String> {
|
||||
fn write_plan(
|
||||
app: &mut VectorcraftApp,
|
||||
plan: SavePlan,
|
||||
again: (&'static str, Value),
|
||||
retry: Option<super::read_only_save::Retry>,
|
||||
) -> Result<Value, String> {
|
||||
let uid = app.session.active().ok_or("no document")?.uid;
|
||||
// Saves of one document finish in order.
|
||||
if app.background.busy_with(uid) {
|
||||
@@ -497,28 +548,60 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan, again: (&'static str, Va
|
||||
let enabled = app.session.prefs.background_save && target.is_some();
|
||||
// The work writes the file; the copy makes it the document's own afterwards.
|
||||
let done = job.clone();
|
||||
// Capture the caller before background work leaves the request: it must never open a panel
|
||||
// or error dialog later, when answering_control has been reset.
|
||||
let by_control = app.automation_asked();
|
||||
let location_refused = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let refused_by_work = location_refused.clone();
|
||||
let own_path = target.clone();
|
||||
let save_write = app.services.save_write.clone().filter(|_| !is_web(app));
|
||||
let save_stream = app.services.save_stream.clone().filter(|_| job.streams() && !is_web(app));
|
||||
// A native file streams into the file as it is encoded (desktop; not to a download).
|
||||
let stream = app.services.stream_shared.clone().filter(|_| job.streams() && !is_web(app));
|
||||
let (exists, allowed) = checks_replaced(app);
|
||||
let known: Vec<String> = allowed.iter().chain(app.picks.written_beside.get(&uid).into_iter().flatten()).cloned().collect();
|
||||
let work = move |write: Writer| {
|
||||
match stream {
|
||||
Some(stream) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)),
|
||||
None => job.write_unless(
|
||||
|paths| {
|
||||
let path = job.plan().path.as_deref().unwrap_or_default();
|
||||
let replaced = replaced_files(exists.as_ref(), path, &known, paths.iter().copied());
|
||||
(!replaced.is_empty()).then(|| json!({ "replaces": replaced }))
|
||||
},
|
||||
|p, b| write(p, b).map_err(EngineError::Other),
|
||||
),
|
||||
}
|
||||
.map_err(|e| e.to_string())
|
||||
let mut own_refused = false;
|
||||
let mut observed = |p: &str, r: vectorcraft_engine::Result<()>| {
|
||||
own_refused = own_path.as_deref() == Some(p) && matches!(&r, Err(EngineError::ReadOnlyLocation(_)));
|
||||
r
|
||||
};
|
||||
let check = |paths: &[&str]| {
|
||||
let path = job.plan().path.as_deref().unwrap_or_default();
|
||||
let replaced = replaced_files(exists.as_ref(), path, &known, paths.iter().copied());
|
||||
(!replaced.is_empty()).then(|| json!({ "replaces": replaced }))
|
||||
};
|
||||
let r = match (save_stream, save_write, stream) {
|
||||
(Some(stream), _, _) => job.write_streamed(&mut |p, fill| observed(p, stream(p, fill))),
|
||||
(_, Some(write), _) => job.write_unless(check, |p, b| observed(p, write(p, b))),
|
||||
(_, _, Some(stream)) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)),
|
||||
_ => job.write_unless(check, |p, b| write(p, b).map_err(EngineError::Other)),
|
||||
};
|
||||
r.map_err(|e| {
|
||||
if let EngineError::ReadOnlyLocation(reason) = &e {
|
||||
refused_by_work.store(own_refused, std::sync::atomic::Ordering::Relaxed);
|
||||
if !by_control {
|
||||
return reason.clone();
|
||||
}
|
||||
}
|
||||
e.to_string()
|
||||
})
|
||||
};
|
||||
let failed = target.clone().unwrap_or_default();
|
||||
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| {
|
||||
if r.is_err()
|
||||
&& location_refused.load(std::sync::atomic::Ordering::Relaxed)
|
||||
&& let Some(retry) = retry
|
||||
&& let Some(r) = retry.instead(app, uid)
|
||||
{
|
||||
return r;
|
||||
}
|
||||
// Not written: the document keeps its file, format and modified state, and the user is told.
|
||||
let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?;
|
||||
let r = r.inspect_err(|e| {
|
||||
if !by_control {
|
||||
report_write_error(app, false, &failed, e);
|
||||
}
|
||||
})?;
|
||||
// Nothing written either: asked first.
|
||||
if let Some(files) = r.get("replaces").and_then(Value::as_array) {
|
||||
let files: Vec<String> = files.iter().filter_map(Value::as_str).map(str::to_string).collect();
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Injected writers and picker answers: no native panel or filesystem permissions are needed.
|
||||
|
||||
use std::io::ErrorKind;
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::{EngineError, READ_ONLY_LOCATION, Session};
|
||||
|
||||
use super::{fileio, save};
|
||||
use crate::control::ControlRequest;
|
||||
use crate::tests::picks::off_the_ui_thread;
|
||||
use crate::{Services, VectorcraftApp, background, dialogs, picks};
|
||||
|
||||
const DOCUMENTS: &str = "/home/artist/Documents";
|
||||
|
||||
#[derive(Default)]
|
||||
struct Files {
|
||||
original: String,
|
||||
attempts: Vec<String>,
|
||||
kind: Option<ErrorKind>,
|
||||
fail_second: bool,
|
||||
}
|
||||
|
||||
fn write(files: &Mutex<Files>, path: &str) -> vectorcraft_engine::Result<()> {
|
||||
let mut files = files.lock().unwrap();
|
||||
files.attempts.push(path.into());
|
||||
if path == files.original || files.fail_second {
|
||||
let reason = format!("{path}: injected refusal");
|
||||
return Err(match files.kind {
|
||||
Some(ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem) => EngineError::ReadOnlyLocation(reason),
|
||||
_ => EngineError::Other(reason),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn app(format: &str, kind: ErrorKind) -> (VectorcraftApp, Arc<Mutex<Files>>) {
|
||||
let files = Arc::new(Mutex::new(Files { original: format!("/locked/Poster.{format}"), kind: Some(kind), ..Default::default() }));
|
||||
let typed = files.clone();
|
||||
let ordinary = files.clone();
|
||||
let services = Services {
|
||||
save_write: Some(Arc::new(move |path, _| write(&typed, path))),
|
||||
write: Some(Box::new(move |path, _| {
|
||||
write(&ordinary, path).map_err(|e| match e {
|
||||
EngineError::ReadOnlyLocation(reason) => reason,
|
||||
e => e.to_string(),
|
||||
})
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 40, "height": 40})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 1, "y": 1, "width": 10, "height": 10})).unwrap();
|
||||
let st = app.session.doc_mut().unwrap();
|
||||
st.path = Some(files.lock().unwrap().original.clone());
|
||||
st.format = fileio::format(format).unwrap().id;
|
||||
st.save_options = if format == "svg" {
|
||||
json!({"decimals": 2, "preserveEditing": false})
|
||||
} else {
|
||||
json!({"pdfCompatible": false, "compress": true, "preview": false})
|
||||
}
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
(app, files)
|
||||
}
|
||||
|
||||
fn state(app: &VectorcraftApp) -> (Option<String>, bool, &'static str, Value) {
|
||||
let st = app.session.doc().unwrap();
|
||||
(st.path.clone(), st.is_dirty(), st.format, json!(st.save_options))
|
||||
}
|
||||
|
||||
fn poll(app: &mut VectorcraftApp) {
|
||||
picks::poll(app, &egui::Context::default());
|
||||
}
|
||||
|
||||
fn normal_error(app: &VectorcraftApp) {
|
||||
let d = app.ui.dialog.as_ref().expect("a persistent error dialog");
|
||||
assert_eq!(d.kind, dialogs::confirm::MESSAGE);
|
||||
assert!(d.str("detail").contains("injected refusal"));
|
||||
assert!(!d.str("detail").contains(READ_ONLY_LOCATION));
|
||||
assert!(app.ui.told.is_empty(), "no duplicate error dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_fallback_prefills_save_as_and_cancel_keeps_the_failed_save_state() {
|
||||
for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem] {
|
||||
let (mut app, files) = app("svg", kind);
|
||||
let before = state(&app);
|
||||
let (shown, _) = off_the_ui_thread(&mut app.services);
|
||||
let r = save::save_current_in(&mut app, &json!({"modified": null}), Some(DOCUMENTS.into())).unwrap();
|
||||
assert_eq!(r["pending"], "fileDialog");
|
||||
let requests = shown.requests();
|
||||
let [picks::PickRequest::Save(pick)] = requests.as_slice() else { panic!("expected one save panel: {requests:?}") };
|
||||
assert_eq!(pick.name, "Poster.svg");
|
||||
assert_eq!(pick.folder.as_deref(), Some(DOCUMENTS));
|
||||
assert_eq!(pick.filters.first(), crate::FilePick::filters(fileio::save_filters("svg")).first());
|
||||
assert!(app.ui.status.contains("choose where to save it"));
|
||||
assert_eq!(state(&app), before);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
shown.answer(&[]);
|
||||
poll(&mut app);
|
||||
assert_eq!(state(&app), before);
|
||||
assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg"]);
|
||||
assert!(!app.picks.is_waiting());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_save_as_retargets_only_after_write_and_keeps_options() {
|
||||
let (mut app, files) = app("svg", ErrorKind::PermissionDenied);
|
||||
let before = state(&app);
|
||||
let uid = app.session.doc().unwrap().uid;
|
||||
let (shown, _) = off_the_ui_thread(&mut app.services);
|
||||
assert_eq!(app.run("file.save", json!({})).unwrap()["pending"], "fileDialog");
|
||||
// Hold the second write after the picker answers: the chosen path is not yet the document's.
|
||||
let (entered, writing) = mpsc::channel();
|
||||
let (release, released) = mpsc::channel();
|
||||
let released = Mutex::new(released);
|
||||
let f = files.clone();
|
||||
app.services.save_write = Some(Arc::new(move |path, _| {
|
||||
entered.send(()).unwrap();
|
||||
released.lock().unwrap().recv().unwrap();
|
||||
write(&f, path)
|
||||
}));
|
||||
app.services.write_shared = Some(Arc::new(|_, _| panic!("typed saves must use their own writer")));
|
||||
app.session.prefs.background_save = true;
|
||||
shown.answer(&["/home/artist/Documents/Poster.svg"]);
|
||||
poll(&mut app);
|
||||
writing.recv().unwrap();
|
||||
assert_eq!(state(&app), before);
|
||||
release.send(()).unwrap();
|
||||
background::wait_all(&mut app);
|
||||
let st = app.session.document_mut(uid).unwrap();
|
||||
assert_eq!(st.path.as_deref(), Some("/home/artist/Documents/Poster.svg"));
|
||||
assert!(!st.is_dirty());
|
||||
assert_eq!(st.format, before.2);
|
||||
assert_eq!(json!(st.save_options), before.3);
|
||||
assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg", "/home/artist/Documents/Poster.svg"]);
|
||||
assert!(shown.requests().is_empty() && app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_second_failure_shows_one_dialog_without_reopening_the_picker() {
|
||||
let (mut app, files) = app("svg", ErrorKind::PermissionDenied);
|
||||
files.lock().unwrap().fail_second = true;
|
||||
let before = state(&app);
|
||||
let (shown, _) = off_the_ui_thread(&mut app.services);
|
||||
app.run("file.save", json!({})).unwrap();
|
||||
shown.answer(&["/elsewhere/Poster.svg"]);
|
||||
poll(&mut app);
|
||||
normal_error(&app);
|
||||
assert_eq!(state(&app), before);
|
||||
assert!(shown.requests().is_empty() && !app.picks.is_waiting());
|
||||
assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg", "/elsewhere/Poster.svg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_other_errors_and_explicit_destinations_keep_the_dialog() {
|
||||
for (command, params, kind) in [
|
||||
("file.save", json!({}), ErrorKind::StorageFull),
|
||||
("file.save", json!({}), ErrorKind::NotFound),
|
||||
("file.save", json!({}), ErrorKind::Other),
|
||||
("file.save", json!({"path": "/locked/Poster.svg"}), ErrorKind::PermissionDenied),
|
||||
("file.saveAs", json!({"path": "/locked/Poster.svg", "options": {"decimals": 2}}), ErrorKind::PermissionDenied),
|
||||
("file.saveCopy", json!({"path": "/locked/Poster.svg", "options": {"decimals": 2}}), ErrorKind::PermissionDenied),
|
||||
("file.exportAs", json!({"path": "/locked/Poster.svg", "format": "svg"}), ErrorKind::PermissionDenied),
|
||||
] {
|
||||
let (mut app, _) = app("svg", kind);
|
||||
let before = state(&app);
|
||||
// A fallback would return pending, so no inline/native dialog is necessary here.
|
||||
app.services.pick_save = Some(Box::new(move |_| panic!("{command}: no fallback")));
|
||||
assert!(app.run(command, params).is_err(), "{command}");
|
||||
normal_error(&app);
|
||||
assert_eq!(state(&app), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_control_returns_the_prefix_and_never_opens_a_panel_even_after_completion() {
|
||||
for wait in [true, false] {
|
||||
let (mut app, _) = app("svg", ErrorKind::PermissionDenied);
|
||||
app.services.pick_save = Some(Box::new(|_| panic!("automation never picks")));
|
||||
app.services.write_shared = Some(Arc::new(|_, _| panic!("typed writer required")));
|
||||
app.session.prefs.background_save = true;
|
||||
let before = state(&app);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
app = app.with_control(rx);
|
||||
let (req, reply) = ControlRequest::new("app.save", json!({"wait": wait}));
|
||||
tx.send(req).unwrap();
|
||||
app.drain_control(&egui::Context::default());
|
||||
let reply = reply.try_recv().unwrap();
|
||||
if wait {
|
||||
assert_eq!(reply["ok"], false);
|
||||
assert!(reply["error"].as_str().unwrap().starts_with("READ_ONLY_LOCATION: "), "{reply}");
|
||||
} else {
|
||||
assert_eq!(reply["result"]["background"], true);
|
||||
assert!(!app.ui.answering_control);
|
||||
background::wait_all(&mut app);
|
||||
assert!(app.ui.status.contains("READ_ONLY_LOCATION: "));
|
||||
}
|
||||
assert_eq!(state(&app), before);
|
||||
assert!(app.ui.dialog.is_none() && app.ui.told.is_empty() && !app.picks.is_waiting());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_automation_continuation_keeps_the_prefix_and_never_opens_a_panel() {
|
||||
let (mut app, files) = app("svg", ErrorKind::PermissionDenied);
|
||||
let before = state(&app);
|
||||
app.services.pick_save = Some(Box::new(|_| panic!("automation never picks")));
|
||||
assert!(!app.automation_is_acting());
|
||||
let r = picks::as_automated(&mut app, true, |app| app.run("file.save", json!({})));
|
||||
assert!(r.unwrap_err().starts_with("READ_ONLY_LOCATION: "));
|
||||
assert_eq!(state(&app), before);
|
||||
assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg"]);
|
||||
assert!(app.ui.dialog.is_none() && app.ui.told.is_empty() && !app.picks.is_waiting());
|
||||
assert!(!app.automation_asked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_background_completion_resumes_the_original_uid_without_repeating_save() {
|
||||
for streamed in [false, true] {
|
||||
let format = if streamed { "vectorcraft" } else { "svg" };
|
||||
let (mut app, files) = app(format, ErrorKind::ReadOnlyFilesystem);
|
||||
let original = files.lock().unwrap().original.clone();
|
||||
let uid = app.session.doc().unwrap().uid;
|
||||
let (shown, _) = off_the_ui_thread(&mut app.services);
|
||||
if streamed {
|
||||
let f = files.clone();
|
||||
app.services.save_stream = Some(Arc::new(move |path, fill| {
|
||||
let mut bytes = Vec::new();
|
||||
fill(&mut bytes).unwrap();
|
||||
write(&f, path)
|
||||
}));
|
||||
app.services.save_write = Some(Arc::new(|_, _| panic!("native save must stream")));
|
||||
}
|
||||
app.services.write_shared = Some(Arc::new(|_, _| panic!("typed writer required")));
|
||||
app.session.prefs.background_save = true;
|
||||
assert_eq!(app.run("file.save", json!({})).unwrap()["background"], true);
|
||||
app.run("file.new", json!({"width": 20, "height": 20})).unwrap();
|
||||
let other = app.session.doc().unwrap().uid;
|
||||
background::wait_all(&mut app);
|
||||
assert_eq!(app.session.doc().unwrap().uid, uid);
|
||||
assert_eq!(shown.requests().len(), 1);
|
||||
// Switch again while the native panel is open: its answer belongs to the original uid.
|
||||
app.run("document.activate", json!({"index": 1})).unwrap();
|
||||
let chosen = format!("/elsewhere/Poster.{format}");
|
||||
shown.answer(&[&chosen]);
|
||||
poll(&mut app);
|
||||
background::wait_all(&mut app);
|
||||
assert_eq!(app.session.doc().unwrap().uid, uid);
|
||||
assert_eq!(app.session.doc().unwrap().path.as_deref(), Some(chosen.as_str()));
|
||||
assert!(!app.session.doc().unwrap().is_dirty());
|
||||
assert!(app.session.document_mut(other).unwrap().path.is_none());
|
||||
assert_eq!(files.lock().unwrap().attempts, [original, chosen]);
|
||||
assert!(shown.requests().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_busy_picker_and_save_changes_keep_the_existing_error_flow() {
|
||||
let (mut app, _) = app("svg", ErrorKind::PermissionDenied);
|
||||
let (shown, _) = off_the_ui_thread(&mut app.services);
|
||||
app.run("file.saveAs", json!({})).unwrap_err();
|
||||
assert_eq!(shown.requests().len(), 1);
|
||||
assert!(app.run("file.save", json!({})).is_err());
|
||||
normal_error(&app);
|
||||
assert_eq!(shown.requests().len(), 1);
|
||||
shown.answer(&[]);
|
||||
poll(&mut app);
|
||||
app.ui.dialog = None;
|
||||
let st = app.session.doc().unwrap();
|
||||
app.ui.dialog = Some(crate::state::Dialog::new(crate::unsaved::KIND, json!({"index": 0, "uid": st.uid, "then": "close"})));
|
||||
assert!(crate::unsaved::confirm(&mut app).is_err());
|
||||
normal_error(&app);
|
||||
assert!(shown.requests().is_empty());
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
assert!(app.session.doc().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_changed_format_asks_for_its_options_before_writing() {
|
||||
let (mut app, files) = app("svg", ErrorKind::PermissionDenied);
|
||||
let before = state(&app);
|
||||
let (shown, _) = off_the_ui_thread(&mut app.services);
|
||||
assert_eq!(app.run("file.save", json!({})).unwrap()["pending"], "fileDialog");
|
||||
shown.answer(&["/elsewhere/Poster.pdf"]);
|
||||
poll(&mut app);
|
||||
let dialog = app.ui.dialog.as_ref().expect("the picked format's Save dialog");
|
||||
assert_eq!(dialog.kind, "savePdf");
|
||||
assert_eq!(dialog.str("path"), "/elsewhere/Poster.pdf");
|
||||
assert_eq!(state(&app), before);
|
||||
assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg"]);
|
||||
}
|
||||
|
||||
/// Typed saves keep the sidecar preflight both before the refused write and at the retry's
|
||||
/// destination. Confirming the ordinary Save still permits its one read-only fallback.
|
||||
#[test]
|
||||
fn readonly_typed_save_checks_sidecars_before_writing_and_after_retry() {
|
||||
for in_background in [false, true] {
|
||||
let (mut app, files) = app("vectorcraft", ErrorKind::PermissionDenied);
|
||||
app.run("artboard.new", json!({"x": 100, "y": 0, "width": 40, "height": 40, "name": "B"})).unwrap();
|
||||
app.session.doc_mut().unwrap().save_options.insert("separateArtboards".into(), json!(true));
|
||||
let before = state(&app);
|
||||
let there = ["/locked/Poster-B.vectorcraft", "/elsewhere/Poster-B.vectorcraft"];
|
||||
app.services.exists = Some(Arc::new(move |path| there.contains(&path)));
|
||||
app.services.write_shared = Some(Arc::new(|_, _| panic!("typed writer required")));
|
||||
app.session.prefs.background_save = in_background;
|
||||
let (shown, _) = off_the_ui_thread(&mut app.services);
|
||||
|
||||
app.run("file.save", json!({})).unwrap();
|
||||
background::wait_all(&mut app);
|
||||
let d = app.ui.dialog.as_ref().expect("ask before writing the existing artboard file");
|
||||
assert_eq!(d.kind, dialogs::confirm::REPLACE_FILES);
|
||||
assert_eq!(d.fields["files"], json!(["/locked/Poster-B.vectorcraft"]));
|
||||
assert!(files.lock().unwrap().attempts.is_empty());
|
||||
assert!(shown.requests().is_empty());
|
||||
assert_eq!(state(&app), before);
|
||||
dialogs::cancel(&mut app);
|
||||
assert_eq!(state(&app), before);
|
||||
assert!(files.lock().unwrap().attempts.is_empty());
|
||||
|
||||
app.run("file.save", json!({})).unwrap();
|
||||
background::wait_all(&mut app);
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
background::wait_all(&mut app);
|
||||
assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.vectorcraft"]);
|
||||
assert_eq!(state(&app), before);
|
||||
let requests = shown.requests();
|
||||
let [picks::PickRequest::Save(pick)] = requests.as_slice() else { panic!("expected one fallback panel: {requests:?}") };
|
||||
assert_eq!(pick.name, "Poster.vectorcraft");
|
||||
|
||||
shown.answer(&["/elsewhere/Poster.vectorcraft"]);
|
||||
poll(&mut app);
|
||||
background::wait_all(&mut app);
|
||||
let d = app.ui.dialog.as_ref().expect("the retry also checks its destination's artboard file");
|
||||
assert_eq!(d.kind, dialogs::confirm::REPLACE_FILES);
|
||||
assert_eq!(d.fields["files"], json!(["/elsewhere/Poster-B.vectorcraft"]));
|
||||
assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.vectorcraft"]);
|
||||
assert_eq!(state(&app), before);
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
background::wait_all(&mut app);
|
||||
assert_eq!(
|
||||
files.lock().unwrap().attempts,
|
||||
[
|
||||
"/locked/Poster.vectorcraft",
|
||||
"/elsewhere/Poster.vectorcraft",
|
||||
"/elsewhere/Poster-Artboard-1.vectorcraft",
|
||||
"/elsewhere/Poster-B.vectorcraft"
|
||||
]
|
||||
);
|
||||
assert_eq!(app.session.doc().unwrap().path.as_deref(), Some("/elsewhere/Poster.vectorcraft"));
|
||||
assert!(!app.session.doc().unwrap().is_dirty());
|
||||
assert!(shown.requests().is_empty() && app.ui.dialog.is_none());
|
||||
}
|
||||
}
|
||||
@@ -122,8 +122,8 @@ pub use craft_ui::docking::Monitor as DockMonitor;
|
||||
#[cfg(test)]
|
||||
pub(crate) use services::browsable_url;
|
||||
pub use services::{
|
||||
DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, PickedInbox, ReadFn, RevealFn, Services,
|
||||
WriteFn,
|
||||
DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, PickedInbox, ReadFn, RevealFn, SaveStreamFn,
|
||||
SaveWriteFn, Services, WriteFn,
|
||||
};
|
||||
use state::{GeometryStamp, InputGeneration, PendingScreenshot, QueuedScreenshot};
|
||||
pub(crate) use state::{InputFrame, take_frame_once};
|
||||
|
||||
@@ -165,7 +165,8 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
||||
"file.saveCopy" => SaveMode::Copy,
|
||||
_ => SaveMode::Template,
|
||||
};
|
||||
io::save(app, mode, p, true).map(|mut r| {
|
||||
let saved = if mode == SaveMode::Save { io::save_current(app, p) } else { io::save(app, mode, p, true) };
|
||||
saved.map(|mut r| {
|
||||
vectorcraft_engine::cmd::fileio::leave_out_unreadable_save_paths(&mut r, p.get("path").and_then(Value::as_str));
|
||||
r
|
||||
})
|
||||
|
||||
@@ -41,9 +41,9 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
|
||||
crate::panels::character::shared_text_flag(app, |t| t.vertical) == Some(id == "type.orientation.vertical")
|
||||
}
|
||||
"type.opticalMarginAlignment" => crate::panels::character::shared_text_flag(app, |t| t.optical_margin) == Some(true),
|
||||
// Dynamic Text of the first selected type on a path or area type, which the item turns the
|
||||
// other way.
|
||||
"type.dynamicText" => selected_text(|k| !matches!(k, vectorcraft_engine::doc::TextKind::Point)).is_some_and(|t| t.dynamic_text),
|
||||
// Dynamic Text of the first selected type, which the item turns the other way (point type
|
||||
// counts as off: the item turns it into area type with Block on).
|
||||
"type.dynamicText" => selected_text(|_| true).is_some_and(|t| t.dynamic_text && !matches!(t.kind, vectorcraft_engine::doc::TextKind::Point)),
|
||||
"view.outline" => app.outline_view(),
|
||||
"view.pixelPreview" => v.pixel_preview,
|
||||
"view.trimView" => v.trim_view,
|
||||
|
||||
@@ -313,8 +313,8 @@ fn optical_margin_alignment_is_a_checked_type_menu_item() {
|
||||
assert_eq!(checked(&app, "type.orientation.horizontal", &Value::Null), Some(false));
|
||||
}
|
||||
|
||||
/// Type › Dynamic Text runs `type.dynamicText`, is enabled for type on a path and area type that
|
||||
/// are in no thread, and is checked while the selected type has it on.
|
||||
/// Type › Dynamic Text runs `type.dynamicText`, is enabled for point type and for type on a path
|
||||
/// and area type that are in no thread, and is checked while the selected type has it on.
|
||||
#[test]
|
||||
fn dynamic_text_is_a_checked_type_menu_item() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
@@ -334,7 +334,8 @@ fn dynamic_text_is_a_checked_type_menu_item() {
|
||||
assert!(!enabled(&app, id));
|
||||
let point = app.run("text.create", json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [point]})).unwrap();
|
||||
assert!(!enabled(&app, id), "point type");
|
||||
assert!(enabled(&app, id), "point type, which it turns into area type with Block on");
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(false), "point type");
|
||||
let path = app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()["id"].clone();
|
||||
let t = app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [t]})).unwrap();
|
||||
@@ -356,6 +357,11 @@ fn dynamic_text_is_a_checked_type_menu_item() {
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(false));
|
||||
app.run(id, json!({})).unwrap();
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(true));
|
||||
// Point type first: unchecked, as the item turns it on.
|
||||
app.run("select.set", json!({"ids": [point, area]})).unwrap();
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(false), "point type first");
|
||||
app.run(id, json!({})).unwrap();
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(true), "both on");
|
||||
}
|
||||
|
||||
/// Type › Type on a Path › Center on Top and Center on Bottom run `type.pathCenter.top` and
|
||||
|
||||
@@ -40,7 +40,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"file.save",
|
||||
"Save",
|
||||
"Cmd+S",
|
||||
"{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first",
|
||||
"{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first; ordinary Save to the document's existing file refused by filesystem permissions or a read-only volume opens Save As in Documents with the same name, format and options; cancelling keeps the original path and modified state. Explicit destinations and Close/Quit keep their existing error dialog. Automation gets READ_ONLY_LOCATION: followed by the reason and no panel",
|
||||
),
|
||||
(
|
||||
"file.place",
|
||||
|
||||
@@ -155,9 +155,10 @@ fn find_filters_list_view_and_arrows_step_through_the_libraries() {
|
||||
assert!(ctx.read_response(tile_id(panel, "#00FF00")).is_none());
|
||||
let first = SWATCH_IDS[0];
|
||||
let last = SwatchLibraries::list(&app).pop().unwrap().id;
|
||||
assert_eq!(last, "grit-textures", "gradient and then pattern libraries come after the swatch libraries");
|
||||
assert_eq!(last, "optical-effects", "gradient and then pattern libraries come after the swatch libraries");
|
||||
assert_eq!(neighbour::<SwatchLibraries>(&app, "radial-glows", 1).as_deref(), Some("geometric-patterns"));
|
||||
assert_eq!(neighbour::<SwatchLibraries>(&app, "geometric-patterns", 1).as_deref(), Some("grit-textures"));
|
||||
assert_eq!(neighbour::<SwatchLibraries>(&app, "grit-textures", 1).as_deref(), Some("optical-effects"));
|
||||
assert_eq!(neighbour::<SwatchLibraries>(&app, first, -1), Some(last), "wraps around");
|
||||
assert_eq!(neighbour::<SwatchLibraries>(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1]));
|
||||
}
|
||||
|
||||
@@ -136,9 +136,12 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() {
|
||||
json!("pattern-borders"),
|
||||
json!("scatter-shapes"),
|
||||
json!("game-trails"),
|
||||
json!("energy-beams")
|
||||
json!("energy-beams"),
|
||||
json!("dry-media")
|
||||
]
|
||||
);
|
||||
let dry = entries.iter().find(|e| under(e) && e.params["library"] == "dry-media").map(|e| e.label.as_str());
|
||||
assert_eq!(dry, Some("Dry Media"));
|
||||
let enabled = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled);
|
||||
assert_eq!(enabled("window.brushLibrary.other"), Some(true));
|
||||
assert_eq!(enabled("ui.saveBrushLibrary"), Some(true), "a new document has the default brushes");
|
||||
@@ -152,6 +155,13 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() {
|
||||
for t in ["Open Brush Library", "Save Brush Library…"] {
|
||||
assert!(texts.iter().any(|x| x.ends_with(t)), "{t}: {texts:?}");
|
||||
}
|
||||
// Dry Media opens in a library panel, and a click on Chalk Grain adds it in one step.
|
||||
let r = app.run("window.brushLibrary", json!({"library": "Dry Media"})).unwrap();
|
||||
assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("dry-media"), Some(4)));
|
||||
let undo = undo_len(&app);
|
||||
click(&mut app, &ctx, "Chalk Grain", Modifiers::NONE, 1.0);
|
||||
assert!(has(&app, "Chalk Grain") && !has(&app, "Wax Crayon"));
|
||||
assert_eq!(undo_len(&app), undo + 1);
|
||||
// Without brushes there is nothing to save.
|
||||
let all: Vec<String> = vectorcraft_brush::library(&app.session.doc().unwrap().doc).into_iter().map(|b| b.name).collect();
|
||||
app.run("brush.delete", json!({ "names": all })).unwrap();
|
||||
|
||||
@@ -223,6 +223,15 @@ pub(crate) fn as_entry<R>(app: &mut VectorcraftApp, entry: impl FnOnce() -> Entr
|
||||
r
|
||||
}
|
||||
|
||||
/// Run a continuation under its own entry: a refused Save's panel must resume Save As, not retry
|
||||
/// the original Save. Restore the enclosing entry for any work it does afterwards.
|
||||
pub(crate) fn as_own_entry<R>(app: &mut VectorcraftApp, entry: Entry, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R {
|
||||
let outer = app.picks.entry.replace(entry);
|
||||
let r = f(app);
|
||||
app.picks.entry = outer;
|
||||
r
|
||||
}
|
||||
|
||||
/// Run `f` as handling the answer of a file dialog automation opened when `automated` (and inside
|
||||
/// one already, as that): [`VectorcraftApp::automation_asked`] is true meanwhile.
|
||||
pub(crate) fn as_automated<R>(app: &mut VectorcraftApp, automated: bool, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R {
|
||||
|
||||
@@ -43,6 +43,13 @@ pub type ReadHeadFn = Box<dyn Fn(&str, usize) -> Result<Vec<u8>, String>>;
|
||||
|
||||
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
|
||||
|
||||
/// A save writer that keeps typed filesystem failures, on either the UI or background thread.
|
||||
pub type SaveWriteFn = Arc<dyn Fn(&str, &[u8]) -> vectorcraft_engine::Result<()> + Send + Sync>;
|
||||
|
||||
/// A typed save writer for native files encoded directly into the output.
|
||||
pub type SaveStreamFn =
|
||||
Arc<dyn Fn(&str, &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> vectorcraft_engine::Result<()> + Send + Sync>;
|
||||
|
||||
/// Shared, so Background Save and Export can check from a worker thread too.
|
||||
pub type ExistsFn = Arc<dyn Fn(&str) -> bool + Send + Sync>;
|
||||
|
||||
@@ -140,6 +147,10 @@ pub struct Services {
|
||||
/// stream into the file as they are encoded instead of being held whole in memory first.
|
||||
/// Without it they are encoded, then written with `write` / `write_shared`.
|
||||
pub stream_shared: Option<background::SharedStreamFn>,
|
||||
/// Typed writers for document saves only. Without them the existing string writers are used
|
||||
/// and failures keep their normal dialog. Downloads never use these callbacks.
|
||||
pub save_write: Option<SaveWriteFn>,
|
||||
pub save_stream: Option<SaveStreamFn>,
|
||||
/// Open a file (or a folder) in the system's default app for it (desktop: Edit Original, Show
|
||||
/// Package). Without it (the web) those answer with an error.
|
||||
pub open_file: Option<RevealFn>,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user