mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
Merge origin/main into fix/compound-path-pen-join
This commit is contained in:
@@ -19,8 +19,7 @@ use vectorcraft_doc::{Appearance, Document, GapOptions, GapSize, LiveShape, Node
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{
|
||||
self as b, BuilderMap, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, Touched, edge_near, face_at, is_live_paint, sample_polyline,
|
||||
sorted_roots,
|
||||
self as b, BuilderMap, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, Touched, edge_near, face_at, is_live_paint, sorted_roots,
|
||||
};
|
||||
use vectorcraft_trace as tr;
|
||||
|
||||
@@ -58,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Live Paint"],
|
||||
Some("Cmd+Alt+X"),
|
||||
"{ids?|id?} build a Live Paint group from the selected paths; with a Live Paint group among them, add the others to it as livePaint.merge does → {id, faces, edges}",
|
||||
"{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); with a Live Paint group among them, add the others to it as livePaint.merge does → {id, faces, edges}",
|
||||
has_selection_or_ids,
|
||||
lp_make
|
||||
),
|
||||
@@ -67,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Merge",
|
||||
["Object", "Live Paint"],
|
||||
None,
|
||||
"{ids?|id?} add the other selected paths (and Live Paint groups) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link) → {id, faces, edges}",
|
||||
"{ids?|id?} add the other selected paths (compound paths, groups of paths and Live Paint groups too; type, images and other objects are refused, nothing changes) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link) → {id, faces, edges}",
|
||||
has_multi_or_ids,
|
||||
lp_merge
|
||||
),
|
||||
@@ -130,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down)}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
@@ -296,7 +295,6 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if map.shapes.is_empty() {
|
||||
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
|
||||
}
|
||||
let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25);
|
||||
if let Some(tol) = split_tol
|
||||
&& let Some(&q) = pts.first()
|
||||
&& let Some((i, run)) = map.stroke_at(&outlines, q, tol)
|
||||
@@ -329,9 +327,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok((ids, None, touched, Some(split)));
|
||||
}
|
||||
let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() };
|
||||
for q in sample_polyline(&pts, step) {
|
||||
touched.add(map.hit(q, piece_tol));
|
||||
}
|
||||
touched.extend(&map.hits_along(&pts, piece_tol));
|
||||
touched.edges = map.erased_edges(&touched);
|
||||
if touched.is_empty() {
|
||||
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
|
||||
@@ -358,7 +354,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() {
|
||||
let pieces: Vec<usize> = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect();
|
||||
if !pieces.is_empty() {
|
||||
// An open path loses the pieces deleted and those between merged (or deleted) regions.
|
||||
// An open path loses the pieces deleted and those between two merged (or deleted) regions.
|
||||
let keep: Vec<usize> =
|
||||
pieces.iter().copied().filter(|k| !touched.lines.contains(k) && !map.line_between(*k, &touched.regions)).collect();
|
||||
if keep.len() == pieces.len() {
|
||||
@@ -609,9 +605,24 @@ fn merged_gap_options(groups: &[GapOptions]) -> GapOptions {
|
||||
if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom }
|
||||
}
|
||||
|
||||
/// Refuse `roots` when one of them is something Live Paint can't take ([`b::live_paint_takes`]):
|
||||
/// its faces and edges come from path outlines alone, so type or an image would vanish among the
|
||||
/// group's hidden sources. Checked before anything changes.
|
||||
fn check_live_paint_takes(d: &Document, roots: &[NodeId]) -> Result<()> {
|
||||
for r in roots {
|
||||
if !b::live_paint_takes(d.node(*r).ok_or(EngineError::NoNode(*r))?) {
|
||||
return Err(EngineError::Other(
|
||||
"Live Paint takes paths and compound paths: convert type, images and other objects to paths first".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make a Live Paint group from `roots`, or add them to the first Live Paint group among them
|
||||
/// as Merge does; returns (group, faces, edges).
|
||||
fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> {
|
||||
check_live_paint_takes(d, roots)?;
|
||||
if roots.iter().any(|r| d.node(*r).is_some_and(is_live_paint)) {
|
||||
return merge_live(d, sel, roots);
|
||||
}
|
||||
@@ -644,6 +655,7 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
|
||||
.copied()
|
||||
.find(|r| d.node(*r).is_some_and(is_live_paint))
|
||||
.ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?;
|
||||
check_live_paint_takes(d, roots)?;
|
||||
let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
|
||||
// Paint to carry over: (point inside face, fill) and (point on edge, stroke).
|
||||
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
|
||||
@@ -951,40 +963,44 @@ fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
|
||||
v.as_str().and_then(TraceView::from_id).map(Some).ok_or_else(|| bad(cmd, format!("view must be one of {}", ids())))
|
||||
}
|
||||
|
||||
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
|
||||
/// The parameters command `c` traces with: the preset's, with `params` over them.
|
||||
fn trace_params(c: &str, p: &Value) -> Result<tr::TraceParams> {
|
||||
let name = str_param(p, "preset").unwrap_or("Default");
|
||||
let enhanced = switch_or(p, "enhanced", false, "imageTrace.make")?;
|
||||
let enhanced = switch_or(p, "enhanced", false, c)?;
|
||||
// (A preset without an enhanced version is traced as it is.)
|
||||
let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name));
|
||||
let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
|
||||
let base = base.ok_or_else(|| bad(c, format!("unknown preset `{name}` (see imageTrace.presets)")))?;
|
||||
let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) };
|
||||
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad("imageTrace.make", "params must be an object")) };
|
||||
let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad(c, "params must be an object")) };
|
||||
// A key that isn't one of the settings is an error, with the nearest setting.
|
||||
if let Some(key) = src.keys().find(|k| !o.contains_key(k.as_str())) {
|
||||
return Err(super::params::unknown(c, key, &o.keys().map(String::as_str).collect::<Vec<_>>()));
|
||||
}
|
||||
for (k, val) in src {
|
||||
o.insert(k.clone(), val.clone());
|
||||
}
|
||||
let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))?;
|
||||
let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
|
||||
if !params.fills && !params.strokes {
|
||||
return Err(bad("imageTrace.make", "Create needs fills, strokes or both"));
|
||||
return Err(bad(c, "Create needs fills, strokes or both"));
|
||||
}
|
||||
if !(params.stroke_width.is_finite() && params.stroke_width > 0.0) {
|
||||
return Err(bad("imageTrace.make", "strokeWidth must be a positive number of pixels"));
|
||||
return Err(bad(c, "strokeWidth must be a positive number of pixels"));
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
/// Document Library in Color mode: look the library's colours up into `params.swatches` → each
|
||||
/// one as traced (sRGB) with the colour to fill with.
|
||||
fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8; 3], Color)>> {
|
||||
const C: &str = "imageTrace.make";
|
||||
/// one as traced (sRGB) with the colour to fill with. Errors name command `c`.
|
||||
fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8; 3], Color)>> {
|
||||
if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let key = params.library.as_str();
|
||||
let colors = super::swatchlib::library_colors(s, key)
|
||||
.ok_or_else(|| bad(C, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?;
|
||||
.ok_or_else(|| bad(c, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?;
|
||||
if colors.is_empty() {
|
||||
return Err(bad(C, format!("`{key}` has no colours")));
|
||||
return Err(bad(c, format!("`{key}` has no colours")));
|
||||
}
|
||||
let library: Vec<([u8; 3], Color)> = colors
|
||||
.into_iter()
|
||||
@@ -1088,9 +1104,9 @@ fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec
|
||||
}
|
||||
|
||||
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
const C: &str = "imageTrace.make";
|
||||
let mut params = trace_params(p)?;
|
||||
let view = view_param(C, p)?;
|
||||
let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" };
|
||||
let mut params = trace_params(c, p)?;
|
||||
let view = view_param(c, p)?;
|
||||
let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// The preset's name if the parameters are exactly that preset's or its enhanced version's,
|
||||
// else "Custom".
|
||||
@@ -1114,14 +1130,14 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| bad(C, "select an image (or an Image Trace group)"))?
|
||||
.ok_or_else(|| bad(c, "select an image (or an Image Trace group)"))?
|
||||
};
|
||||
let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?;
|
||||
// An image over the tracer's size is traced scaled down (the paths are scaled back below).
|
||||
let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let library = trace_library(s, &mut params)?;
|
||||
let library = trace_library(c, s, &mut params)?;
|
||||
let res = tr::trace_within(&raster, ¶ms, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let warnings = trace_warnings((img.width, img.height), (raster.width, raster.height));
|
||||
let warnings = trace_warnings((img.width, img.height), (raster.width, raster.height), res.worked_at);
|
||||
// Pixel space of the decoded raster → the image object's pixel space → document.
|
||||
let sx = img.width.max(1) as f64 / raster.width.max(1) as f64;
|
||||
let sy = img.height.max(1) as f64 / raster.height.max(1) as f64;
|
||||
@@ -1197,18 +1213,21 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
/// Images larger than this on a side are traced, but more slowly: the trace says so.
|
||||
pub const LARGE_TRACE_SIDE: u32 = 1500;
|
||||
|
||||
/// What a trace of an image of `size` pixels, traced at `traced` pixels, tells the user: that a
|
||||
/// large image takes longer (the public documentation's warning for images over 1500 px), and that
|
||||
/// one over the tracer's [`tr::MAX_PIXELS`] was traced scaled down.
|
||||
pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32)) -> Vec<String> {
|
||||
/// What a trace of an image of `size` pixels, traced at `traced` pixels, tells the user: that
|
||||
/// Flat Logo averaged it down to `worked` pixels first, or else that a large image takes longer
|
||||
/// (the public documentation's warning for images over 1500 px) and that one over the tracer's
|
||||
/// [`tr::MAX_PIXELS`] was traced scaled down.
|
||||
pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32), worked: Option<(u32, u32)>) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
if size.0.max(size.1) > LARGE_TRACE_SIDE {
|
||||
if worked.is_none() && size.0.max(size.1) > LARGE_TRACE_SIDE {
|
||||
out.push(format!(
|
||||
"The image is {} × {} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster.",
|
||||
size.0, size.1
|
||||
));
|
||||
}
|
||||
if u64::from(traced.0) * u64::from(traced.1) < u64::from(size.0) * u64::from(size.1) {
|
||||
if let Some(worked) = worked {
|
||||
out.push(format!("Flat Logo traced the image at {} × {} pixels, averaged down from {} × {}.", worked.0, worked.1, size.0, size.1));
|
||||
} else if u64::from(traced.0) * u64::from(traced.1) < u64::from(size.0) * u64::from(size.1) {
|
||||
out.push(format!(
|
||||
"The image was traced at {} × {} pixels, scaled down from {} × {} ({} megapixels at most).",
|
||||
traced.0,
|
||||
@@ -1294,11 +1313,19 @@ mod trace_limit_tests {
|
||||
|
||||
#[test]
|
||||
fn trace_warnings_name_a_large_or_scaled_down_image() {
|
||||
assert!(trace_warnings((1500, 1500), (1500, 1500)).is_empty());
|
||||
assert_eq!(trace_warnings((1501, 10), (1501, 10)).len(), 1);
|
||||
let w = trace_warnings((16000, 12000), (9459, 7094));
|
||||
assert!(trace_warnings((1500, 1500), (1500, 1500), None).is_empty());
|
||||
assert_eq!(trace_warnings((1501, 10), (1501, 10), None).len(), 1);
|
||||
let w = trace_warnings((16000, 12000), (9459, 7094), None);
|
||||
assert_eq!(w.len(), 2, "{w:?}");
|
||||
assert!(w[0].contains("16000 × 12000") && w[1].contains("9459 × 7094") && w[1].contains("64 megapixels"), "{w:?}");
|
||||
// Flat Logo's own working size is named instead of the megapixel limit.
|
||||
let w = trace_warnings((1025, 1025), (1025, 1025), Some((513, 513)));
|
||||
assert_eq!(w, ["Flat Logo traced the image at 513 × 513 pixels, averaged down from 1025 × 1025."]);
|
||||
// Flat Logo traces a large image at its reduced size, so it gives no slow-trace warning.
|
||||
let w = trace_warnings((1600, 1600), (1600, 1600), Some((800, 800)));
|
||||
assert_eq!(w, ["Flat Logo traced the image at 800 × 800 pixels, averaged down from 1600 × 1600."]);
|
||||
let w = trace_warnings((16000, 12000), (9459, 7094), Some((946, 710)));
|
||||
assert_eq!(w, ["Flat Logo traced the image at 946 × 710 pixels, averaged down from 16000 × 12000."]);
|
||||
}
|
||||
|
||||
// A 4000 × 3000 photo at High Fidelity Photo makes about 1.9 million anchors, which must fit;
|
||||
|
||||
@@ -206,6 +206,8 @@ pub(crate) fn draw_inside_group(d: &mut vectorcraft_doc::Document, target: NodeI
|
||||
let gid = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![std::sync::Arc::new(mask)], clip: true }))?;
|
||||
d.move_node(target, Some(gid), usize::MAX)?;
|
||||
// Copied type whose layout caches `reid` dropped has them again.
|
||||
super::typecmd::refresh_bounds_of(d, &[gid]);
|
||||
Ok(gid)
|
||||
}
|
||||
|
||||
|
||||
@@ -436,8 +436,11 @@ fn snap_xf(b: Rect, half: bool) -> Affine {
|
||||
}
|
||||
|
||||
fn pixel_perfect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<NodeId> =
|
||||
if p.get("ids").is_some() { super::edit::outermost(&s.doc()?.doc, targets(s, p)?) } else { super::edit::selected_roots(s)? };
|
||||
let ids: Vec<NodeId> = if p.get("ids").is_some() || p.get("id").is_some() {
|
||||
super::edit::outermost(&s.doc()?.doc, targets(s, p)?)
|
||||
} else {
|
||||
super::edit::selected_roots(s)?
|
||||
};
|
||||
let n = ids.len();
|
||||
s.edit("Make Pixel Perfect", |d, _| {
|
||||
for id in &ids {
|
||||
|
||||
@@ -269,11 +269,11 @@ fn candidates(s: &Session, f: impl Fn(&Node) -> bool) -> Result<Vec<NodeId>> {
|
||||
}
|
||||
|
||||
fn finish_same(s: &mut Session, cmd: &str, ids: Vec<NodeId>) -> Result<Value> {
|
||||
let n = ids.len();
|
||||
let from = s.doc()?.selection.objects.clone();
|
||||
s.select(|_, sel| sel.set(ids))?;
|
||||
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({}), from));
|
||||
Ok(json!({ "count": n }))
|
||||
let st = s.doc_mut()?;
|
||||
st.last_selection_cmd = Some((cmd.to_string(), json!({}), from));
|
||||
Ok(json!({ "count": st.selection.objects.len() }))
|
||||
}
|
||||
|
||||
fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Value> {
|
||||
@@ -312,9 +312,9 @@ fn same_text(s: &mut Session, cmd: &str, eq: fn(&CharStyle, &CharStyle) -> bool)
|
||||
|
||||
fn by_kind(s: &mut Session, f: impl Fn(&Node) -> bool) -> Result<Value> {
|
||||
let ids = candidates(s, f)?;
|
||||
let n = ids.len();
|
||||
s.select(|_, sel| sel.set(ids))?;
|
||||
Ok(json!({ "count": n }))
|
||||
// The objects selected, after Lock Guides has left out guide paths.
|
||||
Ok(json!({ "count": s.doc()?.selection.objects.len() }))
|
||||
}
|
||||
|
||||
fn direction_handles(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
@@ -617,7 +617,7 @@ fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if v {
|
||||
let active = s.active;
|
||||
for (i, st) in s.docs.iter_mut().enumerate() {
|
||||
if Some(i) != active && crate::deselect_guides(&st.doc, &mut st.selection) {
|
||||
if Some(i) != active && crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection) {
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find),
|
||||
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
||||
cmd!(
|
||||
"document.setUnits",
|
||||
@@ -99,8 +99,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Begin Undo Group",
|
||||
[],
|
||||
None,
|
||||
"{label?: string} until edit.undoGroup.end, the edits made in the active document are one undo step, named `label` (else after its first edit): for automation, such as MCP command_batch with `atomic` (#1163)",
|
||||
has_doc,
|
||||
"{label?: string} until edit.undoGroup.end, the edits made in the document active at the begin, and in each document opened or created before then (also when no document is open; the edits of a command that opens a document and edits it stay that command's undo step), are one undo step in that document, named `label` (else after its first edit); in a document that was open before the begin and is switched to (document.activate), each edit stays its own undo step: for automation, such as MCP command_batch with `atomic` (#1163)",
|
||||
always,
|
||||
undo_group_begin
|
||||
),
|
||||
cmd!(
|
||||
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
[],
|
||||
None,
|
||||
"{cancel?: bool} close the undo group: its edits stay one undo step or, with `cancel`, are undone",
|
||||
has_doc,
|
||||
always,
|
||||
undo_group_end
|
||||
),
|
||||
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{} (the Artboard tool chosen: artboard.cut)", has_selection_or_artboard_tool, cut),
|
||||
@@ -243,19 +243,82 @@ fn opt_u64(p: &Value, cmd: &str, key: &str) -> Result<Option<u64>> {
|
||||
}
|
||||
|
||||
fn doc_find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.find";
|
||||
// A filter of the wrong type is an error, not dropped: dropping it would widen the search.
|
||||
let needle = |key: &str| match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(v)) => Ok(Some(v.to_lowercase()).filter(|v| !v.is_empty())),
|
||||
Some(_) => Err(bad("document.find", format!("`{key}` must be a string"))),
|
||||
Some(_) => Err(bad(C, format!("`{key}` must be a string"))),
|
||||
};
|
||||
let filter = inspect::FindFilter { name: needle("name")?, kind: needle("kind")?, text: needle("text")? };
|
||||
if filter.is_empty() {
|
||||
return Err(bad("document.find", "give at least one of `name`, `kind`, `text`"));
|
||||
let rect = |key: &str| match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => match finite_numbers::<4>(v) {
|
||||
Some([x, y, w, h]) if w >= 0.0 && h >= 0.0 => Ok(Some(vectorcraft_geom::Rect::new(x, y, x + w, y + h))),
|
||||
_ => Err(bad(C, format!("`{key}` must be [x, y, width, height]: four finite numbers, width and height not negative"))),
|
||||
},
|
||||
};
|
||||
let bound = |key: &str| match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0).map(Some).ok_or_else(|| bad(C, format!("`{key}` must be a number not below 0"))),
|
||||
};
|
||||
let range = |name: &str| -> Result<inspect::FindRange> {
|
||||
let (min_key, max_key) = (format!("min{name}"), format!("max{name}"));
|
||||
let r = inspect::FindRange { min: bound(&min_key)?, max: bound(&max_key)? };
|
||||
if let (Some(min), Some(max)) = (r.min, r.max)
|
||||
&& min > max
|
||||
{
|
||||
return Err(bad(C, format!("`{min_key}` is greater than `{max_key}`")));
|
||||
}
|
||||
Ok(r)
|
||||
};
|
||||
let anchors = range("Anchors")?;
|
||||
if [anchors.min, anchors.max].iter().flatten().any(|n| n.fract() != 0.0) {
|
||||
return Err(bad(C, "`minAnchors` and `maxAnchors` must be whole numbers"));
|
||||
}
|
||||
let limit = opt_u64(p, "document.find", "limit")?.unwrap_or(100);
|
||||
let (matches, total) = inspect::find_nodes(&s.doc()?.doc, &filter, limit);
|
||||
Ok(json!({"matches": matches, "total": total}))
|
||||
let filter = inspect::FindFilter {
|
||||
name: needle("name")?,
|
||||
kind: needle("kind")?,
|
||||
text: needle("text")?,
|
||||
within: rect("within")?,
|
||||
intersects: rect("intersects")?,
|
||||
width: range("Width")?,
|
||||
height: range("Height")?,
|
||||
thickness: range("Thickness")?,
|
||||
area: range("Area")?,
|
||||
anchors,
|
||||
};
|
||||
if filter.is_empty() {
|
||||
return Err(bad(
|
||||
C,
|
||||
"give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`",
|
||||
));
|
||||
}
|
||||
let select = match p.get("select") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(C, "`select` must be true or false"))?,
|
||||
};
|
||||
let limit = opt_u64(p, C, "limit")?.unwrap_or(100);
|
||||
let (matches, ids) = inspect::find_nodes(&s.doc()?.doc, &filter, limit);
|
||||
let mut reply = json!({"matches": matches, "total": ids.len()});
|
||||
if select {
|
||||
reply["selected"] = select_found(s, p, &ids)?["count"].clone();
|
||||
s.journal_query();
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// Select the objects among `ids` that Select All and the objects inside them take (none hidden or
|
||||
/// locked, nor inside a hidden or locked one), leaving out those inside another of them and the
|
||||
/// members of compound paths, unless a compound path is isolated. Select > Reselect repeats
|
||||
/// `document.find` with `p`.
|
||||
fn select_found(s: &mut Session, p: &Value, ids: &[NodeId]) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let compound = |n: &Node| matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. });
|
||||
let members = st.isolation.and_then(|i| st.doc.ancestry(i)).is_some_and(|a| a.iter().any(|i| st.doc.node(*i).is_some_and(compound)));
|
||||
let found: BTreeSet<NodeId> = ids.iter().copied().collect();
|
||||
super::select::select_where(s, "document.find", p, |d, n| {
|
||||
found.contains(&n.id) && (members || !d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(compound))
|
||||
})
|
||||
}
|
||||
|
||||
/// Document Setup's units (also `document.setup {units}`).
|
||||
@@ -364,6 +427,7 @@ fn undo_group_begin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Value::String(l)) if !l.trim().is_empty() && l.chars().count() <= 200 => Some(l.trim().to_string()),
|
||||
Some(_) => return Err(bad(C, "`label` must be a non-empty name of at most 200 characters")),
|
||||
};
|
||||
s.explicit_undo_group = Some(label.clone());
|
||||
s.begin_named_undo_group(label);
|
||||
ok()
|
||||
}
|
||||
@@ -390,7 +454,7 @@ pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
|
||||
|
||||
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
settle_interaction(s)?;
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
let menu = s.menu.clone();
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
||||
let label = e.label.clone();
|
||||
@@ -405,10 +469,8 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
st.history.trim();
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
// Guides locked since the step was made stay deselected.
|
||||
if guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
// Guides locked and slices locked or hidden since the step was made stay deselected.
|
||||
crate::deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
st.sync_new_art();
|
||||
super::maskedit::follow_history(st, editing);
|
||||
leave_isolation_if_gone(st);
|
||||
@@ -418,7 +480,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
settle_interaction(s)?;
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
let menu = s.menu.clone();
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
|
||||
let label = e.label.clone();
|
||||
@@ -433,9 +495,7 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
st.history.trim();
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
if guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
crate::deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
st.sync_new_art();
|
||||
super::maskedit::follow_history(st, editing);
|
||||
leave_isolation_if_gone(st);
|
||||
@@ -836,7 +896,7 @@ pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selectio
|
||||
/// document, each original and its copy before the copy goes in, and must leave the document's
|
||||
/// tree as it is. The copies take new ids in the order of `roots`. Roots listed twice, or inside
|
||||
/// another root, are copied one at a time: each copy is made from the document as the copies
|
||||
/// before it left it.
|
||||
/// before it left it. Copied type whose layout caches [`Document::reid`] dropped has them again.
|
||||
pub(crate) fn copy_above(d: &mut Document, roots: &[NodeId], mut change: impl FnMut(&mut Document, &Node, &mut Node)) -> Result<Vec<NodeId>> {
|
||||
let paths = d.index_paths(roots.iter().copied());
|
||||
let mut found: Vec<_> = roots.iter().filter_map(|id| paths.get(id)).collect();
|
||||
@@ -863,6 +923,7 @@ pub(crate) fn copy_above(d: &mut Document, roots: &[NodeId], mut change: impl Fn
|
||||
}
|
||||
out
|
||||
};
|
||||
super::typecmd::refresh_bounds_of(d, &out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Appearance",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
can_expand,
|
||||
expand_appearance
|
||||
),
|
||||
@@ -692,9 +692,13 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = s.edit("Expand Appearance", |d, _| {
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let mut out = vec![];
|
||||
let threaded = super::threads::frames(d);
|
||||
for id in &roots {
|
||||
expand_node(d, *id, &brushes, &mut out);
|
||||
}
|
||||
// Outlined frames of threaded text leave their threads with the text they show; type that
|
||||
// stays type under a new id takes its frame's place.
|
||||
super::threads::leave_threads(d, &threaded);
|
||||
if out.is_empty() {
|
||||
return Err(EngineError::Other("Expand Appearance: the selection has a basic appearance".into()));
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The active artboard the app passes, for the steps that act on it.
|
||||
let artboard = p.get("artboard").and_then(Value::as_u64);
|
||||
// What an error rolls back besides the documents: session-level state a step may change
|
||||
// (paint defaults, drawing mode, clipboard, the Untitled-N count).
|
||||
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter);
|
||||
// (paint defaults, drawing mode, clipboard, the Untitled-N count, a begun undo group).
|
||||
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter, s.explicit_undo_group.clone());
|
||||
// A drag or typing still in progress is a step of its own, as before Undo: the batch's steps
|
||||
// would otherwise run inside it and take its baseline as theirs (#1697).
|
||||
if s.active().is_some() {
|
||||
@@ -30,7 +30,7 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Err(e) => {
|
||||
roll_back(s, start, &stash);
|
||||
s.active = active;
|
||||
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter) = saved;
|
||||
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter, s.explicit_undo_group) = saved;
|
||||
s.pending_paint = None;
|
||||
return Err(e);
|
||||
}
|
||||
@@ -99,7 +99,8 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
|
||||
/// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection,
|
||||
/// history, file, format and saved state again (#1697: undoing the batch's interaction alone left
|
||||
/// a Save As step's new file and an Undo step's history in place). Files the steps wrote stay
|
||||
/// written. Lock Guides stays as the steps left it, and while it is on the guides are deselected.
|
||||
/// written. Lock Guides, Lock Slices and Hide Slices stay as the steps left them, and the guides
|
||||
/// and slices they keep from being selected are deselected.
|
||||
fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
|
||||
let open = std::mem::take(&mut s.docs);
|
||||
s.docs = start;
|
||||
@@ -107,8 +108,6 @@ fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
|
||||
// Past the newest revision the document reached, so the restored one redraws.
|
||||
let newest = open.iter().chain(stash).filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision);
|
||||
st.revision = newest.saturating_add(1);
|
||||
if s.menu.guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! EPS export for every EPS path (`document.export {format: eps}`, `document.exportEps`, Export As
|
||||
//! → EPS Options): the options parsed from the params, transparency flattened with a flattener
|
||||
//! preset, one file of the art's bounds or one per artboard, the preview and thumbnail rendered,
|
||||
//! preset, one file of the art's bounds or one per artboard (with a central file of the whole
|
||||
//! document when there are several), the preview and thumbnail rendered,
|
||||
//! and the native document embedded for reopening.
|
||||
|
||||
use std::borrow::Cow;
|
||||
@@ -28,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export EPS",
|
||||
[],
|
||||
None,
|
||||
"{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard) | false (default: one file, the bounding box the visible art, y up from the first artboard's bottom-left corner), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (with useArtboards: the artboards written)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}",
|
||||
"{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art) | false (default: one file, the bounding box the visible art, y up from the first artboard's bottom-left corner), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (with useArtboards: the artboards written)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}",
|
||||
has_doc,
|
||||
export_eps
|
||||
)]
|
||||
@@ -43,7 +44,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
name: "useArtboards",
|
||||
ty: "boolean",
|
||||
default: "null",
|
||||
description: "true: one file per chosen artboard (default all), <file>_<artboard>.eps; false or absent: one file of the visible art (naming artboards also uses them)",
|
||||
description: "true: one file per chosen artboard (default all), <file>_<artboard>.eps, and with two or more a central <file>.eps of the whole document; false or absent: one file of the visible art (naming artboards also uses them)",
|
||||
},
|
||||
FormatOption { name: "level", ty: "integer", default: "3", description: "PostScript language level, 2 or 3" },
|
||||
FormatOption {
|
||||
@@ -197,7 +198,8 @@ fn expanded(cmd: &str, p: &Value, saved: &[super::super::FlattenerPreset]) -> Re
|
||||
}
|
||||
|
||||
/// The files to write: `(artboard, region, origin)` each — every chosen artboard with its corner
|
||||
/// as the origin, or the visible art's bounds with the first artboard's corner.
|
||||
/// as the origin, or the visible art's bounds with the first artboard's corner. With two or more
|
||||
/// chosen artboards, a central file of the visible art comes first (none when no art shows).
|
||||
fn pages(doc: &Document, set: &Settings) -> Result<Vec<(Option<usize>, Rect, Point)>> {
|
||||
let n = doc.artboards.len();
|
||||
let named = set.boards.resolve(n).map_err(|e| bad(C, e))?;
|
||||
@@ -208,14 +210,18 @@ fn pages(doc: &Document, set: &Settings) -> Result<Vec<(Option<usize>, Rect, Poi
|
||||
(None, None) => None,
|
||||
};
|
||||
let corner = |r: Rect| Point::new(r.x0, r.y1);
|
||||
let art = |art: Rect| (None, art, doc.artboards.first().map_or(corner(art), |a| corner(a.rect)));
|
||||
match boards {
|
||||
Some(boards) => boards
|
||||
.into_iter()
|
||||
.map(|b| doc.artboards.get(b).map(|a| (Some(b), a.rect, corner(a.rect))).ok_or_else(|| bad(C, format!("no artboard {}", b + 1))))
|
||||
.collect(),
|
||||
Some(boards) => {
|
||||
let central = (boards.len() > 1).then(|| vectorcraft_render::encode::art_bounds(doc).map(art)).flatten();
|
||||
let each = boards
|
||||
.into_iter()
|
||||
.map(|b| doc.artboards.get(b).map(|a| (Some(b), a.rect, corner(a.rect))).ok_or_else(|| bad(C, format!("no artboard {}", b + 1))));
|
||||
central.map(Ok).into_iter().chain(each).collect()
|
||||
}
|
||||
None => {
|
||||
let art = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
||||
Ok(vec![(None, art, doc.artboards.first().map_or(corner(art), |a| corner(a.rect)))])
|
||||
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
||||
Ok(vec![art(bounds)])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,8 +257,8 @@ fn native(doc: &Document, board: Option<usize>, include_linked: bool) -> Vec<u8>
|
||||
}
|
||||
|
||||
/// Encode `doc` as EPS: one file of the visible art, or with Use Artboards one per chosen
|
||||
/// artboard. Transparency is flattened first (once for all of them); the previews show `doc`
|
||||
/// itself.
|
||||
/// artboard (after a central file of the whole document when there are several). Transparency is
|
||||
/// flattened first (once for all of them); the previews show `doc` itself.
|
||||
pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
let set = parse(p)?;
|
||||
let original = doc;
|
||||
|
||||
@@ -323,6 +323,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
|
||||
} else if !doc.text_threads.is_empty() {
|
||||
crate::cmd::threads::share_optical_margin(&mut doc);
|
||||
// A frame whose first paragraph continues from the frame before draws it without the
|
||||
// paragraph's first-line indent and list marker.
|
||||
crate::cmd::threads::mark_continued(&mut doc);
|
||||
}
|
||||
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
|
||||
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
|
||||
@@ -463,8 +466,12 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
|
||||
st.converted = converted;
|
||||
st.imported_from = source_path;
|
||||
st.import_losses = losses;
|
||||
let title = s.documents()[index].title();
|
||||
Ok(super::merge(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json()))
|
||||
let opened = &s.documents()[index];
|
||||
let (title, file_info_title) = (opened.title(), opened.doc.title.clone());
|
||||
Ok(super::merge(
|
||||
json!({ "index": index, "title": title, "fileInfoTitle": file_info_title, "format": format.id, "warnings": warnings, "restored": restored }),
|
||||
links.to_json(),
|
||||
))
|
||||
}
|
||||
|
||||
/// A file named by a command's params: `{path}` (read from disk) or `{name, dataBase64}`.
|
||||
|
||||
@@ -74,8 +74,9 @@ pub use native::with_compression_pref;
|
||||
pub use pdfimport::{LoadOptions, page_document};
|
||||
pub(crate) use save::job_for;
|
||||
pub use save::{
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, 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, export_folder, 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 use screens::{
|
||||
PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExport, ScreenExportPreset, ScreenSize,
|
||||
@@ -128,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
|
||||
@@ -121,7 +121,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
|
||||
name: "outlineText",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is always outlines",
|
||||
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is always outlines",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -146,10 +146,13 @@ pub(super) struct Extras {
|
||||
/// are left to [`encode`]).
|
||||
pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<(SaveOptions, Extras)> {
|
||||
let o: NativeOptions = super::encode::options(f, p)?;
|
||||
let mut so = SaveOptions::for_doc(doc);
|
||||
so.compress = o.compress.unwrap_or(false);
|
||||
so.version = o.version.unwrap_or(vectorcraft_format::VERSION);
|
||||
so.include_linked = o.include_linked;
|
||||
// Pretty or compact is decided by [`encode_to`] on the document it writes.
|
||||
let mut so = SaveOptions {
|
||||
compress: o.compress.unwrap_or(false),
|
||||
version: o.version.unwrap_or(vectorcraft_format::VERSION),
|
||||
include_linked: o.include_linked,
|
||||
..SaveOptions::default()
|
||||
};
|
||||
if o.embed_profiles.unwrap_or(true) {
|
||||
so.profiles = embedded_profiles(doc);
|
||||
}
|
||||
@@ -190,6 +193,7 @@ pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: imp
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
so.pretty = so.pretty_fits(&native);
|
||||
vectorcraft_format::save_to(&native, &so, w).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ use vectorcraft_doc::Document;
|
||||
|
||||
use super::super::*;
|
||||
use super::{
|
||||
ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded,
|
||||
ARTBOARD_PARAMS, ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref,
|
||||
write_encoded,
|
||||
};
|
||||
use crate::{DocState, Prefs};
|
||||
|
||||
@@ -94,6 +95,9 @@ pub struct SavePlan {
|
||||
/// The `modified` param (as `date_param` reads it): the File Info date a Save or Save As to a file
|
||||
/// stamps; `None`: the time it is written.
|
||||
pub modified: Option<Option<i64>>,
|
||||
/// Warnings about the params themselves: each native option (`compress`, `version`,
|
||||
/// `preview`) given beside the path that the format doesn't read.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl SavePlan {
|
||||
@@ -233,6 +237,14 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
(_, None, None) => checked(cmd, st.format)?,
|
||||
};
|
||||
let path = if mode == SaveMode::Template { path.map(|p| template_path(&p, format)) } else { path };
|
||||
if let Some((key, top, inner)) = disagreeing_option(p, is_svg(format)).map_err(|e| bad(cmd, e))? {
|
||||
return Err(bad(cmd, format!("`{key}: {top}` and `options.{key}: {inner}` disagree: give the option once")));
|
||||
}
|
||||
let notes = NATIVE_OPTIONS
|
||||
.iter()
|
||||
.filter(|k| p.get(**k).is_some_and(|v| !v.is_null()) && !reads_option(format, k))
|
||||
.map(|k| format!("`{k}` was not used: {} files don't read it", format.label))
|
||||
.collect();
|
||||
let mut given: Map<String, Value> = match p.get("options") {
|
||||
Some(Value::Object(o)) => o.iter().filter(|(k, _)| reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())).collect(),
|
||||
None | Some(Value::Null) => Map::new(),
|
||||
@@ -253,7 +265,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
}
|
||||
}
|
||||
// None given: the ones the document was last saved with in this format.
|
||||
let options = if given.is_empty() && format.id == st.format { st.save_options.clone() } else { given };
|
||||
let options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given };
|
||||
// Save writes the document's own file; the other modes only where they are told to.
|
||||
let path =
|
||||
path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten());
|
||||
@@ -272,12 +284,141 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
if let Some(path) = &path {
|
||||
super::check_not_lossy_overwrite(st, path, p, cmd)?;
|
||||
}
|
||||
Ok(SavePlan { mode, path, format, options, name, folder, modified })
|
||||
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes })
|
||||
}
|
||||
|
||||
/// The native save options, which a save also takes beside the path.
|
||||
const NATIVE_OPTIONS: [&str; 3] = ["compress", "version", "preview"];
|
||||
|
||||
/// The first option `p` gives both beside the path and in `options` with different values, as
|
||||
/// `(key, top-level value, options value)`. For an SVG save (`svg`), an SVG option's value on
|
||||
/// either side is the one its `svg: {…}` object gives, else the flat one.
|
||||
fn disagreeing_option(p: &Value, svg: bool) -> std::result::Result<Option<(String, Value, Value)>, String> {
|
||||
let Some(inner) = p.get("options").filter(|o| o.is_object()) else { return Ok(None) };
|
||||
let given = |m: &Value| -> std::result::Result<Map<String, Value>, String> {
|
||||
let mut out: Map<String, Value> = m
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(k, v)| !v.is_null() && !matches!(k.as_str(), "path" | "format" | "options") && !(svg && k.as_str() == "svg"))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
if svg {
|
||||
out.extend(super::svg_options(m)?);
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
let top = given(p)?;
|
||||
Ok(given(inner)?.into_iter().find_map(|(k, v)| {
|
||||
let t = top.get(&k).filter(|t| !same_value(t, &v))?.clone();
|
||||
Some((k, t, v))
|
||||
}))
|
||||
}
|
||||
|
||||
/// `a == b`, with a whole number and a float of the same value (`3` and `3.0`) the same.
|
||||
fn same_value(a: &Value, b: &Value) -> bool {
|
||||
match (a, b) {
|
||||
(Value::Number(x), Value::Number(y)) => x == y || ((x.is_f64() || y.is_f64()) && x.as_f64() == y.as_f64()),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// The options `st` was last saved with, their artboards (`artboard`, `artboards` and `range`)
|
||||
/// following the artboards they named ([`DocState::save_artboards`]): an artboard deleted since is
|
||||
/// left out, and one that moved is named by its new number. When none of the artboards the save
|
||||
/// used is left, the pick is left out, and a save without options writes the artboards it writes
|
||||
/// when none is named. `range` `"all"` stays as it is. Artboards given to a save are still checked.
|
||||
pub fn remembered_save_options(st: &DocState) -> Map<String, Value> {
|
||||
let mut options = st.save_options.clone();
|
||||
let ids: Vec<u32> = st.doc.artboards.iter().map(|a| a.id).collect();
|
||||
// The encoders refuse a document without artboards themselves.
|
||||
if ids.is_empty() {
|
||||
return options;
|
||||
}
|
||||
// Artboard `i` of the save, where it is now (by number when the save's artboards are unknown).
|
||||
let then = &st.save_artboards;
|
||||
let count = if then.is_empty() { ids.len() } else { then.len() };
|
||||
let now = |i: usize| match then.get(i) {
|
||||
Some(id) => ids.iter().position(|x| x == id),
|
||||
None => (then.is_empty() && i < ids.len()).then_some(i),
|
||||
};
|
||||
// Each pick that names artboards: `Some` of where they are now (empty: all gone).
|
||||
let mut moved: Vec<(&str, Option<Vec<usize>>)> = vec![];
|
||||
for k in ARTBOARD_PARAMS {
|
||||
let named = match (k, options.get(k)) {
|
||||
("range", Some(Value::String(r))) if r.trim().is_empty() || r.trim().eq_ignore_ascii_case("all") => continue,
|
||||
("range", Some(Value::String(r))) => match vectorcraft_doc::range::parse_range(r, count) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
moved.push((k, Some(vec![])));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
("artboards", Some(v @ Value::Array(_))) => match serde_json::from_value::<Vec<usize>>(v.clone()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
},
|
||||
("artboard", Some(v)) => match v.as_u64().and_then(|i| usize::try_from(i).ok()) {
|
||||
Some(i) => vec![i],
|
||||
None => continue,
|
||||
},
|
||||
_ => continue,
|
||||
};
|
||||
let there: Vec<usize> = named.iter().filter_map(|i| now(*i)).collect();
|
||||
// A range that reads differently against the artboards there are now (`"2-"` once one
|
||||
// was added) is written out as the artboards it named.
|
||||
let reads = match (k, options.get(k)) {
|
||||
("range", Some(Value::String(r))) => vectorcraft_doc::range::parse_range(r, ids.len()).ok(),
|
||||
_ => Some(named),
|
||||
};
|
||||
moved.push((k, (reads.as_ref() != Some(&there)).then_some(there)));
|
||||
}
|
||||
// The pick a save uses (as `ArtboardPick::resolve` reads it: `range`, then `artboards`, then
|
||||
// `artboard`) is gone: so is the rest.
|
||||
let used = ["range", "artboards", "artboard"].into_iter().find(|k| match options.get(*k) {
|
||||
Some(Value::String(r)) => !r.trim().is_empty(),
|
||||
Some(v) => !v.is_null(),
|
||||
None => false,
|
||||
});
|
||||
let gone = |k: &str| moved.iter().any(|(m, there)| *m == k && there.as_ref().is_some_and(Vec::is_empty));
|
||||
if used.is_some_and(gone) {
|
||||
for k in ARTBOARD_PARAMS {
|
||||
options.remove(k);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
for (k, there) in moved {
|
||||
let Some(there) = there else { continue };
|
||||
let value = match (k, there.first()) {
|
||||
(_, None) => None,
|
||||
("range", Some(_)) => Some(json!(range_text(&there))),
|
||||
("artboards", Some(_)) => Some(json!(there)),
|
||||
(_, Some(i)) => Some(json!(i)),
|
||||
};
|
||||
match value {
|
||||
Some(v) => _ = options.insert(k.into(), v),
|
||||
None => _ = options.remove(k),
|
||||
}
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
/// 0-based artboard indices as a 1-based range in the same order, runs joined (`"1-2, 5"`).
|
||||
fn range_text(picked: &[usize]) -> String {
|
||||
let mut runs: Vec<(usize, usize)> = vec![];
|
||||
for &i in picked {
|
||||
match runs.last_mut() {
|
||||
Some((_, end)) if *end + 1 == i => *end = i,
|
||||
_ => runs.push((i, i)),
|
||||
}
|
||||
}
|
||||
let run = |&(a, b): &(usize, usize)| if a == b { format!("{}", a + 1) } else { format!("{}-{}", a + 1, b + 1) };
|
||||
runs.iter().map(run).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// Does a save in `f` read option `key`? PDF (and a .ai file, a PDF) also reads its General
|
||||
/// settings (`createLayers`…) and presets.
|
||||
fn reads_option(f: &Format, key: &str) -> bool {
|
||||
/// settings (`createLayers`…) and presets. A save drops each option its format doesn't read.
|
||||
pub fn reads_option(f: &Format, key: &str) -> bool {
|
||||
f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key)))
|
||||
}
|
||||
|
||||
@@ -426,6 +567,15 @@ fn blank_pages(doc: &Document) -> Document {
|
||||
/// Why a `.ai` file saved without PDF content looks empty elsewhere.
|
||||
const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages";
|
||||
|
||||
/// `e`, an error of the export encoders, under the save command `cmd` instead of
|
||||
/// `document.export`.
|
||||
fn named_for_save(cmd: &str, e: crate::EngineError) -> crate::EngineError {
|
||||
match e {
|
||||
crate::EngineError::BadParams { cmd: c, msg } if c == "document.export" => bad(cmd, msg),
|
||||
e => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared Save and Export encoder for PDF-compatible `.ai` files (and `.ait` templates, the
|
||||
/// same file).
|
||||
pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
@@ -509,7 +659,7 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
|
||||
if matches!(plan.format.id, "vectorcraft" | "template") {
|
||||
// Not remembered with the options: the preference decides each time it isn't given.
|
||||
params = with_compression_pref(&s.prefs, ¶ms);
|
||||
super::native::save_options(cmd, plan.format, &doc, ¶ms)?;
|
||||
super::native::save_options(cmd, plan.format, &doc, ¶ms).map_err(|e| named_for_save(cmd, e))?;
|
||||
}
|
||||
let boards = separate_boards(cmd, plan.format, &doc, ¶ms)?;
|
||||
let mut params = super::pdf::expand_preset(s, cmd, ¶ms)?.into_owned();
|
||||
@@ -547,24 +697,38 @@ impl SaveJob {
|
||||
|
||||
/// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with
|
||||
/// the warnings (what the format loses first, then the encoder's own notes: PDF options not
|
||||
/// applied yet…).
|
||||
/// applied yet…, then the plan's notes on its params).
|
||||
pub fn encode(&self) -> Result<Encoded> {
|
||||
let mut enc = self.encode_one(&self.doc)?;
|
||||
for &b in &self.boards {
|
||||
let one = self.encode_one(&artboard_doc(&self.doc, b))?;
|
||||
enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes)));
|
||||
}
|
||||
// Lost artboards first: the status bar shows only the first warning.
|
||||
enc.warnings.splice(0..0, self.format_losses());
|
||||
enc.warnings.extend(self.plan.notes.iter().cloned());
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
/// What the file loses against the document: the artboards an SVG file leaves out first (the
|
||||
/// status bar shows only the first warning), then what the format loses.
|
||||
fn format_losses(&self) -> impl Iterator<Item = String> {
|
||||
let f = self.plan.format;
|
||||
let lost = if is_svg(f) { super::svg::lost_artboards_warning(&self.doc, &self.params, f.label) } else { None };
|
||||
enc.warnings.splice(0..0, lost.into_iter().chain(fidelity_warning(f)));
|
||||
Ok(enc)
|
||||
lost.into_iter().chain(fidelity_warning(f))
|
||||
}
|
||||
|
||||
/// The warnings known before the file is encoded, in the order a written file's reply gives
|
||||
/// them: what the format loses, then the plan's notes on its params (a reply sent before the
|
||||
/// file is written, as Background Save's, gives these).
|
||||
pub fn known_warnings(&self) -> Vec<String> {
|
||||
self.format_losses().chain(self.plan.notes.iter().cloned()).collect()
|
||||
}
|
||||
|
||||
/// Encode one file of `doc` in the job's format.
|
||||
fn encode_one(&self, doc: &Document) -> Result<Encoded> {
|
||||
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
|
||||
let enc = if matches!(f.id, "ai" | "ait") { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? };
|
||||
let enc = if matches!(f.id, "ai" | "ait") { encode_ai(cmd, f, doc, &self.params) } else { encode_all(doc, f.id, &self.params) }
|
||||
.map_err(|e| named_for_save(cmd, e))?;
|
||||
// A legacy .ai file per artboard.
|
||||
let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, f, &self.params)?;
|
||||
if enc.files.len() != 1 && !per_artboard {
|
||||
@@ -650,8 +814,7 @@ impl SaveJob {
|
||||
};
|
||||
let r = stream(path, &mut fill);
|
||||
r.map_err(|e| failed.take().unwrap_or(e))?;
|
||||
let warnings: Vec<String> = fidelity_warning(f).into_iter().collect();
|
||||
Ok(json!({ "path": path, "bytes": written, "format": f.id, "warnings": warnings }))
|
||||
Ok(json!({ "path": path, "bytes": written, "format": f.id, "warnings": self.known_warnings() }))
|
||||
}
|
||||
|
||||
/// Once written: Save and Save As (except to a template) make the file `st`'s own: path,
|
||||
@@ -664,6 +827,7 @@ impl SaveJob {
|
||||
st.path = Some(path);
|
||||
st.format = self.plan.format.id;
|
||||
st.save_options = self.plan.options;
|
||||
st.save_artboards = self.snapshot.artboards.iter().map(|a| a.id).collect();
|
||||
st.converted = false;
|
||||
st.recovered = false;
|
||||
if let Some(at) = self.stamped {
|
||||
@@ -756,14 +920,14 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = format(id)
|
||||
.filter(|f| f.write || SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id))
|
||||
.ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?;
|
||||
let saved = st.filter(|d| d.format == f.id).map(|d| &d.save_options);
|
||||
let saved = st.filter(|d| d.format == f.id).map(remembered_save_options);
|
||||
// A native save compresses as Use Compression says, unless told otherwise.
|
||||
let prefs = with_compression_pref(&s.prefs, &json!({}));
|
||||
let mut v = f.to_json();
|
||||
if let Some(options) = v["options"].as_object_mut() {
|
||||
for (name, o) in options.iter_mut() {
|
||||
let default = prefs.get(name).filter(|_| matches!(f.id, "vectorcraft" | "template")).unwrap_or(&o["default"]);
|
||||
o["value"] = saved.and_then(|m| m.get(name)).unwrap_or(default).clone();
|
||||
o["value"] = saved.as_ref().and_then(|m| m.get(name)).unwrap_or(default).clone();
|
||||
}
|
||||
}
|
||||
v["saveFormats"] =
|
||||
@@ -778,7 +942,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (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, compress?: bool (native, also in options: gzip; default: the useCompression preference), 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), 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?: false for native (also carry a PDF of every artboard) | 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: 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; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF 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",
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (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 or preview given flat), 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), 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?: false for native (also carry a PDF of every artboard) | 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: 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; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF 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",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
||||
@@ -702,6 +702,58 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// An option given beside the path (an SVG option flat or in `svg`) and in `options` with different
|
||||
/// values is refused before anything is written; `compress`, `version` or `preview` beside the path of a format that doesn't
|
||||
/// read it is named in the warnings.
|
||||
#[test]
|
||||
fn a_save_refuses_disagreeing_options_and_names_unread_native_ones() {
|
||||
let d = tmp_dir("save-options");
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
let path = d.join("both.vectorcraft").to_string_lossy().to_string();
|
||||
for cmd in ["document.save", "file.saveAs", "file.saveCopy", "file.saveAsTemplate"] {
|
||||
let e = s.execute(cmd, &json!({"path": path, "compress": true, "options": {"compress": false}})).unwrap_err().to_string();
|
||||
assert!(e.contains("`compress: true` and `options.compress: false` disagree") && e.contains(&format!("`{cmd}`")), "{cmd}: {e}");
|
||||
}
|
||||
assert!(!std::path::Path::new(&path).exists(), "nothing is written");
|
||||
assert!(!d.join("both.vctemplate").exists(), "nor a template");
|
||||
// An SVG option given in `svg: {…}` or in `options.svg` counts as given there.
|
||||
let svg = d.join("a.svg").to_string_lossy().to_string();
|
||||
for p in [
|
||||
json!({"svg": {"hiddenLayers": false}, "options": {"hiddenLayers": true}}),
|
||||
json!({"hiddenLayers": false, "options": {"svg": {"hiddenLayers": true}}}),
|
||||
json!({"hiddenLayers": true, "svg": {"hiddenLayers": false}, "options": {"hiddenLayers": true}}),
|
||||
] {
|
||||
let mut p = p;
|
||||
p["path"] = json!(svg);
|
||||
let e = s.execute("file.saveCopy", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("`hiddenLayers: false` and `options.hiddenLayers: true` disagree") && e.contains("`file.saveCopy`"), "{p}: {e}");
|
||||
}
|
||||
assert!(!std::path::Path::new(&svg).exists(), "nothing is written");
|
||||
// Agreeing values, or one of them null, save as asked; `3` and `3.0` agree.
|
||||
for p in [
|
||||
json!({"compress": true, "options": {"compress": true}}),
|
||||
json!({"compress": null, "options": {"compress": true}}),
|
||||
json!({"compress": true, "version": 3, "options": {"compress": true, "version": 3.0}}),
|
||||
] {
|
||||
let mut p = p;
|
||||
p["path"] = json!(path);
|
||||
let r = s.execute("file.saveCopy", &p).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{p}: {r}");
|
||||
assert_eq!(std::fs::read(&path).unwrap()[..2], [0x1f, 0x8b], "{p}");
|
||||
}
|
||||
// An SVG file reads none of the native options; a .ai file reads `compress` only.
|
||||
let r = s.execute("file.saveCopy", &json!({"path": svg, "compress": true, "version": 3, "preview": null})).unwrap();
|
||||
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert!(w.contains(&"`compress` was not used: SVG files don't read it"), "{w:?}");
|
||||
assert!(w.contains(&"`version` was not used: SVG files don't read it") && !w.iter().any(|w| w.starts_with("`preview`")), "{w:?}");
|
||||
assert!(std::fs::read(&svg).unwrap().starts_with(b"<"), "plain SVG");
|
||||
let r = s.execute("file.saveCopy", &json!({"path": d.join("a.ai").to_string_lossy(), "compress": false, "version": 3})).unwrap();
|
||||
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert!(w.iter().any(|w| w.starts_with("`version` was not used")) && !w.iter().any(|w| w.starts_with("`compress`")), "{w:?}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A native save whose file write fails partway (a full disk) leaves the file as it was, and no
|
||||
/// temporary file behind.
|
||||
#[test]
|
||||
|
||||
@@ -82,6 +82,37 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() {
|
||||
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
|
||||
}
|
||||
|
||||
/// A `.ai` file opens with the swatches its palette holds, spot colours the art doesn't use and colour
|
||||
/// groups included, in place of the default swatches; art painted with Registration (`XZ`) still
|
||||
/// opens from the editing data.
|
||||
#[test]
|
||||
fn an_ai_file_opens_with_the_swatches_of_its_palette() {
|
||||
let palette = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n0.04 0.3 0 0 81.96 21 -8 (Pink ink) 0 2 Xx\n(Pink ink)\nPc\n\
|
||||
0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\n1 (Inks) 1 Pg\n0.02 0.1 1 0 (Yellow ink) 0 x\n(Yellow ink)\nPc\nPB\n%AI5_EndPalette\n";
|
||||
// The square the page draws, in the red ink, after a stroke colour of Registration.
|
||||
let art = format!("0 0 0 0 0 0 0 ([Registration]) 0 1 XZ\n0.06 0.98 1 0 (Red ink) 0 x\n{}f\n", ai::rect(10.0, 50.0, 40.0, 90.0));
|
||||
let data = ai::editing_data(200.0, 100.0, &format!("{palette}{}", ai::layer("Art", &art)));
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "inks.ai", &ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap();
|
||||
assert_eq!(r["format"], json!("ai"), "{r}");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(names(&doc.layers), ["Art"], "opened from the editing data: {r}");
|
||||
let swatches: Vec<&str> = doc.swatches.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(swatches, ["[None]", "White", "Pink ink", "Red ink"]);
|
||||
let pink = doc.swatch("Pink ink").unwrap();
|
||||
assert!(pink.spot && pink.global);
|
||||
assert_eq!(pink.paint.color(), Some(vectorcraft_color::Color::lab(81.96, 21.0, -8.0)), "a Lab ink keeps its Lab values");
|
||||
let groups: Vec<&str> = doc.swatch_groups.iter().map(|g| g.name.as_str()).collect();
|
||||
assert_eq!(groups, ["Inks"]);
|
||||
assert!(doc.swatch("Yellow ink").is_some_and(|s| s.spot), "an ink the art doesn't use");
|
||||
let red = &doc.layers[0].children().unwrap()[0];
|
||||
assert!(
|
||||
matches!(red.appearance.fill().map(|f| &f.paint), Some(vectorcraft_color::Paint::Solid { swatch: Some(n), .. }) if n == "Red ink"),
|
||||
"{:?}",
|
||||
red.appearance.fill()
|
||||
);
|
||||
}
|
||||
|
||||
/// A 1-bit RGB image opens from the editing data in the colors its PDF part shows.
|
||||
#[test]
|
||||
fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() {
|
||||
|
||||
@@ -80,8 +80,13 @@ fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() {
|
||||
let r = s.execute("document.export", &json!({"format": "eps", "useArtboards": true, "previewFormat": "none"})).unwrap();
|
||||
let files = r["files"].as_array().unwrap();
|
||||
let names: Vec<&str> = files.iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Untitled-1_Artboard-1.eps", "Untitled-1_Artboard-2.eps"]);
|
||||
for f in files {
|
||||
assert_eq!(names, ["Untitled-1.eps", "Untitled-1_Artboard-1.eps", "Untitled-1_Artboard-2.eps"]);
|
||||
// The central file comes first: the whole document, bounded by the visible art of both artboards.
|
||||
let central = b64(&files[0]);
|
||||
assert_eq!(dsc(&postscript(¢ral), "BoundingBox").as_deref(), Some(format!("10 60 {} 90", second + 40.0).as_str()));
|
||||
let native = vectorcraft_format::load(&vectorcraft_eps::native(¢ral).unwrap()).unwrap();
|
||||
assert_eq!((native.artboards.len(), native.layers[0].children().unwrap().len()), (2, 2));
|
||||
for f in &files[1..] {
|
||||
let bytes = b64(f);
|
||||
let ps = postscript(&bytes);
|
||||
assert_eq!(dsc(&ps, "BoundingBox").as_deref(), Some("0 0 100 100"));
|
||||
@@ -89,6 +94,10 @@ fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() {
|
||||
let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap();
|
||||
assert_eq!(native.artboards.len(), 1);
|
||||
}
|
||||
// Named artboards, two or more, get the central file too.
|
||||
let r = s.execute("document.exportEps", &json!({"artboards": [1, 0], "previewFormat": "none"})).unwrap();
|
||||
let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Untitled-1.eps", "Untitled-1_Artboard-2.eps", "Untitled-1_Artboard-1.eps"]);
|
||||
// A range names the artboards (useArtboards implied); one file keeps the path's name.
|
||||
let r = s.execute("document.exportEps", &json!({"range": "2", "previewFormat": "none"})).unwrap();
|
||||
assert!(r.get("files").is_none());
|
||||
@@ -97,6 +106,30 @@ fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() {
|
||||
// Without art there is nothing to bound.
|
||||
let mut empty = session(100.0, 100.0, 1);
|
||||
assert!(empty.execute("document.exportEps", &json!({})).unwrap_err().to_string().contains("nothing to export"));
|
||||
// Artboards without art still get their files, but no central file.
|
||||
let mut empty = session(100.0, 100.0, 2);
|
||||
let r = empty.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": "none"})).unwrap();
|
||||
assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_artboards_writes_the_central_file_beside_the_artboard_files() {
|
||||
let mut s = session(100.0, 100.0, 2);
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("vc-epsboards-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("logo.eps").to_string_lossy().to_string();
|
||||
s.execute("document.exportEps", &json!({"path": path, "useArtboards": true, "previewFormat": "none"})).unwrap();
|
||||
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
written.sort();
|
||||
assert_eq!(written, ["logo.eps", "logo_Artboard-1.eps", "logo_Artboard-2.eps"]);
|
||||
let central = std::fs::read(&path).unwrap();
|
||||
assert_eq!(dsc(&postscript(¢ral), "BoundingBox").as_deref(), Some("10 60 40 90"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
// Export for Screens still writes one EPS per artboard, with no central file.
|
||||
let rows = json!({"formats": [{"format": "eps", "useArtboards": true}], "settings": {"eps": {"useArtboards": true}}});
|
||||
let r = s.execute("document.exportForScreens", &rows).unwrap();
|
||||
assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Flatten Transparency…",
|
||||
["Object"],
|
||||
None,
|
||||
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
has_doc,
|
||||
flatten
|
||||
),
|
||||
@@ -268,7 +268,14 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if plan.flat.is_empty() && !plan.kept.iter().any(|id| src.node(*id).is_some_and(|n| kept_work(n, &o))) {
|
||||
return Ok(json!({ "ids": roots.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": 0, "vector": 0, "options": options }));
|
||||
}
|
||||
let (ids, vector, rasterized) = s.edit("Flatten Transparency", |d, sel| apply(d, sel, plan, &o))?;
|
||||
let (ids, vector, rasterized) = s.edit("Flatten Transparency", |d, sel| {
|
||||
let threaded = super::threads::frames(d);
|
||||
let out = apply(d, sel, plan, &o)?;
|
||||
// Frames of threaded text that were outlined or flattened leave their threads with the
|
||||
// text they show.
|
||||
super::threads::leave_threads(d, &threaded);
|
||||
Ok(out)
|
||||
})?;
|
||||
let mut out = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": rasterized, "vector": vector, "options": options });
|
||||
if let Some(w) = warning {
|
||||
out["warning"] = json!(w);
|
||||
|
||||
@@ -586,7 +586,9 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active, C)?;
|
||||
let item = item.of_kind(s, !stroke);
|
||||
let targets = match item {
|
||||
ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), |ids| Ok(outermost(&s.doc()?.doc, ids)))?)?,
|
||||
ItemTarget::Top => {
|
||||
leaf_targets(s, &super::given_objects(s, p, C)?.map_or_else(|| selected_roots(s), |ids| Ok(outermost(&s.doc()?.doc, ids)))?)?
|
||||
}
|
||||
_ => item.targets(s, p)?,
|
||||
};
|
||||
if targets.is_empty() {
|
||||
|
||||
@@ -806,7 +806,7 @@ fn column_art(
|
||||
if length(rect) <= 1e-9 || !length(rect).is_finite() {
|
||||
return None;
|
||||
}
|
||||
let cost = c.art.count();
|
||||
let cost = c.art.count_with_masks();
|
||||
// A copy of the design moved by `a`, the totals left in it showing `value`; a copy fitted to `cell`; and a copy moved
|
||||
// by `a` and clipped to what it paints, cut at `at` (an x when `along_x`, else a y) with the side below `at` kept when
|
||||
// `keep_low` (two objects more).
|
||||
@@ -2076,7 +2076,7 @@ fn design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, format!("a document keeps at most {} graph designs", GraphDesign::MAX)));
|
||||
}
|
||||
let art = super::brushsym::selection_art(s, p)?.ok_or_else(|| bad(C, "select the art for the design"))?;
|
||||
if art.count() > GraphDesign::MAX_NODES {
|
||||
if art.count_with_masks() > GraphDesign::MAX_NODES {
|
||||
return Err(bad(C, format!("a design holds at most {} objects", GraphDesign::MAX_NODES)));
|
||||
}
|
||||
// A graph saved as a design is kept as the art it draws.
|
||||
|
||||
@@ -768,6 +768,10 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ch = d.children_mut(parent)?;
|
||||
let index = index.min(ch.len());
|
||||
ch.splice(index..index, nodes);
|
||||
// Copied type whose layout caches `reid` dropped has them again.
|
||||
if copy {
|
||||
super::typecmd::refresh_bounds_of(d, &out);
|
||||
}
|
||||
sel.prune(d);
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(out)
|
||||
@@ -866,6 +870,7 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The last first, so the others keep their places; their guides go with them.
|
||||
for &i in indices.iter().rev() {
|
||||
let id = d.artboards.remove(i).id;
|
||||
d.retire_artboard_id(id);
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(id));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -388,6 +388,10 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ch = d.children_mut(Some(parent))?;
|
||||
let at = index.min(ch.len());
|
||||
ch.splice(at..at, layers);
|
||||
// Copied type whose layout caches `reid` dropped has them again.
|
||||
if build {
|
||||
super::typecmd::refresh_bounds_of(d, &new);
|
||||
}
|
||||
sel.prune(d);
|
||||
Ok(new)
|
||||
})?;
|
||||
|
||||
@@ -96,7 +96,15 @@ impl Visitor for LinkFiles {
|
||||
if let Some((Some(saved_size), Some(saved_hash))) = self.saved.get(i)
|
||||
&& *saved_size == size
|
||||
&& size <= MAX_HASHED
|
||||
&& hash.get_or_insert_with(|| std::fs::File::open(path).and_then(vectorcraft_doc::links::hash_reader).ok()).as_ref()
|
||||
&& hash
|
||||
.get_or_insert_with(|| {
|
||||
// Only the size looked at is read: a file growing meanwhile no longer
|
||||
// matches, rather than being hashed to its new end.
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let (hash, read) = vectorcraft_doc::links::hash_reader_within(file, size).ok()?;
|
||||
(read == size).then_some(hash)
|
||||
})
|
||||
.as_ref()
|
||||
== Some(saved_hash)
|
||||
{
|
||||
items.push(n + i);
|
||||
|
||||
@@ -231,7 +231,12 @@ impl Found {
|
||||
|
||||
/// `base` pointing at this file as read now (`bytes`).
|
||||
fn link(&self, bytes: &[u8], base: &LinkInfo) -> LinkInfo {
|
||||
LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash_bytes(bytes)), ..base.clone() }
|
||||
self.link_hashed(hash_bytes(bytes), base)
|
||||
}
|
||||
|
||||
/// [`Self::link`] with the bytes' hash already taken.
|
||||
fn link_hashed(&self, hash: String, base: &LinkInfo) -> LinkInfo {
|
||||
LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash), ..base.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,8 +547,11 @@ fn clip_image(d: &mut Document, id: NodeId, (frame, room): Clip) -> Result<()> {
|
||||
/// Replace image `id` with the raster at `path`, keeping the object's bounds (what an image
|
||||
/// variable does on `dataset.select`). The file is linked; a missing or unreadable path is an
|
||||
/// error so the dataset application can skip that binding.
|
||||
pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str) -> Result<()> {
|
||||
let path = absolute_path(path);
|
||||
pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_path: Option<&str>) -> Result<()> {
|
||||
// A path stored in the document (a data set's value) is followed as its other stored paths
|
||||
// are: on this computer or the document's own share only, so choosing a data set can't make
|
||||
// the app connect to another computer.
|
||||
let path = fileio::document_path(path, doc_path).map_err(|r| EngineError::Other(format!("the image `{path}` isn't followed: {r}")))?;
|
||||
let bytes = read_file(&path)?;
|
||||
let read = (link_info(&path, &bytes), fileio::raster_image(&bytes)?);
|
||||
install(d, &[id], read, Some(Preserve::Bounds))
|
||||
@@ -618,6 +626,10 @@ impl Resolved {
|
||||
/// image's bounds, which isn't a change to report. Nothing here is an undo step.
|
||||
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved {
|
||||
let mut out = Resolved::default();
|
||||
// The image (with its preview) and hash of each file read for a preview here, by path: images
|
||||
// of other keys linking to the same file share them, rather than each reading, decoding and
|
||||
// keeping a copy of its own.
|
||||
let mut read_images: BTreeMap<String, (ImageBlob, String)> = BTreeMap::new();
|
||||
for g in groups(d, None) {
|
||||
let row = rows(std::iter::once(&g), doc_path);
|
||||
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
|
||||
@@ -655,14 +667,24 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
|
||||
}
|
||||
(Status::Ok, Some(mut f)) => {
|
||||
let preview = previewing(d, &g.key);
|
||||
if let Some((blob, hash)) = read_images.get(&f.path).filter(|_| preview && !eps_preview).cloned() {
|
||||
store_image(d, &g.key, blob, true);
|
||||
let link = f.link_hashed(hash, &g.link);
|
||||
if link != g.link {
|
||||
set_links(d, &g.ids, &link);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Read when the images show the preview, or when the file's details changed.
|
||||
let bytes = if (preview && !eps_preview) || f.bytes.is_some() { f.read().ok() } else { None };
|
||||
if preview && !eps_preview {
|
||||
let Some(img) = bytes.as_deref().and_then(|b| fileio::raster_image(b).ok()) else {
|
||||
let Some((b, img)) = bytes.as_deref().and_then(|b| Some((b, fileio::raster_image(b).ok()?))) else {
|
||||
out.missing.extend(row);
|
||||
continue;
|
||||
};
|
||||
store_image(d, &g.key, img.blob, true);
|
||||
let blob = img.blob.with_proxy();
|
||||
read_images.insert(f.path.clone(), (blob.clone(), hash_bytes(b)));
|
||||
store_image(d, &g.key, blob, true);
|
||||
} else if let Some(b) = d.images.get(&g.key).filter(|b| b.proxy.is_none()) {
|
||||
// Linked before links kept a preview.
|
||||
let b = b.clone().with_proxy();
|
||||
@@ -1266,6 +1288,11 @@ fn placement_json(o: PlacementOptions) -> Value {
|
||||
|
||||
/// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`.
|
||||
fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
|
||||
// Within what the caller that opened the document may read, whoever asks.
|
||||
crate::file_access::confine(st.opened_by.as_ref(), || file_fields_here(st, id, l, out));
|
||||
}
|
||||
|
||||
fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
|
||||
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, st.path.as_deref()).0);
|
||||
let doc_path = st.path.as_deref();
|
||||
let path = fileio::document_path(&l.path, doc_path).ok();
|
||||
|
||||
@@ -140,6 +140,8 @@ pub(crate) fn begin(d: &mut Document, sel: &mut Selection, id: NodeId) -> Result
|
||||
let copy = d.reid(part);
|
||||
ids.push(d.insert(Some(layer), usize::MAX, copy)?);
|
||||
}
|
||||
// Copied type whose layout caches `reid` dropped has them again.
|
||||
super::typecmd::refresh_bounds_of(d, &ids);
|
||||
d.mask_edit = Some(MaskEdit { object: id, layer });
|
||||
sel.set(ids);
|
||||
Ok(layer)
|
||||
|
||||
@@ -969,10 +969,12 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if b.width() <= 0.0 || b.height() <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Named one past the highest artboard id the document has, whatever id it is given.
|
||||
let number = d.artboards.iter().map(|a| a.id).max().map_or(1, |m| m.saturating_add(1));
|
||||
let aid = d.next_artboard_id();
|
||||
d.artboards.push(vectorcraft_doc::Artboard {
|
||||
id: aid,
|
||||
name: format!("Artboard {aid}"),
|
||||
name: format!("Artboard {number}"),
|
||||
rect: b,
|
||||
locked: false,
|
||||
show_center_mark: false,
|
||||
|
||||
@@ -367,6 +367,22 @@ pub(crate) fn id_param(p: &Value, key: &str) -> Option<NodeId> {
|
||||
pub(crate) fn ids_param(p: &Value, key: &str) -> Option<Vec<NodeId>> {
|
||||
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect())
|
||||
}
|
||||
/// The objects `p` gives `command`: its `ids`, else its `id` as `ids` of one (as [`targets`] reads
|
||||
/// them), each checked ([`checked_id`]): a malformed one, or one naming no object, is an error,
|
||||
/// never the selection. `None` when it gives neither (or gives them as `null`).
|
||||
pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result<Option<Vec<NodeId>>> {
|
||||
if p.get("ids").is_some_and(|v| !v.is_null()) {
|
||||
return checked_ids_param(s, p, "ids", command).map(Some);
|
||||
}
|
||||
match p.get("id").filter(|v| !v.is_null()) {
|
||||
None => Ok(None),
|
||||
// A step reference a batch couldn't resolve, as `targets` reports it, under the command.
|
||||
Some(Value::String(r)) if r.starts_with('$') => {
|
||||
Err(bad(command, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve")))
|
||||
}
|
||||
Some(v) => Ok(Some(vec![checked_id(s, v, command)?])),
|
||||
}
|
||||
}
|
||||
/// `v` as an index, a count or a byte offset: past `usize::MAX` reads as `usize::MAX`, past the end of any list.
|
||||
pub(crate) fn index(v: u64) -> usize {
|
||||
usize::try_from(v).unwrap_or(usize::MAX)
|
||||
|
||||
@@ -1148,16 +1148,14 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
let menu = s.menu.clone();
|
||||
let st = s.doc_mut()?;
|
||||
if let Some(i) = st.isolation.take() {
|
||||
super::distortcmds::finish_edit_text(st, i);
|
||||
let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.extras().shaper.is_some())).unwrap_or(i);
|
||||
st.selection.set([target]);
|
||||
// A guide path isolated while Lock Guides is on isn't selected (#1534).
|
||||
if guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
crate::deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
// The art on a locked artboard can't be selected.
|
||||
st.protect_locked_artboards(&[], &Default::default(), None);
|
||||
}
|
||||
|
||||
@@ -237,6 +237,8 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
released.put(d, *id, parts)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
// Copied type whose layout caches `reid` dropped has them again.
|
||||
super::typecmd::refresh_bounds_of(d, &out);
|
||||
sel.set(out);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -684,7 +684,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// active item of the proxy's kind is recoloured in each selected object's own stack instead.
|
||||
fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
let stroke = stroke_param(s, p, cmd)?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
let ids = match super::given_objects(s, p, cmd)? {
|
||||
Some(ids) => ids,
|
||||
None => super::edit::selected_roots(s)?,
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -161,6 +161,32 @@ pub(crate) fn takes_key(doc: &'static str, key: &str) -> bool {
|
||||
doc_keys(doc).is_ok_and(|parsed| parsed.keys.contains(&key)) || accepted(doc, 0).is_some_and(|keys| keys.contains(&key))
|
||||
}
|
||||
|
||||
/// Does the description `doc` take `id` as `ids` of one object (`{ids?|id?}`, `{id?, ids?: …
|
||||
/// (instead of id)}`, `{id} or {ids: […]}`): it names both keys, and no params object of it names
|
||||
/// `ids` beside a required `id`, which is something else (`plugin.run {id, ids?}`: the plug-in's)?
|
||||
pub(crate) fn id_stands_for_ids(doc: &'static str) -> bool {
|
||||
fn head(alternative: &str) -> &str {
|
||||
alternative.split([':', '=', '(']).next().unwrap_or_default().trim()
|
||||
}
|
||||
if !takes_key(doc, "ids") || !takes_key(doc, "id") {
|
||||
return false;
|
||||
}
|
||||
let mut rest = doc;
|
||||
while let Ok((body, after)) = object_body(rest) {
|
||||
let entries = split_top(body, ',');
|
||||
let required_id = entries.iter().any(|e| split_top(e, '|').len() == 1 && head(e) == "id");
|
||||
let names_ids = entries.iter().any(|e| split_top(e, '|').iter().any(|a| head(a).trim_end_matches('?').trim_end() == "ids"));
|
||||
if required_id && names_ids {
|
||||
return false;
|
||||
}
|
||||
match after.trim_start().strip_prefix("or ").or_else(|| after.trim_start().strip_prefix("| ")).filter(|r| r.starts_with('{')) {
|
||||
Some(next) => rest = next,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The edits (insert, delete, replace, swap of neighbours) that turn `a` into `b`, ignoring case.
|
||||
fn distance(a: &str, b: &str) -> usize {
|
||||
let (a, b): (Vec<char>, Vec<char>) = (a.chars().map(|c| c.to_ascii_lowercase()).collect(), b.chars().map(|c| c.to_ascii_lowercase()).collect());
|
||||
@@ -202,7 +228,7 @@ pub fn check_keys(id: &str, doc: &'static str, params: &Value) -> Result<()> {
|
||||
|
||||
/// The parameter error of command `id` for `key`, which isn't one of `keys`: with the nearest of
|
||||
/// them, or all of them.
|
||||
fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
|
||||
pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
|
||||
bad(
|
||||
id,
|
||||
match nearest(key, keys) {
|
||||
|
||||
@@ -126,8 +126,13 @@ pub(crate) fn document_art(
|
||||
return super::pdf_art(name, bytes, path, page as usize, o, bounding, cmd);
|
||||
}
|
||||
let mut l = fileio::load(name, bytes)?;
|
||||
// Its linked images show their files; its placed documents stay as they are.
|
||||
super::super::links::resolve(&mut l.doc, path, false, false);
|
||||
// Its linked images show their files; its placed documents stay as they are, unless their
|
||||
// stored bytes are lost, when resolving reads their files, which can place this one again.
|
||||
// A document already being read further up, or past [`MAX_NESTED_READS`] of them, has its links
|
||||
// left as stored.
|
||||
if let Some(_reading) = Reading::enter(bytes) {
|
||||
super::super::links::resolve(&mut l.doc, path, false, false);
|
||||
}
|
||||
l.doc.drop_edit_modes();
|
||||
let n = l.doc.artboards.len();
|
||||
let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect);
|
||||
@@ -140,6 +145,33 @@ pub(crate) fn document_art(
|
||||
Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings })
|
||||
}
|
||||
|
||||
/// The most placed documents read inside one another while their links are resolved.
|
||||
const MAX_NESTED_READS: usize = 8;
|
||||
|
||||
std::thread_local! {
|
||||
/// The hashes of the documents whose links are being resolved on this thread, outermost first.
|
||||
static READING: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// A document's links being resolved ([`READING`]): the entry goes when this does.
|
||||
struct Reading;
|
||||
|
||||
impl Reading {
|
||||
/// `None` when the document of `bytes` is already being read, or [`MAX_NESTED_READS`] are.
|
||||
fn enter(bytes: &[u8]) -> Option<Self> {
|
||||
let hash = hash_bytes(bytes);
|
||||
READING.with_borrow_mut(|r| (r.len() < MAX_NESTED_READS && !r.contains(&hash)).then(|| r.push(hash))).map(|()| Reading)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Reading {
|
||||
fn drop(&mut self) {
|
||||
READING.with_borrow_mut(|r| {
|
||||
r.pop();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `nodes` as one group, clipped to `clip` (its own ids: what drawing reads).
|
||||
fn group(nodes: Vec<Node>, clip: Option<Rect>) -> Node {
|
||||
let mut children: Vec<Arc<Node>> = nodes.into_iter().map(Arc::new).collect();
|
||||
@@ -159,8 +191,12 @@ pub(crate) fn read_document(p: &PlacedDocument, bytes: &[u8]) -> Option<(Documen
|
||||
/// `p`'s file read again, while it is the file it was read from (how the document model gets it
|
||||
/// when only the preview is stored, [`vectorcraft_doc::placed_document::set_file_reader`]).
|
||||
pub(crate) fn read_file_again(p: &PlacedDocument) -> Option<Vec<u8>> {
|
||||
// The stored path is followed as stored paths are, but with no document to share a network
|
||||
// folder with: a file on another computer is never read here, even one resolving refused
|
||||
// (which leaves the stored path in place), so embedding or exporting can't connect to it.
|
||||
let path = fileio::document_path(&p.link.path, None).ok()?;
|
||||
// One byte over the limit tells a file that is too big, without reading it all.
|
||||
let bytes = fileio::read_file_head(&p.link.path, MAX_FILE + 1).ok()?;
|
||||
let bytes = fileio::read_file_head(&path, MAX_FILE + 1).ok()?;
|
||||
if bytes.len() > MAX_FILE {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@ const MAX_BASED_ON: usize = 32;
|
||||
const MAX_FONTS: usize = 4096;
|
||||
/// The most bytes of a font name kept.
|
||||
const MAX_NAME: usize = 256;
|
||||
/// The most bytes of a style id: a longer one (Word's are under 256 characters) is ignored, both
|
||||
/// where it is defined and where it is used, since every paragraph and run looks its style up by
|
||||
/// it.
|
||||
const MAX_STYLE_ID: usize = 256;
|
||||
/// The most paragraphs placed type takes from one Word document.
|
||||
const MAX_PARAGRAPHS: usize = 100_000;
|
||||
/// The most runs of one character formatting placed type takes from one Word document.
|
||||
@@ -484,7 +488,7 @@ impl Reader {
|
||||
}
|
||||
}
|
||||
Some("style") if self.styles.len() < MAX_STYLES => {
|
||||
let Some(id) = attr(n, "styleId") else { continue };
|
||||
let Some(id) = attr(n, "styleId").filter(|id| id.len() <= MAX_STYLE_ID) else { continue };
|
||||
let default = match attr(n, "type") {
|
||||
Some("paragraph") => Some(&mut self.default_style),
|
||||
Some("character") => Some(&mut self.default_char_style),
|
||||
@@ -495,7 +499,7 @@ impl Reader {
|
||||
}
|
||||
let run = child(n, "rPr").map(|r| self.run_props(r)).unwrap_or_default();
|
||||
let para = child(n, "pPr").map(para_props).unwrap_or_default();
|
||||
let based_on = child(n, "basedOn").and_then(|b| attr(b, "val")).map(str::to_string);
|
||||
let based_on = child(n, "basedOn").and_then(|b| attr(b, "val")).filter(|v| v.len() <= MAX_STYLE_ID).map(str::to_string);
|
||||
self.styles.entry(id.to_string()).or_insert(Style { based_on, run, para });
|
||||
}
|
||||
_ => {}
|
||||
@@ -637,7 +641,12 @@ impl Reader {
|
||||
fn paragraph(&mut self, p: Node, depth: usize) {
|
||||
self.flush();
|
||||
let ppr = child(p, "pPr");
|
||||
let style = ppr.and_then(|n| child(n, "pStyle")).and_then(|n| attr(n, "val")).map(str::to_string).or_else(|| self.default_style.clone());
|
||||
let style = ppr
|
||||
.and_then(|n| child(n, "pStyle"))
|
||||
.and_then(|n| attr(n, "val"))
|
||||
.filter(|v| v.len() <= MAX_STYLE_ID)
|
||||
.map(str::to_string)
|
||||
.or_else(|| self.default_style.clone());
|
||||
let mut para = Para::default();
|
||||
para.apply(&self.default_para);
|
||||
let mut base = Chars::default();
|
||||
@@ -688,7 +697,11 @@ impl Reader {
|
||||
fn run(&mut self, r: Node) {
|
||||
let rpr = child(r, "rPr");
|
||||
let mut chars = self.base;
|
||||
let style = rpr.and_then(|n| child(n, "rStyle")).and_then(|n| attr(n, "val")).or(self.default_char_style.as_deref());
|
||||
let style = rpr
|
||||
.and_then(|n| child(n, "rStyle"))
|
||||
.and_then(|n| attr(n, "val"))
|
||||
.filter(|v| v.len() <= MAX_STYLE_ID)
|
||||
.or(self.default_char_style.as_deref());
|
||||
for s in self.chain(style) {
|
||||
chars.apply(&s.run, true);
|
||||
}
|
||||
|
||||
@@ -543,7 +543,9 @@ impl Session {
|
||||
self.prefs = p;
|
||||
if fonts_folder_changed {
|
||||
let folder = self.prefs.fonts_folder.trim();
|
||||
vectorcraft_text::set_user_font_dirs(if folder.is_empty() { vec![] } else { vec![folder.into()] });
|
||||
// A folder an automation caller chose is read without following its links out of it.
|
||||
let confined = crate::file_access::current().is_some();
|
||||
vectorcraft_text::set_user_font_dirs_confined(if folder.is_empty() { vec![] } else { vec![folder.into()] }, confined);
|
||||
// Once the fonts were scanned, scan again now: the folder's fonts appear (or go) in the
|
||||
// font menus, and type in them lays out again. The first scan reads it anyway.
|
||||
if vectorcraft_text::FontDb::global().installed_fonts_changed() {
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print",
|
||||
[],
|
||||
Some("Cmd+P"),
|
||||
"{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device",
|
||||
"{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out), forPrinter?: bool (the PDF goes to a print queue: a page whose paper is turned to landscape is written upright, the art turned a quarter anticlockwise, as a printer driver turns a landscape job; macOS prints a landscape page as it is, cut off on upright paper; paper wide in Portrait and transverse pages stay as they are)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device",
|
||||
has_doc,
|
||||
print
|
||||
),
|
||||
@@ -187,7 +187,9 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !postscript {
|
||||
let printed = prepared.as_ref().unwrap_or(doc);
|
||||
let typed = type_warnings(&full, which, &areas(&vectorcraft_pdf::preview(printed, &set).map_err(|e| pdf_error(PRINT, e))?));
|
||||
let mut r = vectorcraft_pdf::print(printed, &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(PRINT, e))?;
|
||||
let mut r =
|
||||
vectorcraft_pdf::print(printed, &PrintOptions { settings: set, for_printer: bool_or(p, "forPrinter", false), ..Default::default() })
|
||||
.map_err(|e| pdf_error(PRINT, e))?;
|
||||
r.warnings.extend(typed);
|
||||
return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings }));
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
//! Edit → Edit Colors → Recolor Artwork: list the selection's colours, reduce them to rows of
|
||||
//! Edit → Edit Colors → Recolor Artwork: list the colors of the selection (or of the art Select All
|
||||
//! takes when nothing is selected), reduce them to rows of
|
||||
//! similar colours, and remap each row to a new colour by a recolour method. Colours are keyed by
|
||||
//! their identity ([`ColorKey`]: model and exact values), so a CMYK colour stays CMYK and two
|
||||
//! colours that only look alike stay apart.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::recolor::{ColorKey, Method, Preserve, Rng, RowMap, cluster};
|
||||
use vectorcraft_color::{Color, Paint, Swatch};
|
||||
use vectorcraft_doc::swatches::node_colors;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, NodeId, NodeKind};
|
||||
use vectorcraft_doc::swatches::{node_colors, node_paints};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::colorcmds::{Recolor, Scope};
|
||||
use super::*;
|
||||
@@ -21,8 +22,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Artwork Colors",
|
||||
[],
|
||||
None,
|
||||
"{} → {colors: [{key: colour identity (\"rgb 255 0 0\", \"cmyk 0 100 100 0\", \"gray 40\", \"lab 55 60 -40\"), hex, count, swatch?: the global swatch it is linked to}]} unique colours used by the selection (fills, strokes, gradient stops, text, mesh points and the tiles of pattern fills and strokes), most used first",
|
||||
has_selection,
|
||||
"{ids?: object ids (default: the selection, or with nothing selected the objects select.all takes, without the hidden and locked objects inside them)} → {colors: [{key: colour identity (\"rgb 255 0 0\", \"cmyk 0 100 100 0\", \"gray 40\", \"lab 55 60 -40\"), hex, count, swatch?: the global swatch it is linked to}]} unique colours used by the objects (fills, strokes, gradient stops, text, mesh points and the tiles of pattern fills and strokes), most used first",
|
||||
has_doc,
|
||||
colors
|
||||
),
|
||||
cmd!(
|
||||
@@ -30,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Recolor Artwork",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], note?: set when a colour kept a model other than its new colour's (see model)}",
|
||||
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model)}",
|
||||
has_doc,
|
||||
apply
|
||||
),
|
||||
@@ -39,8 +40,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reduce Colors",
|
||||
[],
|
||||
None,
|
||||
"{colors?: n (rows; default: one per colour, with the tints of a global swatch in its row), method?: as recolor.apply; default scaleTints (picks each row's colour), preserve?: {white?: true, black?: true, grays?: false} (left out of the rows), limitTo?: as recolor.apply (new colours snap to it)} group the selection's colours into rows of similar colours (k-means in Lab, weighted by use) → {map: [{from: [keys, the row's colour first], to: key}], preserved: [keys], method}",
|
||||
has_selection,
|
||||
"{colors?: n (rows; default: one per colour, with the tints of a global swatch in its row), method?: as recolor.apply; default scaleTints (picks each row's colour), preserve?: {white?: true, black?: true, grays?: false} (left out of the rows), limitTo?: as recolor.apply (new colours snap to it), ids?: as recolor.colors} group the colors of the objects (default: as recolor.colors) into rows of similar colours (k-means in Lab, weighted by use) → {map: [{from: [keys, the row's colour first], to: key}], preserved: [keys], method}",
|
||||
has_doc,
|
||||
reduce
|
||||
),
|
||||
cmd!(
|
||||
@@ -62,9 +63,19 @@ struct Used {
|
||||
swatch: Option<String>,
|
||||
}
|
||||
|
||||
/// The unique colours of the selection, most used first (ties by key): fills, strokes, gradient
|
||||
/// stops, text, mesh points and the tiles of the patterns used as fills or strokes.
|
||||
fn used_colors(d: &Document, ids: &[NodeId]) -> Vec<Used> {
|
||||
/// The unique colours of the objects `ids` and the objects inside them, most used first (ties by
|
||||
/// key): fills, strokes, gradient stops, text, mesh points and the tiles of the patterns used as
|
||||
/// fills or strokes. With `only`, an object not in it is left out with everything it holds.
|
||||
fn used_colors(d: &Document, ids: &[NodeId], only: Option<&BTreeSet<NodeId>>) -> Vec<Used> {
|
||||
fn read<'a>(n: &'a Node, only: Option<&BTreeSet<NodeId>>, out: &mut Vec<&'a Node>) {
|
||||
if only.is_some_and(|o| !o.contains(&n.id)) {
|
||||
return;
|
||||
}
|
||||
out.push(n);
|
||||
for c in n.children().into_iter().flatten() {
|
||||
read(c, only, out);
|
||||
}
|
||||
}
|
||||
let mut index: HashMap<ColorKey, usize> = HashMap::new();
|
||||
let mut used: Vec<(ColorKey, Used)> = vec![];
|
||||
let mut count = |c: &Color, link: Option<&str>| {
|
||||
@@ -81,25 +92,22 @@ fn used_colors(d: &Document, ids: &[NodeId]) -> Vec<Used> {
|
||||
};
|
||||
// The patterns used as fills or strokes: their tiles count once each.
|
||||
let mut patterns: Vec<&str> = vec![];
|
||||
let mut nodes = vec![];
|
||||
for n in ids.iter().filter_map(|id| d.node(*id)) {
|
||||
node_colors(n, &mut count);
|
||||
n.walk(&mut |m| {
|
||||
let runs = match &m.kind {
|
||||
NodeKind::Text(t) => t.runs.as_slice(),
|
||||
_ => &[],
|
||||
};
|
||||
let items = m.appearance.items.iter().map(|it| match it {
|
||||
AppearanceItem::Fill(l) => &l.paint,
|
||||
AppearanceItem::Stroke(l) => &l.paint,
|
||||
});
|
||||
for p in items.chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])) {
|
||||
if let Paint::Pattern { pattern, .. } = p
|
||||
&& !patterns.contains(&pattern.as_str())
|
||||
{
|
||||
patterns.push(pattern);
|
||||
}
|
||||
read(n, only, &mut nodes);
|
||||
}
|
||||
for m in nodes {
|
||||
for p in node_paints(m) {
|
||||
match p {
|
||||
Paint::Solid { color, swatch, .. } => count(color, swatch.as_deref()),
|
||||
Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| count(&s.color, s.swatch.as_deref())),
|
||||
Paint::Pattern { pattern, .. } if !patterns.contains(&pattern.as_str()) => patterns.push(pattern),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
if let NodeKind::Mesh(mesh) = &m.kind {
|
||||
mesh.points.iter().for_each(|p| count(&p.color, None));
|
||||
}
|
||||
}
|
||||
for def in patterns.iter().filter_map(|p| d.pattern(p)) {
|
||||
def.art.iter().for_each(|n| node_colors(n, &mut count));
|
||||
@@ -108,9 +116,30 @@ fn used_colors(d: &Document, ids: &[NodeId]) -> Vec<Used> {
|
||||
used.into_iter().map(|(_, u)| u).collect()
|
||||
}
|
||||
|
||||
fn colors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// The objects whose colors `recolor.colors` and `recolor.reduce` read, for [`used_colors`]: `ids`
|
||||
/// (each checked) or else the selection, with everything they hold; with nothing selected, the
|
||||
/// objects Select All takes and the objects inside them, leaving out every hidden or locked object
|
||||
/// with what it holds and the objects of hidden, locked and template layers (the second value
|
||||
/// lists the objects read). Objects listed twice or inside another one listed are dropped, so
|
||||
/// their colors count once.
|
||||
fn scope_ids(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<NodeId>, Option<BTreeSet<NodeId>>)> {
|
||||
let st = s.doc()?;
|
||||
let v: Vec<Value> = used_colors(&st.doc, &st.selection.objects)
|
||||
let ids = match p.get("ids") {
|
||||
Some(_) => checked_ids_param(s, p, "ids", cmd)?,
|
||||
None if st.selection.is_empty() => {
|
||||
let only = super::select::selectable_ids(&st.doc, st.isolation);
|
||||
return Ok((super::select::selectable(&st.doc, st.isolation), Some(only)));
|
||||
}
|
||||
None => st.selection.objects.clone(),
|
||||
};
|
||||
let mut seen = BTreeSet::new();
|
||||
Ok((super::edit::outermost(&st.doc, ids).into_iter().filter(|id| seen.insert(*id)).collect(), None))
|
||||
}
|
||||
|
||||
fn colors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (ids, only) = scope_ids(s, p, "recolor.colors")?;
|
||||
let st = s.doc()?;
|
||||
let v: Vec<Value> = used_colors(&st.doc, &ids, only.as_ref())
|
||||
.iter()
|
||||
.map(|u| {
|
||||
let mut v = json!({"key": key_str(&u.color), "hex": u.color.to_hex(), "count": u.count});
|
||||
@@ -145,8 +174,9 @@ fn reduce(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let def = Preserve::default();
|
||||
let flag = |k: &str, d: bool| p.get("preserve").map_or(d, |v| bool_or(v, k, d));
|
||||
let preserve = Preserve { white: flag("white", def.white), black: flag("black", def.black), grays: flag("grays", def.grays) };
|
||||
let (ids, only) = scope_ids(s, p, C)?;
|
||||
let st = s.doc()?;
|
||||
let (kept, used): (Vec<Used>, Vec<Used>) = used_colors(&st.doc, &st.selection.objects).into_iter().partition(|u| preserve.keeps(&u.color));
|
||||
let (kept, used): (Vec<Used>, Vec<Used>) = used_colors(&st.doc, &ids, only.as_ref()).into_iter().partition(|u| preserve.keeps(&u.color));
|
||||
// Units that stay together: the tints of a global swatch, else one colour each.
|
||||
let mut units: Vec<Vec<&Used>> = vec![];
|
||||
for u in &used {
|
||||
@@ -261,11 +291,23 @@ struct Link {
|
||||
color: Color,
|
||||
}
|
||||
|
||||
/// A global swatch of the color group `recolor.apply` rewrites: its name, the color it is
|
||||
/// rewritten with and the color art linked to it shows afterwards.
|
||||
struct GroupSwatch {
|
||||
name: String,
|
||||
color: Color,
|
||||
shown: Color,
|
||||
}
|
||||
|
||||
/// A parsed map ready to recolour: the row of each current colour, and the swatch of each row's
|
||||
/// new colour.
|
||||
struct ColorMap {
|
||||
rows: Vec<RowMap>,
|
||||
links: Vec<Option<Link>>,
|
||||
/// The new color of each of `rows`.
|
||||
tos: Vec<Color>,
|
||||
/// The global swatches of the color group being rewritten.
|
||||
group: Vec<GroupSwatch>,
|
||||
keys: HashMap<ColorKey, usize>,
|
||||
hexes: HashMap<String, usize>,
|
||||
/// Set when a colour took a new colour of another model in its own model.
|
||||
@@ -273,14 +315,17 @@ struct ColorMap {
|
||||
}
|
||||
|
||||
impl ColorMap {
|
||||
/// `links`: the swatch of each of `rows`' new colours (as many as `rows`, or none).
|
||||
fn new(rows: &[Row], method: Method, own_model: bool, links: &[Option<Link>]) -> Self {
|
||||
let mut out = Self { rows: vec![], links: vec![], keys: HashMap::new(), hexes: HashMap::new(), converted: Default::default() };
|
||||
/// `links`: the swatch of each of `rows`' new colours (as many as `rows`, or none); `group`: the
|
||||
/// global swatches of the color group being rewritten.
|
||||
fn new(rows: &[Row], method: Method, own_model: bool, links: &[Option<Link>], group: Vec<GroupSwatch>) -> Self {
|
||||
let mut out =
|
||||
Self { rows: vec![], links: vec![], tos: vec![], group, keys: HashMap::new(), hexes: HashMap::new(), converted: Default::default() };
|
||||
for (n, r) in rows.iter().enumerate().filter(|(_, r)| !r.from.is_empty()) {
|
||||
let Some(to) = r.new_color() else { continue };
|
||||
let i = out.rows.len();
|
||||
let colors: Vec<Color> = r.from.iter().map(Match::color).collect();
|
||||
out.rows.push(RowMap::new(method, &colors, to).with_own_model(own_model));
|
||||
out.tos.push(to);
|
||||
out.links.push(links.get(n).cloned().flatten());
|
||||
for m in &r.from {
|
||||
match m {
|
||||
@@ -307,11 +352,26 @@ impl ColorMap {
|
||||
|
||||
/// Recolour a colour that can link to a swatch: a row whose new colour is a global or spot
|
||||
/// swatch links its colours to it (at their tints; shades and other hues stay unlinked), a
|
||||
/// process swatch gives its colour in its own model; other rows map the colour, which loses its
|
||||
/// link when it changes. True when anything changed.
|
||||
/// process swatch gives its colour in its own model; a color linked to a global swatch of the
|
||||
/// rewritten group that takes the row's new color keeps its link (at its tint in the row) when
|
||||
/// the row gives it the new color in that color's model;
|
||||
/// other rows map the colour, which loses its link when it changes or when the group's rewrite
|
||||
/// would change it. True when anything changed.
|
||||
fn map_link(&self, c: &mut Color, link: &mut Option<String>, tint: &mut f32) -> bool {
|
||||
let Some(i) = self.row_of(*c) else { return false };
|
||||
let (Some(row), Some(l)) = (self.rows.get(i), self.links.get(i)) else { return false };
|
||||
if l.is_none()
|
||||
&& let Some(g) = link.as_deref().and_then(|n| self.group.iter().find(|g| g.name == n))
|
||||
&& self.tos.get(i) == Some(&g.color)
|
||||
&& !row.converts(*c)
|
||||
&& let Some(t) = row.tint(*c)
|
||||
{
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let shown = g.shown.tinted(t);
|
||||
let changed = *tint != t || *c != shown;
|
||||
(*c, *tint) = (shown, t);
|
||||
return changed;
|
||||
}
|
||||
let new = match (l, row.tint(*c)) {
|
||||
(Some(l), Some(t)) if l.global => {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
@@ -328,7 +388,10 @@ impl ColorMap {
|
||||
row.map(*c)
|
||||
}
|
||||
};
|
||||
if new == *c {
|
||||
// A color its row leaves as it is still loses its link to a swatch of the rewritten group
|
||||
// when the rewrite would give it another color.
|
||||
let rewritten = link.as_deref().and_then(|n| self.group.iter().find(|g| g.name == n)).is_some_and(|g| g.shown.tinted(*tint) != new);
|
||||
if new == *c && !rewritten {
|
||||
return false;
|
||||
}
|
||||
*c = new;
|
||||
@@ -418,17 +481,31 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Link { name, global: w.global, color })
|
||||
})
|
||||
.collect();
|
||||
let map = ColorMap::new(&rows, method, own_model, &links);
|
||||
// The group's global swatches that its rewrite gives a color: art linked to one of them
|
||||
// whose row's new color is that color stays linked.
|
||||
let kept: Vec<GroupSwatch> = group
|
||||
.and_then(|g| d.swatch_groups.iter().find(|x| x.name == g))
|
||||
.map(|g| {
|
||||
g.swatches
|
||||
.iter()
|
||||
.zip(&new)
|
||||
.filter(|(w, _)| w.global && !w.is_reserved())
|
||||
.map(|(w, c)| GroupSwatch { name: w.name.clone(), color: *c, shown: d.linked_color(*c, w.spot) })
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let map = ColorMap::new(&rows, method, own_model, &links, kept);
|
||||
let f = |c: Color| map.map(c);
|
||||
let lf = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| map.map_link(c, link, tint);
|
||||
let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids);
|
||||
let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?;
|
||||
Ok((changed, edit, added, map.converted.get()))
|
||||
})?;
|
||||
let mut out = json!({ "changed": changed, "swatches": added });
|
||||
if let Some(e) = edit {
|
||||
e.defaults(s);
|
||||
out["relinked"] = json!(e.relinked());
|
||||
}
|
||||
let mut out = json!({ "changed": changed, "swatches": added });
|
||||
if converted {
|
||||
out["note"] = json!(
|
||||
"some colours kept their own colour model, so the new colour was converted into it and may look different; \
|
||||
|
||||
@@ -264,6 +264,22 @@ impl FolderStore {
|
||||
builder.create(dir).map_err(|e| format!("{}: {e}", dir.display()))
|
||||
}
|
||||
|
||||
/// Refuse area folder `dir` unless it is a folder itself, not a link to one, and (on Unix)
|
||||
/// belongs to this account. Listing areas leaves links out, so a name another account had
|
||||
/// linked to a folder of its own in a shared recovery folder looked free, and claiming it
|
||||
/// wrote the copies through the link.
|
||||
fn own_area(dir: &std::path::Path) -> std::result::Result<(), String> {
|
||||
let m = std::fs::symlink_metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
if !m.file_type().is_dir() {
|
||||
return Err(format!("{}: not a folder of its own", dir.display()));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
if std::os::unix::fs::MetadataExt::uid(&m) != rustix::process::geteuid().as_raw() {
|
||||
return Err(format!("{}: belongs to another account", dir.display()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The plain names of the files (or, `dirs`, folders) in `dir`; none when it doesn't exist.
|
||||
fn names(dir: &std::path::Path, dirs: bool) -> std::result::Result<Vec<String>, String> {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
@@ -298,6 +314,7 @@ impl RecoveryStore for FolderStore {
|
||||
let f = self.file(name)?;
|
||||
if let Some(dir) = f.parent() {
|
||||
Self::create_area(dir)?;
|
||||
Self::own_area(dir)?;
|
||||
}
|
||||
vectorcraft_format::write_atomic(&f, bytes).map_err(|e| format!("{}: {e}", f.display()))
|
||||
}
|
||||
@@ -317,6 +334,7 @@ impl RecoveryStore for FolderStore {
|
||||
fn lock(&self, area: &str) -> std::result::Result<Lock, String> {
|
||||
let dir = self.area(area)?;
|
||||
Self::create_area(&dir)?;
|
||||
Self::own_area(&dir)?;
|
||||
let path = dir.join(LOCK_FILE);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
@@ -571,17 +589,35 @@ static WRITES: Mutex<()> = Mutex::new(());
|
||||
type Named = (Weak<Vec<u8>>, String);
|
||||
|
||||
/// The entry names of image bytes already hashed ([`blob_name`]), so an image is hashed once,
|
||||
/// not at every interval. Bytes behind a live `Weak` never change (`Arc::make_mut` copies them).
|
||||
static NAMES: Mutex<Vec<Named>> = Mutex::new(Vec::new());
|
||||
/// not at every interval, keyed by the bytes' address. Bytes behind a live `Weak` never change
|
||||
/// (`Arc::make_mut` copies them).
|
||||
static NAMES: Mutex<Names> = Mutex::new(Names { by_address: None, live_after_sweep: 0 });
|
||||
|
||||
struct Names {
|
||||
by_address: Option<std::collections::HashMap<usize, Named>>,
|
||||
/// How many entries were live at the last sweep of dead ones: the next sweep waits until there
|
||||
/// are twice as many, so sweeping costs a constant per name, not a pass over all of them.
|
||||
live_after_sweep: usize,
|
||||
}
|
||||
|
||||
/// The name of the entry holding `bytes`: from their content (128 bits of hash and the length), so
|
||||
/// identical images share one entry and one written before isn't written again.
|
||||
fn blob_name(bytes: &Arc<Vec<u8>>) -> String {
|
||||
let mut names = NAMES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
names.retain(|(w, _)| w.strong_count() > 0);
|
||||
if let Some((_, name)) = names.iter().find(|(w, _)| std::ptr::eq(w.as_ptr(), Arc::as_ptr(bytes))) {
|
||||
let mut guard = NAMES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Names { by_address, live_after_sweep } = &mut *guard;
|
||||
let names = by_address.get_or_insert_with(Default::default);
|
||||
let address = Arc::as_ptr(bytes) as usize;
|
||||
// A dead entry at this address named other bytes that were freed there before.
|
||||
if let Some((w, name)) = names.get(&address)
|
||||
&& w.strong_count() > 0
|
||||
&& std::ptr::eq(w.as_ptr(), Arc::as_ptr(bytes))
|
||||
{
|
||||
return name.clone();
|
||||
}
|
||||
if names.len() >= (*live_after_sweep * 2).max(64) {
|
||||
names.retain(|_, (w, _)| w.strong_count() > 0);
|
||||
*live_after_sweep = names.len();
|
||||
}
|
||||
let hash = |seed: u8| {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
seed.hash(&mut h);
|
||||
@@ -589,7 +625,7 @@ fn blob_name(bytes: &Arc<Vec<u8>>) -> String {
|
||||
h.finish()
|
||||
};
|
||||
let name = format!("img-{:016x}{:016x}-{}{BLOB_EXT}", hash(1), hash(2), bytes.len());
|
||||
names.push((Arc::downgrade(bytes), name.clone()));
|
||||
names.insert(address, (Arc::downgrade(bytes), name.clone()));
|
||||
name
|
||||
}
|
||||
|
||||
@@ -780,6 +816,7 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
|
||||
name: format!("{file}{DOC_EXT}"),
|
||||
folder: None,
|
||||
modified: None,
|
||||
notes: Vec::new(),
|
||||
};
|
||||
let save = fileio::job_for(s, st, plan)?;
|
||||
let meta = Meta {
|
||||
|
||||
@@ -8,6 +8,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::wand::{fill_of, stroke_of};
|
||||
use super::*;
|
||||
use crate::DocState;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -103,7 +104,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill Color",
|
||||
["Select", "Same"],
|
||||
None,
|
||||
"{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent)",
|
||||
"{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent); a member path of a compound path selected first compares as its compound path, except in isolation mode inside that compound path",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let tint = s.prefs.select_same_tint_percent;
|
||||
@@ -115,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Color",
|
||||
["Select", "Same"],
|
||||
None,
|
||||
"{} the objects stroked with the first selected object's stroke colour (tints as select.same.fillColor)",
|
||||
"{} the objects stroked with the first selected object's stroke colour (tints and compound paths as select.same.fillColor)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let tint = s.prefs.select_same_tint_percent;
|
||||
@@ -132,7 +133,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill & Stroke",
|
||||
["Select", "Same"],
|
||||
None,
|
||||
"{} the objects with the first selected object's fill and stroke colours (tints as select.same.fillColor)",
|
||||
"{} the objects with the first selected object's fill and stroke colours (tints and compound paths as select.same.fillColor)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let tint = s.prefs.select_same_tint_percent;
|
||||
@@ -179,7 +180,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Graphic Style",
|
||||
["Select", "Same"],
|
||||
None,
|
||||
"{} the objects linked to the graphic style the first selected object is linked to",
|
||||
"{} the objects linked to the graphic style the first selected object is linked to (compound paths as select.same.fillColor)",
|
||||
has_selection,
|
||||
same_graphic_style
|
||||
),
|
||||
@@ -188,7 +189,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Appearance Attribute",
|
||||
["Select", "Same"],
|
||||
None,
|
||||
"{item?: appearance item index} the objects sharing an appearance attribute of the first selected object: its fill or stroke `item` (default: the Appearance panel's active item), else its first effect, else its topmost fill",
|
||||
"{item?: appearance item index} the objects sharing an appearance attribute of the first selected object: its fill or stroke `item` (default: the Appearance panel's active item), else its first effect, else its topmost fill (compound paths as select.same.fillColor)",
|
||||
has_selection,
|
||||
same_attribute
|
||||
),
|
||||
@@ -199,7 +200,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
/// The objects Select All takes: those of the isolated container, else of every layer (looking
|
||||
/// through sublayers, which are not objects). In isolation mode there are none while the isolated
|
||||
/// container, or a group or layer it is in, is hidden, locked or a template layer.
|
||||
fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
|
||||
pub(crate) fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
|
||||
match iso.and_then(|i| d.node(i)) {
|
||||
Some(c) if d.ancestry(c.id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template()))) => {
|
||||
// A single object isolated is all there is to select.
|
||||
@@ -663,15 +664,32 @@ fn paint_reported(d: &Document, n: &Node) -> bool {
|
||||
compound(n) || (!n.is_container() && !d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(compound))
|
||||
}
|
||||
|
||||
/// The object Select > Same compares the others with, and whether isolation mode is inside a
|
||||
/// compound path. The object is the first selected one, or its compound path when it is a member
|
||||
/// of one; in isolation mode inside a compound path it is the member path itself.
|
||||
fn same_reference<'a>(st: &'a DocState, cmd: &str) -> Result<(&'a Node, bool)> {
|
||||
let compound = |n: &Node| matches!(n.kind, NodeKind::Compound { .. });
|
||||
let members = st.isolation.and_then(|i| st.doc.ancestry(i)).is_some_and(|a| a.iter().any(|i| st.doc.node(*i).is_some_and(compound)));
|
||||
let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
let refn = match st.doc.parent_of(first.id).and_then(|p| st.doc.node(p)) {
|
||||
Some(p) if !members && compound(p) => p,
|
||||
_ => first,
|
||||
};
|
||||
Ok((refn, members))
|
||||
}
|
||||
|
||||
/// Select > Same: the objects `eq` matches with the object [`same_reference`] returns, among those
|
||||
/// [`paint_reported`] accepts. A compound path matches as a whole. In isolation mode inside a
|
||||
/// compound path, its member paths are matched instead.
|
||||
fn same(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
select_where(s, cmd, &json!({}), |_, n| !n.is_container() && eq(n, &refn))
|
||||
let (refn, members) = same_reference(s.doc()?, cmd)?;
|
||||
let refn = refn.clone();
|
||||
select_where(s, cmd, &json!({}), |d, n| (if members { !n.is_container() } else { paint_reported(d, n) }) && eq(n, &refn))
|
||||
}
|
||||
|
||||
/// Select the objects [`walk_selectable`] visits that `f` accepts (not looking inside accepted
|
||||
/// ones); Select > Reselect repeats `cmd` with `p`.
|
||||
fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &Node) -> bool) -> Result<Value> {
|
||||
pub(crate) fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &Node) -> bool) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mut ids = vec![];
|
||||
walk_selectable(&st.doc, st.isolation, |n| {
|
||||
@@ -683,20 +701,17 @@ fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &No
|
||||
});
|
||||
let from = st.selection.objects.clone();
|
||||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||||
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), p.clone(), from));
|
||||
Ok(json!({ "count": ids.len() }))
|
||||
let st = s.doc_mut()?;
|
||||
st.last_selection_cmd = Some((cmd.to_string(), p.clone(), from));
|
||||
// The objects selected, after Lock Guides has left out guide paths.
|
||||
Ok(json!({ "count": st.selection.objects.len() }))
|
||||
}
|
||||
|
||||
fn same_graphic_style(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let cmd = "select.same.graphicStyle";
|
||||
let st = s.doc()?;
|
||||
let id = st
|
||||
.selection
|
||||
.objects
|
||||
.first()
|
||||
.and_then(|id| super::style::linked_style(&st.doc, st.doc.node(*id)?))
|
||||
.map(|g| g.id)
|
||||
.ok_or_else(|| bad(cmd, "the selection has no graphic style"))?;
|
||||
let (refn, _) = same_reference(st, cmd)?;
|
||||
let id = super::style::linked_style(&st.doc, refn).map(|g| g.id).ok_or_else(|| bad(cmd, "the selection has no graphic style"))?;
|
||||
select_where(s, cmd, &json!({}), |d, n| n.extras().graphic_style == Some(id) && super::style::linked_style(d, n).is_some())
|
||||
}
|
||||
|
||||
@@ -709,7 +724,7 @@ enum Attribute {
|
||||
fn same_attribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "select.same.appearanceAttribute";
|
||||
let st = s.doc()?;
|
||||
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
let (refn, _) = same_reference(st, cmd)?;
|
||||
let ap = &refn.appearance;
|
||||
let item = p.get("item").and_then(Value::as_u64).map(index).or(s.appearance_item());
|
||||
let attr = match (item.and_then(|i| ap.items.get(i)), ap.effects.first()) {
|
||||
@@ -750,6 +765,7 @@ fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result<Value> {
|
||||
}
|
||||
true
|
||||
});
|
||||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||||
Ok(json!({ "count": ids.len() }))
|
||||
s.select(|_, sel| sel.set(ids))?;
|
||||
// The objects selected, after Lock Guides has left out guide paths.
|
||||
Ok(json!({ "count": s.doc()?.selection.objects.len() }))
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use vectorcraft_doc::shaper::{EdgeEdit, FILL_PREFIX, FaceEdit, FaceSelector, SOU
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, Shape as _};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{self as b, BuilderMap, Hit};
|
||||
use vectorcraft_tools::builder::{self as b, BuilderMap};
|
||||
|
||||
const MAX_SOURCES: usize = 64;
|
||||
const MAX_POINTS: usize = 4096;
|
||||
@@ -617,8 +617,9 @@ fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let edits = face_edits(&map, &src, &spec);
|
||||
let filled = |i: usize| edits.get(&i).is_none_or(|e| !e.erase) && map.regions.get(i).and_then(|r| fill_source(&src, &r.0)).is_some();
|
||||
let components = visible_components(&map, &src, &edits);
|
||||
// Every face the scribble crosses, however narrow.
|
||||
let faces: std::collections::BTreeSet<usize> =
|
||||
points.iter().filter_map(|p| map.region_at(*p)).filter(|i| filled(*i)).filter_map(|i| components.get(i).copied()).collect();
|
||||
map.hits_along(&pts, None).regions.into_iter().filter(|i| filled(*i)).filter_map(|i| components.get(i).copied()).collect();
|
||||
let touched: Vec<usize> = components.iter().enumerate().filter(|(_, c)| faces.contains(c)).map(|(i, _)| i).collect();
|
||||
let first = points.first().copied().unwrap_or(Point::ZERO);
|
||||
let last = points.last().copied().unwrap_or(first);
|
||||
@@ -642,19 +643,12 @@ fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut strokes = 0;
|
||||
if touched.is_empty() {
|
||||
let mut piece_ids = vec![];
|
||||
// Every stroke the scribble crosses or passes within `tolerance` of, however small that is.
|
||||
let mut hits = map.hits_along(&pts, Some(tolerance));
|
||||
for point in &points {
|
||||
let index = match map.hit(*point, Some(tolerance)) {
|
||||
Some(Hit::Line(i)) => Some(map.edges.len() + i),
|
||||
Some(Hit::Edge(i)) => Some(i),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(i) = index
|
||||
&& !piece_ids.contains(&i)
|
||||
{
|
||||
piece_ids.push(i);
|
||||
}
|
||||
hits.add(map.hit(*point, Some(tolerance)));
|
||||
}
|
||||
let piece_ids = hits.edges.iter().copied().chain(hits.lines.iter().map(|i| map.edges.len() + i));
|
||||
for i in piece_ids {
|
||||
if let Some(e) = pieces(&map).get(i).and_then(|p| edge_selector(&map, &src, p)) {
|
||||
spec.edges.push(e);
|
||||
|
||||
@@ -109,13 +109,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
clip_to_artboard
|
||||
),
|
||||
cmd!(query "view.slices.hide", "Hide Slices", ["View"], None, "{hidden?: bool} toggle (or set) whether the canvas hides the slices (session view state) → {hidden}", has_doc, hide),
|
||||
cmd!(
|
||||
query "view.slices.hide",
|
||||
"Hide Slices",
|
||||
["View"],
|
||||
None,
|
||||
"{hidden?: bool} toggle (or set) whether the canvas hides the slices (session view state); hiding deselects the slices in every open document, and showing them again leaves them deselected and does not restore the selection that was cleared; while they are hidden no command selects one (an object that is an object slice can still be selected as art) → {hidden}",
|
||||
has_doc,
|
||||
hide
|
||||
),
|
||||
cmd!(
|
||||
query "view.slices.lock",
|
||||
"Lock Slices",
|
||||
["View"],
|
||||
None,
|
||||
"{locked?: bool} toggle (or set) the slice lock: locked slices can't be selected or edited with the Slice Selection tool → {locked}",
|
||||
"{locked?: bool} toggle (or set) the slice lock: locked slices can't be selected or edited with the Slice Selection tool; locking deselects the slices in every open document, and unlocking leaves them deselected and does not restore the selection that was cleared; while it is on no command selects one (an object that is an object slice can still be selected as art) → {locked}",
|
||||
has_doc,
|
||||
lock
|
||||
),
|
||||
@@ -124,7 +132,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"List Slices",
|
||||
[],
|
||||
None,
|
||||
"{} the slices as laid out, numbered left to right and top to bottom → {slices: [{number, id (null for auto slices), source: \"user\"|\"object\"|\"auto\", name, x, y, width, height, options, selected}], clipToArtboard, hidden, locked}",
|
||||
"{} the slices as laid out, numbered left to right and top to bottom → {slices: [{number, id (null for auto slices), source: \"user\"|\"object\"|\"auto\", name, x, y, width, height, options, selected (selected with the Slice Selection tool or a slice command, or an object slice whose object is selected)}], clipToArtboard, hidden, locked}",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
@@ -589,18 +597,35 @@ fn clip_to_artboard(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn hide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = p.get("hidden").and_then(Value::as_bool).unwrap_or(!s.menu.slices_hidden);
|
||||
s.menu.slices_hidden = v;
|
||||
if v {
|
||||
deselect_in_every_document(s);
|
||||
}
|
||||
Ok(json!({ "hidden": v }))
|
||||
}
|
||||
|
||||
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.slices_locked);
|
||||
s.menu.slices_locked = v;
|
||||
if v {
|
||||
deselect_in_every_document(s);
|
||||
}
|
||||
Ok(json!({ "locked": v }))
|
||||
}
|
||||
|
||||
/// Locked or hidden slices can't stay selected, in any open document ([`crate::deselect_hidden`]).
|
||||
fn deselect_in_every_document(s: &mut Session) {
|
||||
for st in &mut s.docs {
|
||||
if crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection) {
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
// What the Object → Slice commands act on: the selected objects that are object slices too.
|
||||
let selected = selected_slices(d, &st.selection);
|
||||
let slices: Vec<Value> = d
|
||||
.slice_layout()
|
||||
.iter()
|
||||
@@ -609,7 +634,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
json!({
|
||||
"number": a.number, "id": a.id.map(|i| i.0), "source": a.source.key(), "name": d.slice_name(a),
|
||||
"x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height(),
|
||||
"options": options_json(&o), "selected": a.id.is_some_and(|id| st.selection.slices.contains(&id)),
|
||||
"options": options_json(&o), "selected": a.id.is_some_and(|id| selected.contains(&id)),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -618,9 +643,9 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = s.doc()?.doc.slice_ids();
|
||||
let n = ids.len();
|
||||
s.select(|_, sel| sel.set_slices(ids))?;
|
||||
Ok(json!({ "count": n }))
|
||||
// None while the slices are locked or hidden.
|
||||
Ok(json!({ "count": s.doc()?.selection.slices.len() }))
|
||||
}
|
||||
|
||||
// ---------- the Slice and Slice Selection tools ----------
|
||||
|
||||
@@ -830,6 +830,11 @@ impl GroupEdit {
|
||||
pub(super) fn defaults(&self, s: &mut Session) {
|
||||
self.relinks.iter().for_each(|r| r.defaults(s));
|
||||
}
|
||||
|
||||
/// How many paints linked to the group's swatches the rewrite changed.
|
||||
pub(super) fn relinked(&self) -> usize {
|
||||
self.relinked
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite colour group `group` with `colors`: its swatches take them in order (keeping their
|
||||
|
||||
@@ -892,6 +892,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ruby: Vec::new(),
|
||||
para: super::create::new_type_para(),
|
||||
paras: Vec::new(),
|
||||
first_para_continues: false,
|
||||
area: Default::default(),
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
@@ -900,6 +901,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
optical_margin: false,
|
||||
system_layout: false,
|
||||
fractional_widths: true,
|
||||
list_count: 0,
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
@@ -1140,6 +1142,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
ruby: t.ruby.clone(),
|
||||
para: Default::default(),
|
||||
paras: Vec::new(),
|
||||
first_para_continues: false,
|
||||
area: Default::default(),
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
@@ -1148,6 +1151,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
optical_margin: false,
|
||||
system_layout: t.system_layout,
|
||||
fractional_widths: t.fractional_widths,
|
||||
list_count: 0,
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -153,11 +153,20 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
next.runs = part.runs;
|
||||
next.set_paragraph_styles(part.paras);
|
||||
let optical = optical.unwrap_or(t.optical_margin);
|
||||
if next.runs != t.runs || next.para != t.para || next.paras != t.paras || optical != t.optical_margin {
|
||||
let continues = part.continues;
|
||||
if next.runs != t.runs
|
||||
|| next.para != t.para
|
||||
|| next.paras != t.paras
|
||||
|| optical != t.optical_margin
|
||||
|| part.list_count != t.list_count
|
||||
|| continues != t.first_para_continues
|
||||
{
|
||||
t.runs = next.runs;
|
||||
t.para = next.para;
|
||||
t.paras = next.paras;
|
||||
t.optical_margin = optical;
|
||||
t.list_count = part.list_count;
|
||||
t.first_para_continues = continues;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
@@ -169,6 +178,36 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
doc.text_threads = kept;
|
||||
}
|
||||
|
||||
/// Type that is not a frame of a thread is a story of its own: its numbered list paragraphs count
|
||||
/// from 1. Each such type object with another [`TextObject::list_count`] (a copy of a frame, a
|
||||
/// frame taken out of its thread, a frame converted to point type, which [`reflow`] takes out of
|
||||
/// its thread) gets 0 and is laid out again.
|
||||
pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) {
|
||||
let mut stale = vec![];
|
||||
doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.list_count != 0
|
||||
{
|
||||
stale.push(n.id);
|
||||
}
|
||||
});
|
||||
if stale.is_empty() {
|
||||
return;
|
||||
}
|
||||
let threaded: std::collections::HashSet<NodeId> = doc.text_threads.iter().flatten().copied().filter(|id| text_of(doc, *id).is_some()).collect();
|
||||
stale.retain(|id| !threaded.contains(id));
|
||||
if stale.is_empty() {
|
||||
return;
|
||||
}
|
||||
let doc = std::sync::Arc::make_mut(doc);
|
||||
for id in stale {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.list_count = 0;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes:
|
||||
/// that of the first frame whose setting changed between `before` and `doc`, else the first frame's.
|
||||
fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option<bool> {
|
||||
@@ -194,6 +233,25 @@ pub(crate) fn share_optical_margin(doc: &mut Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the frames of each thread whose first paragraph continues from the frame before
|
||||
/// ([`TextObject::first_para_continues`]), without re-flowing the story: for a document restored
|
||||
/// from a file as it was saved.
|
||||
pub(crate) fn mark_continued(doc: &mut Document) {
|
||||
for thread in doc.text_threads.clone() {
|
||||
let live: Vec<NodeId> = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect();
|
||||
let frames: Vec<&TextObject> = live.iter().filter_map(|id| text_of(doc, *id)).collect();
|
||||
let marks = vectorcraft_text::thread::continued(&frames);
|
||||
for (id, on) in live.iter().zip(marks) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
|
||||
&& t.first_para_continues != on
|
||||
{
|
||||
t.first_para_continues = on;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ids` followed by the other frames of the threads they are in, each once, in thread order.
|
||||
pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut out = ids.to_vec();
|
||||
@@ -207,6 +265,110 @@ pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Every frame of `d`'s threads with its type, for [`leave_threads`] after an edit that may turn
|
||||
/// some of them into other art.
|
||||
pub(crate) fn frames(d: &Document) -> Vec<(NodeId, TextObject)> {
|
||||
d.text_threads.iter().flatten().filter_map(|id| Some((*id, text_of(d, *id)?.clone()))).collect()
|
||||
}
|
||||
|
||||
/// Update the threads for each frame of `was` (from [`frames`] before the edit) that `d` no longer
|
||||
/// holds as area type or type on a path under its id. When the art now at that id holds the frame's
|
||||
/// type under a new id (Expand Appearance groups type with its shadows, outer glows or Crop Marks),
|
||||
/// that type takes the frame's place in its thread. Otherwise (Create Outlines and Expand Appearance
|
||||
/// made outlines of it) the frame leaves its thread with the text it shows, and the other frames keep
|
||||
/// the text they hold. When a thread's last frame leaves, the text it has no room for goes after the
|
||||
/// text of the last frame that stays. A thread left with one frame is no longer a thread. Each frame
|
||||
/// that stays continues a paragraph ([`TextObject::first_para_continues`]) only when it is still
|
||||
/// threaded and the frames before it end inside one. A threaded frame whose mark changes lays out
|
||||
/// its first line again, so its thread re-flows after the edit.
|
||||
pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
|
||||
let gone: Vec<(NodeId, &TextObject, Option<NodeId>)> =
|
||||
was.iter().filter(|(id, _)| text_of(d, *id).is_none()).map(|(id, t)| (*id, t, kept_type(d, *id))).collect();
|
||||
if gone.is_empty() {
|
||||
return;
|
||||
}
|
||||
let find = |id: &NodeId| gone.iter().find(|(g, _, _)| g == id);
|
||||
let mut stayed = vec![];
|
||||
for i in 0..d.text_threads.len() {
|
||||
let thread = d.text_threads[i].clone();
|
||||
if !thread.iter().any(|id| find(id).is_some()) {
|
||||
continue;
|
||||
}
|
||||
let keep: Vec<NodeId> = thread
|
||||
.iter()
|
||||
.filter_map(|id| match find(id) {
|
||||
Some((_, _, kept)) => *kept,
|
||||
None => Some(*id),
|
||||
})
|
||||
.collect();
|
||||
if let (Some((_, last, None)), Some(stay)) = (thread.last().and_then(find), keep.last()) {
|
||||
take_overflow(d, *stay, last);
|
||||
}
|
||||
stayed.extend(keep.iter().copied());
|
||||
d.text_threads[i] = keep;
|
||||
}
|
||||
d.text_threads.retain(|t| t.len() >= 2);
|
||||
let marks: Vec<(NodeId, bool)> = stayed
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let on = d.text_threads.iter().find(|t| t.contains(id)).is_some_and(|t| {
|
||||
let live: Vec<(NodeId, &TextObject)> = t.iter().filter_map(|i| Some((*i, text_of(d, *i)?))).collect();
|
||||
let frames: Vec<&TextObject> = live.iter().map(|(_, f)| *f).collect();
|
||||
live.iter().zip(vectorcraft_text::thread::continued(&frames)).any(|((i, _), on)| on && i == id)
|
||||
});
|
||||
(*id, on)
|
||||
})
|
||||
.collect();
|
||||
for (id, on) in marks {
|
||||
if text_of(d, id).is_some_and(|t| t.first_para_continues != on)
|
||||
&& let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
|
||||
{
|
||||
t.first_para_continues = on;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The only area type or type on a path in the art at `id`.
|
||||
fn kept_type(d: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let mut found = vec![];
|
||||
d.node(id)?.walk(&mut |n| {
|
||||
if matches!(&n.kind, NodeKind::Text(t) if !matches!(t.kind, TextKind::Point)) {
|
||||
found.push(n.id);
|
||||
}
|
||||
});
|
||||
match found[..] {
|
||||
[one] => Some(one),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the text that frame `from` holds beyond what it shows after the text of frame `to`.
|
||||
fn take_overflow(d: &mut Document, to: NodeId, from: &TextObject) {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// `from`'s text split as if it flowed on into a second frame: the second part is what `from`
|
||||
// has no room for. Its share is measured as it is laid out, continuing a paragraph or not and
|
||||
// with its numbered list paragraphs counted on from the frames before it.
|
||||
let parts = vectorcraft_text::thread::distribute_continuing(
|
||||
db,
|
||||
&[from, from],
|
||||
&from.runs,
|
||||
&from.paragraph_styles(),
|
||||
from.first_para_continues,
|
||||
from.list_count,
|
||||
);
|
||||
let Some(rest) = parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty())) else { return };
|
||||
let mut tail = from.clone();
|
||||
tail.runs = rest.runs.clone();
|
||||
tail.set_paragraph_styles(rest.paras.clone());
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(to).map(|n| &mut n.kind) {
|
||||
let head = (**t).clone();
|
||||
t.runs = vectorcraft_text::thread::story(&[&head, &tail]);
|
||||
t.set_paragraph_styles(vectorcraft_text::thread::story_paras(&[&head, &tail], &[]));
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed path converted to an empty area-type frame with `style`.
|
||||
fn frame_from_path(n: &Node, style: vectorcraft_doc::CharStyle, system_layout: bool) -> Option<Node> {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { return None };
|
||||
@@ -387,6 +549,7 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
t.runs = vec![TextRun { text: String::new(), style: style.clone(), inline: None }];
|
||||
t.set_all_paras(first_para.clone());
|
||||
t.first_para_continues = false;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
@@ -406,6 +569,9 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
vectorcraft_text::edit::normalize(&mut story);
|
||||
t.runs = story;
|
||||
t.set_paragraph_styles(paras);
|
||||
// It starts the story, also when it is left alone and so out of a thread.
|
||||
t.first_para_continues = false;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0);
|
||||
d.text_threads[i] = keep;
|
||||
@@ -419,7 +585,18 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn remove(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
s.edit("Remove Threading", |d, _| {
|
||||
d.text_threads.retain(|t| !t.iter().any(|i| ids.contains(i)));
|
||||
let (gone, kept): (Vec<Vec<NodeId>>, Vec<Vec<NodeId>>) =
|
||||
std::mem::take(&mut d.text_threads).into_iter().partition(|t| t.iter().any(|i| ids.contains(i)));
|
||||
d.text_threads = kept;
|
||||
// Each frame's first paragraph now starts in it.
|
||||
for id in gone.iter().flatten() {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind)
|
||||
&& t.first_para_continues
|
||||
{
|
||||
t.first_para_continues = false;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
@@ -815,6 +992,414 @@ mod tests {
|
||||
assert_eq!(text(&s, a), TEXT.repeat(4), "the story returns to the remaining frame");
|
||||
}
|
||||
|
||||
/// `story` threaded through three area type frames, of 150 by 60, 40 and `last` points, each
|
||||
/// showing part of it.
|
||||
fn three_frames(story: &str, last: f64) -> (Session, [u64; 3]) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let frame = |s: &mut Session, x: f64, y: f64, h: f64, text: &str| {
|
||||
new_id(s.execute("text.create", &json!({"x": x, "y": y, "text": text, "area": {"width": 150, "height": h}})).unwrap())
|
||||
};
|
||||
let a = frame(&mut s, 10.0, 10.0, 60.0, story);
|
||||
let b = frame(&mut s, 10.0, 100.0, 40.0, "");
|
||||
let c = frame(&mut s, 200.0, 10.0, last, "");
|
||||
s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap();
|
||||
s.execute("text.thread.add", &json!({"from": b, "to": c})).unwrap();
|
||||
assert_eq!(threads(&s), [[a, b, c]]);
|
||||
assert!([a, b, c].iter().all(|id| !text(&s, *id).is_empty()), "each frame shows part of the story");
|
||||
(s, [a, b, c])
|
||||
}
|
||||
|
||||
/// Whether the document holds area type or type on a path `id`.
|
||||
fn is_frame(s: &Session, id: u64) -> bool {
|
||||
text_of(&s.doc().unwrap().doc, NodeId(id)).is_some()
|
||||
}
|
||||
|
||||
/// Create Outlines and Object › Expand on frames of threaded text take them out of their
|
||||
/// thread with the text they show, which their outlines draw, and the other frames keep the
|
||||
/// text they hold: each character is in the document once. A thread left with one frame is no
|
||||
/// thread. Undo threads the frames again.
|
||||
#[test]
|
||||
fn outlined_frames_leave_their_thread_with_their_text() {
|
||||
for command in ["type.createOutlines", "object.expand"] {
|
||||
for out in [&[0][..], &[1], &[2], &[0, 2]] {
|
||||
let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0);
|
||||
let shown = frames.map(|id| text(&s, id));
|
||||
assert_eq!(shown.concat(), TEXT.repeat(4));
|
||||
let ids: Vec<u64> = out.iter().map(|k| frames[*k]).collect();
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
s.execute(command, &json!({})).unwrap();
|
||||
let rest: Vec<u64> = frames.iter().copied().filter(|id| !ids.contains(id)).collect();
|
||||
for id in &ids {
|
||||
assert!(!is_frame(&s, *id), "{command} {out:?}: frame {id} is outlined");
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
let groups = st.selection.objects.iter().filter(|g| matches!(st.doc.node(**g).map(|n| &n.kind), Some(NodeKind::Group { .. })));
|
||||
assert_eq!(groups.count(), ids.len(), "{command} {out:?}: the outlines are selected");
|
||||
for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) {
|
||||
assert_eq!(text(&s, *id), *before, "{command} {out:?}: frame {id} keeps its text");
|
||||
}
|
||||
assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{command} {out:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(threads(&s), [frames], "{command} {out:?}: undo");
|
||||
assert_eq!(frames.map(|id| text(&s, id)), shown, "{command} {out:?}: undo");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand Appearance outlines a frame with an effect, and the frame leaves its thread with the
|
||||
/// text it shows; Object › Expand of such a frame expands its appearance first.
|
||||
#[test]
|
||||
fn a_frame_whose_appearance_expands_leaves_its_thread_with_its_text() {
|
||||
for command in ["effect.expandAppearance", "object.expand"] {
|
||||
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(4), 200.0);
|
||||
s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [b]})).unwrap();
|
||||
let shown = [a, b, c].map(|id| text(&s, id));
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute(command, &json!({})).unwrap();
|
||||
assert!(!is_frame(&s, b), "{command}: the frame is outlined");
|
||||
assert_eq!((text(&s, a), text(&s, c)), (shown[0].clone(), shown[2].clone()), "{command}: the other frames keep their text");
|
||||
assert_eq!(threads(&s), [[a, c]], "{command}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(threads(&s), [[a, b, c]], "{command}: undo");
|
||||
assert_eq!([a, b, c].map(|id| text(&s, id)), shown, "{command}: undo");
|
||||
}
|
||||
}
|
||||
|
||||
/// Object › Flatten Transparency outlines frames of threaded text with Convert All Text to
|
||||
/// Outlines, and turns transparent frames into flat regions or an image: each such frame leaves
|
||||
/// its thread with the text it shows, and the other frames keep the text they hold. Undo threads
|
||||
/// the frames again. When the last frame is flattened, the frame before it takes the text it has
|
||||
/// no room for.
|
||||
#[test]
|
||||
fn flattened_frames_leave_their_thread_with_their_text() {
|
||||
let cases = [(json!({"textToOutlines": true}), false), (json!({}), true), (json!({"balance": 0}), true)];
|
||||
for (options, transparent) in cases {
|
||||
for out in [&[0][..], &[1], &[2], &[0, 2]] {
|
||||
let case = format!("{options} {out:?}");
|
||||
let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0);
|
||||
let ids: Vec<u64> = out.iter().map(|k| frames[*k]).collect();
|
||||
if transparent {
|
||||
s.execute("transparency.set", &json!({"ids": ids, "opacity": 50})).unwrap();
|
||||
}
|
||||
let shown = frames.map(|id| text(&s, id));
|
||||
assert_eq!(shown.concat(), TEXT.repeat(4));
|
||||
let mut p = options.clone();
|
||||
p["ids"] = json!(ids);
|
||||
s.execute("object.flattenTransparency", &p).unwrap();
|
||||
let rest: Vec<u64> = frames.iter().copied().filter(|id| !ids.contains(id)).collect();
|
||||
for id in &ids {
|
||||
assert!(!is_frame(&s, *id), "{case}: frame {id} is flattened");
|
||||
}
|
||||
for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) {
|
||||
assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text");
|
||||
}
|
||||
assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(threads(&s), [frames], "{case}: undo");
|
||||
assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo");
|
||||
}
|
||||
}
|
||||
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0);
|
||||
let shown = [a, b, c].map(|id| text(&s, id));
|
||||
let laid = {
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") };
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)
|
||||
};
|
||||
assert!(laid.overflow, "the last frame has more text than it shows");
|
||||
let end = laid.lines.last().map_or(0, |l| l.end);
|
||||
let hidden = shown[2][end..].trim_start_matches(' ');
|
||||
s.execute("object.flattenTransparency", &json!({"ids": [c], "textToOutlines": true})).unwrap();
|
||||
assert_eq!(text(&s, a), shown[0]);
|
||||
assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest");
|
||||
assert_eq!(threads(&s), [[a, b]]);
|
||||
}
|
||||
|
||||
/// Expand Appearance keeps type whose only effect is a shadow or Crop Marks as type under a new
|
||||
/// id, in a group under the frame's id: the type takes the frame's place in its thread, and
|
||||
/// each frame keeps its text, the last frame the text it has no room for too. Type in a group
|
||||
/// with Crop Marks keeps its id and stays in its thread.
|
||||
#[test]
|
||||
fn type_that_expand_appearance_keeps_takes_its_frames_place() {
|
||||
for (effect, grouped) in [("stylize.dropShadow", false), ("cropMarks", false), ("cropMarks", true)] {
|
||||
for k in [1, 2] {
|
||||
let case = format!("{effect} grouped {grouped} frame {k}");
|
||||
let (mut s, frames) = three_frames(&TEXT.repeat(12), 100.0);
|
||||
let id = frames[k];
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let target = if grouped { new_id(s.execute("object.group", &json!({})).unwrap()) } else { id };
|
||||
s.execute("effect.apply", &json!({"effect": effect, "item": null, "ids": [target]})).unwrap();
|
||||
let shown = frames.map(|id| text(&s, id));
|
||||
s.execute("select.set", &json!({"ids": [target]})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let mut types = vec![];
|
||||
if let Some(n) = s.doc().unwrap().doc.node(NodeId(target)) {
|
||||
n.walk(&mut |m| {
|
||||
if matches!(m.kind, NodeKind::Text(_)) {
|
||||
types.push(m.id.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
let [kept] = types[..] else { panic!("{case}: the type is kept: {types:?}") };
|
||||
assert_eq!(kept == id, grouped, "{case}: the type has a new id unless it was in a group");
|
||||
let mut now = frames;
|
||||
now[k] = kept;
|
||||
assert_eq!(threads(&s), [now], "{case}: the type takes the frame's place");
|
||||
assert_eq!(now.map(|id| text(&s, id)), shown, "{case}: each frame keeps its text");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(threads(&s), [frames], "{case}: undo");
|
||||
assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text that does not fit a thread's last frame, which its outlines do not draw, stays in
|
||||
/// the story: when the last frame is outlined, the frame before it takes that text after its
|
||||
/// own.
|
||||
#[test]
|
||||
fn outlining_the_last_frame_keeps_the_text_it_has_no_room_for() {
|
||||
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0);
|
||||
let shown = [a, b, c].map(|id| text(&s, id));
|
||||
let laid = {
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") };
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)
|
||||
};
|
||||
assert!(laid.overflow, "the last frame has more text than it shows");
|
||||
let end = laid.lines.last().map_or(0, |l| l.end);
|
||||
let hidden = shown[2][end..].trim_start_matches(' ');
|
||||
s.execute("select.set", &json!({"ids": [c]})).unwrap();
|
||||
s.execute("type.createOutlines", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, a), shown[0]);
|
||||
assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest");
|
||||
assert_eq!(threads(&s), [[a, b]]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!([a, b, c].map(|id| text(&s, id)), shown);
|
||||
}
|
||||
|
||||
/// Frame `id`'s first line: whether its first paragraph continues from the frame before, where
|
||||
/// its text starts from the frame's left edge, and how many list markers it starts with.
|
||||
fn first_line(s: &Session, id: u64) -> (bool, f64, usize) {
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") };
|
||||
let TextKind::Area { frame } = &t.kind else { panic!("not area type") };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let l = &lay.lines[0];
|
||||
let x = lay.glyphs[l.glyph_start..l.glyph_end].iter().find(|g| g.len > 0).map_or(f64::NAN, |g| g.origin.x);
|
||||
let markers = lay.markers.iter().filter(|m| lay.glyphs[m.glyphs.start].line == 0).count();
|
||||
(t.first_para_continues, (x - frame.bounds().unwrap().x0 - 1e-9).round(), markers)
|
||||
}
|
||||
|
||||
/// A paragraph's first-line indent and bullet belong to its first line, in the frame it starts
|
||||
/// in: the frame it continues into starts at its left indent, without the bullet. Re-flowing,
|
||||
/// saving and opening keep it. In a frame that is no longer threaded and in a copy of a frame,
|
||||
/// the first paragraph has its first-line indent and bullet.
|
||||
#[test]
|
||||
fn a_paragraph_continued_into_the_next_frame_starts_it_without_its_first_line_indent_or_bullet() {
|
||||
let (mut s, a, b) = setup();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "bullet"})).unwrap();
|
||||
assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 18.0, 1), (true, 18.0, 0)));
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "none", "leftIndent": 0, "firstLineIndent": 24})).unwrap();
|
||||
assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 24.0, 0), (true, 0.0, 0)));
|
||||
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4));
|
||||
// Typing re-flows the story and keeps it.
|
||||
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap();
|
||||
assert_eq!(first_line(&s, b), (true, 0.0, 0));
|
||||
// Saved and opened: a native file re-flows its threads, a restored document keeps its text.
|
||||
for name in ["thread.vectorcraft", "thread.eps"] {
|
||||
let bytes = if name.ends_with(".eps") {
|
||||
let r = s.execute("document.exportEps", &json!({})).unwrap();
|
||||
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
|
||||
} else {
|
||||
vectorcraft_format::save(&s.doc().unwrap().doc, false)
|
||||
};
|
||||
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
|
||||
assert_eq!(r["restored"], name.ends_with(".eps"), "{r}");
|
||||
assert_eq!(threads(&s), [[a, b]], "{name}");
|
||||
assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 24.0, 0), (true, 0.0, 0)), "{name}");
|
||||
}
|
||||
// A copy of the second frame is in no thread, and starts with the indent.
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
let copy = s.execute("edit.duplicate", &json!({})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
assert_eq!((first_line(&s, b), first_line(&s, copy)), ((true, 0.0, 0), (false, 24.0, 0)));
|
||||
// …and so is a pasted one, and each copy's cached bounds and baselines are its own layout's.
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let pasted = s.execute("edit.paste", &json!({})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
let dragged = s.execute("layer.move", &json!({"ids": [b], "target": a, "place": "above", "copy": true})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
assert_eq!((first_line(&s, pasted), first_line(&s, dragged)), ((false, 24.0, 0), (false, 24.0, 0)));
|
||||
// Every text object in the document has the cached bounds and baselines of its own layout.
|
||||
let own_caches = |s: &Session, what: &str| {
|
||||
let mut texts = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
texts.push((n.id, t));
|
||||
}
|
||||
});
|
||||
for (id, t) in texts {
|
||||
let mut fresh = (**t).clone();
|
||||
refresh_bounds(&mut fresh);
|
||||
assert!(t.cached_bounds.is_some() && t.cached_bounds == fresh.cached_bounds, "{what}: {id:?}");
|
||||
assert_eq!(t.cached_baselines, fresh.cached_baselines, "{what}: {id:?}");
|
||||
}
|
||||
};
|
||||
own_caches(&s, "copies");
|
||||
// Release to Layers (Build), Release Opacity Mask, Edit Opacity Mask and Draw Inside copy the
|
||||
// frame too.
|
||||
let layer = s.doc().unwrap().doc.layer_containing(NodeId(b)).unwrap().0;
|
||||
s.execute("layer.releaseToLayers", &json!({"id": layer, "build": true})).unwrap();
|
||||
own_caches(&s, "Release to Layers (Build)");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("transparency.makeOpacityMask", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("transparency.releaseOpacityMask", &json!({})).unwrap();
|
||||
own_caches(&s, "Release Opacity Mask");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("transparency.editOpacityMask", &json!({"id": a})).unwrap();
|
||||
own_caches(&s, "Edit Opacity Mask");
|
||||
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
|
||||
for _ in 0..3 {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("view.drawMode", &json!({"mode": "inside"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 210, "y": 20, "width": 20, "height": 20})).unwrap();
|
||||
own_caches(&s, "Draw Inside");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("view.drawMode", &json!({"mode": "normal"})).unwrap();
|
||||
assert_eq!((threads(&s), first_line(&s, b)), (vec![vec![a, b]], (true, 0.0, 0)));
|
||||
// Remove Threading: each frame keeps its text, and its first paragraph starts in it.
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("text.thread.remove", &json!({})).unwrap();
|
||||
assert_eq!(first_line(&s, b), (false, 24.0, 0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_line(&s, b), (true, 0.0, 0));
|
||||
// Released, the frame is empty and starts the next text typed into it as a paragraph.
|
||||
let continues = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.first_para_continues,
|
||||
_ => panic!("not text"),
|
||||
};
|
||||
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
|
||||
assert!(text(&s, b).is_empty() && !continues(&s, b));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// With the first frame released, the second one holds the whole story and starts it.
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
|
||||
assert!(threads(&s).is_empty() && text(&s, a).is_empty());
|
||||
assert_eq!(first_line(&s, b), (false, 24.0, 0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Converted to point type, it is in no thread, and its first line starts at the indent where
|
||||
// the frame's left edge was.
|
||||
let left = frame_box(&s, b).x0;
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("type.convertToPointType", &json!({})).unwrap();
|
||||
assert!(!continues(&s, b));
|
||||
{
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind else { panic!("not text") };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let g = lay.glyphs.iter().find(|g| g.len > 0).unwrap();
|
||||
let x = (t.xf * g.origin).x - left;
|
||||
assert!((x - 24.0).abs() < 1e-6, "{x}");
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_line(&s, b), (true, 0.0, 0));
|
||||
// Text that ends the first frame with a paragraph break: the second frame starts a
|
||||
// paragraph, with its indent.
|
||||
let all = text(&s, a).len();
|
||||
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": all, "insert": "Short line.\n".repeat(12)})).unwrap();
|
||||
assert!(text(&s, a).ends_with('\n'), "{:?}", text(&s, a));
|
||||
assert_eq!(first_line(&s, b), (false, 24.0, 0));
|
||||
}
|
||||
|
||||
/// When Create Outlines takes frames out of a thread, a frame that now starts its thread or is
|
||||
/// in no thread starts its paragraph with the first-line indent; a frame after one that ends
|
||||
/// inside the paragraph still continues it. The frames that stay hold each character they held
|
||||
/// once, and keep their text when no mark changes. Undo marks the frames again.
|
||||
#[test]
|
||||
fn a_frame_that_starts_its_thread_after_outlining_has_its_first_line_indent() {
|
||||
for (out, want) in [
|
||||
(&[0][..], [None, Some(false), Some(true)]),
|
||||
(&[0, 2], [None, Some(false), None]),
|
||||
(&[1], [Some(false), None, Some(true)]),
|
||||
(&[2], [Some(false), Some(true), None]),
|
||||
] {
|
||||
let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0);
|
||||
s.execute("text.setFormat", &json!({"ids": frames, "leftIndent": 0, "firstLineIndent": 24})).unwrap();
|
||||
let before = frames.map(|id| first_line(&s, id));
|
||||
assert_eq!(before, [(false, 24.0, 0), (true, 0.0, 0), (true, 0.0, 0)], "{out:?}");
|
||||
let shown = frames.map(|id| text(&s, id));
|
||||
assert_eq!(shown.concat(), TEXT.repeat(4), "{out:?}: the last frame has room for the rest");
|
||||
let ids: Vec<u64> = out.iter().map(|k| frames[*k]).collect();
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
s.execute("type.createOutlines", &json!({})).unwrap();
|
||||
let stay: Vec<usize> = (0..3).filter(|k| want[*k].is_some()).collect();
|
||||
for k in &stay {
|
||||
let on = want[*k] == Some(true);
|
||||
let indent = if on { 0.0 } else { 24.0 };
|
||||
assert_eq!(first_line(&s, frames[*k]), (on, indent, 0), "{out:?}: frame {k}");
|
||||
}
|
||||
let held: String = stay.iter().map(|k| shown[*k].as_str()).collect();
|
||||
assert_eq!(stay.iter().map(|k| text(&s, frames[*k])).collect::<String>(), held, "{out:?}: each character once");
|
||||
if stay.iter().all(|k| want[*k] == Some(before[*k].0)) {
|
||||
for k in &stay {
|
||||
assert_eq!(text(&s, frames[*k]), shown[*k], "{out:?}: frame {k} keeps its text");
|
||||
}
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frames.map(|id| first_line(&s, id)), before, "{out:?}: undo");
|
||||
}
|
||||
}
|
||||
|
||||
/// The text that a thread's last frame has no room for is measured as the frame lays it out,
|
||||
/// without the first-line indent of the paragraph it continues: when the frame is outlined, the
|
||||
/// frame before it takes only that text.
|
||||
#[test]
|
||||
fn outlining_a_continued_last_frame_keeps_only_the_text_it_has_no_room_for() {
|
||||
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0);
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b, c], "leftIndent": 0, "firstLineIndent": 72})).unwrap();
|
||||
assert_eq!(first_line(&s, c), (true, 0.0, 0));
|
||||
let shown = [a, b, c].map(|id| text(&s, id));
|
||||
let laid = {
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") };
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)
|
||||
};
|
||||
assert!(laid.overflow, "the last frame has more text than it shows");
|
||||
let end = laid.lines.last().map_or(0, |l| l.end);
|
||||
let hidden = shown[2][end..].trim_start_matches(' ');
|
||||
s.execute("select.set", &json!({"ids": [c]})).unwrap();
|
||||
s.execute("type.createOutlines", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]));
|
||||
}
|
||||
|
||||
/// The text that a thread's last frame has no room for is measured with the frame's numbered
|
||||
/// paragraphs counted on from the frames before it: when the frame is outlined, the frame before
|
||||
/// it takes only that text.
|
||||
#[test]
|
||||
fn outlining_a_numbered_last_frame_keeps_only_the_text_it_has_no_room_for() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect();
|
||||
let story = format!("{items}{}", TEXT.repeat(4));
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap());
|
||||
let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap());
|
||||
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
|
||||
// A hanging indent that "I." fits and "VIII." does not: the text after "VIII." starts at
|
||||
// the next tab stop.
|
||||
s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "leftIndent": 14, "firstLineIndent": -14})).unwrap();
|
||||
let shown = [a, c].map(|id| text(&s, id));
|
||||
assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{shown:?}");
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") };
|
||||
assert_eq!(t.list_count, 7);
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
assert_eq!(laid.markers.first().map(|m| m.text.as_str()), Some("VIII."));
|
||||
assert!(laid.overflow, "the last frame has more text than it shows");
|
||||
let end = laid.lines.last().map_or(0, |l| l.end);
|
||||
let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_count: 0, ..t });
|
||||
assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "counted from 1, the frame would show other text");
|
||||
let hidden = shown[1][end..].trim_start_matches(' ');
|
||||
s.execute("select.set", &json!({"ids": [c]})).unwrap();
|
||||
s.execute("type.createOutlines", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]));
|
||||
}
|
||||
|
||||
/// A story of one-line paragraphs that each start with a quotation mark, threaded from area
|
||||
/// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for.
|
||||
fn quoted_thread() -> (Session, u64, u64) {
|
||||
@@ -923,4 +1508,178 @@ mod tests {
|
||||
assert_eq!((optical(&s, a), optical(&s, b)), ((first, first), (first, first)), "{name}, first frame {first}");
|
||||
}
|
||||
}
|
||||
|
||||
/// One count runs through a thread: the second frame's numbered paragraphs go on from the
|
||||
/// first frame's, also after an edit, a save and a reopen. A pasted or duplicated copy of a
|
||||
/// frame, a frame converted to point type, the frames of a thread that Remove Threading breaks
|
||||
/// and the frame a Release Selection leaves alone count from 1.
|
||||
#[test]
|
||||
fn numbered_list_paragraphs_count_on_through_a_thread() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
|
||||
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
|
||||
let numbers = |s: &Session, id: u64| {
|
||||
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
|
||||
};
|
||||
let all = |from: usize| (from..=10).map(|n| format!("{n}.")).collect::<Vec<_>>();
|
||||
let first = numbers(&s, a);
|
||||
assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}");
|
||||
assert_eq!([first.clone(), numbers(&s, b)].concat(), all(1));
|
||||
// Typing a paragraph into the first frame numbers it and moves the rest on.
|
||||
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "Item 0\n"})).unwrap();
|
||||
assert_eq!([numbers(&s, a), numbers(&s, b)].concat(), [vec!["1.".to_string()], all(2), vec!["11.".to_string()]].concat());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") };
|
||||
let after: Vec<String> = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), back).markers.iter().map(|m| m.text.clone()).collect();
|
||||
assert_eq!(after, all(first.len() + 1), "reopened");
|
||||
// A pasted copy of the second frame is in no thread.
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.objects.first().copied().unwrap();
|
||||
assert_ne!(copy, NodeId(b));
|
||||
assert_eq!(numbers(&s, copy.0).first().map(String::as_str), Some("1."));
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
let duplicate = s.execute("edit.duplicate", &json!({"dx": 10})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
assert_eq!(numbers(&s, duplicate).first().map(String::as_str), Some("1."), "a duplicate");
|
||||
let layer = s.doc().unwrap().doc.layers[0].id.0;
|
||||
let moved = s.execute("layer.move", &json!({"ids": [b], "target": layer, "copy": true})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
assert_eq!(numbers(&s, moved).first().map(String::as_str), Some("1."), "a copy dragged in the Layers panel");
|
||||
assert_eq!(numbers(&s, b), all(first.len() + 1), "the frame itself");
|
||||
// Convert To Point Type takes the second frame out of the thread with its text.
|
||||
s.execute("type.convertToPointType", &json!({"ids": [b]})).unwrap();
|
||||
assert!(threads(&s).is_empty());
|
||||
assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1."), "the frame converted to point type");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(numbers(&s, b), all(first.len() + 1), "undo");
|
||||
// Remove Threading: each frame is a story of its own.
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
s.execute("text.thread.remove", &json!({})).unwrap();
|
||||
assert!(threads(&s).is_empty());
|
||||
assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1."));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(numbers(&s, b), all(first.len() + 1), "undo");
|
||||
// Release Selection of the first frame: the second one holds the whole story, from 1.
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
|
||||
assert!(threads(&s).is_empty());
|
||||
assert_eq!(numbers(&s, b), all(1), "the frame left holds the story");
|
||||
}
|
||||
|
||||
/// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art
|
||||
/// looks as it did; Break Link puts the frames in no thread, and each counts from 1.
|
||||
#[test]
|
||||
fn a_symbol_made_of_a_numbered_thread_keeps_its_numbers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
|
||||
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
|
||||
let count = |t: &TextObject| t.list_count;
|
||||
let carried = text_of(&s.doc().unwrap().doc, NodeId(b)).map(count).unwrap();
|
||||
assert!(carried > 0);
|
||||
let instance = new_id(s.execute("symbol.new", &json!({"name": "Numbered"})).unwrap());
|
||||
let counts = |n: &Node| {
|
||||
let mut counts = vec![];
|
||||
n.walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
counts.push(t.list_count);
|
||||
}
|
||||
});
|
||||
counts.sort_unstable();
|
||||
counts
|
||||
};
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(counts(&doc.symbols.iter().find(|x| x.name == "Numbered").unwrap().art), [0, carried]);
|
||||
// Break Link puts the frames on the artboard in no thread: each counts from 1.
|
||||
s.execute("select.set", &json!({"ids": [instance]})).unwrap();
|
||||
s.execute("symbol.breakLink", &json!({})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(doc.layers.iter().flat_map(|l| counts(l)).collect::<Vec<_>>(), [0, 0]);
|
||||
}
|
||||
|
||||
/// A frame of a numbered thread pasted while an opacity mask is edited numbers from 1 in the
|
||||
/// mask art too.
|
||||
#[test]
|
||||
fn a_frame_pasted_into_an_edited_opacity_mask_numbers_from_1() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
|
||||
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
|
||||
assert!(text_of(&s.doc().unwrap().doc, NodeId(b)).is_some_and(|t| t.list_count > 0));
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let obj = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 280})).unwrap());
|
||||
let m = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [obj, m]})).unwrap();
|
||||
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||||
s.execute("transparency.editOpacityMask", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let mut counts = vec![];
|
||||
doc.node(NodeId(obj)).unwrap().extras().mask.as_ref().unwrap().art.walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
counts.push(t.list_count);
|
||||
}
|
||||
});
|
||||
assert_eq!(counts, [0], "the pasted frame in the mask art");
|
||||
}
|
||||
|
||||
/// A duplicate of a frame that carried a count of 99 or more numbers from 1 and has the layout
|
||||
/// bounds of what it draws: "01." leaves the text at the left indent, where a three-digit
|
||||
/// number pushed it on to the next tab stop and the paragraph took more lines.
|
||||
#[test]
|
||||
fn a_duplicated_frame_numbers_from_1_with_its_own_bounds() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let story = format!("{}Itemized lists paragraph text", "x\n".repeat(110));
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 1500}})).unwrap());
|
||||
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 100, "height": 300})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b], "number": "twoDigits"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
let copy = s.execute("edit.duplicate", &json!({"dx": 200})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
let laid = |s: &Session, id: u64| {
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("area type") };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
(lay.markers[0].text.clone(), lay.lines.len(), lay.bounds, t.cached_bounds.unwrap())
|
||||
};
|
||||
let (number, lines, _, _) = laid(&s, b);
|
||||
let (copy_number, copy_lines, bounds, cached) = laid(&s, copy);
|
||||
assert!(number.len() == 4 && number.starts_with('1'), "a three-digit number: {number}");
|
||||
assert_eq!(copy_number, "01.");
|
||||
assert!(copy_lines < lines, "{copy_lines} {lines}");
|
||||
assert!((cached.y1 - bounds.y1).abs() < 1e-6, "the copy's bounds: {cached:?} {bounds:?}");
|
||||
}
|
||||
|
||||
/// Release Selection that leaves one frame of a thread gives that frame the whole story and
|
||||
/// the layout bounds of its new text.
|
||||
#[test]
|
||||
fn the_frame_a_release_leaves_alone_has_the_bounds_of_the_whole_story() {
|
||||
let (mut s, a, b) = setup();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
|
||||
assert!(threads(&s).is_empty());
|
||||
assert_eq!(text(&s, b), TEXT.repeat(4));
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(b)) else { panic!("area type") };
|
||||
let bounds = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds;
|
||||
let cached = t.cached_bounds.unwrap();
|
||||
assert!((cached.y0 - bounds.y0).abs() < 1e-6 && (cached.y1 - bounds.y1).abs() < 1e-6, "{cached:?} {bounds:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Outlines",
|
||||
["Type"],
|
||||
Some("Cmd+Shift+O"),
|
||||
"{} convert the selected text to groups of compound paths (one per glyph) → {ids}",
|
||||
"{} convert the selected text to groups of compound paths (one per glyph); a frame of threaded text leaves its thread with the text it shows → {ids}",
|
||||
has_selection,
|
||||
create_outlines
|
||||
),
|
||||
@@ -544,32 +544,53 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut new_sel: Vec<NodeId> = roots.iter().copied().filter(|r| !texts.contains(r)).collect();
|
||||
let mut out = vec![];
|
||||
let threaded = super::threads::frames(d);
|
||||
for tid in texts {
|
||||
let Some(n) = d.node(tid).cloned() else { continue };
|
||||
let NodeKind::Text(t) = &n.kind else { continue };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let mut children = vec![];
|
||||
// Where each run's glyphs end among `children`: the canvas paints a run's ruby with the
|
||||
// run, before the runs after it, so its outlines go there too.
|
||||
let mut run_end: std::collections::BTreeMap<usize, usize> = Default::default();
|
||||
for (gi, g) in lay.glyphs.iter().enumerate() {
|
||||
// Inline graphics become instances of their symbols, in place.
|
||||
if let Some(ig) = lay.inlines.iter().find(|i| i.glyph == gi)
|
||||
&& let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref())
|
||||
{
|
||||
let id = d.alloc_id();
|
||||
let xf = t.xf * ig.xf * d.symbol_natural_xf(&art.symbol);
|
||||
children.push(Arc::new(Node::new(id, NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf })));
|
||||
continue;
|
||||
}
|
||||
// Colour bitmap glyphs (emoji) become embedded images, in place, their pixels taken
|
||||
// into the document: glyphs that show the same image share them.
|
||||
if let Some(image) = g.placed_image_node(t.xf) {
|
||||
children.push(Arc::new(d.reid(&image)));
|
||||
continue;
|
||||
}
|
||||
// COLR glyphs become groups of their layers, in place, with ids of the document's.
|
||||
if let Some(art) = g.placed_colour(t.xf) {
|
||||
children.push(Arc::new(d.reid(&art)));
|
||||
continue;
|
||||
'glyph: {
|
||||
// Inline graphics become instances of their symbols, in place.
|
||||
if let Some(ig) = lay.inlines.iter().find(|i| i.glyph == gi)
|
||||
&& let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref())
|
||||
{
|
||||
let id = d.alloc_id();
|
||||
let xf = t.xf * ig.xf * d.symbol_natural_xf(&art.symbol);
|
||||
children.push(Arc::new(Node::new(id, NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf })));
|
||||
break 'glyph;
|
||||
}
|
||||
// Colour bitmap glyphs (emoji) become embedded images, in place, their pixels
|
||||
// taken into the document: glyphs that show the same image share them.
|
||||
if let Some(image) = g.placed_image_node(t.xf) {
|
||||
children.push(Arc::new(d.reid(&image)));
|
||||
break 'glyph;
|
||||
}
|
||||
// COLR glyphs become groups of their layers, in place, with ids of the document's.
|
||||
if let Some(art) = g.placed_colour(t.xf) {
|
||||
children.push(Arc::new(d.reid(&art)));
|
||||
break 'glyph;
|
||||
}
|
||||
let path = PathData::from_bezpath(&g.outline).transformed(t.xf);
|
||||
if path.is_empty() {
|
||||
break 'glyph;
|
||||
}
|
||||
let st = t.runs.get(g.run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
|
||||
let mut node = shape_node(d, path, None);
|
||||
node.appearance = st.appearance().into();
|
||||
children.push(Arc::new(node));
|
||||
}
|
||||
run_end.insert(g.run, children.len());
|
||||
}
|
||||
// Ruby (furigana) glyphs, where the layout put them, in their base text's run's paint,
|
||||
// right after that run's glyphs, as the canvas draws them (colour glyphs in ruby draw
|
||||
// nothing there either).
|
||||
let mut ruby: Vec<(usize, Arc<Node>)> = vec![];
|
||||
for g in &lay.ruby {
|
||||
let path = PathData::from_bezpath(&g.outline).transformed(t.xf);
|
||||
if path.is_empty() {
|
||||
continue;
|
||||
@@ -577,8 +598,21 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = t.runs.get(g.run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
|
||||
let mut node = shape_node(d, path, None);
|
||||
node.appearance = st.appearance().into();
|
||||
children.push(Arc::new(node));
|
||||
// After the glyphs of its run and of the runs before it.
|
||||
let at = run_end.range(..=g.run).map(|(_, end)| *end).max().unwrap_or(0);
|
||||
ruby.push((at, Arc::new(node)));
|
||||
}
|
||||
ruby.sort_by_key(|(at, _)| *at);
|
||||
let mut ruby = ruby.into_iter().peekable();
|
||||
let mut placed = Vec::with_capacity(children.len() + ruby.len());
|
||||
for (i, c) in children.into_iter().enumerate() {
|
||||
while let Some((_, r)) = ruby.next_if(|(at, _)| *at == i) {
|
||||
placed.push(r);
|
||||
}
|
||||
placed.push(c);
|
||||
}
|
||||
placed.extend(ruby.map(|(_, r)| r));
|
||||
let mut children = placed;
|
||||
// Underline and strikethrough bars become paths of their run's paint too (#847).
|
||||
for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t) {
|
||||
let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
|
||||
@@ -602,6 +636,8 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
new_sel.push(gid);
|
||||
}
|
||||
}
|
||||
// Outlined frames of threaded text leave their threads with the text they show.
|
||||
super::threads::leave_threads(d, &threaded);
|
||||
sel.set(new_sel);
|
||||
Ok(out)
|
||||
})?;
|
||||
@@ -1501,6 +1537,79 @@ mod area_tests {
|
||||
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None));
|
||||
}
|
||||
|
||||
/// Numbered lists: `list: "number"` numbers the paragraphs 1, 2, 3 and `number` picks the
|
||||
/// style; a list switched between bullets and numbers keeps its bullet and its indents; an
|
||||
/// unknown value is an error; the list is saved, reopened and carried by paragraph styles.
|
||||
#[test]
|
||||
fn numbered_lists_are_set_per_paragraph_saved_and_styled() {
|
||||
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let list = |s: &Session, i: usize| text(s).para_at(i).list;
|
||||
let numbers = |s: &Session| {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
|
||||
};
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number"})).unwrap();
|
||||
let arabic = Some(ParaList::numbered(NumberStyle::Arabic));
|
||||
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (arabic, arabic, arabic));
|
||||
assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0));
|
||||
assert_eq!(numbers(&s), ["1.", "2.", "3."]);
|
||||
// `number` picks the style, `list: "number"` keeps it, and the count runs on through both.
|
||||
s.execute("text.setFormat", &json!({"number": "upperRoman", "start": 0, "end": 0})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 0})).unwrap();
|
||||
let roman = Some(ParaList::numbered(NumberStyle::UpperRoman));
|
||||
assert_eq!(list(&s, 0), roman);
|
||||
assert_eq!(numbers(&s), ["I.", "2.", "3."]);
|
||||
// A bullet makes it bulleted, a number style numbered again, and `list: "bullet"` brings
|
||||
// back the bullet it had; the indents stay as they are.
|
||||
s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap();
|
||||
assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square)));
|
||||
s.execute("text.setFormat", &json!({"number": "lowerLetters", "start": 0, "end": 0})).unwrap();
|
||||
let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters) });
|
||||
assert_eq!(list(&s, 0), letters);
|
||||
assert_eq!(numbers(&s), ["a.", "2.", "3."]);
|
||||
assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0));
|
||||
for bad in [json!({"number": "roman"}), json!({"number": 1}), json!({"list": "numbered"}), json!({"number": true})] {
|
||||
assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!((list(&s, 0), list(&s, 1)), (letters, arabic), "an unknown value changes nothing");
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "number": "lowerLetters"}));
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (letters, arabic, arabic));
|
||||
// Return at the end of a numbered item starts the next number.
|
||||
s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": "\nFour"})).unwrap();
|
||||
assert_eq!(numbers(&s), ["a.", "2.", "3.", "4."]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// "none" turns the list off; the next numbered paragraph starts again at 1.
|
||||
s.execute("text.setFormat", &json!({"number": "none", "start": 4, "end": 4})).unwrap();
|
||||
assert_eq!(list(&s, 1), None);
|
||||
assert_eq!(numbers(&s), ["a.", "1."]);
|
||||
// A style that holds a numbered list sets it, a style captured from a numbered paragraph
|
||||
// carries its list, and one captured from a paragraph without a list turns it off.
|
||||
s.execute("paraStyle.new", &json!({"name": "Roman", "attrs": {"list": {"number": "upperRoman"}}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Roman", "id": id})).unwrap();
|
||||
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (roman, roman, roman));
|
||||
assert_eq!(numbers(&s), ["I.", "II.", "III."]);
|
||||
let plain = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Plain"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Plain", "id": plain})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Captured", "id": id})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Captured", "id": plain})).unwrap();
|
||||
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(plain)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!(t.para_at(0).list, roman);
|
||||
s.execute("paraStyle.apply", &json!({"name": "Plain", "id": id})).unwrap();
|
||||
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None));
|
||||
}
|
||||
|
||||
/// Type › Create Outlines makes a path of a list item's bullet as well as of its characters.
|
||||
#[test]
|
||||
fn create_outlines_makes_a_path_of_the_bullet() {
|
||||
@@ -2176,6 +2285,72 @@ mod ruby_tests {
|
||||
assert!(text_of(&s, id).ruby.is_empty());
|
||||
}
|
||||
|
||||
/// Create Outlines keeps the ruby: one path per inked glyph of the base text and of the
|
||||
/// reading, each where the layout (and so the canvas) puts it.
|
||||
#[test]
|
||||
fn create_outlines_keeps_the_ruby_where_it_is_laid_out() {
|
||||
use kurbo::Shape;
|
||||
let (mut s, id) = session_with_text();
|
||||
s.execute("type.ruby", &json!({"text": "かんじ", "start": 0, "end": 6})).unwrap();
|
||||
let t = text_of(&s, id).clone();
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let inked = |gs: &[vectorcraft_text::PositionedGlyph]| -> Vec<kurbo::Rect> {
|
||||
gs.iter().filter(|g| !g.outline.elements().is_empty()).map(|g| t.xf.transform_rect_bbox(g.outline.bounding_box())).collect()
|
||||
};
|
||||
let (base, ruby) = (inked(&lay.glyphs), inked(&lay.ruby));
|
||||
assert_eq!(ruby.len(), 3, "the reading's three kana have ink");
|
||||
let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let paths: Vec<kurbo::Rect> = d.node(NodeId(g)).unwrap().children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect();
|
||||
assert_eq!(paths.len(), base.len() + ruby.len(), "a path per glyph, the reading's included: {paths:?}");
|
||||
let near = |a: &kurbo::Rect, b: &kurbo::Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 0.05);
|
||||
for r in &ruby {
|
||||
assert!(paths.iter().any(|p| near(p, r)), "the kana at {r:?} among {paths:?}");
|
||||
}
|
||||
// Undo brings the type back with its ruby.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text_of(&s, id).ruby.len(), 1);
|
||||
}
|
||||
|
||||
/// The outlines stack as the canvas paints type, run by run (`render`'s `TextGeom::run`): a
|
||||
/// run's reading goes right after its base glyphs, below the glyphs of the runs after it. Here
|
||||
/// the red 漢's wide reading hangs over the big blue 字.
|
||||
#[test]
|
||||
fn create_outlines_stacks_each_reading_with_its_run() {
|
||||
use kurbo::Shape;
|
||||
let (mut s, id) = session_with_text();
|
||||
s.execute("text.setText", &json!({"id": id, "text": "漢字"})).unwrap();
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "fill": "#ff0000"})).unwrap();
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "size": 60, "fill": "#0000ff"})).unwrap();
|
||||
s.execute("type.ruby", &json!({"text": "かんじかんじ", "start": 0, "end": 3, "size": 100})).unwrap();
|
||||
let t = text_of(&s, id).clone();
|
||||
assert_eq!(t.runs.len(), 2, "{:?}", t.runs);
|
||||
let (red, blue) = (t.runs[0].style.fill.clone(), t.runs[1].style.fill.clone());
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let ink = |gs: &mut dyn Iterator<Item = &vectorcraft_text::PositionedGlyph>| {
|
||||
gs.filter(|g| !g.outline.elements().is_empty()).map(|g| g.outline.bounding_box()).reduce(|a, b| a.union(b)).unwrap()
|
||||
};
|
||||
let (reading, big) = (ink(&mut lay.ruby.iter()), ink(&mut lay.glyphs.iter().filter(|g| g.run == 1)));
|
||||
assert!(reading.intersect(big).area() > 0.0, "the reading {reading:?} hangs over 字 {big:?}");
|
||||
let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let paints: Vec<String> = d
|
||||
.node(NodeId(g))
|
||||
.unwrap()
|
||||
.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| match c.appearance.fill().map(|f| &f.paint) {
|
||||
Some(p) if *p == red => "red".into(),
|
||||
Some(p) if *p == blue => "blue".into(),
|
||||
p => format!("{p:?}"),
|
||||
})
|
||||
.collect();
|
||||
// 漢 and its six kana, then 字 over them.
|
||||
let want: Vec<String> = ["red"; 7].into_iter().chain(["blue"]).map(String::from).collect();
|
||||
assert_eq!(paints, want);
|
||||
}
|
||||
|
||||
/// Ruby is written to SVG and PDF with its text: SVG outlines the type where ruby is present
|
||||
/// (a reader's `<text>` can't place the reading), PDF differs when the ruby is there.
|
||||
#[test]
|
||||
|
||||
@@ -329,6 +329,8 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let TextKind::Area { frame } = &t.kind else { continue };
|
||||
let fb = frame.bounds().unwrap_or_default();
|
||||
// Point type is in no thread: its first paragraph starts in it.
|
||||
t.first_para_continues = false;
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let plain = t.plain_text();
|
||||
let baseline = lay.lines.first().map(|l| l.baseline).unwrap_or(fb.y0 + t.first_style().size * 0.8);
|
||||
|
||||
@@ -469,6 +469,7 @@ fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
const C: &str = "dataset.select";
|
||||
let doc_path = s.doc()?.path.clone();
|
||||
let (applied, skipped, mut typed, bound) = s.edit("Apply Data Set", |d, sel| {
|
||||
let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
|
||||
// One object may be driven by several variables; walk them in definition order, so two
|
||||
@@ -513,7 +514,7 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
true
|
||||
}),
|
||||
// Swap the linked file; keep the object's bounds. A missing path skips.
|
||||
(VariableKind::Image, DataValue::Image(path)) => links::set_image_from_path(d, id, path).is_ok(),
|
||||
(VariableKind::Image, DataValue::Image(path)) => links::set_image_from_path(d, id, path, doc_path.as_deref()).is_ok(),
|
||||
(VariableKind::Graph, DataValue::Graph(csv)) => super::graph::apply_csv(d, id, csv).is_ok(),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
+104
-18
@@ -2,7 +2,8 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{ArrowAlign, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, WidthProfile};
|
||||
use vectorcraft_doc::{ArrowAlign, Document, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, WidthProfile};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -265,6 +266,8 @@ fn document_base(s: &Session) -> Value {
|
||||
json!({
|
||||
"uid": st.uid,
|
||||
"title": st.title(),
|
||||
// The File Info title (`file.info`), where `title` comes from the file name once the document has one.
|
||||
"fileInfoTitle": d.title,
|
||||
"path": st.path,
|
||||
"dirty": st.is_dirty(),
|
||||
"revision": st.revision,
|
||||
@@ -296,20 +299,87 @@ fn document_base(s: &Session) -> Value {
|
||||
|
||||
/// What `document.find` matches: every given filter must hit (AND). All matching is
|
||||
/// case-insensitive; `name` and `text` are substrings, `kind` is the exact panel label
|
||||
/// (`Layer`, `Group`, `Path`, `Type`, `Image`, ...).
|
||||
/// (`Layer`, `Group`, `Path`, `Type`, `Image`, ...). The geometric filters read an object's
|
||||
/// geometric bounds as `document.inspect` reports them; layers and sublayers fail them, and so
|
||||
/// does a node without bounds (an empty group); `anchors` counts a path's anchor points, summed
|
||||
/// over a compound path's members, and a node that is neither fails it.
|
||||
#[derive(Default)]
|
||||
pub struct FindFilter {
|
||||
pub name: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub text: Option<String>,
|
||||
/// The bounds lie inside this rectangle, edges included.
|
||||
pub within: Option<Rect>,
|
||||
/// The bounds touch or overlap this rectangle.
|
||||
pub intersects: Option<Rect>,
|
||||
/// The bounds' width.
|
||||
pub width: FindRange,
|
||||
/// The bounds' height.
|
||||
pub height: FindRange,
|
||||
/// The smaller of the bounds' width and height.
|
||||
pub thickness: FindRange,
|
||||
/// The bounds' width times their height.
|
||||
pub area: FindRange,
|
||||
/// The anchor points of a path, or of a compound path's members together.
|
||||
pub anchors: FindRange,
|
||||
}
|
||||
|
||||
/// Inclusive limits on one measure of a [`FindFilter`]; neither given matches everything.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct FindRange {
|
||||
pub min: Option<f64>,
|
||||
pub max: Option<f64>,
|
||||
}
|
||||
|
||||
impl FindRange {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.min.is_none() && self.max.is_none()
|
||||
}
|
||||
|
||||
fn hits(&self, v: f64) -> bool {
|
||||
self.min.is_none_or(|m| v >= m) && self.max.is_none_or(|m| v <= m)
|
||||
}
|
||||
}
|
||||
|
||||
impl FindFilter {
|
||||
/// True when no effective filter was given (empty strings count as absent).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
[self.name.as_ref(), self.kind.as_ref(), self.text.as_ref()].iter().all(|f| f.is_none())
|
||||
&& self.within.is_none()
|
||||
&& self.intersects.is_none()
|
||||
&& self.ranges().iter().all(|r| r.is_empty())
|
||||
&& self.anchors.is_empty()
|
||||
}
|
||||
|
||||
fn hits(&self, n: &Node) -> bool {
|
||||
/// The limits read from the bounds.
|
||||
fn ranges(&self) -> [FindRange; 4] {
|
||||
[self.width, self.height, self.thickness, self.area]
|
||||
}
|
||||
|
||||
fn hits(&self, n: &Node, symbols: &SymbolBoxes) -> bool {
|
||||
if self.within.is_some() || self.intersects.is_some() || self.ranges().iter().any(|r| !r.is_empty()) {
|
||||
if matches!(n.kind, NodeKind::Layer { .. }) {
|
||||
return false;
|
||||
}
|
||||
let Some(b) = n.geometric_bounds_with(symbols) else { return false };
|
||||
if self.within.is_some_and(|r| b.x0 < r.x0 || b.y0 < r.y0 || b.x1 > r.x1 || b.y1 > r.y1) {
|
||||
return false;
|
||||
}
|
||||
if self.intersects.is_some_and(|r| b.x0 > r.x1 || b.x1 < r.x0 || b.y0 > r.y1 || b.y1 < r.y0) {
|
||||
return false;
|
||||
}
|
||||
let (w, h) = (b.width(), b.height());
|
||||
if !(self.width.hits(w) && self.height.hits(h) && self.thickness.hits(w.min(h)) && self.area.hits(w * h)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if !self.anchors.is_empty() && !anchor_count(n).is_some_and(|c| self.anchors.hits(c as f64)) {
|
||||
return false;
|
||||
}
|
||||
self.hits_text(n)
|
||||
}
|
||||
|
||||
fn hits_text(&self, n: &Node) -> bool {
|
||||
if let Some(needle) = &self.name
|
||||
&& !n.display_name().to_lowercase().contains(needle)
|
||||
{
|
||||
@@ -330,31 +400,47 @@ impl FindFilter {
|
||||
}
|
||||
}
|
||||
|
||||
/// The anchor points of a path, or of a compound path's members together; `None` for any other
|
||||
/// node.
|
||||
fn anchor_count(n: &Node) -> Option<usize> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => Some(path.anchor_count()),
|
||||
NodeKind::Compound { children, .. } => Some(children.iter().filter_map(|c| anchor_count(c)).sum()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Search the whole layer tree, top of the stack first like the Layers panel.
|
||||
/// Returns at most `limit` hits as `{id, name, kind, path}` (`path` is the ancestor
|
||||
/// chain, layer first) plus the total hit count, so a capped reply stays explicit.
|
||||
pub fn find_nodes(doc: &Document, filter: &FindFilter, limit: u64) -> (Vec<Value>, u64) {
|
||||
/// chain, layer first) plus the ids of every hit, so a capped reply stays explicit.
|
||||
pub fn find_nodes(doc: &Document, filter: &FindFilter, limit: u64) -> (Vec<Value>, Vec<NodeId>) {
|
||||
use std::sync::Arc;
|
||||
let mut out = Vec::new();
|
||||
let mut total = 0u64;
|
||||
let mut ancestors: Vec<u64> = Vec::new();
|
||||
fn walk(nodes: &[Arc<Node>], ancestors: &mut Vec<u64>, filter: &FindFilter, limit: u64, out: &mut Vec<Value>, total: &mut u64) {
|
||||
struct Walk<'a> {
|
||||
filter: &'a FindFilter,
|
||||
symbols: SymbolBoxes<'a>,
|
||||
limit: u64,
|
||||
ancestors: Vec<u64>,
|
||||
out: Vec<Value>,
|
||||
ids: Vec<NodeId>,
|
||||
}
|
||||
fn walk(nodes: &[Arc<Node>], w: &mut Walk) {
|
||||
for n in nodes.iter().rev() {
|
||||
if filter.hits(n) {
|
||||
*total += 1;
|
||||
if (out.len() as u64) < limit {
|
||||
out.push(json!({"id": n.id.0, "name": n.display_name(), "kind": n.kind_label(), "path": ancestors}));
|
||||
if w.filter.hits(n, &w.symbols) {
|
||||
w.ids.push(n.id);
|
||||
if (w.out.len() as u64) < w.limit {
|
||||
w.out.push(json!({"id": n.id.0, "name": n.display_name(), "kind": n.kind_label(), "path": w.ancestors}));
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children() {
|
||||
ancestors.push(n.id.0);
|
||||
walk(ch, ancestors, filter, limit, out, total);
|
||||
ancestors.pop();
|
||||
w.ancestors.push(n.id.0);
|
||||
walk(ch, w);
|
||||
w.ancestors.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(&doc.layers, &mut ancestors, filter, limit, &mut out, &mut total);
|
||||
(out, total)
|
||||
let mut w = Walk { filter, symbols: doc.symbol_boxes(), limit, ancestors: Vec::new(), out: Vec::new(), ids: Vec::new() };
|
||||
walk(&doc.layers, &mut w);
|
||||
(w.out, w.ids)
|
||||
}
|
||||
|
||||
/// Cap names as `stroke.set` takes them.
|
||||
|
||||
+169
-39
@@ -12,7 +12,6 @@ pub mod guard;
|
||||
pub mod inspect;
|
||||
pub mod journal;
|
||||
pub mod link_watch;
|
||||
pub mod namematch;
|
||||
pub mod steps;
|
||||
pub mod store;
|
||||
mod tooling;
|
||||
@@ -378,6 +377,10 @@ pub struct DocState {
|
||||
/// That format's options as last saved (SVG options for SVG, the Save PDF settings for PDF;
|
||||
/// empty for native files): Save reuses them and `file.formatOptions` reads them back.
|
||||
pub save_options: serde_json::Map<String, Value>,
|
||||
/// The ids of the document's artboards, in order, when `save_options` were stored: the
|
||||
/// artboards their `artboard`, `artboards` and `range` named by number then
|
||||
/// ([`cmd::fileio::remembered_save_options`]).
|
||||
pub save_artboards: Vec<u32>,
|
||||
/// Opened from an older native file (former name or format version): the title says
|
||||
/// "[Converted]" and Save asks for a new name instead of overwriting it.
|
||||
pub converted: bool,
|
||||
@@ -401,6 +404,10 @@ pub struct DocState {
|
||||
/// Transform Again after a perspective move or scale (Perspective Selection tool): the
|
||||
/// `perspective.transform` params it repeats. `None` once an ordinary transform follows.
|
||||
pub last_perspective: Option<Value>,
|
||||
/// The folders a confined (automation) caller that opened or made the document may read: the
|
||||
/// paths the document links to are looked at within them later too, when the Links panel
|
||||
/// asks about a link outside any caller's scope.
|
||||
pub opened_by: Option<Arc<file_access::AutomationRoots>>,
|
||||
}
|
||||
|
||||
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -565,6 +572,7 @@ impl DocState {
|
||||
transparency_grid,
|
||||
format: "vectorcraft",
|
||||
save_options: Default::default(),
|
||||
save_artboards: vec![],
|
||||
converted: false,
|
||||
view,
|
||||
recovered: false,
|
||||
@@ -574,6 +582,7 @@ impl DocState {
|
||||
imported_from: None,
|
||||
import_losses: vec![],
|
||||
last_perspective: None,
|
||||
opened_by: file_access::current(),
|
||||
}
|
||||
}
|
||||
/// Unsaved changes: the document differs from the saved one (selection changes and changes that
|
||||
@@ -859,6 +868,19 @@ pub(crate) fn deselect_guides(d: &Document, sel: &mut Selection) -> bool {
|
||||
any
|
||||
}
|
||||
|
||||
/// Deselect what the View menu keeps from being selected: the guides while Lock Guides is on
|
||||
/// ([`deselect_guides`]), and the slices that the Slice Selection tool or a slice command selected
|
||||
/// ([`Selection::slices`]) while Lock Slices or Hide Slices is on. A selected object that is an
|
||||
/// object slice stays selected. → whether any was selected.
|
||||
pub(crate) fn deselect_hidden(menu: &cmd::menucmds::MenuState, d: &Document, sel: &mut Selection) -> bool {
|
||||
let guides = menu.guides_locked && deselect_guides(d, sel);
|
||||
let slices = (menu.slices_locked || menu.slices_hidden) && !sel.slices.is_empty();
|
||||
if slices {
|
||||
sel.slices.clear();
|
||||
}
|
||||
guides || slices
|
||||
}
|
||||
|
||||
/// The error of an edit that would nest objects deeper than [`vectorcraft_doc::MAX_DEPTH`].
|
||||
pub const TOO_DEEP: &str = "objects can't be nested more than 32 deep";
|
||||
const _: () = assert!(vectorcraft_doc::MAX_DEPTH == 32, "TOO_DEEP (and its catalog rows) names the cap");
|
||||
@@ -1405,6 +1427,13 @@ pub struct Session {
|
||||
/// The depth of the command whose values [`Session::note_journal`] keeps: 1 (the top-level
|
||||
/// command), or a step's while a batch runs it ([`Session::execute_step`]).
|
||||
note_depth: u32,
|
||||
/// The running top-level query command changed the selection and is journaled like any other
|
||||
/// command ([`Session::journal_query`]).
|
||||
journal_query: bool,
|
||||
/// From `edit.undoGroup.begin` to `edit.undoGroup.end`: the name of the undo step (`None`: after
|
||||
/// its first edit). Each document opened or created meanwhile opens an undo group of its own
|
||||
/// ([`Session::add_document`]). `None` otherwise.
|
||||
pub(crate) explicit_undo_group: Option<Option<String>>,
|
||||
/// While `command.batch` runs: the documents its steps closed or replaced (Revert), which an
|
||||
/// error brings back; `None` otherwise.
|
||||
pub(crate) batch_stash: Option<Vec<DocState>>,
|
||||
@@ -1536,6 +1565,8 @@ impl Session {
|
||||
ui_language: None,
|
||||
journal_note: Default::default(),
|
||||
note_depth: 1,
|
||||
journal_query: false,
|
||||
explicit_undo_group: None,
|
||||
batch_stash: None,
|
||||
place_hand_off: None,
|
||||
queue_hand_off: None,
|
||||
@@ -1638,7 +1669,9 @@ impl Session {
|
||||
refresh_tree_text_bounds(tree);
|
||||
}
|
||||
}
|
||||
/// Add a document and make it active.
|
||||
/// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`,
|
||||
/// an undo group with the begun group's name opens in it: here outside a command, and after the
|
||||
/// command that adds it otherwise ([`Session::execute`]).
|
||||
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
|
||||
// Every edit is checked against the document before it, so the first one is within the cap
|
||||
// (importers and the native reader have seen to theirs already, with a warning).
|
||||
@@ -1653,6 +1686,11 @@ impl Session {
|
||||
self.docs.push(st);
|
||||
let i = self.docs.len() - 1;
|
||||
self.active = Some(i);
|
||||
if self.depth == 0
|
||||
&& let Some(label) = self.explicit_undo_group.clone()
|
||||
{
|
||||
self.begin_named_undo_group(label);
|
||||
}
|
||||
i
|
||||
}
|
||||
/// Replace the document in tab `index` (File → Revert): new content, cleared history and
|
||||
@@ -1677,6 +1715,17 @@ impl Session {
|
||||
st.revision = old.revision + 1;
|
||||
st.format = old.format;
|
||||
st.save_options = old.save_options.clone();
|
||||
// The remembered artboard pick follows the artboards it named when the file read back has
|
||||
// the artboards the document had at the save, in the same order and with the same ids (a
|
||||
// native file keeps them; a PDF's pages get new ids in page order); otherwise it is left
|
||||
// out.
|
||||
if old.save_artboards.is_empty() || st.doc.artboards.iter().map(|a| a.id).eq(old.save_artboards.iter().copied()) {
|
||||
st.save_artboards = old.save_artboards.clone();
|
||||
} else {
|
||||
for k in cmd::fileio::ARTBOARD_PARAMS {
|
||||
st.save_options.remove(k);
|
||||
}
|
||||
}
|
||||
st.converted = old.converted;
|
||||
st.imported_from = old.imported_from.clone();
|
||||
st.import_losses = old.import_losses.clone();
|
||||
@@ -1715,6 +1764,24 @@ impl Session {
|
||||
r
|
||||
}
|
||||
|
||||
/// Error, under the command's own id, at the first of `ids` that is malformed, names no node, or
|
||||
/// names a layer or sublayer: a command in [`SELECTS_GIVEN_IDS`] acts on objects.
|
||||
fn check_given_ids(&self, id: &str, params: &Value) -> Result<()> {
|
||||
let (Some(st), Some(ids)) = (self.active(), params.get("ids")) else { return Ok(()) };
|
||||
let Some(given) = ids.as_array() else { return cmd::checked_ids_param(self, params, "ids", id).map(drop) };
|
||||
for value in given {
|
||||
let node = cmd::checked_id(self, value, id)?;
|
||||
if st.doc.node(node).is_some_and(|n| n.is_layer()) {
|
||||
return Err(if id.starts_with("object.arrange.") {
|
||||
cmd::bad(id, format!("id {value} is a layer, not an object; move layers with layer.move"))
|
||||
} else {
|
||||
cmd::bad(id, format!("id {value} is a layer, not an object"))
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a command by id. This is THE entry point for every frontend.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
@@ -1722,13 +1789,28 @@ impl Session {
|
||||
// passes that its command's description doesn't name fails the test.
|
||||
#[cfg(test)]
|
||||
self.check_params(id, params)?;
|
||||
// Objects given as `id` (where it stands for `ids` of one) or as `ids` beside it are checked
|
||||
// before anything else: a malformed one, or one naming no object, is an error under this
|
||||
// command's id, never the selection in its place. `effect.apply {id: "stylize.dropShadow"}`
|
||||
// without `effect` names the effect (its older key), not an object.
|
||||
let effect_named = id == "effect.apply"
|
||||
&& params.get("effect").is_none()
|
||||
&& params.get("ids").is_none_or(Value::is_null)
|
||||
&& params.get("id").and_then(Value::as_str).is_some_and(|e| !e.starts_with('$'));
|
||||
if cmd::params::id_stands_for_ids(spec.params) && !effect_named && self.active().is_some() {
|
||||
cmd::given_objects(self, params, id)?;
|
||||
}
|
||||
if SELECTS_GIVEN_IDS.contains(&id) {
|
||||
self.check_given_ids(id, params)?;
|
||||
}
|
||||
// Clear's menu needs a selection; an agent supplying explicit targets only needs a
|
||||
// document. The command validates those targets before making any edit.
|
||||
let given = given_ids(spec.params, params);
|
||||
let enabled = if id == "edit.clear" && params.get("ids").is_some() {
|
||||
cmd::has_doc(self)
|
||||
} else if let Some(ids) = given_ids(spec.params, params) {
|
||||
// Objects given as `ids` stand in for the selection: the command is enabled as it is
|
||||
// with them selected (#1575).
|
||||
} else if let Some(ids) = given.clone() {
|
||||
// Objects given as `ids` (or `id`) stand in for the selection: the command is enabled
|
||||
// as it is with them selected (#1575).
|
||||
self.enabled_with_selection(spec, ids)
|
||||
} else {
|
||||
(spec.enabled)(self)
|
||||
@@ -1741,15 +1823,21 @@ impl Session {
|
||||
if self.depth == 0 {
|
||||
self.journal_note.clear();
|
||||
self.note_depth = 1;
|
||||
self.journal_query = false;
|
||||
self.batch_stash = None;
|
||||
}
|
||||
// A selection-only command given `ids` selects them first, in the same undo step (#2130);
|
||||
// when the command then fails, the selection (and the current layer selecting them made
|
||||
// current) goes back to how it was, as when the ids are refused.
|
||||
let select_first = SELECTS_GIVEN_IDS.contains(&id).then(|| params.get("ids")).flatten();
|
||||
let selected_before = select_first.and(self.active()).map(|d| (d.uid, d.selection.clone(), d.active_layer));
|
||||
// A selection-only command given `ids` (or `id`) selects them first, in the same undo step
|
||||
// (#2130); when the command then fails, the selection (and the current layer selecting
|
||||
// them made current) goes back to how it was, as when the ids are refused.
|
||||
let select_first = SELECTS_GIVEN_IDS
|
||||
.contains(&id)
|
||||
.then(|| {
|
||||
params.get("ids").filter(|v| !v.is_null()).cloned().or_else(|| given.map(|ids| ids.iter().map(|i| i.0).collect::<Vec<_>>().into()))
|
||||
})
|
||||
.flatten();
|
||||
let selected_before = select_first.as_ref().and(self.active()).map(|d| (d.uid, d.selection.clone(), d.active_layer));
|
||||
let run = |s: &mut Session| {
|
||||
if let Some(ids) = select_first {
|
||||
if let Some(ids) = &select_first {
|
||||
s.execute("select.set", &serde_json::json!({ "ids": ids }))?;
|
||||
}
|
||||
let r = (spec.run)(s, params);
|
||||
@@ -1766,12 +1854,17 @@ impl Session {
|
||||
// The entries the command journals belong to the undo groups of the documents it edits, and to
|
||||
// the group of the document it starts in when it ends there too.
|
||||
let (doc, journal, grouped) = (self.active().map(|d| d.uid), self.journal.len(), self.undo_group_docs());
|
||||
let open = (self.depth == 0).then(|| self.docs.iter().map(|d| d.uid).collect::<Vec<_>>());
|
||||
let r = if self.depth == 0 { self.run_guarded(id, run) } else { self.run_nested(run) };
|
||||
let r = r?;
|
||||
if self.depth == 0 {
|
||||
self.inherit_new_art();
|
||||
}
|
||||
if spec.journal && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) {
|
||||
if let Some(open) = open {
|
||||
self.open_undo_groups_in_new_documents(&open);
|
||||
}
|
||||
let journaled = spec.journal || (self.depth == 0 && std::mem::take(&mut self.journal_query));
|
||||
if journaled && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) {
|
||||
let p = self.noted(params);
|
||||
self.journal.push((id.to_string(), p));
|
||||
}
|
||||
@@ -1797,6 +1890,14 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Journal the running query command when it is the top-level command: it changed the
|
||||
/// selection (`document.find {select: true}`), so an action recorded around it repeats that.
|
||||
pub(crate) fn journal_query(&mut self) {
|
||||
if self.depth == 1 {
|
||||
self.journal_query = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `id` as a step of the running command, which journals its steps (`command.batch`):
|
||||
/// → the step's result and its params with what it noted ([`Session::note_journal`]), for the
|
||||
/// step in the running command's journal entry.
|
||||
@@ -1885,7 +1986,7 @@ impl Session {
|
||||
if !self.previewing && cmd::typing_in_progress(self) {
|
||||
cmd::settle_interaction(self)?;
|
||||
}
|
||||
let guides_locked = self.menu.guides_locked;
|
||||
let menu = self.menu.clone();
|
||||
let st = self.doc_mut()?;
|
||||
let before = st.doc.clone();
|
||||
let before_sel = st.selection.clone();
|
||||
@@ -1911,6 +2012,9 @@ impl Session {
|
||||
cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc));
|
||||
// Text Wrap: area type follows its wrap objects; then threads re-flow.
|
||||
cmd::textwrap::refresh(Arc::make_mut(&mut st.doc));
|
||||
// Type in no thread numbers its list paragraphs from 1 (before the mask copies
|
||||
// its editing layer's art).
|
||||
cmd::threads::restart_unthreaded_counts(&mut st.doc);
|
||||
// Opacity-mask editing: the mask follows its art on the editing layer.
|
||||
if st.doc.mask_edit.is_some() {
|
||||
cmd::maskedit::sync(Arc::make_mut(&mut st.doc));
|
||||
@@ -1942,10 +2046,10 @@ impl Session {
|
||||
match result {
|
||||
Ok(v) => {
|
||||
st.selection.prune(&st.doc);
|
||||
// An edit that selects what it unlocks, shows or pastes leaves locked guides out.
|
||||
if guides_locked {
|
||||
deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
// An edit that selects what it unlocks, shows or pastes leaves locked guides out,
|
||||
// and one that selects the slices it makes leaves them out while they are locked
|
||||
// or hidden.
|
||||
deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
// The art on a locked artboard can't stay selected, however it got there, except
|
||||
// the new art this edit made and new art still selected.
|
||||
st.protect_locked_artboards(&before_sel.objects, &before_sel.new_art, Some(&*before));
|
||||
@@ -1998,16 +2102,15 @@ impl Session {
|
||||
/// Change only the selection (not an undo step).
|
||||
pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> {
|
||||
self.active_appearance_item = None;
|
||||
let guides_locked = self.menu.guides_locked;
|
||||
let menu = self.menu.clone();
|
||||
let st = self.doc_mut()?;
|
||||
let before = st.selection.objects.clone();
|
||||
let new_art = st.selection.new_art.clone();
|
||||
f(&st.doc, &mut st.selection);
|
||||
st.selection.prune(&st.doc);
|
||||
// Lock Guides locks the paths made into guides too (#1534), whatever selects them.
|
||||
if guides_locked {
|
||||
deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
// Lock Guides locks the paths made into guides too (#1534), and Lock Slices and Hide
|
||||
// Slices keep the slices out, whatever selects them.
|
||||
deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
// The art on a locked artboard can't be selected, whatever selects it, except new art
|
||||
// that was selected and still is.
|
||||
st.protect_locked_artboards(&before, &new_art, None);
|
||||
@@ -2069,6 +2172,7 @@ impl Session {
|
||||
/// Re-apply `cmd` on top of the interaction snapshot (replacing the previous preview).
|
||||
pub fn preview(&mut self, cmd: &str, params: &Value) -> Result<Value> {
|
||||
{
|
||||
let menu = self.menu.clone();
|
||||
let st = self.doc_mut()?;
|
||||
let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) };
|
||||
// Undoing the previous preview counts as a change, so views redraw even when the command
|
||||
@@ -2081,6 +2185,9 @@ impl Session {
|
||||
if selection_changed {
|
||||
st.selection.clone_from(&it.selection);
|
||||
}
|
||||
// Guides locked and slices locked or hidden during the interaction stay deselected,
|
||||
// even when the preview fails.
|
||||
deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
cmd::globaledit::follow_history(st);
|
||||
}
|
||||
// The previous preview's paint is undone with it.
|
||||
@@ -2156,13 +2263,11 @@ impl Session {
|
||||
pub fn cancel_interaction(&mut self) -> Result<()> {
|
||||
self.clear_deferred_overlay_cache();
|
||||
self.pending_paint = None;
|
||||
let guides_locked = self.menu.guides_locked;
|
||||
let menu = self.menu.clone();
|
||||
let st = self.doc_mut()?;
|
||||
st.undo_interaction();
|
||||
// Guides locked during the interaction stay deselected.
|
||||
if guides_locked {
|
||||
deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
// Guides locked and slices locked or hidden during the interaction stay deselected.
|
||||
deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2189,6 +2294,20 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Between `edit.undoGroup.begin` and `edit.undoGroup.end`, open an undo group with the begun
|
||||
/// group's name in each document that the top-level command just run opened or created (its
|
||||
/// [`DocState::uid`] is not in `open`). The group takes the edits after that command: the
|
||||
/// command's own edits there stay its undo step, and a canceled group keeps them, as the journal
|
||||
/// entry that made the document stays.
|
||||
fn open_undo_groups_in_new_documents(&mut self, open: &[u64]) {
|
||||
let Some(label) = &self.explicit_undo_group else { return };
|
||||
for st in &mut self.docs {
|
||||
if !open.contains(&st.uid) && st.undo_group.is_none() {
|
||||
st.undo_group = Some(UndoGroup { first: None, journal: vec![], label: label.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The documents with an undo group open ([`DocState::uid`]), each with the document it holds.
|
||||
fn undo_group_docs(&self) -> Vec<(u64, *const Document)> {
|
||||
self.docs.iter().filter(|st| st.undo_group.is_some()).map(|st| (st.uid, Arc::as_ptr(&st.doc))).collect()
|
||||
@@ -2221,8 +2340,15 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Close the open undo groups: their edits stay one undo step or, `cancel`led (Escape), are
|
||||
/// undone and dropped from the journal.
|
||||
/// undone and dropped from the journal. A group begun with `edit.undoGroup.begin` ends too, so
|
||||
/// documents opened after this open no undo group.
|
||||
pub fn end_undo_group(&mut self, cancel: bool) {
|
||||
self.explicit_undo_group = None;
|
||||
self.close_undo_groups(cancel);
|
||||
}
|
||||
|
||||
/// [`Session::end_undo_group`] without ending a group begun with `edit.undoGroup.begin`.
|
||||
fn close_undo_groups(&mut self, cancel: bool) {
|
||||
let mut journal: Vec<(usize, Option<usize>)> = vec![];
|
||||
for st in &mut self.docs {
|
||||
let Some(g) = st.undo_group.take() else { continue };
|
||||
@@ -2234,9 +2360,7 @@ impl Session {
|
||||
{
|
||||
st.doc = e.doc;
|
||||
st.selection = Arc::unwrap_or_clone(e.selection);
|
||||
if self.menu.guides_locked {
|
||||
deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
deselect_hidden(&self.menu, &st.doc, &mut st.selection);
|
||||
st.sync_new_art();
|
||||
cmd::globaledit::follow_history(st);
|
||||
st.revision += 1;
|
||||
@@ -2265,7 +2389,7 @@ impl Session {
|
||||
// The other documents' groups are set aside while `end_undo_group` closes this one's.
|
||||
let others: Vec<(u64, UndoGroup)> =
|
||||
self.docs.iter_mut().filter(|st| st.uid != uid).filter_map(|st| Some((st.uid, st.undo_group.take()?))).collect();
|
||||
self.end_undo_group(cancel);
|
||||
self.close_undo_groups(cancel);
|
||||
for (other, g) in others {
|
||||
if let Some(st) = self.document_mut(other) {
|
||||
st.undo_group = Some(g);
|
||||
@@ -2298,7 +2422,7 @@ impl Session {
|
||||
self.end_document_undo_group(scope.uid, cancel);
|
||||
return;
|
||||
};
|
||||
let guides_locked = self.menu.guides_locked;
|
||||
let menu = self.menu.clone();
|
||||
let Some(st) = self.document_mut(scope.uid) else { return };
|
||||
let Some(g) = &mut st.undo_group else { return };
|
||||
let newest = g.first.as_ref().is_some_and(|f| st.history.undo.last().is_some_and(|e| Arc::ptr_eq(&e.doc, f)));
|
||||
@@ -2317,9 +2441,7 @@ impl Session {
|
||||
st.doc = doc;
|
||||
st.selection = selection;
|
||||
cmd::globaledit::follow_history(st);
|
||||
if guides_locked {
|
||||
deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
st.history.recost_last(&st.doc);
|
||||
st.revision += 1;
|
||||
self.journal.truncate(scope.journal);
|
||||
@@ -2739,8 +2861,16 @@ pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[
|
||||
];
|
||||
|
||||
/// The non-empty `ids` given to a command whose params description names `ids` as a key (the
|
||||
/// key callers outside the app may pass it, [`cmd::params::takes_key`]).
|
||||
/// key callers outside the app may pass it, [`cmd::params::takes_key`]), or its `id` as `ids` of
|
||||
/// one when no `ids` are given and the description takes `id` so
|
||||
/// ([`cmd::params::id_stands_for_ids`]), as [`cmd::targets`] reads them.
|
||||
fn given_ids(description: &'static str, params: &Value) -> Option<Vec<NodeId>> {
|
||||
let ids = cmd::ids_param(params, "ids").filter(|ids| !ids.is_empty())?;
|
||||
cmd::params::takes_key(description, "ids").then_some(ids)
|
||||
if !cmd::params::takes_key(description, "ids") {
|
||||
return None;
|
||||
}
|
||||
match params.get("ids").filter(|v| !v.is_null()) {
|
||||
Some(_) => cmd::ids_param(params, "ids"),
|
||||
None => cmd::id_param(params, "id").filter(|_| cmd::params::id_stands_for_ids(description)).map(|id| vec![id]),
|
||||
}
|
||||
.filter(|ids| !ids.is_empty())
|
||||
}
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
//! Matching a name another app's file writes (a shortcut set's entry name) against VectorCraft's
|
||||
//! commands, tools and panels, by their ids and labels only. A match is proposed when one candidate
|
||||
//! scores well and clearly ahead of the next; otherwise the best few are offered to choose from.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A command, tool or panel a name can stand for.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Candidate {
|
||||
/// `select.all`, `tool:rectangle` or `panel:layers`.
|
||||
pub id: String,
|
||||
/// Its labels: the English one, and the one in the interface language.
|
||||
pub labels: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a name stands for, as far as [`propose`] can tell.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Proposal {
|
||||
/// The candidate at this index of the list, with its score (0 to 1).
|
||||
Match(usize, f64),
|
||||
/// The best candidates, best first, when none stands out.
|
||||
Choice(Vec<usize>),
|
||||
}
|
||||
|
||||
/// The lowest score proposed.
|
||||
const THRESHOLD: f64 = 0.6;
|
||||
/// How far the best score must lead the next one to be proposed.
|
||||
const MARGIN: f64 = 0.1;
|
||||
/// The most candidates a [`Proposal::Choice`] lists.
|
||||
const CHOICES: usize = 5;
|
||||
/// The lowest score a [`Proposal::Choice`] lists.
|
||||
const CHOICE_FLOOR: f64 = 0.3;
|
||||
|
||||
/// `s` with only its letters and digits, lowercased.
|
||||
fn folded(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
/// The words of `s`, lowercased: runs of letters and digits, split where a lowercase letter or a digit
|
||||
/// meets an uppercase one (`selectAll`: `select`, `all`).
|
||||
fn words(s: &str) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut word = String::new();
|
||||
let mut prev_lower = false;
|
||||
for c in s.chars() {
|
||||
if !c.is_alphanumeric() {
|
||||
if !word.is_empty() {
|
||||
out.push(std::mem::take(&mut word));
|
||||
}
|
||||
prev_lower = false;
|
||||
continue;
|
||||
}
|
||||
if c.is_uppercase() && prev_lower && !word.is_empty() {
|
||||
out.push(std::mem::take(&mut word));
|
||||
}
|
||||
prev_lower = c.is_lowercase() || c.is_ascii_digit();
|
||||
word.extend(c.to_lowercase());
|
||||
}
|
||||
if !word.is_empty() {
|
||||
out.push(word);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The words of a candidate's id: its segments after any `tool:` or `panel:`, split as [`words`].
|
||||
fn id_words(id: &str) -> Vec<String> {
|
||||
let id = id.strip_prefix("tool:").or_else(|| id.strip_prefix("panel:")).unwrap_or(id);
|
||||
id.split('.').flat_map(words).collect()
|
||||
}
|
||||
|
||||
/// How many words `a` and `b` share, each word counted once, as a Dice coefficient (0 to 1).
|
||||
fn word_overlap(a: &[String], b: &[String]) -> f64 {
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut rest: Vec<&String> = b.iter().collect();
|
||||
let mut shared = 0usize;
|
||||
for w in a {
|
||||
if let Some(i) = rest.iter().position(|x| *x == w) {
|
||||
rest.swap_remove(i);
|
||||
shared += 1;
|
||||
}
|
||||
}
|
||||
(2 * shared) as f64 / (a.len() + b.len()) as f64
|
||||
}
|
||||
|
||||
/// The pairs of neighboring characters in `s`.
|
||||
fn pairs(s: &str) -> Vec<(char, char)> {
|
||||
let c: Vec<char> = s.chars().collect();
|
||||
c.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
|
||||
}
|
||||
|
||||
/// How many character pairs `a` and `b` (each sorted) share, as a Dice coefficient (0 to 1): for
|
||||
/// names written without spaces. Linear in their lengths, so long names in a file stay quick.
|
||||
fn pair_similarity(a: &[(char, char)], b: &[(char, char)]) -> f64 {
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let (mut i, mut j, mut shared) = (0usize, 0usize, 0usize);
|
||||
while let (Some(p), Some(q)) = (a.get(i), b.get(j)) {
|
||||
match p.cmp(q) {
|
||||
std::cmp::Ordering::Less => i += 1,
|
||||
std::cmp::Ordering::Greater => j += 1,
|
||||
std::cmp::Ordering::Equal => {
|
||||
shared += 1;
|
||||
i += 1;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(2 * shared) as f64 / (a.len() + b.len()) as f64
|
||||
}
|
||||
|
||||
/// A name or label as the scores compare it: its letters and digits (lowercased), its words and the
|
||||
/// character pairs of the former (sorted).
|
||||
struct Text {
|
||||
folded: String,
|
||||
words: Vec<String>,
|
||||
pairs: Vec<(char, char)>,
|
||||
}
|
||||
|
||||
impl Text {
|
||||
fn of(s: &str) -> Self {
|
||||
let folded = folded(s);
|
||||
let mut pairs = pairs(&folded);
|
||||
pairs.sort_unstable();
|
||||
Self { pairs, words: words(s), folded }
|
||||
}
|
||||
}
|
||||
|
||||
/// A candidate as the scores compare it.
|
||||
struct Prepared {
|
||||
/// Its id's words, and those joined.
|
||||
id_words: Vec<String>,
|
||||
id: String,
|
||||
labels: Vec<Text>,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
fn of(c: &Candidate) -> Self {
|
||||
let id_words = id_words(&c.id);
|
||||
Self { id: id_words.concat(), id_words, labels: c.labels.iter().map(|l| Text::of(l)).collect() }
|
||||
}
|
||||
|
||||
/// See [`score`].
|
||||
fn score(&self, name: &Text) -> f64 {
|
||||
if name.folded.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
if name.folded == self.id || self.labels.iter().any(|l| l.folded == name.folded) {
|
||||
return 1.0;
|
||||
}
|
||||
let by_words = self.labels.iter().map(|l| word_overlap(&name.words, &l.words)).fold(word_overlap(&name.words, &self.id_words), f64::max);
|
||||
let by_pairs = self.labels.iter().map(|l| pair_similarity(&name.pairs, &l.pairs)).fold(0.0, f64::max);
|
||||
by_words.max(by_pairs * 0.9)
|
||||
}
|
||||
}
|
||||
|
||||
/// How well `name` fits `candidate` (0 to 1): 1 when it equals a label or the id, its letters and
|
||||
/// digits compared without case; else the best of word overlap and character-pair similarity, the
|
||||
/// latter counted a little lower.
|
||||
pub fn score(name: &str, candidate: &Candidate) -> f64 {
|
||||
Prepared::of(candidate).score(&Text::of(name))
|
||||
}
|
||||
|
||||
/// The candidate `name` stands for, when one scores at least 0.6 and 0.1 ahead of every other; else
|
||||
/// the best five that score at least 0.3, to choose from.
|
||||
pub fn propose(name: &str, candidates: &[Candidate]) -> Proposal {
|
||||
Matcher::new(candidates).propose(name)
|
||||
}
|
||||
|
||||
/// [`propose`] for many names against the same candidates: each candidate's words and character
|
||||
/// pairs are worked out once, and a name is scored only against the candidates that share a word or
|
||||
/// a character pair with it (the others score 0).
|
||||
pub struct Matcher {
|
||||
prepared: Vec<Prepared>,
|
||||
/// Candidates by their joined id words and their labels' letters and digits.
|
||||
exact: HashMap<String, Vec<usize>>,
|
||||
/// Candidates by a word of their id or labels.
|
||||
by_word: HashMap<String, Vec<usize>>,
|
||||
/// Candidates by a character pair of their labels.
|
||||
by_pair: HashMap<(char, char), Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Matcher {
|
||||
pub fn new(candidates: &[Candidate]) -> Self {
|
||||
let prepared: Vec<Prepared> = candidates.iter().map(Prepared::of).collect();
|
||||
let (mut exact, mut by_word, mut by_pair) = (HashMap::new(), HashMap::new(), HashMap::new());
|
||||
fn add<K: std::hash::Hash + Eq>(map: &mut HashMap<K, Vec<usize>>, key: K, i: usize) {
|
||||
let list = map.entry(key).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
for (i, p) in prepared.iter().enumerate() {
|
||||
add(&mut exact, p.id.clone(), i);
|
||||
for l in &p.labels {
|
||||
add(&mut exact, l.folded.clone(), i);
|
||||
}
|
||||
for w in p.id_words.iter().chain(p.labels.iter().flat_map(|l| &l.words)) {
|
||||
add(&mut by_word, w.clone(), i);
|
||||
}
|
||||
for pair in p.labels.iter().flat_map(|l| &l.pairs) {
|
||||
add(&mut by_pair, *pair, i);
|
||||
}
|
||||
}
|
||||
Self { prepared, exact, by_word, by_pair }
|
||||
}
|
||||
|
||||
/// See [`propose`].
|
||||
pub fn propose(&self, name: &str) -> Proposal {
|
||||
let text = Text::of(name);
|
||||
let mut seen = vec![false; self.prepared.len()];
|
||||
let lists = self
|
||||
.exact
|
||||
.get(&text.folded)
|
||||
.into_iter()
|
||||
.chain(text.words.iter().filter_map(|w| self.by_word.get(w)))
|
||||
// Each pair looked up once (they are sorted).
|
||||
.chain(text.pairs.chunk_by(|a, b| a == b).filter_map(|c| self.by_pair.get(c.first()?)));
|
||||
let mut scored: Vec<(usize, f64)> = vec![];
|
||||
for &i in lists.flatten() {
|
||||
if let Some(s) = seen.get_mut(i)
|
||||
&& !*s
|
||||
{
|
||||
*s = true;
|
||||
let score = self.prepared.get(i).map_or(0.0, |p| p.score(&text));
|
||||
if score >= CHOICE_FLOOR {
|
||||
scored.push((i, score));
|
||||
}
|
||||
}
|
||||
}
|
||||
scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
match (scored.first(), scored.get(1)) {
|
||||
(Some(&(i, best)), next) if best >= THRESHOLD && next.is_none_or(|n| best - n.1 >= MARGIN) => Proposal::Match(i, best),
|
||||
_ => Proposal::Choice(scored.iter().take(CHOICES).map(|(i, _)| *i).collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn candidates(list: &[(&str, &str)]) -> Vec<Candidate> {
|
||||
list.iter().map(|(id, label)| Candidate { id: id.to_string(), labels: vec![label.to_string()] }).collect()
|
||||
}
|
||||
|
||||
fn proposed<'a>(name: &str, list: &'a [Candidate]) -> Option<&'a str> {
|
||||
match propose(name, list) {
|
||||
Proposal::Match(i, _) => list.get(i).map(|c| c.id.as_str()),
|
||||
Proposal::Choice(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_words_still_find_the_tool() {
|
||||
let tools = candidates(&[
|
||||
("tool:rectangle", "Rectangle Tool"),
|
||||
("tool:roundedRectangle", "Rounded Rectangle Tool"),
|
||||
("tool:rectangularGrid", "Rectangular Grid Tool"),
|
||||
("tool:ellipse", "Ellipse Tool"),
|
||||
]);
|
||||
assert_eq!(proposed("Acme Rectangle Tool", &tools), Some("tool:rectangle"));
|
||||
assert_eq!(proposed("Rounded Rectangle", &tools), Some("tool:roundedRectangle"));
|
||||
assert_eq!(proposed("ellipse tool", &tools), Some("tool:ellipse"), "case aside, the label itself");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_only_names_match_ids() {
|
||||
let commands = candidates(&[("acme.copyAll", "Everything"), ("acme.copyAllOnPage", "Everything on This Page"), ("acme.clear", "Clear Out")]);
|
||||
assert_eq!(proposed("acmecopyall", &commands), Some("acme.copyAll"));
|
||||
assert_eq!(proposed("Acme Copy All On Page", &commands), Some("acme.copyAllOnPage"));
|
||||
assert_eq!(proposed("clear out", &commands), Some("acme.clear"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_need_a_choice() {
|
||||
let tools = candidates(&[("tool:rectangle", "Rectangle Tool"), ("tool:ellipse", "Ellipse Tool"), ("tool:star", "Star Tool")]);
|
||||
assert_eq!(propose("Tool", &tools), Proposal::Choice(vec![0, 1, 2]));
|
||||
assert_eq!(propose("Nothing Alike", &tools), Proposal::Choice(vec![]));
|
||||
assert_eq!(propose("", &tools), Proposal::Choice(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_without_spaces_match_by_character_pairs() {
|
||||
let list = vec![
|
||||
Candidate { id: "tool:rectangle".into(), labels: vec!["Rectangle Tool".into(), "長方形ツール".into()] },
|
||||
Candidate { id: "tool:ellipse".into(), labels: vec!["Ellipse Tool".into(), "楕円形ツール".into()] },
|
||||
];
|
||||
assert_eq!(proposed("長方形ツール", &list), Some("tool:rectangle"));
|
||||
assert_eq!(proposed("楕円形 ツール", &list), Some("tool:ellipse"), "spaces and punctuation aside");
|
||||
assert_eq!(proposed("楕円形ツール2", &list), Some("tool:ellipse"), "most character pairs shared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_index_finds_what_scoring_every_candidate_finds() {
|
||||
let list = candidates(&[
|
||||
("tool:rectangle", "Rectangle Tool"),
|
||||
("tool:roundedRectangle", "Rounded Rectangle Tool"),
|
||||
("tool:ellipse", "Ellipse Tool"),
|
||||
("select.all", "All"),
|
||||
("select.none", "Deselect"),
|
||||
("edit.undo", "Undo"),
|
||||
("foo.bar", "Zzz"),
|
||||
("panel:layers", "Layers"),
|
||||
]);
|
||||
let matcher = Matcher::new(&list);
|
||||
for name in ["Acme Rectangle Tool", "rounded", "acmecopyall", "foobar", "zz", "Lay", "Undo Redo", "ツール", "", "e"] {
|
||||
let mut scored: Vec<(usize, f64)> =
|
||||
list.iter().enumerate().map(|(i, c)| (i, score(name, c))).filter(|(_, s)| *s >= CHOICE_FLOOR).collect();
|
||||
scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
let every = match (scored.first(), scored.get(1)) {
|
||||
(Some(&(i, best)), next) if best >= THRESHOLD && next.is_none_or(|n| best - n.1 >= MARGIN) => Proposal::Match(i, best),
|
||||
_ => Proposal::Choice(scored.iter().take(CHOICES).map(|(i, _)| *i).collect()),
|
||||
};
|
||||
assert_eq!(matcher.propose(name), every, "{name}");
|
||||
}
|
||||
assert_eq!(proposed("foobar", &list), Some("foo.bar"), "the id alone, sharing no word or pair with the label");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_split_camel_case_and_separators() {
|
||||
assert_eq!(words("selectAll_onArtboard 2x"), ["select", "all", "on", "artboard", "2x"]);
|
||||
assert_eq!(words("Acme\u{a0}Lasso-Tool"), ["acme", "lasso", "tool"]);
|
||||
assert_eq!(id_words("tool:roundedRectangle"), ["rounded", "rectangle"]);
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,20 @@ use serde_json::Value;
|
||||
/// Most nesting [`resolve`] walks into: deeper parameters are left as they are.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// The most the values references copy in may weigh together ([`weight`]: about their JSON size),
|
||||
/// for one step's parameters. Each reference copies its value, so a short list of references to
|
||||
/// one large result multiplied it.
|
||||
const MAX_COPIED: usize = 256 << 20;
|
||||
|
||||
/// `params` with every step reference replaced from `results` (the results of the steps run so
|
||||
/// far, in order); an error names a reference that resolves to nothing.
|
||||
/// far, in order); an error names a reference that resolves to nothing, or the reference past
|
||||
/// which the copied values weigh more than [`MAX_COPIED`].
|
||||
pub fn resolve(params: &Value, results: &[Value]) -> Result<Value, String> {
|
||||
resolve_at(params, results, 0)
|
||||
let mut allowance = MAX_COPIED;
|
||||
resolve_at(params, results, 0, &mut allowance)
|
||||
}
|
||||
|
||||
fn resolve_at(v: &Value, results: &[Value], depth: usize) -> Result<Value, String> {
|
||||
fn resolve_at(v: &Value, results: &[Value], depth: usize, allowance: &mut usize) -> Result<Value, String> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Ok(v.clone());
|
||||
}
|
||||
@@ -25,16 +32,49 @@ fn resolve_at(v: &Value, results: &[Value], depth: usize) -> Result<Value, Strin
|
||||
Value::String(s) => match s.strip_prefix("$$") {
|
||||
Some(rest) => Value::String(format!("${rest}")),
|
||||
None => match parse(s) {
|
||||
Some((step, path)) => lookup(s, step, &path, results)?,
|
||||
Some((step, path)) => {
|
||||
let found = lookup(s, step, &path, results)?;
|
||||
*allowance = weight(found, *allowance)
|
||||
.and_then(|w| allowance.checked_sub(w))
|
||||
.ok_or_else(|| format!("`{s}`: the step's references copy in more than {} MB of values", MAX_COPIED >> 20))?;
|
||||
found.clone()
|
||||
}
|
||||
None => v.clone(),
|
||||
},
|
||||
},
|
||||
Value::Array(a) => Value::Array(a.iter().map(|x| resolve_at(x, results, depth + 1)).collect::<Result<_, _>>()?),
|
||||
Value::Object(o) => Value::Object(o.iter().map(|(k, x)| Ok((k.clone(), resolve_at(x, results, depth + 1)?))).collect::<Result<_, String>>()?),
|
||||
Value::Array(a) => Value::Array(a.iter().map(|x| resolve_at(x, results, depth + 1, allowance)).collect::<Result<_, _>>()?),
|
||||
Value::Object(o) => {
|
||||
Value::Object(o.iter().map(|(k, x)| Ok((k.clone(), resolve_at(x, results, depth + 1, allowance)?))).collect::<Result<_, String>>()?)
|
||||
}
|
||||
_ => v.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// About the bytes `v` takes as JSON (8 per value, plus its strings and keys); `None` once that
|
||||
/// passes `limit`, without looking further.
|
||||
fn weight(v: &Value, limit: usize) -> Option<usize> {
|
||||
let mut total = 0usize;
|
||||
let mut todo = vec![v];
|
||||
while let Some(v) = todo.pop() {
|
||||
total = total.checked_add(8)?;
|
||||
match v {
|
||||
Value::String(s) => total = total.checked_add(s.len())?,
|
||||
Value::Array(a) => todo.extend(a),
|
||||
Value::Object(o) => {
|
||||
for (k, x) in o {
|
||||
total = total.checked_add(k.len())?;
|
||||
todo.push(x);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if total > limit {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
|
||||
/// Which step a reference names.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Step {
|
||||
@@ -71,6 +111,9 @@ fn parse(s: &str) -> Option<(Step, Vec<Seg>)> {
|
||||
rest = r;
|
||||
} else {
|
||||
let (n, r) = rest.strip_prefix('[')?.split_once(']')?;
|
||||
if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
path.push(Seg::Index(n.parse().ok()?));
|
||||
rest = r;
|
||||
}
|
||||
@@ -78,7 +121,7 @@ fn parse(s: &str) -> Option<(Step, Vec<Seg>)> {
|
||||
Some((step, path))
|
||||
}
|
||||
|
||||
fn lookup(text: &str, step: Step, path: &[Seg], results: &[Value]) -> Result<Value, String> {
|
||||
fn lookup<'a>(text: &str, step: Step, path: &[Seg], results: &'a [Value]) -> Result<&'a Value, String> {
|
||||
let (i, found) = match step {
|
||||
Step::Index(i) => (i, results.get(i)),
|
||||
Step::Last => (results.len().saturating_sub(1), results.last()),
|
||||
@@ -93,7 +136,7 @@ fn lookup(text: &str, step: Step, path: &[Seg], results: &[Value]) -> Result<Val
|
||||
};
|
||||
cur = next.ok_or_else(|| format!("`{text}`: step {i}'s result has no such value (write $$ for a literal $)"))?;
|
||||
}
|
||||
Ok(cur.clone())
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -109,6 +152,28 @@ mod tests {
|
||||
assert_eq!(resolve(&p, &results).unwrap(), json!({"id": 2, "ids": [6, 5], "nested": {"all": {"ids": [5, 6]}}, "n": 3, "flag": true}));
|
||||
}
|
||||
|
||||
/// References may copy one result many times, but not past [`MAX_COPIED`] together.
|
||||
#[test]
|
||||
fn copies_of_a_large_result_are_bounded() {
|
||||
let big = json!({"text": "x".repeat(MAX_COPIED / 16)});
|
||||
let results = [big];
|
||||
let few = json!(["$0", "$0", "$0"]);
|
||||
assert!(resolve(&few, &results).is_ok());
|
||||
let many = Value::Array(vec![json!("$0"); 32]);
|
||||
let e = resolve(&many, &results).unwrap_err();
|
||||
assert!(e.contains("MB of values"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_index_must_be_plain_digits() {
|
||||
let results = [json!({"ids": [5, 6]})];
|
||||
assert_eq!(resolve(&json!("$0.ids[1]"), &results).unwrap(), json!(6));
|
||||
for s in ["$0.ids[+1]", "$0.ids[]", "$0.ids[ 1]", "$0.ids[-1]"] {
|
||||
let p = json!(s);
|
||||
assert!(resolve(&p, &results).map_or(true, |v| v == p), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_strings_and_escapes_are_left_alone() {
|
||||
let results = [json!({"id": 2})];
|
||||
|
||||
@@ -2616,6 +2616,156 @@ fn document_find_searches_names_kinds_and_text() {
|
||||
assert_eq!(s.execute("document.find", &json!({"kind": "nope"})).unwrap()["total"], 0);
|
||||
}
|
||||
|
||||
/// `document.find` filters by geometric bounds (inside or touching a rectangle, width, height,
|
||||
/// thickness and area) and by anchor count, and `select: true` selects the matches that can be
|
||||
/// selected.
|
||||
#[test]
|
||||
fn document_find_filters_by_geometry_and_selects() {
|
||||
let mut s = session();
|
||||
let big = rect(&mut s, 0.0, 0.0, 100.0, 100.0).0;
|
||||
let sliver = rect(&mut s, 200.0, 0.0, 95.0, 0.9).0;
|
||||
let star = s.execute("shape.star", &json!({"cx": 400, "cy": 50, "radius1": 40, "radius2": 20, "points": 5})).unwrap()["id"].as_u64().unwrap();
|
||||
let left = rect(&mut s, 600.0, 0.0, 10.0, 10.0).0;
|
||||
let right = rect(&mut s, 620.0, 0.0, 10.0, 10.0).0;
|
||||
let compound = s.execute("object.compoundPath.make", &json!({"ids": [left, right]})).unwrap()["id"].as_u64().unwrap();
|
||||
let hidden = rect(&mut s, 0.0, 300.0, 5.0, 5.0).0;
|
||||
s.execute("object.hide", &json!({"ids": [hidden]})).unwrap();
|
||||
let locked = rect(&mut s, 20.0, 300.0, 5.0, 5.0).0;
|
||||
s.execute("object.lock", &json!({"ids": [locked]})).unwrap();
|
||||
let dot = rect(&mut s, 40.0, 300.0, 5.0, 5.0).0;
|
||||
let found = |s: &mut Session, p: Value| {
|
||||
let v = s.execute("document.find", &p).unwrap();
|
||||
let mut ids: Vec<u64> = v["matches"].as_array().unwrap().iter().map(|m| m["id"].as_u64().unwrap()).collect();
|
||||
ids.sort_unstable();
|
||||
assert_eq!(v["total"], ids.len(), "{p}");
|
||||
ids
|
||||
};
|
||||
let sorted = |mut v: Vec<u64>| {
|
||||
v.sort_unstable();
|
||||
v
|
||||
};
|
||||
|
||||
// Edges count as inside; a rectangle touching the bounds intersects them.
|
||||
assert_eq!(found(&mut s, json!({"within": [0, 0, 100, 100]})), [big]);
|
||||
assert_eq!(found(&mut s, json!({"within": [0, 0, 99, 100]})), Vec::<u64>::new());
|
||||
assert_eq!(found(&mut s, json!({"intersects": [100, 0, 100, 0.5], "kind": "rectangle"})), sorted(vec![big, sliver]));
|
||||
// Size: a thin sliver of either orientation, and the bounds' area.
|
||||
assert_eq!(found(&mut s, json!({"maxThickness": 1})), [sliver]);
|
||||
assert_eq!(found(&mut s, json!({"maxHeight": 1})), [sliver]);
|
||||
assert_eq!(found(&mut s, json!({"minWidth": 90, "maxWidth": 96})), [sliver]);
|
||||
assert_eq!(found(&mut s, json!({"minArea": 9000, "maxArea": 10000})), [big]);
|
||||
assert_eq!(found(&mut s, json!({"maxArea": 30})), sorted(vec![hidden, locked, dot]));
|
||||
// Anchors: a compound path counts its members' together; the layer never matches.
|
||||
assert_eq!(found(&mut s, json!({"minAnchors": 10, "maxAnchors": 10})), [star]);
|
||||
assert_eq!(found(&mut s, json!({"minAnchors": 8, "maxAnchors": 8})), [compound]);
|
||||
let four = sorted(vec![big, sliver, left, right, hidden, locked, dot]);
|
||||
assert_eq!(found(&mut s, json!({"maxAnchors": 4})), four);
|
||||
|
||||
// A find that only reads is not journaled; one that selects is, so an action replays it.
|
||||
let journaled = s.journal.len();
|
||||
s.execute("document.find", &json!({"maxAnchors": 4})).unwrap();
|
||||
assert_eq!(s.journal.len(), journaled);
|
||||
// select: every match except hidden, locked and compound members; the reply counts them.
|
||||
let v = s.execute("document.find", &json!({"maxAnchors": 4, "select": true, "limit": 0})).unwrap();
|
||||
assert_eq!(s.journal_for_action(journaled), [("document.find".to_string(), json!({"maxAnchors": 4, "select": true, "limit": 0}))]);
|
||||
assert_eq!((v["total"].clone(), v["selected"].clone()), (json!(7), json!(3)));
|
||||
let selection = |s: &Session| sorted(s.doc().unwrap().selection.objects.iter().map(|i| i.0).collect());
|
||||
assert_eq!(selection(&s), sorted(vec![big, sliver, dot]));
|
||||
// Without select the selection stays, and the reply has no `selected`.
|
||||
let v = s.execute("document.find", &json!({"maxThickness": 1})).unwrap();
|
||||
assert!(v.get("selected").is_none(), "{v}");
|
||||
assert_eq!(selection(&s), sorted(vec![big, sliver, dot]));
|
||||
// Select > Reselect repeats the find.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("select.reselect", &json!({})).unwrap();
|
||||
assert_eq!(selection(&s), sorted(vec![big, sliver, dot]));
|
||||
// A compound path matched selects as itself.
|
||||
s.execute("document.find", &json!({"minAnchors": 8, "maxAnchors": 8, "select": true})).unwrap();
|
||||
assert_eq!(selection(&s), [compound]);
|
||||
// Inside an isolated compound path its members are what selects.
|
||||
s.execute("object.isolate", &json!({"id": compound})).unwrap();
|
||||
let v = s.execute("document.find", &json!({"maxAnchors": 4, "select": true})).unwrap();
|
||||
assert_eq!(v["selected"], 2);
|
||||
assert_eq!(selection(&s), sorted(vec![left, right]));
|
||||
|
||||
for bad in [
|
||||
json!({"within": [0, 0, -1, 5]}),
|
||||
json!({"within": [0, 0, 1]}),
|
||||
json!({"intersects": "everywhere"}),
|
||||
json!({"minArea": -1}),
|
||||
json!({"maxWidth": "5 pt"}),
|
||||
json!({"minWidth": 5, "maxWidth": 1}),
|
||||
json!({"minAnchors": 1.5}),
|
||||
json!({"maxThickness": 1, "select": "yes"}),
|
||||
json!({"select": true}),
|
||||
] {
|
||||
assert!(s.execute("document.find", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `document.find {select: true}` counts the objects it selected: a guide path that Lock Guides
|
||||
/// leaves out of the selection is a match, not a selected object.
|
||||
#[test]
|
||||
fn document_find_counts_the_objects_it_selected() {
|
||||
let mut s = session();
|
||||
let guide = rect(&mut s, 0.0, 0.0, 5.0, 5.0);
|
||||
s.execute("select.set", &json!({"ids": [guide.0]})).unwrap();
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
rect(&mut s, 20.0, 0.0, 5.0, 5.0);
|
||||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||||
let v = s.execute("document.find", &json!({"maxArea": 30, "select": true})).unwrap();
|
||||
assert_eq!((v["total"].clone(), v["selected"].clone()), (json!(2), json!(1)));
|
||||
assert_eq!(s.doc().unwrap().selection.objects.len(), 1);
|
||||
}
|
||||
|
||||
/// Select > Object reports the number of objects it selected: a guide path that Lock Guides
|
||||
/// leaves out is not counted.
|
||||
#[test]
|
||||
fn select_object_counts_the_objects_it_selected() {
|
||||
let mut s = session();
|
||||
let line = |s: &mut Session, x: f64| {
|
||||
let id = s.execute("path.create", &json!({"anchors": [{"x": x, "y": 0}, {"x": x + 10.0, "y": 10}]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("brush.apply", &json!({"name": "Charcoal", "ids": [id]})).unwrap();
|
||||
id
|
||||
};
|
||||
let guide = line(&mut s, 0.0);
|
||||
s.execute("select.set", &json!({"ids": [guide]})).unwrap();
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
line(&mut s, 20.0);
|
||||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||||
for cmd in ["select.object.openPaths", "select.object.brushStrokes"] {
|
||||
let v = s.execute(cmd, &json!({})).unwrap();
|
||||
assert_eq!((v["count"].clone(), s.doc().unwrap().selection.objects.len()), (json!(1), 1), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The geometric filters of `document.find` match objects only: a layer or sublayer whose bounds
|
||||
/// fit is not a match, so `total` counts what `select: true` can select. The text filters still
|
||||
/// find layers.
|
||||
#[test]
|
||||
fn document_find_geometric_filters_leave_out_layers() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 5.0, 5.0).0;
|
||||
let sub = s.execute("layer.newSublayer", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = rect(&mut s, 10.0, 0.0, 5.0, 5.0).0;
|
||||
assert_eq!(s.doc().unwrap().doc.parent_of(NodeId(b)), Some(NodeId(sub)));
|
||||
let found = |s: &mut Session, p: Value| {
|
||||
let v = s.execute("document.find", &p).unwrap();
|
||||
let mut ids: Vec<u64> = v["matches"].as_array().unwrap().iter().map(|m| m["id"].as_u64().unwrap()).collect();
|
||||
ids.sort_unstable();
|
||||
assert_eq!(v["total"], ids.len(), "{p}");
|
||||
ids
|
||||
};
|
||||
assert_eq!(found(&mut s, json!({"within": [0, 0, 15, 5]})), [a, b]);
|
||||
assert_eq!(found(&mut s, json!({"intersects": [0, 0, 1, 1]})), [a]);
|
||||
assert_eq!(found(&mut s, json!({"maxArea": 30})), [a, b]);
|
||||
assert_eq!(found(&mut s, json!({"minWidth": 15})), Vec::<u64>::new());
|
||||
assert_eq!(found(&mut s, json!({"kind": "layer", "within": [0, 0, 15, 5]})), Vec::<u64>::new());
|
||||
assert_eq!(found(&mut s, json!({"kind": "layer"})).len(), 2);
|
||||
let v = s.execute("document.find", &json!({"within": [0, 0, 15, 5], "select": true})).unwrap();
|
||||
assert_eq!((v["total"].clone(), v["selected"].clone()), (json!(2), json!(2)));
|
||||
}
|
||||
|
||||
/// Type reports the paint its characters show, and its own object-level paint apart from it.
|
||||
#[test]
|
||||
fn inspect_reports_the_paint_of_types_characters() {
|
||||
|
||||
@@ -49,8 +49,13 @@ fn area(s: &Session, id: NodeId) -> f64 {
|
||||
|
||||
/// Rect A (0..100) and rect B (50..150), both 100 tall, selected. A red, B blue.
|
||||
fn two(s: &mut Session) -> (NodeId, NodeId) {
|
||||
two_at(s, 50.0)
|
||||
}
|
||||
|
||||
/// [`two`] with B at `x`..`x`+100.
|
||||
fn two_at(s: &mut Session, x: f64) -> (NodeId, NodeId) {
|
||||
let a = rect(s, 0.0, 0.0, 100.0, 100.0);
|
||||
let b = rect(s, 50.0, 0.0, 100.0, 100.0);
|
||||
let b = rect(s, x, 0.0, 100.0, 100.0);
|
||||
set_fill(s, a, Color::rgb(1.0, 0.0, 0.0));
|
||||
set_fill(s, b, Color::rgb(0.0, 0.0, 1.0));
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
@@ -81,6 +86,35 @@ fn shape_builder_drag_merges_touched_regions() {
|
||||
assert_eq!(layer_children(&s), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_drag_merges_a_region_thinner_than_the_sampling_step() {
|
||||
let mut s = session();
|
||||
two_at(&mut s, 99.9);
|
||||
assert_eq!(s.execute("shapeBuilder.regions", &json!({})).unwrap()["regions"].as_array().unwrap().len(), 3);
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 50], [150, 50]]})).unwrap();
|
||||
assert_eq!(r["regions"], 3, "the 0.1 pt overlap too");
|
||||
let out = ids(&r);
|
||||
assert_eq!(out.len(), 1, "{out:?}");
|
||||
assert_eq!(outline(&node(&s, out[0])).0.subpaths.len(), 1);
|
||||
assert!((area(&s, out[0]) - 19990.0).abs() < 1.0, "{}", area(&s, out[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_drag_merges_a_narrow_overlap_when_far_art_is_selected() {
|
||||
let mut s = session();
|
||||
let (a, b) = two_at(&mut s, 99.0);
|
||||
// A far square makes the selection 4,010 pt wide.
|
||||
let far = rect(&mut s, 4000.0, 4000.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0, far.0]})).unwrap();
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50.3, 50], [150.3, 50]]})).unwrap();
|
||||
assert_eq!(r["regions"], 3);
|
||||
let out = ids(&r);
|
||||
assert_eq!(out.len(), 2, "the merged shape and the far square: {out:?}");
|
||||
let merged = NodeId(r["merged"].as_u64().unwrap());
|
||||
assert_eq!(outline(&node(&s, merged)).0.subpaths.len(), 1);
|
||||
assert!((area(&s, merged) - 19900.0).abs() < 1.0, "{}", area(&s, merged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_click_splits_out_one_region() {
|
||||
let mut s = session();
|
||||
@@ -331,6 +365,54 @@ fn shape_builder_tool_drives_the_command() {
|
||||
assert_eq!(layer_children(&s), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_tool_merges_the_narrow_shape_it_highlights_in_a_wide_selection() {
|
||||
let mut s = session();
|
||||
let strip = rect(&mut s, 199.5, 100.0, 1.0, 100.0);
|
||||
// A far square makes the selection 9,800.5 pt wide.
|
||||
let far = rect(&mut s, 9900.0, 100.0, 100.0, 100.0);
|
||||
s.execute("select.set", &json!({"ids": [strip.0, far.0]})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("shapeBuilder", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 190.0, 150.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 210.0, 150.0), v).unwrap();
|
||||
assert_eq!(s.overlays(v).len(), 3, "the strip's hatch and outline, and the drag path");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 210.0, 150.0), v).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
|
||||
assert_eq!(layer_children(&s), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_tool_highlights_and_merges_a_narrow_overlap() {
|
||||
let mut s = session();
|
||||
two_at(&mut s, 99.95);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("shapeBuilder", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 50.3, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 150.3, 50.0), v).unwrap();
|
||||
assert_eq!(s.overlays(v).len(), 7, "the three regions' hatches and outlines, and the drag path");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 150.3, 50.0), v).unwrap();
|
||||
assert_eq!(layer_children(&s), 1, "one shape, no leftover strips");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let merged = d.children(d.default_layer()).unwrap()[0].id;
|
||||
assert_eq!(outline(&node(&s, merged)).0.subpaths.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_tool_zoomed_out_picks_a_narrow_shape() {
|
||||
let mut s = session();
|
||||
let strip = rect(&mut s, 199.5, 100.0, 1.0, 100.0);
|
||||
let near = rect(&mut s, 300.0, 100.0, 3.0, 100.0);
|
||||
s.execute("select.set", &json!({"ids": [strip.0, near.0]})).unwrap();
|
||||
let v = ViewInfo { zoom: 0.05, ..Default::default() };
|
||||
s.select_tool("shapeBuilder", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 190.0, 150.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 260.0, 150.0), v).unwrap();
|
||||
assert_eq!(s.overlays(v).len(), 3, "the strip's hatch and outline, and the drag path");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 260.0, 150.0), v).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
|
||||
}
|
||||
|
||||
/// The first and last stop colours of a gradient paint.
|
||||
fn gradient_ends(p: &Paint) -> Option<([f32; 3], [f32; 3])> {
|
||||
let Paint::Gradient(g) = p else { return None };
|
||||
@@ -423,6 +505,27 @@ fn shape_builder_click_on_a_side_of_a_line_splits_the_shape() {
|
||||
assert_eq!(open_paths(&s, &out), vec![(2, Rect::new(50.0, -20.0, 50.0, 120.0))]);
|
||||
}
|
||||
|
||||
/// A line that ends inside a shape has the same region on both sides there: a click or a drag
|
||||
/// across it, or deleting the region, leaves it whole.
|
||||
#[test]
|
||||
fn shape_builder_merge_keeps_a_line_that_ends_inside_the_shape() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
set_fill(&mut s, a, Color::rgb(1.0, 0.0, 0.0));
|
||||
let l = line(&mut s, (50.0, -20.0), (50.0, 99.0));
|
||||
s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap();
|
||||
let mut lines = vec![];
|
||||
for params in [json!({"points": [[25, 50]]}), json!({"points": [[25, 50], [75, 50]]}), json!({"points": [[25, 50]], "erase": true})] {
|
||||
let r = s.execute("shapeBuilder.merge", ¶ms).unwrap();
|
||||
assert_eq!(r["regions"], 1, "{params}");
|
||||
let merged = r["merged"].as_u64().map(|m| (area(&s, NodeId(m)) - 10000.0).abs() < 1.0);
|
||||
assert_eq!(merged, params["erase"].is_null().then_some(true), "{params}");
|
||||
lines.push(open_paths(&s, &ids(&r)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(lines, vec![vec![(2, Rect::new(50.0, -20.0, 50.0, 99.0))]; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_merge_across_a_line_removes_the_piece_between() {
|
||||
let mut s = session();
|
||||
@@ -1581,6 +1684,50 @@ fn live_paint_merge_keeps_paint_and_adds_faces() {
|
||||
assert_eq!(layer_children(&s), 1);
|
||||
}
|
||||
|
||||
/// Live Paint builds its faces and edges from path outlines alone, so Merge, Make and the Live
|
||||
/// Paint Bucket refuse type, an image, or a group holding type: before, they went into the
|
||||
/// group's hidden sources and vanished from the canvas. Nothing changes, not even the undo
|
||||
/// history. Paths, and groups of paths, still merge.
|
||||
#[test]
|
||||
fn live_paint_refuses_type_and_images_and_changes_nothing() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let text = NodeId(s.execute("text.create", &json!({"x": 200, "y": 300, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||||
let image = add_image(&mut s, &vectorcraft_trace::Raster::from_fn(4, 4, |_, _| [0, 0, 0, 255]), Affine::translate((300.0, 300.0)));
|
||||
let path = rect(&mut s, 400.0, 20.0, 40.0, 40.0);
|
||||
s.execute("select.set", &json!({"ids": [text.0, path.0]})).unwrap();
|
||||
let text_group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let state = |s: &Session| {
|
||||
let d = s.doc().unwrap();
|
||||
(d.doc.clone(), d.selection.clone(), d.history.undo.len())
|
||||
};
|
||||
let want = "Live Paint takes paths and compound paths: convert type, images and other objects to paths first";
|
||||
for other in [text, image, text_group] {
|
||||
let calls = [
|
||||
("livePaint.merge", json!({"ids": [g.0, other.0]})),
|
||||
("livePaint.make", json!({"ids": [g.0, other.0]})),
|
||||
("livePaint.make", json!({"ids": [other.0]})),
|
||||
("livePaint.fill", json!({"ids": [other.0], "point": [200, 300], "color": "#00ff00"})),
|
||||
];
|
||||
for (c, p) in calls {
|
||||
s.execute("select.set", &json!({"ids": [g.0, other.0]})).unwrap();
|
||||
let before = state(&s);
|
||||
let r = s.execute(c, &p);
|
||||
assert!(matches!(&r, Err(EngineError::Other(m)) if m == want), "{c} {p}: {r:?}");
|
||||
assert!(state(&s) == before, "{c} {p} changed the document");
|
||||
}
|
||||
}
|
||||
// A group of paths and a path still merge.
|
||||
let (a, b) = (rect(&mut s, 120.0, 20.0, 100.0, 20.0), rect(&mut s, 120.0, 60.0, 100.0, 20.0));
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let paths = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let c = rect(&mut s, 0.0, 120.0, 100.0, 20.0);
|
||||
let r = s.execute("livePaint.merge", &json!({"ids": [g.0, paths.0, c.0]})).unwrap();
|
||||
assert_eq!(NodeId(r["id"].as_u64().unwrap()), g);
|
||||
assert!(vectorcraft_tools::builder::live_paint_takes(&node(&s, g)));
|
||||
assert!(!vectorcraft_tools::builder::live_paint_takes(&node(&s, text_group)));
|
||||
}
|
||||
|
||||
/// Give Live Paint group `g` a lock, hidden state, appearance, knockout, opacity mask and
|
||||
/// graphic style link, and return it as it then is.
|
||||
fn dress_live_group(s: &mut Session, g: NodeId) -> Node {
|
||||
@@ -1953,6 +2100,105 @@ fn a_line_across_a_live_paint_shape_splits_its_face() {
|
||||
assert_eq!(face(80.0), Paint::solid(Color::from_hex("#0000ff").unwrap()));
|
||||
}
|
||||
|
||||
/// The total length of the stroked paths among `out` that have no fill.
|
||||
fn stroked_length(s: &Session, out: &[NodeId]) -> f64 {
|
||||
out.iter()
|
||||
.map(|i| node(s, *i))
|
||||
.filter(|n| n.appearance.fill_paint().is_none() && !n.appearance.stroke_paint().is_none())
|
||||
.map(|n| outline(&n).0.length())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// The Shape Builder takes a selected Live Paint group's filled faces as regions and its stroked
|
||||
/// edges as lines; its unfilled faces are not regions. The merge replaces the group as it does a
|
||||
/// path, and the edges keep their strokes, except those between the merged faces.
|
||||
#[test]
|
||||
fn shape_builder_uses_the_filled_faces_of_a_live_paint_group() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [125, 50], "none": true})).unwrap();
|
||||
let edges = vectorcraft_tools::builder::edges(&node(&s, g)).len();
|
||||
let c = rect(&mut s, 160.0, 20.0, 100.0, 20.0);
|
||||
set_fill(&mut s, c, Color::rgb(0.0, 1.0, 0.0));
|
||||
s.execute("select.set", &json!({"ids": [g.0, c.0]})).unwrap();
|
||||
let map = s.execute("shapeBuilder.regions", &json!({})).unwrap();
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap();
|
||||
let out = ids(&r);
|
||||
let unfilled_faces =
|
||||
out.iter().map(|i| node(&s, *i)).filter(|n| n.appearance.fill_paint().is_none() && n.appearance.stroke_paint().is_none()).count();
|
||||
assert_eq!(
|
||||
(map["regions"].as_array().unwrap().len(), map["lines"].as_array().unwrap().len() >= edges, unfilled_faces),
|
||||
(4, true, 0),
|
||||
"the two faces, the rectangle and the area the edges enclose; the edges as lines; no unfilled face comes back"
|
||||
);
|
||||
let enclosed: Vec<&Value> = map["regions"].as_array().unwrap().iter().filter(|r| r["sources"].as_array().unwrap().is_empty()).collect();
|
||||
assert_eq!(enclosed.len(), 1, "{map}");
|
||||
let bounds: Vec<f64> = enclosed[0]["bounds"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
assert!(bounds.iter().zip([100.0, 0.0, 150.0, 100.0]).all(|(a, b)| (a - b).abs() < 0.5), "{bounds:?}");
|
||||
assert_eq!(r["regions"], 2);
|
||||
let merged = NodeId(r["merged"].as_u64().unwrap());
|
||||
assert!((area(&s, merged) - 10000.0).abs() < 1.0, "{}", area(&s, merged));
|
||||
assert!(s.doc().unwrap().doc.node(g).is_none());
|
||||
// The outline of 0..150 × 0..100 and the edge at x = 100 keep their strokes; the edge at x = 50
|
||||
// between the merged faces is removed.
|
||||
assert!((stroked_length(&s, &out) - 600.0).abs() < 1.0, "{}", stroked_length(&s, &out));
|
||||
}
|
||||
|
||||
/// An edge that closes on itself is a line around its face, not a second shape over it.
|
||||
#[test]
|
||||
fn shape_builder_takes_a_closed_live_paint_edge_as_a_line() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
set_fill(&mut s, a, Color::rgb(1.0, 0.0, 0.0));
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let group = node(&s, g);
|
||||
let edges = vectorcraft_tools::builder::edges(&group);
|
||||
assert_eq!(edges.len(), 1);
|
||||
assert!(outline(edges[0]).0.subpaths.iter().any(|sp| sp.closed), "the edge closes on itself");
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
// A click on the face keeps its fill on the merged shape and the edge's stroke around it.
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 50]]})).unwrap();
|
||||
let out = ids(&r);
|
||||
let merged = node(&s, NodeId(r["merged"].as_u64().unwrap()));
|
||||
assert_eq!(merged.appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert!((stroked_length(&s, &out) - 400.0).abs() < 1.0, "{}", stroked_length(&s, &out));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
let map = s.execute("shapeBuilder.regions", &json!({})).unwrap();
|
||||
let regions = map["regions"].as_array().unwrap();
|
||||
assert_eq!((regions.len(), map["lines"].as_array().unwrap().len()), (1, 1), "{map}");
|
||||
assert_eq!(regions[0]["sources"].as_array().unwrap().len(), 1, "{map}");
|
||||
// Unfilled, the face is not a region, and the edge still encloses all of it, closing segment
|
||||
// included.
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 50], "none": true})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
let map = s.execute("shapeBuilder.regions", &json!({})).unwrap();
|
||||
let regions = map["regions"].as_array().unwrap();
|
||||
assert_eq!(regions.len(), 1, "{map}");
|
||||
assert!(regions[0]["sources"].as_array().unwrap().is_empty(), "{map}");
|
||||
assert!((regions[0]["area"].as_f64().unwrap() - 10000.0).abs() < 1.0, "{map}");
|
||||
}
|
||||
|
||||
/// A Live Paint group with no filled face keeps its stroked edges when another selected object is
|
||||
/// merged.
|
||||
#[test]
|
||||
fn shape_builder_keeps_the_edges_of_an_unfilled_live_paint_group() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
for x in [25, 75, 125] {
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [x, 50], "none": true})).unwrap();
|
||||
}
|
||||
let c = rect(&mut s, 160.0, 20.0, 100.0, 20.0);
|
||||
set_fill(&mut s, c, Color::rgb(0.0, 1.0, 0.0));
|
||||
s.execute("select.set", &json!({"ids": [g.0, c.0]})).unwrap();
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[200, 30]]})).unwrap();
|
||||
let out = ids(&r);
|
||||
assert!(s.doc().unwrap().doc.node(g).is_none());
|
||||
// Every edge of the group: the outline of 0..150 × 0..100 and the edges at x = 50 and 100.
|
||||
assert!((stroked_length(&s, &out) - 700.0).abs() < 1.0, "{}", stroked_length(&s, &out));
|
||||
}
|
||||
|
||||
// ---------- Image Trace ----------
|
||||
|
||||
/// A 100×100 image with a black disc (r = 30 px), placed at (50, 50) scaled ×2.
|
||||
@@ -2025,6 +2271,36 @@ fn a_large_image_traces_with_a_warning() {
|
||||
assert_eq!(s.execute("imageTrace.make", &json!({})).unwrap()["warnings"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_logo_says_when_it_traces_an_image_averaged_down() {
|
||||
// Flat Logo works at most 1024 px on a side: a 1025 px image is averaged down to 513 px.
|
||||
let params = json!({"params": {"mode": "logo", "logoColors": ["#000000", "#F0F0DC"]}});
|
||||
let warnings = |n: u32| {
|
||||
let art = vectorcraft_trace::Raster::from_fn(n, n, |x, y| {
|
||||
if !(50..n - 50).contains(&x) || !(50..n - 50).contains(&y) {
|
||||
[0, 0, 0, 0]
|
||||
} else if (i64::from(x) - i64::from(y) / 8).rem_euclid(40) < 2 {
|
||||
[0, 0, 0, 255]
|
||||
} else {
|
||||
[240, 240, 220, 255]
|
||||
}
|
||||
});
|
||||
let mut s = session();
|
||||
add_image(&mut s, &art, Affine::IDENTITY);
|
||||
let r = s.execute("imageTrace.makeAndExpand", ¶ms).unwrap();
|
||||
assert!(r["paths"].as_u64().unwrap() > 0, "{r}");
|
||||
r["warnings"].clone()
|
||||
};
|
||||
let w = warnings(1025);
|
||||
let w = w.as_array().unwrap();
|
||||
assert_eq!(w.len(), 1, "{w:?}");
|
||||
let text = w[0].as_str().unwrap();
|
||||
assert!(text.contains("513 × 513") && text.contains("1025 × 1025"), "{text}");
|
||||
assert_eq!(warnings(1024), json!([]));
|
||||
// Over 1500 px, Flat Logo still traces at its reduced size: no slow-trace warning.
|
||||
assert_eq!(warnings(1600), json!(["Flat Logo traced the image at 800 × 800 pixels, averaged down from 1600 × 1600."]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_make_places_paths_over_the_image() {
|
||||
let mut s = session();
|
||||
@@ -2161,6 +2437,40 @@ fn image_trace_gradients_fills_with_a_gradient() {
|
||||
assert!(flat.len() > 1 && flat.iter().all(|n| matches!(n.appearance.fill_paint(), Paint::Solid { .. })));
|
||||
}
|
||||
|
||||
/// A key inside `params` that isn't a setting is refused with the nearest setting, the image is
|
||||
/// left as it was, and the error names the command that was run.
|
||||
#[test]
|
||||
fn image_trace_refuses_an_unknown_setting() {
|
||||
for id in ["imageTrace.make", "imageTrace.makeAndExpand"] {
|
||||
let mut s = session();
|
||||
let img = image_doc(&mut s);
|
||||
let e = s.execute(id, &json!({"preset": "6 Colors", "params": {"mode": "color", "colours": 3}})).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("`{id}`")) && e.contains("unknown parameter `colours`; did you mean `colors`?"), "{e}");
|
||||
assert!(matches!(node(&s, img).kind, NodeKind::Image(_)));
|
||||
let e = s.execute(id, &json!({"params": {"zz": 1}})).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown parameter `zz`; the command takes: ") && e.contains("colorDetail"), "{e}");
|
||||
let e = s.execute(id, &json!({"enhanced": "yes"})).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("`{id}`")), "{e}");
|
||||
let library = json!({"preset": "6 Colors", "params": {"palette": "documentLibrary", "library": "No Such Library"}});
|
||||
let e = s.execute(id, &library).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("`{id}`")) && e.contains("no swatch library"), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every preset's settings, as imageTrace.presets lists them, trace when passed back whole.
|
||||
#[test]
|
||||
fn image_trace_takes_every_preset_back_as_params() {
|
||||
let list = session().execute("imageTrace.presets", &json!({})).unwrap();
|
||||
for preset in list["presets"].as_array().unwrap() {
|
||||
for params in [&preset["params"], &preset["enhanced"]].into_iter().filter(|p| !p.is_null()) {
|
||||
let mut s = session();
|
||||
image_doc(&mut s);
|
||||
let r = s.execute("imageTrace.make", &json!({"preset": preset["name"], "params": params}));
|
||||
assert!(r.is_ok(), "{}: {r:?}", preset["name"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A preset's enhanced version: the preset with Auto Grouping and Shapes, under the preset's name.
|
||||
#[test]
|
||||
fn image_trace_enhanced_presets() {
|
||||
|
||||
@@ -89,10 +89,14 @@ fn pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters() {
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let pdf = vectorcraft_pdf::export(&d, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
|
||||
// The glyphs each text-showing line writes (the font's codes are octal escapes), and whether
|
||||
// it is invisible.
|
||||
// it is invisible. Only text objects count: the file ID in the trailer and the XMP is random
|
||||
// base64, which now and then spells `Tj` or `TJ`.
|
||||
let raw = String::from_utf8_lossy(&pdf);
|
||||
let shown: Vec<(bool, usize)> =
|
||||
raw.lines().filter(|l| l.contains("Tj") || l.contains("TJ")).map(|l| (l.contains("3 Tr"), l.matches("\\0").count())).collect();
|
||||
let shown: Vec<(bool, usize)> = raw
|
||||
.lines()
|
||||
.filter(|l| l.starts_with("BT ") && (l.contains("Tj") || l.contains("TJ")))
|
||||
.map(|l| (l.contains("3 Tr"), l.matches("\\0").count()))
|
||||
.collect();
|
||||
assert_eq!(shown, [(false, 2)], "both characters once, as text");
|
||||
// The glyphs as the font draws them (its Type 3 glyph procedures, `<width> 0 d0` and what
|
||||
// they paint): the SVG glyph's draws nothing, rather than the font's plain outline over the
|
||||
|
||||
@@ -141,6 +141,29 @@ fn pencil_with_shift_draws_a_straight_line() {
|
||||
assert!((angle - 75.0).abs() < 1e-6, "{angle}");
|
||||
}
|
||||
|
||||
/// #3799: with Alt held from the press to the release, a wavy Pencil drag draws one open straight
|
||||
/// segment from the press point to the release point, at the angle the pointer took.
|
||||
#[test]
|
||||
fn pencil_with_alt_draws_an_open_straight_line() {
|
||||
let mut s = session();
|
||||
let v = view();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
s.select_tool("pencil", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 400.0).with_mods(alt), v).unwrap();
|
||||
for (x, y) in [(120.0, 380.0), (125.0, 330.0), (160.0, 320.0), (170.0, 333.0)] {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(alt), v).unwrap();
|
||||
}
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 170.0, 333.0).with_mods(alt), v).unwrap();
|
||||
let ps = paths(&s);
|
||||
assert_eq!(ps.len(), 1);
|
||||
assert!(!ps[0].1.is_closed());
|
||||
let sp = &ps[0].1.subpaths[0];
|
||||
assert_eq!(sp.anchors.len(), 2, "{sp:?}");
|
||||
assert!(sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p), "no handles: {sp:?}");
|
||||
assert!(sp.anchors[0].p.distance(Point::new(100.0, 400.0)) < 1e-6, "{sp:?}");
|
||||
assert!(sp.anchors[1].p.distance(Point::new(170.0, 333.0)) < 1e-6, "{sp:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pencil_continues_selected_open_path() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -112,6 +112,29 @@ fn saving_to_a_file_stamps_the_modified_date() {
|
||||
assert!(s.execute("file.info", &json!({})).unwrap()["modified"].is_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_and_open_report_the_file_info_title_beside_the_file_name() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-fileinfotitle-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("poster.vectorcraft");
|
||||
let mut s = session();
|
||||
// Untitled, the two are the same.
|
||||
let r = s.execute("document.inspect", &json!({"depth": 0})).unwrap();
|
||||
assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("Poster"), Some("Poster")));
|
||||
s.execute("file.info", &json!({"title": "Poster: final"})).unwrap();
|
||||
s.execute("document.save", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
let r = s.execute("document.inspect", &json!({"depth": 0})).unwrap();
|
||||
assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("poster.vectorcraft"), Some("Poster: final")));
|
||||
// Opened from the file, in another session.
|
||||
let mut s = Session::new();
|
||||
let opened = s.execute("document.open", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
assert_eq!((opened["title"].as_str(), opened["fileInfoTitle"].as_str()), (Some("poster.vectorcraft"), Some("Poster: final")));
|
||||
let r = s.execute("document.inspect", &json!({"depth": 0})).unwrap();
|
||||
assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("poster.vectorcraft"), Some("Poster: final")));
|
||||
assert_eq!(r["fileInfoTitle"], s.execute("file.info", &json!({})).unwrap()["title"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_info_carries_author_subject_and_keywords() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -53,6 +53,139 @@ fn only_an_ids_key_counts() {
|
||||
// A word in what the description says about another key isn't the key.
|
||||
assert_eq!(given_ids("{name: of the swatch the ids of the selection take}", &p), None);
|
||||
assert_eq!(given_ids("{ids?: [id]}", &json!({"ids": []})), None);
|
||||
// `id` is `ids` of one where the description names both keys, and `ids` aren't given.
|
||||
let one = json!({"id": 3});
|
||||
assert_eq!(given_ids("{ids?|id?}", &one), Some(vec![NodeId(3)]));
|
||||
assert_eq!(given_ids("{id?, ids?: [id…] (instead of id)}", &one), Some(vec![NodeId(3)]));
|
||||
assert_eq!(given_ids("{ids?: [id…]}", &one), None, "a description without `id`");
|
||||
assert_eq!(given_ids("{id?: dimension id}", &one), None, "a description without `ids`");
|
||||
assert_eq!(given_ids("{ids?|id?}", &json!({"id": "a1"})), None, "not an object id");
|
||||
assert_eq!(given_ids("{ids?|id?}", &json!({"id": 3, "ids": [4]})), Some(vec![NodeId(4)]), "`ids` first");
|
||||
assert_eq!(given_ids("{ids?|id?}", &json!({"id": 3, "ids": null})), Some(vec![NodeId(3)]));
|
||||
// A required `id` beside `ids` is something else (`plugin.run`'s plug-in); `{id} or {ids}` is
|
||||
// one object or several.
|
||||
assert_eq!(given_ids("{id, params?: {…}, ids?: [..]}", &one), None, "a plug-in's id");
|
||||
assert!(!cmd::params::id_stands_for_ids("{id, params?: {…}, ids?: [..]}"));
|
||||
assert!(cmd::params::id_stands_for_ids("{id} or {ids: [id…]}: toggles them"));
|
||||
assert!(cmd::params::id_stands_for_ids("{ids|id, target}"));
|
||||
}
|
||||
|
||||
/// `{"id": x}` with params besides it, and the same as `{"ids": [x]}`.
|
||||
fn id_and_ids(cmd: &str, x: u64, d: u64) -> (serde_json::Value, serde_json::Value) {
|
||||
let (mut one, mut list) = (params_for(cmd, d), params_for(cmd, d));
|
||||
one["id"] = json!(x);
|
||||
list["ids"] = json!([x]);
|
||||
(one, list)
|
||||
}
|
||||
|
||||
/// [`doc_with_nested_groups`] with point type `Ab` added, nothing selected → its session and the
|
||||
/// type's id.
|
||||
fn doc_with_type() -> (Session, [u64; 4], u64) {
|
||||
let (mut s, ids) = doc_with_nested_groups();
|
||||
let t = s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Ab", "size": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
(s, ids, t)
|
||||
}
|
||||
|
||||
/// What `cmd` gives in a fresh [`doc_with_type`] (nothing selected) with `params`: its result or
|
||||
/// error, and the document after it.
|
||||
fn run_fresh(
|
||||
cmd: &str,
|
||||
params: &serde_json::Value,
|
||||
) -> (std::result::Result<serde_json::Value, String>, Option<std::sync::Arc<vectorcraft_doc::Document>>) {
|
||||
let (mut s, ..) = doc_with_type();
|
||||
let r = s.execute(cmd, params).map_err(|e| e.to_string());
|
||||
(r, s.doc().ok().map(|d| d.doc.clone()))
|
||||
}
|
||||
|
||||
/// Every command whose params description names both `id` and `ids` (`{ids?|id?}`, `{id?, ids?:
|
||||
/// … (instead of id)}`), with nothing selected: `id` does what `ids` of that one object does (the
|
||||
/// same result or the same error, and the same document), for objects of the document and for an
|
||||
/// id the document doesn't have. Before, the commands that need a selection refused `id` as
|
||||
/// "nothing selected" and took `ids`.
|
||||
#[test]
|
||||
fn id_does_what_ids_of_one_does_with_nothing_selected() {
|
||||
let (fresh, [a, inner, _, d], t) = doc_with_type();
|
||||
let (mut checked, mut failures, mut refused, mut refused_ran) = (vec![], vec![], vec![], vec![]);
|
||||
for spec in command_specs() {
|
||||
if !cmd::params::id_stands_for_ids(spec.params) {
|
||||
continue;
|
||||
}
|
||||
// Those the menus refuse with nothing selected are the ones that refused `id`.
|
||||
if (spec.enabled)(&fresh).is_err() {
|
||||
refused.push(spec.id);
|
||||
}
|
||||
// A rectangle, a group, type, and an id the document doesn't have.
|
||||
for x in [a, inner, t, 999_999] {
|
||||
let (one, list) = id_and_ids(spec.id, x, d);
|
||||
let (with_id, with_ids) = (run_fresh(spec.id, &one), run_fresh(spec.id, &list));
|
||||
if with_id != with_ids {
|
||||
failures.push(format!("{} {x}: with id {:.160}; with ids {:.160}", spec.id, format!("{:?}", with_id.0), format!("{:?}", with_ids.0)));
|
||||
} else if refused.last() == Some(&spec.id) && x != 999_999 && with_id.0.is_ok() && !refused_ran.contains(&spec.id) {
|
||||
refused_ran.push(spec.id);
|
||||
}
|
||||
}
|
||||
checked.push(spec.id);
|
||||
}
|
||||
assert!(failures.is_empty(), "{} differ:\n{}", failures.len(), failures.join("\n"));
|
||||
// The commands that refused `id` with nothing selected (29), and those of them that run on
|
||||
// these objects (14: the rest need other art, as with `ids`).
|
||||
assert!(refused.len() >= 26 && refused_ran.len() >= 14, "{refused_ran:?} ran of {refused:?}; checked {}", checked.len());
|
||||
}
|
||||
|
||||
/// Every command that takes `id` as `ids` of one ([`cmd::params::id_stands_for_ids`]), with an
|
||||
/// object selected: an `id` that is malformed (a string, negative, a fraction, an object) or names
|
||||
/// no object is refused under the command's id, as the same value in `ids` is, and the document
|
||||
/// and the selection stay as they were. It never stands for the selection instead. (A string `id`
|
||||
/// given to `effect.apply` without `effect` names the effect, which "bad" isn't: refused too.)
|
||||
#[test]
|
||||
fn a_bad_id_is_refused_as_the_same_ids_are_and_changes_nothing() {
|
||||
let (_, [.., d], _) = doc_with_type();
|
||||
let (mut failures, mut checked) = (vec![], 0);
|
||||
for spec in command_specs() {
|
||||
if !cmd::params::id_stands_for_ids(spec.params) {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
for bad in [json!("bad"), json!(-1), json!(1.5), json!({"id": 1}), json!(999_999)] {
|
||||
let run = |key: &str, value: serde_json::Value| {
|
||||
let (mut s, ..) = doc_with_type();
|
||||
s.execute("select.set", &json!({ "ids": [d] })).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let mut p = params_for(spec.id, d);
|
||||
p[key] = value;
|
||||
let r = s.execute(spec.id, &p).map_err(|e| e.to_string());
|
||||
let st = s.doc().unwrap();
|
||||
(r, st.doc == before && st.selection.objects == vec![NodeId(d)])
|
||||
};
|
||||
let (with_id, with_ids) = (run("id", bad.clone()), run("ids", json!([bad.clone()])));
|
||||
let effect_named = spec.id == "effect.apply" && bad.is_string();
|
||||
if with_id.0.is_ok() || !with_id.1 || (with_id != with_ids && !effect_named) {
|
||||
failures.push(format!(
|
||||
"{} {bad}: with id {:.160} (unchanged: {}); with ids {:.160}",
|
||||
spec.id,
|
||||
format!("{:?}", with_id.0),
|
||||
with_id.1,
|
||||
format!("{:?}", with_ids.0)
|
||||
));
|
||||
}
|
||||
}
|
||||
// A step reference no batch resolved is refused under this command's id too.
|
||||
let (mut s, ..) = doc_with_type();
|
||||
s.execute("select.set", &json!({ "ids": [d] })).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let mut p = params_for(spec.id, d);
|
||||
p["id"] = json!("$missing");
|
||||
match s.execute(spec.id, &p) {
|
||||
Err(EngineError::BadParams { cmd, msg }) if cmd == spec.id && msg.contains("step reference") => {}
|
||||
r => failures.push(format!("{} \"$missing\": {r:?}", spec.id)),
|
||||
}
|
||||
if s.doc().unwrap().doc != before {
|
||||
failures.push(format!("{} \"$missing\": the document changed", spec.id));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n"));
|
||||
assert!(checked >= 100, "{checked} commands");
|
||||
}
|
||||
|
||||
/// Every command that names `ids` and is refused with nothing selected: given the object as `ids`
|
||||
|
||||
@@ -520,6 +520,144 @@ fn an_undo_group_is_one_undo_step() {
|
||||
assert_eq!(undo_len(&s), undo + 2);
|
||||
}
|
||||
|
||||
/// Between `edit.undoGroup.begin` and `edit.undoGroup.end`, each document created meanwhile opens an
|
||||
/// undo group with the begun group's name, also when no document was open at the begin: its edits
|
||||
/// are one undo step there, and a cancel undoes them and leaves the document open. A group opened
|
||||
/// without the command (a scrubbed field) and a begin that a failed batch rolled back open no group
|
||||
/// in a new document.
|
||||
#[test]
|
||||
fn an_undo_group_begun_by_command_opens_in_each_document_created_before_its_end() {
|
||||
let mut s = Session::new();
|
||||
let rect = json!({"x": 10, "y": 10, "width": 100, "height": 50});
|
||||
let new = json!({"width": 400, "height": 300});
|
||||
let labels = |s: &Session| s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect::<Vec<_>>();
|
||||
let objects = |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len());
|
||||
s.execute("edit.undoGroup.begin", &json!({"label": "Job"})).unwrap();
|
||||
for _ in 0..2 {
|
||||
s.execute("file.new", &new).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("shape.ellipse", &rect).unwrap();
|
||||
}
|
||||
s.execute("edit.undoGroup.end", &json!({})).unwrap();
|
||||
assert_eq!(labels(&s), ["Job"]);
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(labels(&s), ["Job"]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(objects(&s), 0, "one undo takes back both edits");
|
||||
|
||||
// Closed: the next document's edits are steps of their own.
|
||||
s.execute("file.new", &new).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
assert_eq!(labels(&s).len(), 2);
|
||||
|
||||
// Canceled: the new document stays open without the group's edits, and the journal replays to it.
|
||||
let count = s.documents().len();
|
||||
s.execute("edit.undoGroup.begin", &json!({})).unwrap();
|
||||
s.execute("file.new", &new).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("edit.undoGroup.end", &json!({"cancel": true})).unwrap();
|
||||
assert_eq!(s.documents().len(), count + 1);
|
||||
assert_eq!((objects(&s), labels(&s).len()), (0, 0));
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s));
|
||||
|
||||
// A scrubbed field's group stays in its own document.
|
||||
s.begin_undo_group();
|
||||
s.execute("file.new", &new).unwrap();
|
||||
assert!(s.doc().unwrap().undo_group.is_none());
|
||||
s.end_undo_group(false);
|
||||
|
||||
// A group begun by command and closed with `end_undo_group` opens no group in a later document.
|
||||
s.execute("edit.undoGroup.begin", &json!({})).unwrap();
|
||||
s.end_undo_group(false);
|
||||
s.execute("file.new", &new).unwrap();
|
||||
assert!(s.doc().unwrap().undo_group.is_none());
|
||||
|
||||
// A batch that begins a group and then fails leaves no group begun.
|
||||
let steps = json!([{"command": "edit.undoGroup.begin"}, {"command": "no.such.command"}]);
|
||||
assert!(s.execute("command.batch", &json!({ "commands": steps })).is_err());
|
||||
s.execute("file.new", &new).unwrap();
|
||||
assert!(s.doc().unwrap().undo_group.is_none());
|
||||
}
|
||||
|
||||
/// Between `edit.undoGroup.begin` and `edit.undoGroup.end`, a command that creates a document and
|
||||
/// edits it (a `command.batch`) keeps its edits as an undo step of its own in that document, and the
|
||||
/// edits after it are the group's step. A cancel undoes only those, keeps the document and the
|
||||
/// command's edits, and the journal replays to the same documents.
|
||||
#[test]
|
||||
fn an_undo_group_leaves_out_the_edits_of_the_command_that_creates_its_document() {
|
||||
let mut s = Session::new();
|
||||
let rect = json!({"x": 10, "y": 10, "width": 100, "height": 50});
|
||||
let batch = json!({"commands": [
|
||||
{"command": "file.new", "params": {"width": 400, "height": 300}},
|
||||
{"command": "shape.rectangle", "params": rect},
|
||||
]});
|
||||
let labels = |s: &Session| s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect::<Vec<_>>();
|
||||
let objects = |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len());
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
for cancel in [false, true] {
|
||||
s.execute("edit.undoGroup.begin", &json!({"label": "Job"})).unwrap();
|
||||
s.execute("command.batch", &batch).unwrap();
|
||||
s.execute("shape.ellipse", &rect).unwrap();
|
||||
s.execute("shape.ellipse", &rect).unwrap();
|
||||
s.execute("edit.undoGroup.end", &json!({ "cancel": cancel })).unwrap();
|
||||
let (objects, steps) = (objects(&s), labels(&s).len());
|
||||
assert_eq!((objects, steps), if cancel { (1, 1) } else { (3, 2) }, "cancel: {cancel}");
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s), "cancel: {cancel}");
|
||||
}
|
||||
assert_eq!(s.documents().len(), 3);
|
||||
}
|
||||
|
||||
/// Between `edit.undoGroup.begin` and `edit.undoGroup.end`, switching to a document that was open
|
||||
/// before the begin opens no group in it: each edit made there is an undo step of its own. The edits
|
||||
/// made in the document active at the begin, and in a document created meanwhile, stay one undo step
|
||||
/// each after a switch away and back, and the journal replays to the same documents.
|
||||
#[test]
|
||||
fn an_undo_group_begun_by_command_opens_no_group_in_a_document_switched_to() {
|
||||
let mut s = Session::new();
|
||||
let rect = json!({"x": 10, "y": 10, "width": 100, "height": 50});
|
||||
let new = json!({"width": 400, "height": 300});
|
||||
let labels = |s: &Session, i: usize| s.documents()[i].history.undo.iter().map(|e| e.label.clone()).collect::<Vec<_>>();
|
||||
let objects = |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len());
|
||||
s.execute("file.new", &new).unwrap();
|
||||
s.execute("file.new", &new).unwrap();
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
s.execute("edit.undoGroup.begin", &json!({"label": "Job"})).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("document.activate", &json!({"index": 1})).unwrap();
|
||||
assert!(s.doc().unwrap().undo_group.is_none());
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("shape.ellipse", &rect).unwrap();
|
||||
s.execute("file.new", &new).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("document.activate", &json!({"index": 1})).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("document.activate", &json!({"index": 2})).unwrap();
|
||||
s.execute("shape.ellipse", &rect).unwrap();
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
s.execute("shape.ellipse", &rect).unwrap();
|
||||
s.execute("edit.undoGroup.end", &json!({})).unwrap();
|
||||
assert_eq!((labels(&s, 0), labels(&s, 2)), (vec!["Job".to_string()], vec!["Job".to_string()]));
|
||||
assert_eq!(labels(&s, 1).len(), 3, "{:?}", labels(&s, 1));
|
||||
assert!(s.documents().iter().all(|d| d.undo_group.is_none()));
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(objects(&s), 0, "one undo takes back both edits in the document active at the begin");
|
||||
s.execute("document.activate", &json!({"index": 1})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(objects(&s), 2, "one undo takes back one edit in the document switched to");
|
||||
}
|
||||
|
||||
/// `edit.undoGroup.begin`'s params text gives the undo step's default name next to `label`, and the
|
||||
/// clause on a document switched to says only that its edits stay steps of their own.
|
||||
#[test]
|
||||
fn the_undo_group_params_text_names_the_default_label_next_to_label() {
|
||||
let params = cmd::find_command("edit.undoGroup.begin").unwrap().params;
|
||||
assert!(params.contains("named `label` (else after its first edit); in a document"), "{params}");
|
||||
assert!(params.contains("each edit stays its own undo step: for automation"), "{params}");
|
||||
}
|
||||
|
||||
/// An undo scope ([`Session::begin_undo_scope`]) in a document without an open undo group opens
|
||||
/// one, and [`Session::end_undo_scope`] closes that group only, whichever document is active, and
|
||||
/// leaves another document's group open; canceled, it undoes only that group's edits and cuts the
|
||||
|
||||
@@ -153,9 +153,12 @@ fn group_open_in_another_document() -> (Session, String, String, String) {
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
let char_style = run(&mut s, "library.add", json!({"library": lib, "kind": "charStyle"}))["name"].as_str().unwrap().to_string();
|
||||
let para_style = run(&mut s, "library.add", json!({"library": lib, "kind": "paraStyle"}))["name"].as_str().unwrap().to_string();
|
||||
// The second document exists before the group begins, so no group opens in it.
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
||||
run(&mut s, "document.activate", json!({"index": 0}));
|
||||
run(&mut s, "edit.undoGroup.begin", json!({}));
|
||||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}));
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
||||
run(&mut s, "document.activate", json!({"index": 1}));
|
||||
let u = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [u]}));
|
||||
(s, lib, char_style, para_style)
|
||||
|
||||
@@ -977,6 +977,24 @@ fn convert_to_artboards_and_rearrange() {
|
||||
assert_eq!(bounds(&s, b), Rect::new(50.0, 675.0, 60.0, 685.0));
|
||||
}
|
||||
|
||||
/// Convert to Artboards names an artboard one past the highest artboard id the document has, also
|
||||
/// after a delete of the last artboard or an undo of a new one.
|
||||
#[test]
|
||||
fn convert_to_artboards_names_by_the_artboards_the_document_has() {
|
||||
let name_after = |change: &dyn Fn(&mut Session)| {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
change(&mut s);
|
||||
let a = rect(&mut s, 3000.0, 0.0, 200.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("artboard.convertToArtboards", &json!({})).unwrap();
|
||||
s.doc().unwrap().doc.artboards.last().map(|b| b.name.clone()).unwrap()
|
||||
};
|
||||
assert_eq!(name_after(&|s| _ = s.execute("artboard.delete", &json!({"index": 2})).unwrap()), "Artboard 3");
|
||||
assert_eq!(name_after(&|s| _ = s.execute("edit.undo", &json!({})).unwrap()), "Artboard 3");
|
||||
}
|
||||
|
||||
/// Rearrange All Artboards refuses `columns` that are not a whole number (a fraction, a negative number or a string),
|
||||
/// and the artboards stay where they were; 1.0, as the Rearrange All Artboards dialog sends a count, is one column.
|
||||
#[test]
|
||||
@@ -1285,6 +1303,40 @@ fn select_same_paint_reads_type_characters() {
|
||||
assert_eq!(same(&mut s, t1, "select.same.fillAndStroke"), vec![t1, r]);
|
||||
}
|
||||
|
||||
/// Select > Same matches a compound path on its own paint, as a whole: never its member paths.
|
||||
/// When a member path is selected first, the other objects are compared with its compound path.
|
||||
#[test]
|
||||
fn select_same_matches_compound_paths_on_their_own_paint() {
|
||||
let mut s = session();
|
||||
let [a, b] = [0.0, 20.0].map(|x| rect(&mut s, x, 0.0, 10.0, 10.0));
|
||||
let outer = rect(&mut s, 100.0, 0.0, 50.0, 50.0);
|
||||
let inner = rect(&mut s, 110.0, 10.0, 20.0, 20.0);
|
||||
let r = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||||
for id in [a, b, outer, inner] {
|
||||
fill(&mut s, id, "#000000");
|
||||
}
|
||||
fill(&mut s, r, "#ff0000");
|
||||
sel(&mut s, &[outer, inner]);
|
||||
let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
|
||||
let same = |s: &mut Session, from: NodeId, cmd: &str| {
|
||||
sel(s, &[from]);
|
||||
s.execute(cmd, &json!({})).unwrap();
|
||||
let mut v = selected(s);
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
for from in [a, c, outer] {
|
||||
assert_eq!(same(&mut s, from, "select.same.fillColor"), vec![a, b, c], "from {from:?}");
|
||||
}
|
||||
assert_eq!(same(&mut s, inner, "select.same.shapeType"), vec![c]);
|
||||
fill(&mut s, c, "#ff0000");
|
||||
assert_eq!(same(&mut s, a, "select.same.fillColor"), vec![a, b]);
|
||||
assert_eq!(same(&mut s, r, "select.same.fillColor"), vec![r, c]);
|
||||
// Isolated in the compound path, its member paths are the objects to match.
|
||||
s.execute("object.isolate", &json!({"id": c.0})).unwrap();
|
||||
assert_eq!(same(&mut s, outer, "select.same.shapeType"), vec![outer, inner]);
|
||||
}
|
||||
|
||||
/// Select by Color needs no object to start from: it matches fills and strokes as
|
||||
/// `document.inspect` reports them, `#rrggbb`.
|
||||
#[test]
|
||||
@@ -2365,6 +2417,23 @@ fn a_canceled_interaction_leaves_locked_guides_deselected() {
|
||||
assert!(selected(&s).is_empty());
|
||||
}
|
||||
|
||||
/// A drag preview that fails puts back the selection from before the drag, without the guide
|
||||
/// paths that Lock Guides locked during it, so a move after the drag moves nothing.
|
||||
#[test]
|
||||
fn a_failed_preview_leaves_locked_guides_deselected() {
|
||||
let mut s = session();
|
||||
let g = guide_path(&mut s, 0.0);
|
||||
sel(&mut s, &[g]);
|
||||
s.begin_interaction("Move").unwrap();
|
||||
s.preview("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||||
assert!(s.preview("object.move", &json!({"dx": "far", "dy": 0})).is_err());
|
||||
assert!(selected(&s).is_empty());
|
||||
s.commit_interaction().unwrap();
|
||||
let _ = s.execute("object.move", &json!({"dx": 10, "dy": 0}));
|
||||
assert_eq!(bounds(&s, g).x0, 0.0);
|
||||
}
|
||||
|
||||
fn guides(s: &Session) -> Vec<(bool, f64)> {
|
||||
s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect()
|
||||
}
|
||||
|
||||
@@ -96,6 +96,31 @@ fn saves_for_older_versions() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_older_version_is_pretty_only_when_its_own_json_fits() {
|
||||
// One path whose anchors fit the pretty size limit in the current version but not as
|
||||
// version 1 writes them (both handles of every anchor as `{x, y}` maps).
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_geom::path::{PathData, SubPath};
|
||||
let path_doc = |anchors: usize| {
|
||||
let mut b = vectorcraft_testkit::fixtures::DocBuilder::new(1000.0, 1000.0);
|
||||
let points: Vec<Point> = (0..anchors).map(|j| Point::new(j as f64, (j % 2) as f64)).collect();
|
||||
let node = b.path_node(PathData::single(SubPath::polyline(&points, false)), Default::default());
|
||||
b.add(node);
|
||||
b.doc
|
||||
};
|
||||
let per_anchor = serde_json::to_vec_pretty(&path_doc(1000)).unwrap().len() / 1000;
|
||||
let d = path_doc(vectorcraft_format::PRETTY_MAX_BYTES / 2 / per_anchor);
|
||||
let mut s = Session::new();
|
||||
open(&mut s, "traced.vectorcraft", &vectorcraft_format::save_file(&d));
|
||||
let is_pretty = |bytes: &[u8]| bytes.windows(3).any(|w| w == b"\n ");
|
||||
assert!(is_pretty(&saved(&mut s, json!({"compress": false}))));
|
||||
let old = saved(&mut s, json!({"version": 1}));
|
||||
assert!(!is_pretty(&old), "{} bytes", old.len());
|
||||
open(&mut s, "old.drawcraft", &old);
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_is_at_most_256_pixels() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -463,11 +463,11 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
|
||||
}
|
||||
}
|
||||
|
||||
/// With Alt held, the Pencil and the Paintbrush show their closing cursors, and Use Precise Cursors turns those into
|
||||
/// a crosshair too.
|
||||
/// With Alt held while drawing, the Pencil and the Paintbrush show their closing cursors, and Use Precise Cursors
|
||||
/// turns those into a crosshair too.
|
||||
#[test]
|
||||
fn alt_shows_the_pencil_and_paintbrush_closing_cursors() {
|
||||
use vectorcraft_tools::{Cursor, Mods};
|
||||
use vectorcraft_tools::{Cursor, Mods, PointerEvent, PointerKind};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let at = vectorcraft_geom::Point::new(300.0, 300.0);
|
||||
@@ -476,7 +476,10 @@ fn alt_shows_the_pencil_and_paintbrush_closing_cursors() {
|
||||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": precise})).unwrap();
|
||||
for (tool, c) in [("pencil", Cursor::PencilClose), ("paintbrush", Cursor::PaintbrushClose)] {
|
||||
s.select_tool(tool, ViewInfo::default()).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 250.0, 250.0), ViewInfo::default()).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 300.0, 300.0), ViewInfo::default()).unwrap();
|
||||
assert_eq!(s.cursor(at, alt, ViewInfo::default()), if precise { Cursor::Crosshair } else { c }, "{tool}, precise {precise}");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 300.0, 300.0), ViewInfo::default()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,3 +212,15 @@ fn type_that_does_not_fit_its_frame_is_named() {
|
||||
let (_, v) = print(&mut s, json!({}));
|
||||
assert_eq!(over(&v), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_a_printer_a_landscape_page_is_written_upright() {
|
||||
let (mut s, _) = session();
|
||||
let size = |pdf: &[u8]| vectorcraft_pdf::info(pdf, None).unwrap().pages.iter().map(|p| (p.width, p.height)).collect::<Vec<_>>();
|
||||
let settings = json!({"autoRotate": false, "orientation": "landscape"});
|
||||
let (saved, _) = print(&mut s, json!({"settings": settings}));
|
||||
assert_eq!(size(&saved), [(792.0, 612.0)]);
|
||||
let (spooled, v) = print(&mut s, json!({"settings": settings, "forPrinter": true}));
|
||||
assert_eq!(size(&spooled), [(612.0, 792.0)]);
|
||||
assert_eq!(v["pages"], 1);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,97 @@ fn five_colours_reduce_to_two() {
|
||||
assert_eq!(run(&mut s, "recolor.reduce", json!({"colors": (1u64 << 32) + 2}))["map"].as_array().unwrap().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_with_nothing_selected_come_from_all_the_art() {
|
||||
let mut s = session();
|
||||
let red = rect(&mut s, json!("#ff0000"));
|
||||
let blue = rect(&mut s, json!("#0000ff"));
|
||||
run(&mut s, "select.set", json!({"ids": []}));
|
||||
let hexes = |v: &Value| v["colors"].as_array().unwrap().iter().map(|c| c["hex"].as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
let mut all = hexes(&run(&mut s, "recolor.colors", json!({})));
|
||||
all.sort();
|
||||
assert_eq!(all, ["#0000ff", "#ff0000"]);
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty(), "the selection is left as it was");
|
||||
assert_eq!(run(&mut s, "recolor.reduce", json!({}))["map"].as_array().unwrap().len(), 2);
|
||||
// `ids` names the objects; otherwise the selection does.
|
||||
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({"ids": [blue.0]}))), ["#0000ff"]);
|
||||
// An id listed twice counts once.
|
||||
let once = run(&mut s, "recolor.colors", json!({"ids": [red.0]}))["colors"].clone();
|
||||
assert_eq!(run(&mut s, "recolor.colors", json!({"ids": [red.0, red.0]}))["colors"], once);
|
||||
assert_eq!(run(&mut s, "recolor.reduce", json!({"ids": [red.0]}))["map"][0]["from"], json!(["rgb 255 0 0"]));
|
||||
run(&mut s, "select.set", json!({"ids": [red.0]}));
|
||||
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({}))), ["#ff0000"]);
|
||||
let e = s.execute("recolor.colors", &json!({"ids": [9999]})).unwrap_err().to_string();
|
||||
assert!(e.contains("no such object id 9999"), "{e}");
|
||||
// Recolor Artwork still recolors only what is selected.
|
||||
run(&mut s, "select.set", json!({"ids": []}));
|
||||
assert!(s.execute("recolor.apply", &json!({"map": {"#ff0000": "#00ff00"}})).is_err());
|
||||
assert_eq!(fill(&s, red).to_hex(), "#ff0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_with_nothing_selected_leave_out_what_select_all_leaves_out() {
|
||||
let mut s = session();
|
||||
rect(&mut s, json!("#ff0000"));
|
||||
let hidden = rect(&mut s, json!("#0000ff"));
|
||||
let locked = rect(&mut s, json!("#00ff00"));
|
||||
run(&mut s, "object.hide", json!({"ids": [hidden.0]}));
|
||||
run(&mut s, "object.lock", json!({"ids": [locked.0]}));
|
||||
// A hidden layer and a locked layer, each holding a rectangle.
|
||||
for (fill, props) in [("#ffff00", json!({"visible": false})), ("#ff00ff", json!({"locked": true}))] {
|
||||
let layer = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap();
|
||||
let id = run(&mut s, "shape.rectangle", json!({"x": 300, "y": 10, "width": 15, "height": 15}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "paint.setFill", json!({"ids": [id], "color": fill}));
|
||||
assert_eq!(s.doc().unwrap().doc.layer_containing(NodeId(id)), Some(NodeId(layer)));
|
||||
let mut p = props;
|
||||
p["id"] = json!(layer);
|
||||
run(&mut s, "layer.setProps", p);
|
||||
}
|
||||
let hexes = |v: &Value| v["colors"].as_array().unwrap().iter().map(|c| c["hex"].as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
run(&mut s, "select.set", json!({"ids": []}));
|
||||
let none = hexes(&run(&mut s, "recolor.colors", json!({})));
|
||||
assert_eq!(none, ["#ff0000"]);
|
||||
assert_eq!(run(&mut s, "recolor.reduce", json!({}))["map"], json!([{"from": ["rgb 255 0 0"], "to": "rgb 255 0 0"}]));
|
||||
select_all(&mut s);
|
||||
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({}))), none, "the same colors as after Select All");
|
||||
// `ids` still reads hidden and locked objects.
|
||||
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({"ids": [hidden.0]}))), ["#0000ff"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_with_nothing_selected_leave_out_hidden_and_locked_objects_in_groups() {
|
||||
let mut s = session();
|
||||
rect(&mut s, json!("#ff0000"));
|
||||
let hidden = rect(&mut s, json!("#0000ff"));
|
||||
let locked = rect(&mut s, json!("#00ff00"));
|
||||
// A group inside the group, holding a hidden rectangle.
|
||||
let inner_hidden = rect(&mut s, json!("#ffff00"));
|
||||
run(&mut s, "select.set", json!({"ids": [inner_hidden.0]}));
|
||||
run(&mut s, "object.group", json!({}));
|
||||
select_all(&mut s);
|
||||
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
|
||||
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1, "one group holds the art");
|
||||
run(&mut s, "object.hide", json!({"ids": [hidden.0, inner_hidden.0]}));
|
||||
run(&mut s, "object.lock", json!({"ids": [locked.0]}));
|
||||
let hexes = |v: &Value| {
|
||||
let mut h = v["colors"].as_array().unwrap().iter().map(|c| c["hex"].as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
h.sort();
|
||||
h
|
||||
};
|
||||
run(&mut s, "select.set", json!({"ids": []}));
|
||||
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({}))), ["#ff0000"]);
|
||||
assert_eq!(run(&mut s, "recolor.reduce", json!({}))["map"], json!([{"from": ["rgb 255 0 0"], "to": "rgb 255 0 0"}]));
|
||||
// Isolation mode reads the same objects of the group.
|
||||
run(&mut s, "object.isolate", json!({"id": g.0}));
|
||||
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({}))), ["#ff0000"]);
|
||||
run(&mut s, "object.exitIsolation", json!({}));
|
||||
// The group named in `ids`, or selected, is read with everything it holds.
|
||||
let all = ["#0000ff", "#00ff00", "#ff0000", "#ffff00"];
|
||||
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({"ids": [g.0]}))), all);
|
||||
run(&mut s, "select.set", json!({"ids": [g.0]}));
|
||||
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({}))), all);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_flags_leave_neutrals_out() {
|
||||
let mut s = session();
|
||||
@@ -288,6 +379,82 @@ fn edit_group_relinks_art_and_apply_rewrites_the_group_with_the_art() {
|
||||
assert_eq!(g.swatches.iter().map(|w| w.paint.color().unwrap()).collect::<Vec<_>>(), [Color::rgb8(1, 2, 3), Color::cmyk(0.0, 0.0, 0.0, 0.2)]);
|
||||
}
|
||||
|
||||
/// Art linked to a global swatch of the group that `recolor.apply` rewrites stays linked when the
|
||||
/// swatch takes its row's new color: it shows that color at its tint and follows later edits.
|
||||
#[test]
|
||||
fn apply_with_a_group_keeps_art_linked_to_its_global_swatches() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#2878f0"]}));
|
||||
let name = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Brand").unwrap().swatches[0].name.clone();
|
||||
run(&mut s, "swatch.edit", json!({"name": name, "global": true}));
|
||||
let full = rect(&mut s, json!("#000000"));
|
||||
let tint = rect(&mut s, json!("#000000"));
|
||||
run(&mut s, "paint.setFill", json!({"ids": [full.0], "swatch": name}));
|
||||
run(&mut s, "paint.setFill", json!({"ids": [tint.0], "swatch": name, "tint": 50}));
|
||||
let link = |s: &Session, id: NodeId| match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() {
|
||||
vectorcraft_color::Paint::Solid { color, swatch, tint } => (color, swatch, tint),
|
||||
p => panic!("{p:?}"),
|
||||
};
|
||||
let swatch_hex = |s: &Session| s.doc().unwrap().doc.swatch(&name).unwrap().paint.color().unwrap().to_hex();
|
||||
let orange = Color::from_hex("#ff6a00").unwrap();
|
||||
select_all(&mut s);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
// The 50% tint is not in the row: it follows the swatch.
|
||||
let r = run(&mut s, "recolor.apply", json!({"map": {"#2878f0": "#ff6a00"}, "group": "Brand"}));
|
||||
assert_eq!(r["relinked"], 1, "{r}");
|
||||
assert_eq!(link(&s, full), (orange, Some(name.clone()), 1.0), "the recolored art stays linked");
|
||||
let (c, w, t) = link(&s, tint);
|
||||
assert!(w.as_deref() == Some(name.as_str()) && t == 0.5 && close(c, orange.tinted(0.5)), "{c:?} {w:?} {t}");
|
||||
assert_eq!(swatch_hex(&s), "#ff6a00");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
// A later edit of the swatch reaches the art.
|
||||
let r = run(&mut s, "swatch.edit", json!({"name": name, "color": "#00ff00"}));
|
||||
assert_eq!(r["relinked"], 2, "{r}");
|
||||
assert_eq!(fill(&s, full).to_hex(), "#00ff00");
|
||||
assert!(close(fill(&s, tint), Color::from_hex("#00ff00").unwrap().tinted(0.5)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(swatch_hex(&s), "#2878f0");
|
||||
assert_eq!(link(&s, full), (Color::from_hex("#2878f0").unwrap(), Some(name.clone()), 1.0), "undo restores the art");
|
||||
// A row holding the tint too (as recolor.reduce gives it): the tint keeps its tint and its link.
|
||||
select_all(&mut s);
|
||||
let tint_key = vectorcraft_color::recolor::ColorKey::of(&fill(&s, tint)).to_string();
|
||||
let rows = json!([{"from": ["rgb 40 120 240", tint_key], "to": "#ff6a00"}]);
|
||||
run(&mut s, "recolor.apply", json!({"map": rows, "method": "scaleTints", "group": "Brand"}));
|
||||
let (c, w, t) = link(&s, tint);
|
||||
assert!(w.as_deref() == Some(name.as_str()) && (t - 0.5).abs() < 0.01 && close(c, orange.tinted(t)), "{c:?} {w:?} {t}");
|
||||
assert_eq!(link(&s, full), (orange, Some(name.clone()), 1.0));
|
||||
// A swatch rewritten with another color than its art's row: that art takes the row's color, unlinked.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "recolor.apply", json!({"map": {"#2878f0": "#ff6a00"}, "group": "Brand", "groupColors": ["#123456"]}));
|
||||
assert_eq!(link(&s, full), (orange, None, 1.0));
|
||||
assert_eq!(swatch_hex(&s), "#123456");
|
||||
// A row that leaves the art's color as it is while the swatch is rewritten with another color:
|
||||
// the art keeps its color, unlinked.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
select_all(&mut s);
|
||||
let blue = Color::from_hex("#2878f0").unwrap();
|
||||
run(&mut s, "recolor.apply", json!({"map": {"#2878f0": "#2878f0"}, "group": "Brand", "groupColors": ["#123456"]}));
|
||||
assert_eq!(link(&s, full), (blue, None, 1.0));
|
||||
assert_eq!(swatch_hex(&s), "#123456");
|
||||
// A CMYK new color for RGB art keeps the art's model (model: "keep"), so the art takes the
|
||||
// color in RGB, unlinked, while the swatch takes it in CMYK.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
select_all(&mut s);
|
||||
let r = run(&mut s, "recolor.apply", json!({"map": {"#2878f0": {"c": 0, "m": 60, "y": 100, "k": 0}}, "group": "Brand"}));
|
||||
assert!(r["note"].is_string(), "{r}");
|
||||
let (c, w, _) = link(&s, full);
|
||||
assert!(matches!(c, Color::Rgb { .. }) && w.is_none(), "{c:?} {w:?}");
|
||||
assert!(matches!(s.doc().unwrap().doc.swatch(&name).unwrap().paint.color(), Some(Color::Cmyk { .. })));
|
||||
// Without a group the swatch is unchanged, so the art takes the new color unlinked.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "recolor.apply", json!({"map": {"#2878f0": "#ff6a00"}}));
|
||||
assert_eq!(link(&s, full), (orange, None, 1.0));
|
||||
assert_eq!(swatch_hex(&s), "#2878f0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_group_refuses_to_change_built_in_swatches() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -160,6 +160,31 @@ fn pdf_compatible_files_carry_a_pdf() {
|
||||
assert!(vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({})).unwrap()["dataBase64"])).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_option_errors_name_the_save_command() {
|
||||
let mut s = three_boards();
|
||||
for (cmd, p) in [
|
||||
("file.saveCopy", json!({"format": "pdf", "range": "5"})),
|
||||
("file.saveCopy", json!({"format": "svg", "artboard": 5})),
|
||||
("file.saveAs", json!({"format": "svg", "range": "4"})),
|
||||
("document.save", json!({"format": "pdf", "artboards": [3]})),
|
||||
("file.saveCopy", json!({"format": "ai", "compress": "yes"})),
|
||||
("file.saveCopy", json!({"format": "ai", "pdfCompatible": false, "artboard": "B"})),
|
||||
("file.saveCopy", json!({"format": "ai", "aiVersion": 7, "artboard": "B"})),
|
||||
("file.saveCopy", json!({"format": "ait", "compress": "yes"})),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("`{cmd}`")) && !e.contains("document.export"), "{p}: {e}");
|
||||
}
|
||||
// A native file written to a path.
|
||||
let dir = Folder::new("save-errors");
|
||||
for (cmd, name) in [("document.save", "a.vectorcraft"), ("file.saveAs", "b.vectorcraft"), ("file.saveCopy", "c.vectorcraft")] {
|
||||
let p = json!({"path": dir.file(name), "compress": "yes"});
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("`{cmd}`")) && !e.contains("document.export"), "{p}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_without_pdf_content_and_compression() {
|
||||
let mut s = three_boards();
|
||||
@@ -188,6 +213,130 @@ fn ai_without_pdf_content_and_compression() {
|
||||
assert!(loose.len() > packed.len(), "{} vs {}", loose.len(), packed.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remembered_artboard_the_document_no_longer_has_is_dropped() {
|
||||
let dir = Folder::new("remembered-artboards");
|
||||
for (name, p) in [
|
||||
("legacy.ai", json!({"aiVersion": "7", "range": "3"})),
|
||||
("separate.ai", json!({"separateArtboards": true, "range": "3"})),
|
||||
("separate.vectorcraft", json!({"separateArtboards": true, "range": "1, 3"})),
|
||||
("doc.pdf", json!({"range": "3"})),
|
||||
("doc.svg", json!({"artboard": 2})),
|
||||
] {
|
||||
let mut s = three_boards();
|
||||
let mut first = p.clone();
|
||||
first["path"] = json!(dir.file(name));
|
||||
s.execute("file.saveAs", &first).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
s.execute("artboard.delete", &json!({"index": 2})).unwrap();
|
||||
let v = s.execute("file.formatOptions", &json!({})).unwrap();
|
||||
for k in ["artboard", "range"].iter().filter(|k| p.get(**k).is_some()) {
|
||||
assert_ne!(v["options"][*k]["value"], p[*k], "{name}: Save no longer uses {k}");
|
||||
}
|
||||
s.execute("document.save", &json!({})).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
// Given to the save, it is still refused.
|
||||
let e = s.execute("document.save", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("from 1 to 2") || e.contains("no artboard 2"), "{name}: {e}");
|
||||
}
|
||||
// A remembered artboard that is still there is kept.
|
||||
let mut s = three_boards();
|
||||
s.execute("file.saveAs", &json!({"path": dir.file("kept.ai"), "aiVersion": "7", "range": "2"})).unwrap();
|
||||
s.execute("artboard.delete", &json!({"index": 2})).unwrap();
|
||||
let v = s.execute("file.formatOptions", &json!({})).unwrap();
|
||||
assert_eq!(v["options"]["range"]["value"], json!("2"));
|
||||
let r = s.execute("document.save", &json!({})).unwrap();
|
||||
assert!(r["path"].as_str().is_some_and(|p| p.ends_with("kept.ai")), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remembered_artboard_pick_follows_the_artboards_it_named() {
|
||||
let dir = Folder::new("remembered-pick");
|
||||
// Save each artboard of A, B and C to a file with `range`, change the artboards, then save
|
||||
// without options: the range Save now uses and the artboards it writes, by file name.
|
||||
let save = |range: &str, change: &[(&str, Value)]| -> (Value, Vec<String>) {
|
||||
let mut s = three_boards();
|
||||
let path = dir.file("x.ai");
|
||||
s.execute("file.saveAs", &json!({"path": path, "separateArtboards": true, "range": range})).unwrap();
|
||||
for (cmd, p) in change {
|
||||
s.execute(cmd, p).unwrap();
|
||||
}
|
||||
let range = s.execute("file.formatOptions", &json!({})).unwrap()["options"]["range"]["value"].clone();
|
||||
let r = s.execute("document.save", &json!({})).unwrap();
|
||||
let files = r["files"].as_array().unwrap().iter().skip(1).map(|f| f.as_str().unwrap().rsplit(['/', '\\']).next().unwrap().to_string());
|
||||
(range, files.collect())
|
||||
};
|
||||
let delete = |i: usize| ("artboard.delete", json!({"index": i}));
|
||||
assert_eq!(save("1, 3", &[delete(2)]), (json!("1"), vec!["x-A.ai".to_string()]));
|
||||
assert_eq!(save("1, 3", &[delete(0)]), (json!("2"), vec!["x-C.ai".to_string()]));
|
||||
assert_eq!(save("1-3", &[delete(1)]), (json!("1-2"), vec!["x-A.ai".to_string(), "x-C.ai".to_string()]));
|
||||
let (range, mut files) = save("1, 3", &[("artboard.reorder", json!({"index": 2, "to": 0}))]);
|
||||
files.sort();
|
||||
assert_eq!((range, files), (json!("2, 1"), vec!["x-A.ai".to_string(), "x-C.ai".to_string()]));
|
||||
// None of them left: every artboard, as when none is named.
|
||||
assert_eq!(save("1, 3", &[delete(2), delete(0)]), (Value::Null, vec!["x-B.ai".to_string()]));
|
||||
assert_eq!(save("all", &[delete(0)]), (json!("all"), vec!["x-B.ai".to_string(), "x-C.ai".to_string()]));
|
||||
// An artboard made after one was deleted is not taken for it.
|
||||
let new = ("artboard.new", json!({"x": 600, "y": 0, "width": 100, "height": 100, "name": "D"}));
|
||||
let all = ["x-A.ai", "x-B.ai", "x-D.ai"].map(String::from).to_vec();
|
||||
assert_eq!(save("3", &[delete(2), new.clone()]), (Value::Null, all));
|
||||
assert_eq!(save("1, 3", &[delete(2), new.clone()]), (json!("1"), vec!["x-A.ai".to_string()]));
|
||||
// A range open at its end keeps to the artboards it named.
|
||||
assert_eq!(save("2-", std::slice::from_ref(&new)), (json!("2-3"), vec!["x-B.ai".to_string(), "x-C.ai".to_string()]));
|
||||
// Nor is one made after undoing the artboard (and the three rectangles made after it).
|
||||
let undo = ("edit.undo", json!({}));
|
||||
let undone = [undo.clone(), undo.clone(), undo.clone(), undo, new];
|
||||
assert_eq!(save("1, 3", &undone), (json!("1"), vec!["x-A.ai".to_string()]));
|
||||
|
||||
// The other saves that remember a pick: a PDF's range, an SVG's artboard, a legacy .ai's
|
||||
// range and a native file's separate files.
|
||||
for (name, p, gone, after) in [
|
||||
("doc.pdf", json!({"range": "1, 3"}), 0, json!({"range": "2"})),
|
||||
// `range` wins over `artboard`, which a save then does not use.
|
||||
("unused.pdf", json!({"artboard": 0, "range": "3"}), 0, json!({"range": "2"})),
|
||||
("doc.svg", json!({"artboard": 2}), 0, json!({"artboard": 1})),
|
||||
("legacy.ai", json!({"aiVersion": "7", "range": "1, 3"}), 2, json!({"range": "1"})),
|
||||
("doc.vectorcraft", json!({"separateArtboards": true, "range": "1, 3"}), 0, json!({"range": "2"})),
|
||||
] {
|
||||
let mut s = three_boards();
|
||||
let mut first = p.clone();
|
||||
first["path"] = json!(dir.file(name));
|
||||
s.execute("file.saveAs", &first).unwrap();
|
||||
s.execute("artboard.delete", &json!({"index": gone})).unwrap();
|
||||
let v = s.execute("file.formatOptions", &json!({})).unwrap();
|
||||
for (k, value) in after.as_object().unwrap() {
|
||||
assert_eq!(&v["options"][k]["value"], value, "{name}: {k}");
|
||||
}
|
||||
s.execute("document.save", &json!({})).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
if name.ends_with(".pdf") {
|
||||
let bytes = std::fs::read(dir.file(name)).unwrap();
|
||||
let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
|
||||
assert_eq!(info["pages"], 1, "only the artboard that was 3");
|
||||
}
|
||||
}
|
||||
|
||||
// File > Revert: a PDF holds only the pages it was saved with, so its pick is left out; a
|
||||
// native file keeps every artboard, and the pick stays.
|
||||
let mut s = three_boards();
|
||||
s.execute("file.saveAs", &json!({"path": dir.file("revert.pdf"), "range": "1, 3"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 50, "width": 30, "height": 30})).unwrap();
|
||||
s.execute("file.revert", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 2);
|
||||
assert_eq!(s.execute("file.formatOptions", &json!({})).unwrap()["options"]["range"]["value"], Value::Null);
|
||||
// A PDF without the editable document reads back with new ids in page order: after a reorder,
|
||||
// the pick is left out, not taken for another reorder.
|
||||
let mut s = three_boards();
|
||||
s.execute("artboard.reorder", &json!({"index": 2, "to": 0})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": dir.file("reordered.pdf"), "range": "1-3", "preserveEditing": false})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 50, "width": 30, "height": 30})).unwrap();
|
||||
s.execute("file.revert", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect::<Vec<_>>(), [1, 2, 3]);
|
||||
assert_eq!(s.execute("file.formatOptions", &json!({})).unwrap()["options"]["range"]["value"], Value::Null);
|
||||
let mut s = three_boards();
|
||||
s.execute("file.saveAs", &json!({"path": dir.file("revert.vectorcraft"), "separateArtboards": true, "range": "1, 3"})).unwrap();
|
||||
s.execute("artboard.delete", &json!({"index": 0})).unwrap();
|
||||
s.execute("file.revert", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("file.formatOptions", &json!({})).unwrap()["options"]["range"]["value"], json!("1, 3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_options_list_the_save_options() {
|
||||
let mut s = three_boards();
|
||||
|
||||
@@ -143,6 +143,78 @@ fn bad_ids_are_refused_and_change_nothing() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer or sublayer is not an object these commands act on: its id is refused, alone or with an
|
||||
/// object's, and the document, the selection and the undo history stay as they were.
|
||||
#[test]
|
||||
fn layer_ids_are_refused_and_change_nothing() {
|
||||
let (mut s, ids) = doc();
|
||||
let layer = s.execute("layer.new", &json!({"name": "Top"})).unwrap()["id"].as_u64().unwrap();
|
||||
let sub = s.execute("layer.newSublayer", &json!({"parent": layer})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [ids[3]]})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let (before, undo) = (st.doc.clone(), st.history.undo.len());
|
||||
let params = |cmd: &str| match cmd {
|
||||
"edit.duplicate" => json!({"dx": 5, "dy": 5}),
|
||||
"object.move" => json!({"dx": 12, "dy": -4}),
|
||||
"object.rotate" => json!({"angle": 30}),
|
||||
"object.scale" => json!({"sx": 150}),
|
||||
"object.reflect" => json!({"axis": "vertical"}),
|
||||
"object.shear" => json!({"angle": 20}),
|
||||
_ => json!({}),
|
||||
};
|
||||
for cmd in SELECTS_GIVEN_IDS {
|
||||
for given in [json!([layer]), json!([sub]), json!([ids[0], layer])] {
|
||||
let mut p = params(cmd);
|
||||
p["ids"] = given.clone();
|
||||
let r = s.execute(cmd, &p);
|
||||
let Err(EngineError::BadParams { msg, .. }) = &r else { panic!("{cmd} {given}: {r:?}") };
|
||||
assert!(msg.contains("is a layer, not an object"), "{cmd} {given}: {msg}");
|
||||
assert_eq!(msg.contains("layer.move"), cmd.starts_with("object.arrange."), "{cmd} {given}: {msg}");
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc == before, "{cmd} {given}: the document changed");
|
||||
assert_eq!(st.selection.objects, vec![NodeId(ids[3])], "{cmd} {given}: the selection stays");
|
||||
assert_eq!(st.history.undo.len(), undo, "{cmd} {given}: an undo step was recorded");
|
||||
}
|
||||
}
|
||||
// An unknown id before the layer's is still reported as unknown.
|
||||
let r = s.execute("object.arrange.sendToBack", &json!({"ids": [999, layer]}));
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "no such object id 999"), "{r:?}");
|
||||
}
|
||||
|
||||
/// An id that names no object is reported by the command that was run, not by the `select.set`
|
||||
/// that checks the ids.
|
||||
#[test]
|
||||
fn id_errors_name_the_command_run() {
|
||||
let (mut s, ids) = doc();
|
||||
let layer = s.execute("layer.new", &json!({"name": "Top"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [ids[3]]})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for cmd in SELECTS_GIVEN_IDS {
|
||||
for (given, want) in [
|
||||
(json!([999]), "no such object id 999".to_string()),
|
||||
(json!([999, layer]), "no such object id 999".to_string()),
|
||||
(json!([ids[0], ids[1], -1]), "invalid object id -1: expected a non-negative integer".to_string()),
|
||||
(json!(ids[0]), "ids must be an array of non-negative integer object ids".to_string()),
|
||||
] {
|
||||
let mut p = match *cmd {
|
||||
"edit.duplicate" => json!({"dx": 5, "dy": 5}),
|
||||
"object.move" => json!({"dx": 12, "dy": -4}),
|
||||
"object.rotate" => json!({"angle": 30}),
|
||||
"object.scale" => json!({"sx": 150}),
|
||||
"object.reflect" => json!({"axis": "vertical"}),
|
||||
"object.shear" => json!({"angle": 20}),
|
||||
_ => json!({}),
|
||||
};
|
||||
p["ids"] = given.clone();
|
||||
let r = s.execute(cmd, &p);
|
||||
let Err(EngineError::BadParams { cmd: named, msg }) = &r else { panic!("{cmd} {given}: {r:?}") };
|
||||
assert_eq!((named.as_str(), msg), (*cmd, &want), "{cmd} {given}");
|
||||
assert!(s.doc().unwrap().doc == before, "{cmd} {given}: the document changed");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![NodeId(ids[3])], "{cmd} {given}: the selection stays");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_that_fails_after_selecting_its_ids_puts_the_selection_back() {
|
||||
let (mut s, ids) = doc();
|
||||
|
||||
@@ -108,6 +108,18 @@ fn shaper_interior_punch_removes_the_whole_visible_front_face() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_scribble_merges_a_face_narrower_than_its_sampling_step() {
|
||||
let mut s = session();
|
||||
rectangle(&mut s, 0.0, "#0000ff");
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": []})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 49.8, "y": 10, "width": 0.4, "height": 80})).unwrap();
|
||||
// Every crossing of the 0.4 pt strip falls between two of the 2 pt samples.
|
||||
let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [75.0, 65.0]]);
|
||||
assert_eq!(fill(&s, g, 50.0, 50.0).as_deref(), Some("#0000ff"), "the strip merges into the face around it");
|
||||
assert_eq!(fill(&s, g, 25.0, 50.0).as_deref(), Some("#0000ff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_exiting_scribble_erases_only_touched_fill_and_boundary_strokes() {
|
||||
let mut s = session();
|
||||
@@ -141,6 +153,22 @@ fn shaper_erases_only_protruding_stroke_pieces_keeps_fill_and_original_line() {
|
||||
assert_eq!(node(&s, line), &original_line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_scribble_with_a_small_tolerance_erases_a_stroke_it_crosses_between_samples() {
|
||||
for (tolerance, from, to) in [(0.1, 140.25, 160.25), (0.01, 50.0123, 250.0123)] {
|
||||
let mut s = session();
|
||||
s.execute("paint.setFill", &json!({"color":"#ffff00"})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap();
|
||||
s.execute("shape.line", &json!({"x1":50,"y1":150,"x2":250,"y2":150})).unwrap();
|
||||
// No sample along the stroke lands within `tolerance` of the line at y = 150.
|
||||
let v = s.execute("shaper.scribble", &json!({"points": [[60.0, from], [60.0, to]], "tolerance": tolerance})).unwrap();
|
||||
assert_eq!(v["strokes"], json!(1), "tolerance {tolerance}: {v}");
|
||||
let g = NodeId(v["id"].as_u64().unwrap());
|
||||
assert!(!stroke(&s, g, 75.0, 150.0));
|
||||
assert!(stroke(&s, g, 150.0, 150.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Export Selection crops a Shaper group to the art it shows, not to its transparent source art.
|
||||
#[test]
|
||||
fn shaper_export_selection_crops_to_the_shown_art() {
|
||||
|
||||
@@ -215,6 +215,7 @@ fn clip_to_artboard_hide_lock_select_and_delete_all() {
|
||||
assert_eq!((l["hidden"].clone(), l["locked"].clone(), l["clipToArtboard"].clone()), (json!(true), json!(true), json!(true)));
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": false}));
|
||||
assert!(!s.slices_hidden());
|
||||
run(&mut s, "view.slices.lock", json!({"locked": false}));
|
||||
|
||||
let a = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
||||
run(&mut s, "object.slice.make", json!({}));
|
||||
@@ -232,6 +233,253 @@ fn clip_to_artboard_hide_lock_select_and_delete_all() {
|
||||
assert!(s.execute("object.slice.deleteAll", &json!({})).is_err(), "nothing to delete");
|
||||
}
|
||||
|
||||
/// The slices selected with the Slice Selection tool or a slice command.
|
||||
fn picked(s: &Session) -> Vec<NodeId> {
|
||||
s.doc().unwrap().selection.slices.clone()
|
||||
}
|
||||
|
||||
/// View → Lock Slices deselects the slices in every open document, and while it is on nothing
|
||||
/// selects one, so the commands that act on the selected slices leave them alone. Turning it off
|
||||
/// leaves them deselected.
|
||||
#[test]
|
||||
fn lock_slices_deselects_the_slices_and_keeps_them_unselected() {
|
||||
let mut s = session();
|
||||
let id = user_slice(&mut s, [0.0, 0.0, 50.0, 50.0]);
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
run(&mut s, "view.slices.lock", json!({"locked": true}));
|
||||
assert!(picked(&s).is_empty(), "after Lock Slices");
|
||||
let _ = s.execute("object.slice.move", &json!({"dx": 10, "dy": 0}));
|
||||
let _ = s.execute("object.slice.options", &json!({"name": "hero"}));
|
||||
assert_eq!(rect_of(&mut s, id), [0.0, 0.0, 50.0, 50.0]);
|
||||
assert_eq!(s.doc().unwrap().doc.slices[0].options.name, "");
|
||||
assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 0);
|
||||
assert_eq!(run(&mut s, "object.slice.select", json!({"slices": [id]}))["selected"], json!([]));
|
||||
run(&mut s, "object.slice.create", json!({"x": 100, "y": 100, "width": 20, "height": 20}));
|
||||
assert!(picked(&s).is_empty(), "after Create Slice");
|
||||
// Locking from another document deselects this one's slices too.
|
||||
run(&mut s, "view.slices.lock", json!({"locked": false}));
|
||||
assert!(picked(&s).is_empty(), "after turning Lock Slices off");
|
||||
assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 2);
|
||||
run(&mut s, "file.new", json!({"width": 300, "height": 300}));
|
||||
run(&mut s, "view.slices.lock", json!({"locked": true}));
|
||||
run(&mut s, "document.activate", json!({"index": 0}));
|
||||
assert!(picked(&s).is_empty(), "in the other document");
|
||||
}
|
||||
|
||||
/// View → Hide Slices deselects the slices in every open document, and while they are hidden
|
||||
/// nothing selects one, so the commands that act on the selected slices leave them alone. Turning
|
||||
/// it off leaves them deselected.
|
||||
#[test]
|
||||
fn hide_slices_deselects_the_slices_and_keeps_them_unselected() {
|
||||
let mut s = session();
|
||||
let id = user_slice(&mut s, [0.0, 0.0, 50.0, 50.0]);
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": true}));
|
||||
assert!(picked(&s).is_empty(), "after Hide Slices");
|
||||
let _ = s.execute("object.slice.move", &json!({"dx": 10, "dy": 0}));
|
||||
let _ = s.execute("object.slice.options", &json!({"name": "hero"}));
|
||||
assert_eq!(rect_of(&mut s, id), [0.0, 0.0, 50.0, 50.0]);
|
||||
assert_eq!(s.doc().unwrap().doc.slices[0].options.name, "");
|
||||
assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 0);
|
||||
assert_eq!(run(&mut s, "object.slice.select", json!({"slices": [id]}))["selected"], json!([]));
|
||||
run(&mut s, "object.slice.create", json!({"x": 100, "y": 100, "width": 20, "height": 20}));
|
||||
assert!(picked(&s).is_empty(), "after Create Slice");
|
||||
// Hiding from another document deselects this one's slices too.
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": false}));
|
||||
assert!(picked(&s).is_empty(), "after turning Hide Slices off");
|
||||
assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 2);
|
||||
run(&mut s, "file.new", json!({"width": 300, "height": 300}));
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": true}));
|
||||
run(&mut s, "document.activate", json!({"index": 0}));
|
||||
assert!(picked(&s).is_empty(), "in the other document");
|
||||
}
|
||||
|
||||
/// Undo and Redo bring back a selection without the slices that Lock Slices has locked since, so
|
||||
/// a move after them leaves the slice where it is.
|
||||
#[test]
|
||||
fn undo_and_redo_leave_locked_slices_deselected() {
|
||||
let mut s = session();
|
||||
let id = run(&mut s, "object.slice.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "object.slice.move", json!({"dx": 10, "dy": 0}));
|
||||
run(&mut s, "view.slices.lock", json!({"locked": true}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(picked(&s).is_empty(), "after Undo");
|
||||
let _ = s.execute("object.slice.move", &json!({"dx": 10, "dy": 0}));
|
||||
assert_eq!(rect_of(&mut s, id)[0], 0.0);
|
||||
// Undone with the slice selected, redone once it is locked.
|
||||
run(&mut s, "view.slices.lock", json!({"locked": false}));
|
||||
run(&mut s, "object.slice.select", json!({"slices": [id]}));
|
||||
run(&mut s, "object.slice.move", json!({"dx": 10, "dy": 0}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "view.slices.lock", json!({"locked": true}));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(picked(&s).is_empty(), "after Redo");
|
||||
let _ = s.execute("object.slice.move", &json!({"dx": 10, "dy": 0}));
|
||||
assert_eq!(rect_of(&mut s, id)[0], 10.0);
|
||||
}
|
||||
|
||||
/// Undo and Redo bring back a selection without the slices that Hide Slices has hidden since, so
|
||||
/// a move after them leaves the slice where it is.
|
||||
#[test]
|
||||
fn undo_and_redo_leave_hidden_slices_deselected() {
|
||||
let mut s = session();
|
||||
let id = run(&mut s, "object.slice.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "object.slice.move", json!({"dx": 10, "dy": 0}));
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": true}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(picked(&s).is_empty(), "after Undo");
|
||||
let _ = s.execute("object.slice.move", &json!({"dx": 10, "dy": 0}));
|
||||
assert_eq!(rect_of(&mut s, id)[0], 0.0);
|
||||
// Undone with the slice selected, redone once it is hidden.
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": false}));
|
||||
run(&mut s, "object.slice.select", json!({"slices": [id]}));
|
||||
run(&mut s, "object.slice.move", json!({"dx": 10, "dy": 0}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": true}));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(picked(&s).is_empty(), "after Redo");
|
||||
let _ = s.execute("object.slice.move", &json!({"dx": 10, "dy": 0}));
|
||||
assert_eq!(rect_of(&mut s, id)[0], 10.0);
|
||||
}
|
||||
|
||||
/// A failed batch, a canceled undo group, a failed drag preview and a canceled drag bring back the
|
||||
/// selection from before them, without the slices that Lock Slices locked inside them.
|
||||
#[test]
|
||||
fn a_failed_batch_a_canceled_undo_group_and_a_canceled_drag_leave_locked_slices_deselected() {
|
||||
let mut s = session();
|
||||
let id = run(&mut s, "object.slice.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap();
|
||||
let steps = json!([{"command": "view.slices.lock", "params": {"locked": true}}, {"command": "object.isolate", "params": {}}]);
|
||||
assert!(s.execute("command.batch", &json!({"commands": steps})).is_err());
|
||||
assert!(s.slices_locked());
|
||||
assert!(picked(&s).is_empty(), "after the failed batch");
|
||||
run(&mut s, "view.slices.lock", json!({"locked": false}));
|
||||
run(&mut s, "object.slice.select", json!({"slices": [id]}));
|
||||
run(&mut s, "edit.undoGroup.begin", json!({}));
|
||||
run(&mut s, "object.slice.move", json!({"dx": 10, "dy": 0}));
|
||||
run(&mut s, "view.slices.lock", json!({"locked": true}));
|
||||
run(&mut s, "edit.undoGroup.end", json!({"cancel": true}));
|
||||
assert_eq!(rect_of(&mut s, id)[0], 0.0);
|
||||
assert!(picked(&s).is_empty(), "after the canceled undo group");
|
||||
run(&mut s, "view.slices.lock", json!({"locked": false}));
|
||||
run(&mut s, "object.slice.select", json!({"slices": [id]}));
|
||||
s.begin_interaction("Move Slice").unwrap();
|
||||
s.preview("object.slice.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
run(&mut s, "view.slices.lock", json!({"locked": true}));
|
||||
// A preview that fails puts back the snapshot, without the locked slice.
|
||||
assert!(s.preview("object.slice.move", &json!({"dx": "far", "dy": 0})).is_err());
|
||||
assert!(picked(&s).is_empty(), "after a failed preview");
|
||||
s.cancel_interaction().unwrap();
|
||||
assert_eq!(rect_of(&mut s, id)[0], 0.0);
|
||||
assert!(picked(&s).is_empty(), "after the canceled drag");
|
||||
}
|
||||
|
||||
/// A failed batch, a canceled undo group, a failed drag preview and a canceled drag bring back the
|
||||
/// selection from before them, without the slices that Hide Slices hid inside them.
|
||||
#[test]
|
||||
fn a_failed_batch_a_canceled_undo_group_and_a_canceled_drag_leave_hidden_slices_deselected() {
|
||||
let mut s = session();
|
||||
let id = run(&mut s, "object.slice.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap();
|
||||
let steps = json!([{"command": "view.slices.hide", "params": {"hidden": true}}, {"command": "object.isolate", "params": {}}]);
|
||||
assert!(s.execute("command.batch", &json!({"commands": steps})).is_err());
|
||||
assert!(s.slices_hidden());
|
||||
assert!(picked(&s).is_empty(), "after the failed batch");
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": false}));
|
||||
run(&mut s, "object.slice.select", json!({"slices": [id]}));
|
||||
run(&mut s, "edit.undoGroup.begin", json!({}));
|
||||
run(&mut s, "object.slice.move", json!({"dx": 10, "dy": 0}));
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": true}));
|
||||
run(&mut s, "edit.undoGroup.end", json!({"cancel": true}));
|
||||
assert_eq!(rect_of(&mut s, id)[0], 0.0);
|
||||
assert!(picked(&s).is_empty(), "after the canceled undo group");
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": false}));
|
||||
run(&mut s, "object.slice.select", json!({"slices": [id]}));
|
||||
s.begin_interaction("Move Slice").unwrap();
|
||||
s.preview("object.slice.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": true}));
|
||||
// A preview that fails puts back the snapshot, without the hidden slice.
|
||||
assert!(s.preview("object.slice.move", &json!({"dx": "far", "dy": 0})).is_err());
|
||||
assert!(picked(&s).is_empty(), "after a failed preview");
|
||||
s.cancel_interaction().unwrap();
|
||||
assert_eq!(rect_of(&mut s, id)[0], 0.0);
|
||||
assert!(picked(&s).is_empty(), "after the canceled drag");
|
||||
}
|
||||
|
||||
/// A canceled undo scope that joined an open undo group brings back the selection from when the
|
||||
/// scope began, without the slices that Lock Slices or Hide Slices took out inside it.
|
||||
#[test]
|
||||
fn a_canceled_undo_scope_leaves_locked_or_hidden_slices_deselected() {
|
||||
for (command, key) in [("view.slices.lock", "locked"), ("view.slices.hide", "hidden")] {
|
||||
let mut s = session();
|
||||
let id = run(&mut s, "object.slice.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "object.slice.select", json!({"slices": [id]}));
|
||||
run(&mut s, "edit.undoGroup.begin", json!({}));
|
||||
run(&mut s, "object.slice.move", json!({"dx": 10, "dy": 0}));
|
||||
let scope = s.begin_undo_scope().unwrap();
|
||||
run(&mut s, command, json!({ key: true }));
|
||||
s.end_undo_scope(scope, true);
|
||||
assert!(picked(&s).is_empty(), "{command}: after the canceled scope");
|
||||
let _ = s.execute("object.slice.move", &json!({"dx": 10, "dy": 0}));
|
||||
assert_eq!(rect_of(&mut s, id)[0], 10.0, "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Select slice `id` and the first ruler guide together, through [`Session::select`].
|
||||
fn pick_slice_and_guide(s: &mut Session, id: u64) {
|
||||
s.select(|_, sel| {
|
||||
sel.slices = vec![NodeId(id)];
|
||||
sel.guides = vec![0];
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// With Lock Guides, Lock Slices and Hide Slices all on, Undo and a selection deselect both the
|
||||
/// guide and the slice, which is locked and hidden at once, and the slices stay deselected until
|
||||
/// both Lock Slices and Hide Slices are off.
|
||||
#[test]
|
||||
fn locked_guides_and_a_locked_hidden_slice_are_deselected_together() {
|
||||
let mut s = session();
|
||||
let id = user_slice(&mut s, [0.0, 0.0, 50.0, 50.0]);
|
||||
run(&mut s, "guide.add", json!({"vertical": true, "pos": 100}));
|
||||
pick_slice_and_guide(&mut s, id);
|
||||
// A step whose selection before it holds the guide and the slice, for Undo to bring back.
|
||||
rect(&mut s, 200.0, 200.0, 10.0, 10.0);
|
||||
run(&mut s, "view.guides.lock", json!({"locked": true}));
|
||||
run(&mut s, "view.slices.lock", json!({"locked": true}));
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": true}));
|
||||
let none = |s: &Session| {
|
||||
let sel = &s.doc().unwrap().selection;
|
||||
sel.slices.is_empty() && sel.guides.is_empty()
|
||||
};
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(none(&s), "after Undo");
|
||||
pick_slice_and_guide(&mut s, id);
|
||||
assert!(none(&s), "after a selection");
|
||||
// Shown while still locked, or unlocked while still hidden, the slices stay out.
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": false}));
|
||||
assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 0);
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": true}));
|
||||
run(&mut s, "view.slices.lock", json!({"locked": false}));
|
||||
assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 0);
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": false}));
|
||||
assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 1);
|
||||
}
|
||||
|
||||
/// `slice.list` reports an object slice as selected while its object is selected: the Object →
|
||||
/// Slice commands act on it then, and the canvas draws it as selected.
|
||||
#[test]
|
||||
fn slice_list_reports_an_object_slice_selected_with_its_object() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
|
||||
run(&mut s, "object.slice.make", json!({}));
|
||||
rect(&mut s, 100.0, 100.0, 20.0, 20.0);
|
||||
run(&mut s, "object.slice.make", json!({}));
|
||||
let selected = |s: &mut Session| slices(s).into_iter().filter(|v| v["selected"] == true).map(|v| v["id"].clone()).collect::<Vec<_>>();
|
||||
run(&mut s, "select.set", json!({"ids": [a]}));
|
||||
assert_eq!(selected(&mut s), [json!(a)]);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert!(selected(&mut s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slices_survive_a_native_round_trip() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -256,6 +256,36 @@ fn select_same_appearance_attribute() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]);
|
||||
}
|
||||
|
||||
/// With a member path of a compound path selected first, Select > Same > Graphic Style and
|
||||
/// Appearance Attribute compare the other objects with the compound path, except in isolation mode
|
||||
/// inside it.
|
||||
#[test]
|
||||
fn select_same_style_and_attribute_from_a_compound_path_member() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
set_fill(&mut s, a, "#ff0000");
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.roughen"}));
|
||||
run(&mut s, "graphicStyle.new", json!({"name": "Rough"}));
|
||||
run(&mut s, "graphicStyle.apply", json!({"name": "Rough"}));
|
||||
let outer = rect(&mut s, 100.0);
|
||||
let inner = rect(&mut s, 120.0);
|
||||
run(&mut s, "select.set", json!({"ids": [outer.0, inner.0]}));
|
||||
let c = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "graphicStyle.apply", json!({"name": "Rough"}));
|
||||
rect(&mut s, 300.0);
|
||||
for cmd in ["select.same.graphicStyle", "select.same.appearanceAttribute"] {
|
||||
for from in [c, outer] {
|
||||
run(&mut s, "select.set", json!({"ids": [from.0]}));
|
||||
run(&mut s, cmd, json!({}));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a, c], "{cmd} from {from:?}");
|
||||
}
|
||||
}
|
||||
// Isolated in the compound path, its member paths are compared, and they carry no style.
|
||||
run(&mut s, "object.isolate", json!({"id": c.0}));
|
||||
run(&mut s, "select.set", json!({"ids": [outer.0]}));
|
||||
assert!(s.execute("select.same.graphicStyle", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_info_lists_style_names() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -399,6 +399,18 @@ impl Session {
|
||||
self.tool.wants_ticks()
|
||||
}
|
||||
|
||||
/// Is the active tool waiting for work it runs on another thread ([`Tool::waiting`])?
|
||||
pub fn tool_waiting(&self) -> bool {
|
||||
self.tool.waiting()
|
||||
}
|
||||
|
||||
/// Lets the active tool check on the work [`Session::tool_waiting`] reports, and with `wait`
|
||||
/// wait for it to finish ([`Tool::poll`]).
|
||||
pub fn tool_poll(&mut self, view: ViewInfo, wait: bool) -> Result<()> {
|
||||
self.with_tool_cx(view, |t, cx| t.poll(cx, wait));
|
||||
self.take_tool_panic()
|
||||
}
|
||||
|
||||
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
// 1–4 pick the active perspective plane while the grid is shown.
|
||||
if let Some(plane) = self.plane_key(key) {
|
||||
|
||||
Reference in New Issue
Block a user