mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 15:57:49 +00:00
Merge upstream main into pencil-alt-close-straight
This commit is contained in:
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply as Filter",
|
||||
["Object", "Plug-ins", "Color Effects"],
|
||||
None,
|
||||
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}",
|
||||
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step (none when nothing changes); their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}",
|
||||
has_selection,
|
||||
filter
|
||||
),
|
||||
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Nearest Swatch",
|
||||
["Object", "Plug-ins", "Color Effects"],
|
||||
None,
|
||||
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
|
||||
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step (none when nothing changes): a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
|
||||
has_selection,
|
||||
nearest::apply
|
||||
),
|
||||
|
||||
@@ -72,7 +72,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 (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with its front-most covering source's fill; 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); each covered face starts with its front-most covering source's fill; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}",
|
||||
has_selection_or_ids,
|
||||
lp_make
|
||||
),
|
||||
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Merge",
|
||||
["Object", "Live Paint"],
|
||||
None,
|
||||
"{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}",
|
||||
"{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); a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}",
|
||||
has_multi_or_ids,
|
||||
lp_merge
|
||||
),
|
||||
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gap Options",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color; a hex color with an alpha part, #rrggbbaa or #rgba, is an error), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}",
|
||||
"{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color; a hex color with an alpha part, #rrggbbaa or #rgba, is an error), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint; when every group already has the options and closeGaps finds no gap, nothing changes (no undo step) → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}",
|
||||
has_selection_or_ids,
|
||||
lp_gap_options
|
||||
),
|
||||
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Bucket",
|
||||
[],
|
||||
None,
|
||||
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}",
|
||||
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces; when every face it reaches already has the paint, nothing changes (no undo step, and the recent colors stay as they are) → {group, face}",
|
||||
has_doc,
|
||||
lp_fill
|
||||
),
|
||||
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Bucket (Stroke)",
|
||||
[],
|
||||
None,
|
||||
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point → {group, edge}",
|
||||
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point; when it already has the paint and the width, nothing changes (no undo step, and the recent colors stay as they are) → {group, edge}",
|
||||
has_doc,
|
||||
lp_stroke_edge
|
||||
),
|
||||
|
||||
@@ -52,8 +52,34 @@ fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<
|
||||
Ok((gid, nf, ne))
|
||||
}
|
||||
|
||||
/// Select `ids` alone, without an undo step, when they are not selected that way already.
|
||||
fn select_only(s: &mut Session, ids: &[NodeId]) -> Result<()> {
|
||||
let st = s.doc()?;
|
||||
let mut sel = st.selection.clone();
|
||||
sel.set(ids.iter().copied());
|
||||
if sel != st.selection {
|
||||
s.select(|_, selection| *selection = sel)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make or Merge given one Live Paint group and nothing else has nothing to add to it, so it leaves
|
||||
/// the document as it is, records no undo step and selects the group: → its reply. `None` for any
|
||||
/// other `roots`.
|
||||
fn lone_live_group(s: &mut Session, roots: &[NodeId]) -> Result<Option<Value>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let Some(&g) = roots.first().filter(|g| roots.iter().all(|r| r == *g)) else { return Ok(None) };
|
||||
let Some(n) = d.node(g).filter(|n| is_live_paint(n)) else { return Ok(None) };
|
||||
let reply = json!({ "id": g.0, "faces": b::faces(n).len(), "edges": b::edges(n).len() });
|
||||
select_only(s, &[g])?;
|
||||
Ok(Some(reply))
|
||||
}
|
||||
|
||||
pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = root_ids(s, p)?;
|
||||
if let Some(reply) = lone_live_group(s, &roots)? {
|
||||
return Ok(reply);
|
||||
}
|
||||
let (id, faces, edges) = s.edit("Make Live Paint", |d, sel| make_live(d, sel, &roots))?;
|
||||
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
|
||||
}
|
||||
@@ -130,6 +156,9 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
|
||||
|
||||
pub(super) fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = root_ids(s, p)?;
|
||||
if let Some(reply) = lone_live_group(s, &roots)? {
|
||||
return Ok(reply);
|
||||
}
|
||||
let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| merge_live(d, sel, &roots))?;
|
||||
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
|
||||
}
|
||||
@@ -230,27 +259,58 @@ pub(super) fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let close = switch_or(p, "closeGaps", false, C)?;
|
||||
let targets = live_targets(s, p)?;
|
||||
let with_params = |mut options: GapOptions| {
|
||||
options.detect = detect.unwrap_or(options.detect);
|
||||
options.size = size.unwrap_or(options.size);
|
||||
options.color = color.unwrap_or(options.color);
|
||||
options
|
||||
};
|
||||
// When every group already has these options and Close Gaps With Paths has no gap to close,
|
||||
// nothing changes: no undo step, and the reply is the same.
|
||||
let unchanged = {
|
||||
let d = &s.doc()?.doc;
|
||||
let (mut faces, mut gaps, mut same) = (0, vec![], true);
|
||||
for g in &targets {
|
||||
let old = d.node(*g).ok_or(EngineError::NoNode(*g))?;
|
||||
let options = old.gap_options();
|
||||
if with_params(options) != options {
|
||||
same = false;
|
||||
break;
|
||||
}
|
||||
let open = open_gaps(old, options);
|
||||
if close && !open.is_empty() {
|
||||
same = false;
|
||||
break;
|
||||
}
|
||||
faces += b::faces(old).len();
|
||||
gaps.extend(open);
|
||||
}
|
||||
same.then_some((faces, gaps))
|
||||
};
|
||||
if let Some((faces, gaps)) = unchanged {
|
||||
select_only(s, &targets)?;
|
||||
return Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": 0 }));
|
||||
}
|
||||
let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| {
|
||||
let (mut faces, mut gaps, mut closed) = (0, vec![], 0);
|
||||
let mut changed = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let old = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let mut options = old.gap_options();
|
||||
options.detect = detect.unwrap_or(options.detect);
|
||||
options.size = size.unwrap_or(options.size);
|
||||
options.color = color.unwrap_or(options.color);
|
||||
let options = with_params(old.gap_options());
|
||||
let mut sources = source_nodes(&old);
|
||||
let mut added = 0;
|
||||
if close {
|
||||
// Close Gaps With Paths adds an unpainted line across each gap to the group's paths.
|
||||
for gap in po::find_gaps(&source_shapes(&sources).0, options.size.points()) {
|
||||
let line = PathData::single(SubPath::polyline(&gap, false));
|
||||
sources.push(Arc::new(Node::path(d.alloc_id(), line, Appearance::default())));
|
||||
closed += 1;
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
closed += added;
|
||||
let mut n = old.clone();
|
||||
// The faces change only with what paint stops at, or with new paths.
|
||||
if close || options.max_gap() != old.gap_options().max_gap() {
|
||||
if added > 0 || options.max_gap() != old.gap_options().max_gap() {
|
||||
let (children, _, _) = build_children(d, sources, options.max_gap())?;
|
||||
if let Some(ch) = n.children_mut() {
|
||||
*ch = children;
|
||||
@@ -305,27 +365,9 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
|
||||
let group = object_param(p, "group", C)?;
|
||||
let roots = if group.is_none() { root_ids(s, p)? } else { vec![] };
|
||||
let (g, face) = s.edit("Live Paint Bucket", |d, sel| {
|
||||
let gid = match group {
|
||||
Some(g) => {
|
||||
if !d.node(g).is_some_and(is_live_paint) {
|
||||
return Err(bad(C, format!("{g} is not a Live Paint group")));
|
||||
}
|
||||
g
|
||||
}
|
||||
None => {
|
||||
let live_at = |face: fn(&Node, Point) -> Option<&Arc<Node>>| {
|
||||
roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some()))
|
||||
};
|
||||
match (live_at(b::visible_face_at), live_at(face_at)) {
|
||||
(Some(g), _) => g,
|
||||
// At a point on a hidden face of a Live Paint group among the objects, the command returns an error
|
||||
// and does not merge the objects.
|
||||
(None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())),
|
||||
(None, None) => make_live(d, sel, &roots)?.0,
|
||||
}
|
||||
}
|
||||
};
|
||||
// The faces the click or drag paints in Live Paint group `gid`: the visible face at the point,
|
||||
// and every visible face the drag crosses; → (the face at the point, the faces).
|
||||
let targets_in = |d: &Document, gid: NodeId| -> Result<(NodeId, std::collections::BTreeSet<NodeId>)> {
|
||||
let fid = d
|
||||
.node(gid)
|
||||
.and_then(|n| b::visible_face_at(n, pt))
|
||||
@@ -355,11 +397,58 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
targets.extend(map.hits_along(&chunk, None).regions.iter().filter_map(|i| face_ids.get(*i).copied()));
|
||||
}
|
||||
}
|
||||
for target in targets {
|
||||
d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone());
|
||||
Ok((fid, targets))
|
||||
};
|
||||
// The Live Paint group the fill paints; `None` when the objects are made into one first.
|
||||
let live = {
|
||||
let d = &s.doc()?.doc;
|
||||
match group {
|
||||
Some(g) => {
|
||||
if !d.node(g).is_some_and(is_live_paint) {
|
||||
return Err(bad(C, format!("{g} is not a Live Paint group")));
|
||||
}
|
||||
Some(g)
|
||||
}
|
||||
None => {
|
||||
let live_at = |face: fn(&Node, Point) -> Option<&Arc<Node>>| {
|
||||
roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some()))
|
||||
};
|
||||
match (live_at(b::visible_face_at), live_at(face_at)) {
|
||||
(Some(g), _) => Some(g),
|
||||
// At a point on a hidden face of a Live Paint group among the objects, the command returns an error
|
||||
// and does not merge the objects.
|
||||
(None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((gid, fid))
|
||||
})?;
|
||||
};
|
||||
let (g, face) = match live {
|
||||
Some(gid) => {
|
||||
let (fid, targets) = targets_in(&s.doc()?.doc, gid)?;
|
||||
// Faces that already have the paint stay as they are: when every face has it, nothing
|
||||
// changes and no undo step is recorded.
|
||||
let d = &s.doc()?.doc;
|
||||
let unpainted: Vec<NodeId> = targets.into_iter().filter(|t| d.node(*t).is_none_or(|f| f.appearance.fill_paint() != paint)).collect();
|
||||
if unpainted.is_empty() {
|
||||
return Ok(json!({ "group": gid.0, "face": fid.0 }));
|
||||
}
|
||||
s.edit("Live Paint Bucket", |d, _| {
|
||||
for target in unpainted {
|
||||
d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone());
|
||||
}
|
||||
Ok((gid, fid))
|
||||
})?
|
||||
}
|
||||
None => s.edit("Live Paint Bucket", |d, sel| {
|
||||
let gid = make_live(d, sel, &roots)?.0;
|
||||
let (fid, targets) = targets_in(d, gid)?;
|
||||
for target in targets {
|
||||
d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone());
|
||||
}
|
||||
Ok((gid, fid))
|
||||
})?,
|
||||
};
|
||||
s.remember_paint(&paint);
|
||||
Ok(json!({ "group": g.0, "face": face.0 }))
|
||||
}
|
||||
@@ -375,9 +464,22 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "width out of range"));
|
||||
}
|
||||
let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0);
|
||||
let edge = s.edit("Live Paint Bucket", |d, _| {
|
||||
let (eid, unchanged) = {
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?;
|
||||
let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
|
||||
let e = b::visible_edge_near(n, pt, tol).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
|
||||
// An edge that already has the paint and the width (or has no stroke and gets none) stays as
|
||||
// it is, and no undo step is recorded.
|
||||
let unchanged = match e.appearance.stroke() {
|
||||
Some(st) => st.paint == paint && st.width == width,
|
||||
None => paint.is_none(),
|
||||
};
|
||||
(e.id, unchanged)
|
||||
};
|
||||
if unchanged {
|
||||
return Ok(json!({ "group": g.0, "edge": eid.0 }));
|
||||
}
|
||||
let edge = s.edit("Live Paint Bucket", |d, _| {
|
||||
let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?;
|
||||
e.appearance.set_stroke(paint.clone());
|
||||
if let Some(st) = e.appearance.stroke_mut() {
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Invert Colors",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
|
||||
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
|
||||
has_selection,
|
||||
|s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert)
|
||||
),
|
||||
@@ -104,7 +104,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Front to Back",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md) → {changed}",
|
||||
"{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md); no undo step when no fill changes → {changed}",
|
||||
has_selection,
|
||||
|s, _| blend(s, BlendOrder::Stack)
|
||||
),
|
||||
@@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Horizontally",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{} ≥3 filled objects or meshes: fills graded between the leftmost and rightmost → {changed}",
|
||||
"{} ≥3 filled objects or meshes: fills graded between the leftmost and rightmost; no undo step when no fill changes → {changed}",
|
||||
has_selection,
|
||||
|s, _| blend(s, BlendOrder::Horizontal)
|
||||
),
|
||||
@@ -122,7 +122,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Vertically",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{} ≥3 filled objects or meshes: fills graded between the topmost and bottommost → {changed}",
|
||||
"{} ≥3 filled objects or meshes: fills graded between the topmost and bottommost; no undo step when no fill changes → {changed}",
|
||||
has_selection,
|
||||
|s, _| blend(s, BlendOrder::Vertical)
|
||||
),
|
||||
@@ -457,8 +457,8 @@ pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str,
|
||||
run_pass(s, p, cmd, label, Recolor::with_links(Scope::of(p, cmd)?, f, links))
|
||||
}
|
||||
|
||||
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`;
|
||||
/// errors name the command `cmd`. The reply's `notChanged` counts the locked and hidden objects the
|
||||
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`,
|
||||
/// or none when the pass changes nothing; errors name the command `cmd`. The reply's `notChanged` counts the locked and hidden objects the
|
||||
/// selection left out ([`left_out`]) that the pass would have changed.
|
||||
fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -> Result<Value> {
|
||||
let (ids, left) = match ids_param(p, "ids", cmd)? {
|
||||
@@ -471,20 +471,47 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -
|
||||
}
|
||||
};
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let (n, not_changed) = s.edit(label, |d, _| {
|
||||
let (n, not_changed) = edit_if_changed(s, label, |_, d| {
|
||||
let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden));
|
||||
let n = keeping_series_paint(d, &ids, |d| {
|
||||
let n = pass.run(d, &ids);
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?;
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok((n, not_changed))
|
||||
Ok(((n, not_changed), n > 0))
|
||||
})?;
|
||||
let mut out = json!({ "changed": n });
|
||||
not_changed.report(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`Session::edit`] for an Edit Colors pass, which may change nothing: `f` runs on a copy of the
|
||||
/// document (it gets the document as it is too) and returns its result with whether it changed the
|
||||
/// copy. Only a changed copy becomes the document, as one undo step named `label`; otherwise the
|
||||
/// undo and redo steps and the saved state stay as they were.
|
||||
pub(crate) fn edit_if_changed<T>(s: &mut Session, label: &str, f: impl FnOnce(&Document, &mut Document) -> Result<(T, bool)>) -> Result<T> {
|
||||
if s.script_running() {
|
||||
return Err(EngineError::Busy);
|
||||
}
|
||||
// A typing session ends here as `s.edit` would end it, before the copy is taken, so that the
|
||||
// copy holds what was typed.
|
||||
if !s.previewing && super::typing_in_progress(s) {
|
||||
super::settle_interaction(s)?;
|
||||
}
|
||||
let before = s.doc()?.doc.clone();
|
||||
let mut doc = (*before).clone();
|
||||
let (v, changed) = f(&before, &mut doc)?;
|
||||
// The document is shared with `before` no longer, so `s.edit` does not copy it again.
|
||||
drop(before);
|
||||
if changed {
|
||||
s.edit(label, |d, _| {
|
||||
*d = doc;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Run `recolor` on `d` (it returns how many objects it changed) over `roots`, then store on their graphs what it changed
|
||||
/// on the graph series marks among `roots` and inside them, so the marks keep their new paint when the graph is drawn
|
||||
/// again.
|
||||
@@ -597,11 +624,6 @@ fn pass_targets(s: &Session, p: &Value, cmd: &str) -> Result<Targets> {
|
||||
fn to_indexed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "edit.colors.toIndexed";
|
||||
let indexing = Indexing::of(s, p)?;
|
||||
// A typing session ends here as `s.edit` would end it, before the copy is taken, so that the
|
||||
// copy holds what was typed.
|
||||
if !s.previewing && super::typing_in_progress(s) {
|
||||
super::settle_interaction(s)?;
|
||||
}
|
||||
let remake = |b: &ImageBlob| indexing.convert(b);
|
||||
let images = Scope { fill: false, stroke: false, images: true, patterns: false };
|
||||
let pass = Recolor { remake: Some(&remake), ..Recolor::new(images, &same) };
|
||||
@@ -609,22 +631,18 @@ fn to_indexed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// indexed PNG yet; it is not converted to find out. The empty PNG only gives it a new key.
|
||||
let would = |b: &ImageBlob| (!b.indexed_png() && b.pixel_size().is_some()).then(|| ImageBlob::png(vec![]));
|
||||
let (ids, left) = pass_targets(s, p, C)?;
|
||||
let st = s.doc()?;
|
||||
let colorized_ok = super::paint::check_colorized(&st.doc).is_ok();
|
||||
let not_changed = NotChanged::of(
|
||||
Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(&st.doc, &left).into_iter().map(|(_, hidden)| hidden),
|
||||
);
|
||||
let mut doc = (*st.doc).clone();
|
||||
let n = pass.run(&mut doc, &ids);
|
||||
if let Some((w, h)) = indexing.too_large.get() {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS)));
|
||||
}
|
||||
if n > 0 {
|
||||
s.edit("Convert to Indexed Color", |d, _| {
|
||||
*d = doc;
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))
|
||||
})?;
|
||||
}
|
||||
let (n, not_changed) = edit_if_changed(s, "Convert to Indexed Color", |now, doc| {
|
||||
let colorized_ok = super::paint::check_colorized(now).is_ok();
|
||||
let not_changed = NotChanged::of(
|
||||
Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(now, &left).into_iter().map(|(_, hidden)| hidden),
|
||||
);
|
||||
let n = pass.run(doc, &ids);
|
||||
if let Some((w, h)) = indexing.too_large.get() {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS)));
|
||||
}
|
||||
super::paint::check_colorized_kept(colorized_ok, doc).map_err(|e| bad(C, e))?;
|
||||
Ok(((n, not_changed), n > 0))
|
||||
})?;
|
||||
let mut out = json!({ "changed": n });
|
||||
not_changed.report(&mut out);
|
||||
Ok(out)
|
||||
@@ -923,7 +941,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?;
|
||||
Ok(changed)
|
||||
};
|
||||
let changed = s.edit(label, |d, _| keeping_series_paint(d, &ids, run))?;
|
||||
let changed = edit_if_changed(s, label, |_, d| keeping_series_paint(d, &ids, run).map(|n| (n, n > 0)))?;
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
||||
|
||||
@@ -781,7 +781,10 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the first of two
|
||||
// brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. A row names the type
|
||||
// as the New Brush dialog does (`Art Brush`), and the panel translates these values when it shows them.
|
||||
let kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = library.iter().map(|b| (b.name.as_str(), &b.kind)).collect();
|
||||
let mut kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = HashMap::new();
|
||||
for b in &library {
|
||||
kinds.entry(b.name.as_str()).or_insert(&b.kind);
|
||||
}
|
||||
let brush_entries: Vec<Value> =
|
||||
brushes.iter().map(|name| json!({ "name": name, "type": kinds.get(name.as_str()).map(|k| k.type_id()) })).collect();
|
||||
let brush_rows: Vec<(String, String)> = brushes
|
||||
|
||||
@@ -25,7 +25,7 @@ use super::dimension::EndedLink;
|
||||
use super::edit::selected_roots;
|
||||
use super::menucmds::{run_raw, squash};
|
||||
use super::object::as_clipping_path;
|
||||
use super::pathops::{assemble, node_path, outline_strokes, replace_leaves, styled_copy, with_transparency};
|
||||
use super::pathops::{assemble, node_path, outlinable, outline_strokes, replace_leaves, styled_copy, with_transparency};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_selection, number_or, switch_or, text_param};
|
||||
|
||||
const C: &str = "object.expand";
|
||||
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines (its own fills and strokes on paths of its whole outline, which Fill and Stroke expand), live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Options",
|
||||
[],
|
||||
None,
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands (in type, on the outline Object makes of a character), which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)",
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands (in type, on the outline Object makes of a character, or of the whole type for the type object's own fills), which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let roots = selected_roots(s)?;
|
||||
@@ -189,21 +189,37 @@ impl Expandable {
|
||||
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
|
||||
e.repeats |= matches!(c.kind, NodeKind::Repeat(_));
|
||||
e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
|
||||
// Type's outlines take its characters' stroke and fill, and a path of its whole outline
|
||||
// takes the type object's own fills and strokes (see `type.createOutlines`).
|
||||
// Any stroke Outline Stroke outlines, not only the topmost one.
|
||||
let own_stroke = c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st)));
|
||||
let own_pattern = c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some());
|
||||
let own_gradient = c.appearance.items.iter().any(|i| gradient_fill(i).is_some());
|
||||
// Type without an outline (empty, or only spaces) gets no path for its own fills and strokes.
|
||||
let whole = match &c.kind {
|
||||
NodeKind::Text(t) if own_stroke || own_pattern || own_gradient => text_whole_outline(t),
|
||||
_ => None,
|
||||
};
|
||||
e.stroke |= match &c.kind {
|
||||
// Type's outlines take its characters' stroke (see `type.createOutlines`).
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()),
|
||||
_ => shape && !c.appearance.stroke_paint().is_none(),
|
||||
NodeKind::Text(t) => (own_stroke && whole.is_some()) || t.runs.iter().any(|r| r.style.has_stroke()),
|
||||
_ => shape && own_stroke,
|
||||
};
|
||||
// Once one is found, the next path is not measured.
|
||||
e.gradient = e.gradient
|
||||
|| match &c.kind {
|
||||
NodeKind::Text(t) => text_gradient_expands(d, t),
|
||||
NodeKind::Text(t) => {
|
||||
text_gradient_expands(d, t)
|
||||
|| (own_gradient && whole.as_ref().is_some_and(|(path, tb)| text_object_gradient_expands(d, c, path, *tb)))
|
||||
}
|
||||
_ => shape && gradient_expands(d, c),
|
||||
};
|
||||
e.fill |= e.gradient;
|
||||
e.fill |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())),
|
||||
_ => shape && c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()),
|
||||
NodeKind::Text(t) => {
|
||||
(own_pattern && whole.is_some())
|
||||
|| t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some()))
|
||||
}
|
||||
_ => shape && own_pattern,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -275,6 +291,33 @@ fn text_gradient_expands(d: &Document, t: &TextObject) -> bool {
|
||||
|| vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t).iter().any(|(run, bar)| expands(*run, bar))
|
||||
}
|
||||
|
||||
/// The path of type `t`'s whole outline in the document (glyphs, ruby glyphs and underline and
|
||||
/// strikethrough bars), which `type.createOutlines` paints with the type object's own fills and
|
||||
/// strokes, and the layout's bounds there; `None` when the type has no outline.
|
||||
fn text_whole_outline(t: &TextObject) -> Option<(PathData, Rect)> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let lay = vectorcraft_text::layout(db, t);
|
||||
let mut all = BezPath::new();
|
||||
for g in lay.glyphs.iter().chain(&lay.ruby) {
|
||||
all.extend(g.outline.iter());
|
||||
}
|
||||
for (_, bar) in vectorcraft_text::decorations(&lay, db, t) {
|
||||
all.extend(bar.iter());
|
||||
}
|
||||
let path = PathData::from_bezpath(&all).transformed(t.xf);
|
||||
(!path.is_empty()).then(|| (path, t.xf.transform_rect_bbox(lay.bounds)))
|
||||
}
|
||||
|
||||
/// Whether type node `n` has a gradient fill of its own (not its characters') that Fill expands
|
||||
/// once Object has outlined it: [`gradient_expands`] on `path`, the type's whole outline
|
||||
/// ([`text_whole_outline`]), with its unplaced gradients fixed to `tb`, the layout's bounds in the
|
||||
/// document. Leaves the document as it is.
|
||||
fn text_object_gradient_expands(d: &Document, n: &Node, path: &PathData, tb: Rect) -> bool {
|
||||
let mut appearance = Appearance { items: n.appearance.items.clone(), ..Default::default() };
|
||||
appearance.pin_gradients(tb);
|
||||
gradient_expands(d, &Node::path(NodeId(u64::MAX), path.clone(), appearance))
|
||||
}
|
||||
|
||||
/// [`gradient_expands`] for a path that no live-effect plug-in paints ([`expand_piece_fills`]).
|
||||
fn piece_gradient_expands(d: &Document, l: &Node) -> bool {
|
||||
if !l.appearance.items.iter().filter_map(gradient_fill).any(|(_, g)| g.gradient.is_spread()) {
|
||||
|
||||
@@ -6182,3 +6182,66 @@ fn graph_commands_refuse_params_of_the_wrong_type() {
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, null], [2, 3]], "series": null, "transpose": null})).unwrap();
|
||||
assert_eq!(spec(&s).cells(), vec![vec![Some(1.0), None], vec![Some(2.0), Some(3.0)]]);
|
||||
}
|
||||
|
||||
/// A `.ai` in PostScript form whose graph's records read whole (version 7 specification, appendix A)
|
||||
/// opens as a graph whose data can be edited: Graph Data redraws it on its plot. Saving it as `.ai`
|
||||
/// says its data and settings aren't written, in either form.
|
||||
#[test]
|
||||
fn a_graph_opened_from_ai_keeps_its_data_for_editing() {
|
||||
let graph = "%_Gs\n%_100 200 300 100 Gb\n%_5 0 0 14 0 1 0 0 6 44 Gy\n%_10 2 0 0 90 80 0 Gd\n\
|
||||
%_1 () Ga\n%_0 13 0 0 0 0 0 () GA\n%_2 () Ga\n%_0 14 0 0 0 0 0 () GA\n%_4 () Ga\n%_0 14 0 0 0 0 0 () GA\n\
|
||||
%_2 2 1 1 Gz\n%_() Gc (East) Gc (Q1) Gc 5 Gc GC\n\
|
||||
u\nu\n0.6 0 1 0 k\n110 100 m\n110 150 L\n130 150 L\n130 100 L\nf\nU\n%_2 1 0 0 Go\n\
|
||||
u\n0 0 0 1 K\n100 200 m\n100 100 L\n300 100 L\nS\nU\nU\n%_GS\n";
|
||||
let text = format!(
|
||||
"%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 400 400\n%AI5_FileFormat 3\n%AI3_Cropmarks: 0 0 400 400\n%%EndComments\n\
|
||||
%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Graph) Ln\n0 A\n{graph}LB\n\
|
||||
%AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n"
|
||||
);
|
||||
let dir = vectorcraft_testkit::temp_dir("graph-ai-open");
|
||||
let path = dir.join("graph.ai");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
let mut found = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if n.extras().graph.is_some() {
|
||||
found.push(n.id);
|
||||
}
|
||||
});
|
||||
assert_eq!(found.len(), 1);
|
||||
let id = found[0];
|
||||
let before = spec(&s, id);
|
||||
assert_eq!(
|
||||
(before.series.as_slice(), before.categories.as_slice(), before.rows.as_slice()),
|
||||
(&["East".to_string()][..], &["Q1".to_string()][..], &[vec![5.0]][..])
|
||||
);
|
||||
// The fill of the series' first mark.
|
||||
let series_fill = |s: &Session| {
|
||||
let mut fill = None;
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if n.extras().series_index == Some(0) && fill.is_none() {
|
||||
let first = n.children().and_then(|c| c.first().cloned());
|
||||
fill = first.and_then(|m| m.appearance.items.iter().find(|i| i.is_fill()).map(|i| i.paint().clone()));
|
||||
}
|
||||
});
|
||||
fill
|
||||
};
|
||||
let painted = series_fill(&s);
|
||||
assert!(painted.is_some());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "csv": ",East\nQ1,8\nQ2,2"})).unwrap();
|
||||
let after = spec(&s, id);
|
||||
assert_eq!(after.rows, [[8.0], [2.0]]);
|
||||
assert_eq!(after.rect, before.rect, "the graph is redrawn on its plot");
|
||||
assert_eq!(series_fill(&s), painted, "the series keeps the file's paint");
|
||||
for (name, version) in [("v7.ai", Some("7")), ("pdf.ai", None)] {
|
||||
let mut p = json!({"path": dir.join(name), "format": "ai"});
|
||||
if let Some(v) = version {
|
||||
p["aiVersion"] = json!(v);
|
||||
}
|
||||
let r = s.execute("file.saveAs", &p).unwrap();
|
||||
let notes = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Invert",
|
||||
[],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md) → {changed}",
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md); no undo step when no color changes → {changed}",
|
||||
has_doc,
|
||||
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
|
||||
),
|
||||
@@ -105,7 +105,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Complement",
|
||||
[],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default → {changed}",
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default; no undo step when no color changes → {changed}",
|
||||
has_doc,
|
||||
|s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model())
|
||||
),
|
||||
|
||||
@@ -962,7 +962,7 @@ pub(crate) fn ordered_map<T: Sync, R: Send>(
|
||||
// ---------- Outline Stroke ----------
|
||||
|
||||
/// Can Outline Stroke turn `st` into filled art?
|
||||
fn outlinable(st: &StrokeLayer) -> bool {
|
||||
pub(crate) fn outlinable(st: &StrokeLayer) -> bool {
|
||||
st.visible && !st.paint.is_none() && st.width > 0.0
|
||||
}
|
||||
|
||||
|
||||
@@ -793,7 +793,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Preferences",
|
||||
[],
|
||||
None,
|
||||
"{path?} write the preferences as a JSON file {format: \"vectorcraft-preferences\", version: 1, prefs: {key: value, …}}: every preference prefs.list lists and the eyedropper, perspectiveWidget, pathfinder, glyphSnap and pixelSnap groups, without the folder preferences fontsFolder, pluginsFolder, recoveryFolder and templatesFolder and without saved presets; a file over 1 MB, which prefs.import would refuse, is an error and is not written → {path, count}; without path → {data: the file's text, count}",
|
||||
"{path?} write the preferences as a JSON file {format: \"vectorcraft-preferences\", version: 1, prefs: {key: value, …}}: every preference prefs.list lists and the eyedropper, perspectiveWidget, pathfinder, glyphSnap and pixelSnap groups, without the folder preferences fontsFolder, pluginsFolder, scratchPrimary, scratchSecondary, recoveryFolder and templatesFolder and without saved presets; a file over 1 MB, which prefs.import would refuse, is an error and is not written → {path, count}; without path → {data: the file's text, count}",
|
||||
always,
|
||||
export
|
||||
),
|
||||
@@ -802,7 +802,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Preferences",
|
||||
[],
|
||||
None,
|
||||
"{path? | data?: the file's text} apply the preferences of a file prefs.export wrote (at most 1 MB). Every value is checked first, as prefs.set checks it: another format, a newer version, an unknown preference, a value of the wrong type or out of range, or a folder preference is an error, and nothing changes. Preferences the file leaves out keep their values; the open document's units stay as they are; no undo step; not a command.batch step → {imported: count}",
|
||||
"{path? | data?: the file's text} apply the preferences of a file prefs.export wrote (at most 1 MB). Every value is checked first, as prefs.set checks it: another format, a newer version, an unknown preference, a value of the wrong type or out of range, or a folder preference (for scratchPrimary and scratchSecondary, a folder; the choices Startup and None are read) is an error, and nothing changes. Preferences the file leaves out keep their values; the open document's units stay as they are; no undo step; not a command.batch step → {imported: count}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
@@ -818,8 +818,20 @@ pub const PREFS_VERSION: u64 = 1;
|
||||
/// The largest preferences file `prefs.import` reads.
|
||||
const MAX_PREFS_FILE_BYTES: usize = 1 << 20;
|
||||
|
||||
/// Folder preferences, which name folders on one computer: a preferences file leaves them out.
|
||||
const FOLDER_PREFS: &[&str] = &["fontsFolder", "pluginsFolder", "recoveryFolder", "templatesFolder"];
|
||||
/// Folder preferences, which name folders on one computer: a preferences file leaves them out, and
|
||||
/// under automation read roots one outside them is not shown.
|
||||
pub const FOLDER_PREFS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
|
||||
|
||||
/// The scratch disk choices that are not folders.
|
||||
pub const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"];
|
||||
|
||||
/// Does `value`, set as preference `key`, name a folder: is `key` a folder preference
|
||||
/// ([`FOLDER_PREFS`]) and `value`, trimmed as the app uses it, neither empty (the default folder)
|
||||
/// nor a scratch disk choice ([`SCRATCH_CHOICES`])?
|
||||
pub fn names_a_folder(key: &str, value: &str) -> bool {
|
||||
let path = value.trim();
|
||||
FOLDER_PREFS.contains(&key) && !path.is_empty() && !(key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path))
|
||||
}
|
||||
|
||||
/// The preferences `prefs.export` writes: every [`PREF_SPECS`] key but the [`FOLDER_PREFS`], and
|
||||
/// the [`PREF_GROUPS`].
|
||||
@@ -868,7 +880,9 @@ fn read_prefs_file(bytes: &[u8]) -> std::result::Result<Map<String, Value>, Stri
|
||||
if prefs.is_empty() {
|
||||
return Err("the file holds no preferences".into());
|
||||
}
|
||||
if let Some(key) = prefs.keys().find(|k| FOLDER_PREFS.contains(&k.as_str())) {
|
||||
// A scratch disk choice or an empty scratch disk names no folder, and is read.
|
||||
let folder = |k: &str, v: &Value| FOLDER_PREFS.contains(&k) && (!k.starts_with("scratch") || v.as_str().is_some_and(|v| names_a_folder(k, v)));
|
||||
if let Some(key) = prefs.iter().find(|(k, v)| folder(k, v)).map(|(k, _)| k) {
|
||||
return Err(format!("`{key}` names a folder, which preferences files leave out"));
|
||||
}
|
||||
Ok(prefs.clone())
|
||||
@@ -893,14 +907,8 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "imported": values.len() }))
|
||||
}
|
||||
|
||||
/// The preferences that name a folder: the [`FOLDER_PREFS`] and the scratch disks.
|
||||
const PATH_PREFS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
|
||||
|
||||
/// The scratch disk choices that are not folders.
|
||||
const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"];
|
||||
|
||||
/// The preferences as `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show them to the
|
||||
/// request being served. Under automation read roots, a folder preference ([`PATH_PREFS`]) outside
|
||||
/// request being served. Under automation read roots, a folder preference ([`FOLDER_PREFS`]) outside
|
||||
/// them is `null`, as `ui.inspect` leaves it out, and an export preset's `folder` outside them is
|
||||
/// left out. An empty value (the default folder) and a scratch
|
||||
/// disk choice ([`SCRATCH_CHOICES`]) are shown. Without roots the preferences are shown as they are.
|
||||
@@ -911,7 +919,7 @@ fn shown_prefs(prefs: &Prefs) -> Value {
|
||||
}
|
||||
// The app trims a folder before it uses it.
|
||||
let hidden = |path: &str| !path.trim().is_empty() && crate::file_access::check_read(path.trim()).is_err();
|
||||
for key in PATH_PREFS {
|
||||
for key in FOLDER_PREFS {
|
||||
let Some(path) = all[key].as_str() else { continue };
|
||||
if hidden(path) && !(key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path.trim())) {
|
||||
all[key] = Value::Null;
|
||||
|
||||
@@ -15,7 +15,7 @@ use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use super::colorcmds::{NotChanged, Recolor, Scope, keeping_series_paint, left_out};
|
||||
use super::colorcmds::{NotChanged, Recolor, Scope, edit_if_changed, keeping_series_paint, left_out};
|
||||
use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -34,7 +34,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 (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"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, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} 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. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {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), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}",
|
||||
"{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 (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"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, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} 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 (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {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), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}",
|
||||
has_doc,
|
||||
apply
|
||||
),
|
||||
@@ -1661,7 +1661,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let rename = text_param(p, "rename", C)?;
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let (changed, edit, added, converted, not_changed) = s.edit(label, |d, _| {
|
||||
let (changed, edit, added, converted, not_changed) = edit_if_changed(s, label, |now, d| {
|
||||
// The rows' library swatches join the document (one it has with that name and colour is
|
||||
// used as it is).
|
||||
let mut added = vec![];
|
||||
@@ -1716,7 +1716,10 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let not_changed = NotChanged::of(
|
||||
before.iter().filter(|(id, colors, _)| d.node(*id).is_some_and(|now| shown_colors(d, now) == *colors)).map(|(_, _, hidden)| *hidden),
|
||||
);
|
||||
Ok((changed, edit, added, map.converted.get(), not_changed))
|
||||
// Rewriting the group's swatches and relinking its art are not counted in `changed`, so a
|
||||
// pass that counts nothing compares the documents.
|
||||
let any = changed > 0 || !added.is_empty() || *d != *now;
|
||||
Ok(((changed, edit, added, map.converted.get(), not_changed), any))
|
||||
})?;
|
||||
let mut out = json!({ "changed": changed, "swatches": added });
|
||||
not_changed.report(&mut out);
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, AppearanceItem, AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, PathData, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
@@ -39,7 +41,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); a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}",
|
||||
"{} convert the selected text to groups of compound paths (one per glyph); the type object's own fills and strokes (not its characters') go on paths of its whole outline, below or above the glyphs as its Characters row places them; a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}",
|
||||
has_selection,
|
||||
create_outlines
|
||||
),
|
||||
@@ -672,13 +674,39 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
placed.extend(ruby.map(|(_, r)| r));
|
||||
let mut children = placed;
|
||||
// The whole outline the object's own fills and strokes paint: every glyph, ruby glyph
|
||||
// and bar, as the canvas paints them.
|
||||
let mut all = kurbo::BezPath::new();
|
||||
for g in lay.glyphs.iter().chain(&lay.ruby) {
|
||||
all.extend(g.outline.iter());
|
||||
}
|
||||
// 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) {
|
||||
all.extend(bar.iter());
|
||||
let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
|
||||
let mut node = shape_node(d, PathData::from_bezpath(&bar).transformed(t.xf), None);
|
||||
node.appearance = st.appearance().into();
|
||||
children.push(Arc::new(node));
|
||||
}
|
||||
// The object's own fills and strokes (not its characters') paint the whole outline:
|
||||
// those below the Characters row under the characters, the others over them, with
|
||||
// their unplaced gradients fixed to the layout's bounds in the document.
|
||||
let all = PathData::from_bezpath(&all).transformed(t.xf);
|
||||
if !n.appearance.items.is_empty() && !all.is_empty() {
|
||||
let (below, above) = n.appearance.split_contents();
|
||||
let tb = t.xf.transform_rect_bbox(lay.bounds);
|
||||
let mut whole = |items: &[AppearanceItem]| {
|
||||
let mut ap = Appearance { items: items.to_vec(), ..Default::default() };
|
||||
ap.pin_gradients(tb);
|
||||
Arc::new(Node::path(d.alloc_id(), all.clone(), ap))
|
||||
};
|
||||
if !below.is_empty() {
|
||||
children.insert(0, whole(below));
|
||||
}
|
||||
if !above.is_empty() {
|
||||
children.push(whole(above));
|
||||
}
|
||||
}
|
||||
let (par, idx, _) = d.position(tid).ok_or(EngineError::NoNode(tid))?;
|
||||
d.remove(tid)?;
|
||||
if children.is_empty() {
|
||||
|
||||
@@ -2446,6 +2446,193 @@ fn live_paint_stroke_edge() {
|
||||
assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [300, 300]})).is_err());
|
||||
}
|
||||
|
||||
/// The undo and redo depths, the unsaved state and the revision of the active document.
|
||||
fn history_state(s: &Session) -> (usize, usize, bool, u64) {
|
||||
let st = s.doc().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty(), st.revision)
|
||||
}
|
||||
|
||||
/// Draw a rectangle and undo it, so a redo step is waiting, then mark the document saved with `g`
|
||||
/// alone selected.
|
||||
fn redo_waiting_and_saved(s: &mut Session, g: NodeId) {
|
||||
rect(s, 300.0, 300.0, 10.0, 10.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
}
|
||||
|
||||
/// The active document and its [`history_state`].
|
||||
type Snapshot = (vectorcraft_doc::Document, (usize, usize, bool, u64));
|
||||
|
||||
fn snapshot(s: &Session) -> Snapshot {
|
||||
((*s.doc().unwrap().doc).clone(), history_state(s))
|
||||
}
|
||||
|
||||
/// After a Live Paint edit that changed nothing: the document, the undo and redo steps, the saved
|
||||
/// state and the revision are as `before`, and Redo still brings back the rectangle
|
||||
/// [`redo_waiting_and_saved`] undid. Undoes the rectangle again, marks the document saved and
|
||||
/// takes a new `before`.
|
||||
fn assert_unchanged(s: &mut Session, before: &mut Snapshot, what: &str) {
|
||||
assert!(*s.doc().unwrap().doc == before.0, "{what}: the document is as it was");
|
||||
assert_eq!(history_state(s), before.1, "{what}: no undo step, the redo step kept, still saved");
|
||||
let children = layer_children(s);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(layer_children(s), children + 1, "{what}: Redo brings back the rectangle");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
*before = snapshot(s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_fill_same_paint_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let drag = json!({"group": g.0, "point": [25, 50], "points": [[25, 50], [125, 50]], "color": "#00ff00"});
|
||||
s.execute("livePaint.fill", &drag).unwrap();
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
// A color used since stays the newest recent color and the last color.
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
s.remember_paint_now(&Paint::solid(blue));
|
||||
let recent = (s.recent_colors.clone(), s.last_solid);
|
||||
let mut before = snapshot(&s);
|
||||
let click = s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "color": "#00ff00"})).unwrap();
|
||||
let face = vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(75.0, 50.0)).unwrap().id;
|
||||
assert_eq!(click, json!({"group": g.0, "face": face.0}), "the usual reply");
|
||||
assert_unchanged(&mut s, &mut before, "a click on a face with the paint");
|
||||
s.execute("livePaint.fill", &drag).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "a drag over faces with the paint");
|
||||
// Without a group, the click finds the Live Paint group among the selected objects.
|
||||
s.execute("livePaint.fill", &json!({"point": [125, 50], "color": "#00ff00"})).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "a click in the selected group");
|
||||
assert_eq!((s.recent_colors.clone(), s.last_solid), recent, "the recent colors are as they were");
|
||||
// A drag that reaches one face without the paint is one undo step and remembers the color.
|
||||
let red = Paint::solid(Color::from_hex("#ff0000").unwrap());
|
||||
for x in [25, 75] {
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [x, 50], "color": "#ff0000"})).unwrap();
|
||||
}
|
||||
s.remember_paint_now(&Paint::solid(blue));
|
||||
let depth = s.doc().unwrap().history.undo.len();
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "points": [[25, 50], [125, 50]], "color": "#ff0000"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), depth + 1);
|
||||
assert_eq!(Paint::solid(s.last_solid), red);
|
||||
let gn = node(&s, g);
|
||||
for x in [25.0, 75.0, 125.0] {
|
||||
assert_eq!(vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().appearance.fill_paint(), red, "{x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_bucket_click_with_the_face_paint_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0));
|
||||
let click = |s: &mut Session, x: f64| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, x, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, x, 50.0), v).unwrap();
|
||||
};
|
||||
click(&mut s, 125.0);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
click(&mut s, 125.0);
|
||||
assert_unchanged(&mut s, &mut before, "a Bucket click on a face with the paint");
|
||||
// A drag over faces with the paint changes nothing either.
|
||||
click(&mut s, 25.0);
|
||||
click(&mut s, 75.0);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 125.0, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "a Bucket drag over faces with the paint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_stroke_edge_same_paint_and_width_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let call = json!({"group": g.0, "point": [100, 50], "color": "#ff0000", "width": 3});
|
||||
let first = s.execute("livePaint.strokeEdge", &call).unwrap();
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
s.remember_paint_now(&Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||||
let last = s.last_solid;
|
||||
let mut before = snapshot(&s);
|
||||
assert_eq!(s.execute("livePaint.strokeEdge", &call).unwrap(), first, "the usual reply");
|
||||
assert_unchanged(&mut s, &mut before, "an edge with the paint and the width");
|
||||
assert_eq!(s.last_solid, last, "the last color is as it was");
|
||||
// Another width is an edit.
|
||||
s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100, 50], "color": "#ff0000", "width": 2})).unwrap();
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_gap_options_same_values_are_no_op() {
|
||||
let mut s = session();
|
||||
let g = live_square_with_a_gap(&mut s);
|
||||
let set = json!({"detect": true, "size": "small", "color": "#123456"});
|
||||
let first = s.execute("livePaint.gapOptions", &set).unwrap();
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
assert_eq!(s.execute("livePaint.gapOptions", &set).unwrap(), first, "the usual reply");
|
||||
assert_unchanged(&mut s, &mut before, "the options the group has");
|
||||
s.execute("livePaint.gapOptions", &json!({})).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "no options given");
|
||||
// Close Gaps With Paths with a gap to close is an edit.
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"closeGaps": true})).unwrap();
|
||||
assert_eq!(r["closed"], 1);
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_gap_options_close_without_gaps_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"id": g.0, "closeGaps": true})).unwrap();
|
||||
assert_eq!((ids(&r), &r["faces"], &r["closed"], gaps_of(&r).len()), (vec![g], &json!(3), &json!(0), 0));
|
||||
assert_unchanged(&mut s, &mut before, "no gap to close");
|
||||
// With other options, the faces keep their ids when no path is added.
|
||||
let faces = |s: &Session| vectorcraft_tools::builder::faces(&node(s, g)).iter().map(|f| f.id).collect::<Vec<_>>();
|
||||
let ids_before = faces(&s);
|
||||
s.execute("livePaint.gapOptions", &json!({"id": g.0, "closeGaps": true, "color": "#00ff00"})).unwrap();
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
assert_eq!(faces(&s), ids_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_make_existing_group_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
let r = s.execute("livePaint.make", &json!({})).unwrap();
|
||||
assert_eq!((NodeId(r["id"].as_u64().unwrap()), &r["faces"]), (g, &json!(3)));
|
||||
assert_eq!(r["edges"].as_u64().unwrap(), vectorcraft_tools::builder::edges(&node(&s, g)).len() as u64);
|
||||
assert_unchanged(&mut s, &mut before, "Make with the group alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_merge_single_group_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let other = rect(&mut s, 300.0, 0.0, 50.0, 50.0);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
// Given by id, the group is selected without an undo step.
|
||||
s.execute("select.set", &json!({"ids": [other.0]})).unwrap();
|
||||
let before = snapshot(&s);
|
||||
let r = s.execute("livePaint.merge", &json!({"ids": [g.0]})).unwrap();
|
||||
assert_eq!((NodeId(r["id"].as_u64().unwrap()), &r["faces"]), (g, &json!(3)));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
assert!(*s.doc().unwrap().doc == before.0);
|
||||
let (undo, redo, dirty, _) = history_state(&s);
|
||||
assert_eq!((undo, redo, dirty), (before.1.0, before.1.1, before.1.2));
|
||||
// A path to add is an edit.
|
||||
s.execute("livePaint.merge", &json!({"ids": [g.0, other.0]})).unwrap();
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
}
|
||||
|
||||
/// Shape Builder refuses an on/off option that is not true or false and a tolerance or gap that is
|
||||
/// not a number, given as text or as another value of the wrong kind, naming the key; the document
|
||||
/// and the undo history stay as they were. True, false and null still work.
|
||||
|
||||
@@ -109,6 +109,48 @@ fn saturate_keeps_a_color_it_does_not_change() {
|
||||
assert_eq!(paint(&s, b), cmyk);
|
||||
}
|
||||
|
||||
/// An Edit Colors pass, a Blend or Recolor Artwork that changes nothing records no undo step, and the
|
||||
/// step that redo would bring back stays; one that changes a color still records one.
|
||||
#[test]
|
||||
fn edit_colors_that_change_nothing_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let gray = rect(&mut s, json!("#808080"));
|
||||
let a = rect(&mut s, json!("#ff0000"));
|
||||
let b = rect(&mut s, json!("#808080"));
|
||||
let c = rect(&mut s, json!("#0000ff"));
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
|
||||
s.execute("edit.colors.blendFrontToBack", &json!({})).unwrap();
|
||||
// A step for redo to bring back.
|
||||
s.execute("edit.colors.invert", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let history = |s: &Session| {
|
||||
let h = &s.doc().unwrap().history;
|
||||
(h.undo.len(), h.redo.len())
|
||||
};
|
||||
let before = history(&s);
|
||||
assert_eq!(before.1, 1, "one step to redo");
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let cases = [
|
||||
("edit.colors.invert", json!({"fill": false, "stroke": false})),
|
||||
("edit.colors.saturate", json!({"intensity": 0})),
|
||||
("edit.colors.toRGB", json!({})),
|
||||
("edit.colors.adjustBalance", json!({"mode": "rgb", "r": 0})),
|
||||
("edit.colors.blendFrontToBack", json!({})),
|
||||
("recolor.apply", json!({"map": {"#ff0000": "#ff0000"}})),
|
||||
// The complement of a gray is the same gray.
|
||||
("paint.complement", json!({"ids": [gray.0], "stroke": false})),
|
||||
("addon.color.filter", json!({"effect": "addon.color.invert", "fill": false, "stroke": false})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let v = s.execute(id, &p).unwrap();
|
||||
assert_eq!(v["changed"], 0, "{id}");
|
||||
assert_eq!(history(&s), before, "{id}: no undo step, and redo is kept");
|
||||
assert_eq!(s.doc().unwrap().doc, doc, "{id}: the document is unchanged");
|
||||
}
|
||||
s.execute("edit.colors.invert", &json!({})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0), "a pass that changes colors is one undo step");
|
||||
}
|
||||
|
||||
/// Adjust Color Balance refuses a channel that is not a number, which it would otherwise read as 0.
|
||||
#[test]
|
||||
fn adjust_balance_refuses_a_channel_that_is_not_a_number() {
|
||||
|
||||
@@ -712,6 +712,159 @@ fn type_in_symbol_art_reports_a_gradient_only_when_it_expands() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected type "Hi" at 64 pt whose characters have no paint, with fills and strokes of its own:
|
||||
/// each of `items` is an `appearance.setItem` edit of a new object-level stroke when it has a
|
||||
/// `weight`, else of a new object-level fill; the first `below` of them paint below the characters.
|
||||
fn type_with_own_paint(s: &mut Session, items: &[Value], below: usize) -> NodeId {
|
||||
let t = run(s, "text.create", json!({"x": 25, "y": 110, "text": "Hi", "size": 64}))["id"].as_u64().unwrap();
|
||||
run(s, "select.set", json!({"ids": [t]}));
|
||||
run(s, "paint.setFill", json!({"none": true}));
|
||||
run(s, "paint.setStroke", json!({"none": true}));
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let add = if item.get("weight").is_some() { "appearance.addStroke" } else { "appearance.addFill" };
|
||||
run(s, add, json!({"target": "object"}));
|
||||
let mut p = item.clone();
|
||||
p["index"] = json!(i);
|
||||
run(s, "appearance.setItem", p);
|
||||
}
|
||||
run(s, "appearance.moveItem", json!({"from": "contents", "to": below}));
|
||||
NodeId(t)
|
||||
}
|
||||
|
||||
fn blue_stroke() -> Value {
|
||||
json!({"color": "#0000ff", "weight": 5})
|
||||
}
|
||||
|
||||
fn red_blue() -> Value {
|
||||
json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}})
|
||||
}
|
||||
|
||||
/// Create Outlines draws what the type drew: a gradient fill of the type object's own, below its
|
||||
/// characters, keeps one gradient across both glyphs over the layout's box, and its stroke stays
|
||||
/// over them.
|
||||
#[test]
|
||||
fn create_outlines_draws_the_type_objects_own_paint() {
|
||||
let mut s = session();
|
||||
type_with_own_paint(&mut s, &[red_blue(), blue_stroke()], 1);
|
||||
let before = render(&s);
|
||||
run(&mut s, "type.createOutlines", json!({}));
|
||||
let after = render(&s);
|
||||
let n = differing(&before, &after, 24);
|
||||
assert!(n < 40, "{n} pixels differ");
|
||||
assert!(differing(&before, &render(&session()), 24) > 1000, "the type paints something");
|
||||
}
|
||||
|
||||
/// `object.expand.info` reports the type object's own stroke, pattern fill and gradient fill, which
|
||||
/// Object turns into paths of its whole outline: a gradient only when that outline's box needs no
|
||||
/// more runs than are written.
|
||||
#[test]
|
||||
fn type_objects_own_stroke_pattern_and_gradient_are_reported() {
|
||||
let mut s = session();
|
||||
type_with_own_paint(&mut s, &[blue_stroke()], 0);
|
||||
let info = run(&mut s, "object.expand.info", json!({}));
|
||||
assert_eq!((info["object"].clone(), info["stroke"].clone(), info["fill"].clone()), (json!(true), json!(true), json!(false)), "{info}");
|
||||
|
||||
let mut s = session();
|
||||
pattern_rect(&mut s, Rect::new(150.0, 150.0, 190.0, 190.0), false);
|
||||
let t = type_with_own_paint(&mut s, &[json!({"swatch": "Dots"})], 0);
|
||||
run(&mut s, "select.set", json!({"ids": [t.0]}));
|
||||
let info = run(&mut s, "object.expand.info", json!({}));
|
||||
assert_eq!((info["fill"].clone(), info["gradient"].clone(), info["stroke"].clone()), (json!(true), json!(false), json!(false)), "{info}");
|
||||
|
||||
// 0.4 pt runs across about 80 pt: under the limit; 0.02 pt runs: far over it.
|
||||
for (len, expands) in [(40.0, true), (2.0, false)] {
|
||||
let mut s = session();
|
||||
let gradient = json!({"gradient": {"kind": "linear", "spread": "repeat", "repeats": 100, "start": [25, 100], "end": [25.0 + len, 100],
|
||||
"stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}});
|
||||
type_with_own_paint(&mut s, &[gradient], 0);
|
||||
let info = run(&mut s, "object.expand.info", json!({}));
|
||||
assert_eq!((info["gradient"].clone(), info["fill"].clone()), (json!(expands), json!(expands)), "{len}: {info}");
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
let (gradients, texts) = gradients_and_type(&s);
|
||||
assert_eq!(texts, 0, "{len}: outlined");
|
||||
assert_eq!(gradients == 0, expands, "{len}: {gradients} gradient fills left");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type with only spaces has no outline for its own stroke or pattern fill to go on, so
|
||||
/// `object.expand.info` reports neither.
|
||||
#[test]
|
||||
fn type_without_an_outline_reports_no_own_stroke_or_fill() {
|
||||
let mut s = session();
|
||||
pattern_rect(&mut s, Rect::new(150.0, 150.0, 190.0, 190.0), false);
|
||||
let t = run(&mut s, "text.create", json!({"x": 25, "y": 110, "text": " ", "size": 64}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
run(&mut s, "paint.setFill", json!({"none": true}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||||
for (i, item) in [json!({"swatch": "Dots"}), blue_stroke()].into_iter().enumerate() {
|
||||
let add = if item.get("weight").is_some() { "appearance.addStroke" } else { "appearance.addFill" };
|
||||
run(&mut s, add, json!({"target": "object"}));
|
||||
let mut p = item;
|
||||
p["index"] = json!(i);
|
||||
run(&mut s, "appearance.setItem", p);
|
||||
}
|
||||
let info = run(&mut s, "object.expand.info", json!({}));
|
||||
assert_eq!(
|
||||
(info["object"].clone(), info["stroke"].clone(), info["fill"].clone(), info["gradient"].clone()),
|
||||
(json!(true), json!(false), json!(false), json!(false)),
|
||||
"{info}"
|
||||
);
|
||||
}
|
||||
|
||||
/// How many objects in the document have a visible stroke with paint and weight.
|
||||
fn painted_strokes(s: &Session) -> usize {
|
||||
let mut n = 0;
|
||||
s.doc().unwrap().doc.walk(|c| {
|
||||
n += usize::from(
|
||||
c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.visible && !st.paint.is_none() && st.width > 0.0)),
|
||||
);
|
||||
});
|
||||
n
|
||||
}
|
||||
|
||||
/// A visible stroke under a topmost stroke with no paint still has something for Stroke to
|
||||
/// outline, on type's own appearance and on a path: `object.expand.info` reports it, and Expand
|
||||
/// outlines it.
|
||||
#[test]
|
||||
fn a_stroke_under_an_unpainted_stroke_is_reported_and_outlined() {
|
||||
let mut s = session();
|
||||
type_with_own_paint(&mut s, &[blue_stroke(), json!({"none": true, "weight": 1})], 0);
|
||||
let mut r = session();
|
||||
run(&mut r, "shape.rectangle", json!({"x": 40, "y": 40, "width": 100, "height": 80}));
|
||||
run(&mut r, "paint.setStroke", json!({"color": "#0000ff"}));
|
||||
run(&mut r, "stroke.set", json!({"weight": 4}));
|
||||
run(&mut r, "appearance.addStroke", json!({"target": "object"}));
|
||||
let top = node(&r, selected(&r).id).appearance.items.len() - 1;
|
||||
run(&mut r, "appearance.setItem", json!({"index": top, "none": true}));
|
||||
for (what, s) in [("type", &mut s), ("path", &mut r)] {
|
||||
let info = run(s, "object.expand.info", json!({}));
|
||||
assert_eq!(info["stroke"], json!(true), "{what}: {info}");
|
||||
run(s, "object.expand", json!({}));
|
||||
assert_eq!(painted_strokes(s), 0, "{what}: the stroke is outlined");
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand keeps the type object's own stroke and gradient fill through Object: Stroke outlines the
|
||||
/// stroke and Fill expands the gradient, and with those options off they stay live on the path of
|
||||
/// the whole outline. The picture stays the same either way.
|
||||
#[test]
|
||||
fn expand_keeps_the_type_objects_own_paint() {
|
||||
for (opts, live) in [(json!({}), false), (json!({"fill": false, "stroke": false}), true)] {
|
||||
let mut s = session();
|
||||
type_with_own_paint(&mut s, &[red_blue(), blue_stroke()], 1);
|
||||
let before = render(&s);
|
||||
run(&mut s, "object.expand", opts.clone());
|
||||
let after = render(&s);
|
||||
assert!(mean_diff(&before, &after) < 2.0, "{opts}: {}", mean_diff(&before, &after));
|
||||
let (gradients, texts) = gradients_and_type(&s);
|
||||
assert_eq!(texts, 0, "{opts}: outlined");
|
||||
assert_eq!(gradients == 1, live, "{opts}: {gradients} gradient fills");
|
||||
let mut strokes = 0;
|
||||
s.doc().unwrap().doc.walk(|n| strokes += usize::from(!n.appearance.stroke_paint().is_none()));
|
||||
assert_eq!(strokes == 1, live, "{opts}: {strokes} strokes");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() {
|
||||
for gradient in [
|
||||
|
||||
@@ -1043,6 +1043,51 @@ fn create_outlines_matches_text_bounds() {
|
||||
assert!(s.doc().unwrap().doc.node(t).is_some());
|
||||
}
|
||||
|
||||
/// The type object's own fills and strokes (not its characters') become paths of its whole outline,
|
||||
/// with ids of their own: those below the Characters row under the glyph paths, the others over
|
||||
/// them, in their order. The group keeps no appearance of its own, and Undo brings the type back.
|
||||
#[test]
|
||||
fn create_outlines_keeps_the_objects_own_fills_and_strokes() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Hi", 64.0);
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
for (cmd, p) in [
|
||||
("appearance.addFill", json!({"target": "object"})),
|
||||
("appearance.setItem", json!({"index": 0, "color": "#ff0000"})),
|
||||
("appearance.addStroke", json!({"target": "object"})),
|
||||
("appearance.setItem", json!({"index": 1, "color": "#0000ff", "weight": 5})),
|
||||
("appearance.addFill", json!({"target": "object"})),
|
||||
("appearance.setItem", json!({"index": 2, "color": "#00ff00", "opacity": 50})),
|
||||
// The red fill paints below the characters; the stroke and the green fill over them.
|
||||
("appearance.moveItem", json!({"from": "contents", "to": 1})),
|
||||
] {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||
}
|
||||
let own = node(&s, t).appearance.items.clone();
|
||||
assert_eq!(own.len(), 3);
|
||||
let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap());
|
||||
let g = node(&s, r[0]);
|
||||
assert!(g.appearance.items.is_empty(), "the group paints nothing of its own");
|
||||
let ch = g.children().unwrap();
|
||||
assert_eq!(ch.len(), 4, "a path below, the two glyphs, a path above");
|
||||
let (first, last) = (&ch[0], &ch[3]);
|
||||
assert_eq!(first.appearance.items, own[..1], "the item below the characters");
|
||||
assert_eq!(last.appearance.items, own[1..], "the items over them, in their order");
|
||||
for c in [first, last] {
|
||||
assert!(matches!(c.kind, NodeKind::Path { .. }), "{:?}", c.kind);
|
||||
assert!(c.id != t && c.id != g.id, "a fresh id");
|
||||
}
|
||||
assert!(first.id != last.id);
|
||||
for c in &ch[1..3] {
|
||||
assert_eq!(c.appearance.fill_paint(), Paint::solid(Color::BLACK), "the characters keep their own paint");
|
||||
}
|
||||
// The whole-outline paths cover both glyphs.
|
||||
let glyphs = ch[1].geometric_bounds().unwrap().union(ch[2].geometric_bounds().unwrap());
|
||||
assert!(close(first.geometric_bounds().unwrap(), glyphs, 1e-6), "{:?} vs {glyphs:?}", first.geometric_bounds());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, t).appearance.items, own, "one undo step brings the type back");
|
||||
}
|
||||
|
||||
/// Colour bitmap glyphs (emoji) become embedded images where they sat; glyphs showing one image
|
||||
/// share its pixels.
|
||||
#[test]
|
||||
|
||||
@@ -12,7 +12,7 @@ fn is_bad_params(r: Result<Value>) -> bool {
|
||||
}
|
||||
|
||||
/// Folder preferences, which a preferences file leaves out.
|
||||
const FOLDERS: [&str; 4] = ["fontsFolder", "pluginsFolder", "recoveryFolder", "templatesFolder"];
|
||||
const FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
|
||||
|
||||
/// A preferences file holding `prefs`, as `prefs.export` writes one.
|
||||
fn file(prefs: Value) -> String {
|
||||
@@ -46,7 +46,9 @@ fn in_file(s: &Session) -> Value {
|
||||
fn exported_preferences_import_unchanged() {
|
||||
let mut s = changed();
|
||||
s.prefs.fonts_folder = "/fonts/here".into();
|
||||
s.prefs.scratch_primary = "/scratch/here".into();
|
||||
let r = s.execute("prefs.export", &json!({})).unwrap();
|
||||
assert!(!r["data"].as_str().unwrap().contains("/scratch/here"), "a scratch disk folder is not written");
|
||||
let text = r["data"].as_str().unwrap().to_string();
|
||||
let written: Value = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!((written["format"].as_str(), written["version"].as_u64()), (Some(PREFS_FORMAT), Some(1)));
|
||||
@@ -71,6 +73,7 @@ fn exported_preferences_import_unchanged() {
|
||||
assert_eq!(t.doc().unwrap().doc.units, Unit::Millimeters, "the document's units stay as they are");
|
||||
assert_eq!(t.doc().unwrap().history.undo.len(), undo, "no undo step");
|
||||
assert_eq!(t.prefs.fonts_folder, "", "folders are not carried over");
|
||||
assert_eq!(t.prefs.scratch_primary, "Startup", "nor is a scratch disk");
|
||||
|
||||
// Through a file, too.
|
||||
let path = std::env::temp_dir().join(format!("vc-prefs-{}.json", std::process::id())).to_string_lossy().to_string();
|
||||
@@ -96,6 +99,10 @@ fn a_hand_written_file_sets_what_it_names_and_leaves_the_rest() {
|
||||
assert_eq!(s.prefs.keyboard_increment, 3.0);
|
||||
assert_eq!(s.prefs.guide_color, "#abcdef");
|
||||
assert_eq!(s.prefs.history_states, 77, "a preference the file leaves out keeps its value");
|
||||
// A scratch disk choice names no folder, and is read.
|
||||
let choices = file(json!({"scratchPrimary": "None", "scratchSecondary": "Startup"}));
|
||||
assert_eq!(s.execute("prefs.import", &json!({"data": choices})).unwrap(), json!({"imported": 2}));
|
||||
assert_eq!((s.prefs.scratch_primary.as_str(), s.prefs.scratch_secondary.as_str()), ("None", "Startup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -119,6 +126,8 @@ fn a_bad_preferences_file_is_refused_and_nothing_changes() {
|
||||
(json!({"data": file(json!({"uiAccent": "blue", "historyStates": 5000}))}), "`historyStates` must be a whole number between 5 and 1000"),
|
||||
(json!({"data": file(json!({"uiAccent": "blue", "eyedropper": 5}))}), "`eyedropper` must be an object"),
|
||||
(json!({"data": file(json!({"uiAccent": "blue", "fontsFolder": "/fonts"}))}), "`fontsFolder` names a folder"),
|
||||
(json!({"data": file(json!({"uiAccent": "blue", "scratchPrimary": "/scratch"}))}), "`scratchPrimary` names a folder"),
|
||||
(json!({"data": file(json!({"uiAccent": "blue", "scratchSecondary": " scratch "}))}), "`scratchSecondary` names a folder"),
|
||||
(json!({"data": too_large}), "larger than 1 MB"),
|
||||
(json!({"data": 5}), "`data` must be a string"),
|
||||
(json!({"path": "/a.json", "data": file(json!({"uiAccent": "blue"}))}), "give `path` or `data`"),
|
||||
|
||||
Reference in New Issue
Block a user