mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 23:05:05 +00:00
Merge main into feat/objects-on-path-select-move-detach
This commit is contained in:
@@ -5,6 +5,7 @@ mod arrange;
|
||||
mod cleanup;
|
||||
mod color;
|
||||
mod contour;
|
||||
mod cutting;
|
||||
mod document;
|
||||
mod image;
|
||||
mod pathtools;
|
||||
@@ -23,6 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
v.extend(cleanup::specs());
|
||||
v.extend(color::specs());
|
||||
v.extend(contour::specs());
|
||||
v.extend(cutting::specs());
|
||||
v.extend(document::specs());
|
||||
v.extend(image::specs());
|
||||
v.extend(pathtools::specs());
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Cutting Tools (`addon.cutting.*`), a built-in add-on that prepares art for cutting. Stencil
|
||||
//! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together
|
||||
//! ([`stencil`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialog are the UI's.
|
||||
|
||||
mod stencil;
|
||||
|
||||
use super::super::CommandSpec;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
stencil::specs()
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Stencil Bridges (`addon.cutting.bridges`), a command of the built-in Cutting Tools add-on: it
|
||||
//! cuts bridges out of the selected art so that a stencil cut from it holds together. Each island
|
||||
//! of the stencil sheet (the inside of an O, a hole in a shape) stays joined to the sheet by strips
|
||||
//! of a set width across the art (<https://en.wikipedia.org/wiki/Stencil> describes islands and
|
||||
//! bridges). The art becomes one compound path with the gaps cut, and type is measured by its
|
||||
//! glyph outlines. The original goes to a new hidden layer or is replaced. The geometry is
|
||||
//! `vectorcraft_pathops::addons::stencil`.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_pathops::addons::stencil::{BRIDGE_WIDTH_RANGE, BridgeDirection, BridgeSpec, MAX_BRIDGES_PER_ISLAND, stencil_bridges};
|
||||
|
||||
use super::super::super::edit::selected_roots;
|
||||
use super::super::super::object::choice;
|
||||
use super::super::super::pathops::{length_given, node_path, pf_leaves, shape_node, style_of};
|
||||
use super::super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Stencil Bridges' command (the id in [`specs`]).
|
||||
pub const BRIDGES: &str = "addon.cutting.bridges";
|
||||
/// The ways bridges run, as `direction` takes them.
|
||||
pub const DIRECTIONS: [&str; 3] = ["vertical", "horizontal", "shortest"];
|
||||
/// A bridge's width when none is given, in points.
|
||||
pub const DEFAULT_WIDTH: f64 = 2.0;
|
||||
/// The name of the hidden layer the original art goes to (numbered when it is taken).
|
||||
const ORIGINAL_LAYER: &str = "Stencil Original";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"addon.cutting.bridges",
|
||||
"Stencil Bridges…",
|
||||
["Object", "Plug-ins", "Cutting Tools"],
|
||||
None,
|
||||
"{width?: pt 0.01..1000 (2), count?: whole number 1..16 of bridges each island gets (1; fewer where it has no room and more where other islands are joined to the sheet through it), direction?: \"vertical\" | \"horizontal\" | \"shortest\" (the shortest crossing among 12 directions 15° apart) (\"vertical\"), keepOriginal?: true (the selected objects that make up the art go to a new hidden layer named \"Stencil Original\"; false: they are replaced)} cut bridges out of the selected art so that a stencil cut from it holds together: every island (an area the art encloses, such as the inside of an O or a hole in a shape) is joined to the area around the art by strips `width` wide across the art, the shortest crossings first, then spread around the island. The art is measured by the area its outlines enclose, whatever their paint (open paths closed as a fill closes them; type by its glyph outlines; groups by their members; hidden objects left out) and becomes one compound path with the gaps cut, painted as the topmost selected shape, in the place of the topmost selected object that has outlines. Selected objects with no outlines in the art stay where they are. Art with no islands, or none a bridge of the width fits, is left as it is, with a warning and no undo step; islands the cut result still encloses are counted in `unbridged`, with a warning. Art that reaches more than 10000000 pt from the origin, has more than 200000 anchors, whose combined area has more than 4000 outlines, whose outlines need more than 200000 straight segments, or that takes more work to measure than a fixed budget allows is refused. One undo step; the new path is selected → {id, islands, bridges, unbridged, original (the hidden layer's id, or null), warning?}",
|
||||
has_selection,
|
||||
bridges
|
||||
)]
|
||||
}
|
||||
|
||||
fn bridges(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = BRIDGES;
|
||||
let width = length_given(p, "width", C)?.unwrap_or(DEFAULT_WIDTH);
|
||||
let (lo, hi) = BRIDGE_WIDTH_RANGE;
|
||||
if !(lo..=hi).contains(&width) {
|
||||
return Err(bad(C, format!("`width` must be from {lo} to {hi} pt")));
|
||||
}
|
||||
let count = number_or(p, "count", 1.0, C)?;
|
||||
if count.fract() != 0.0 || !(1.0..=MAX_BRIDGES_PER_ISLAND as f64).contains(&count) {
|
||||
return Err(bad(C, format!("`count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND}")));
|
||||
}
|
||||
let direction = match choice(p, C, "direction", &DIRECTIONS)?.unwrap_or("vertical") {
|
||||
"horizontal" => BridgeDirection::Horizontal,
|
||||
"shortest" => BridgeDirection::Shortest,
|
||||
_ => BridgeDirection::Vertical,
|
||||
};
|
||||
let keep = switch_or(p, "keepOriginal", true, C)?;
|
||||
let selected = selected_roots(s)?;
|
||||
let (bridged, style, roots) = {
|
||||
let d = &s.doc()?.doc;
|
||||
// The art's outlines, the selected objects they come from, and the topmost object that
|
||||
// has some, whose paint the result takes.
|
||||
let mut parts: Vec<(PathData, FillRule)> = vec![];
|
||||
let mut roots = vec![];
|
||||
let mut top = None;
|
||||
for id in selected {
|
||||
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let mut leaves = vec![];
|
||||
shown_leaves(d, n, &mut leaves);
|
||||
let before = parts.len();
|
||||
for leaf in leaves {
|
||||
if let Some(part) = node_path(&leaf).filter(|(p, _)| !p.is_empty()) {
|
||||
parts.push(part);
|
||||
top = Some(leaf);
|
||||
}
|
||||
}
|
||||
if parts.len() > before {
|
||||
roots.push(id);
|
||||
}
|
||||
}
|
||||
let Some(top) = top else {
|
||||
let label = "Stencil Bridges";
|
||||
return Err(EngineError::Other(format!("{label}: select paths, compound paths, groups or text")));
|
||||
};
|
||||
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
|
||||
let spec = BridgeSpec { width, count: count as usize, direction };
|
||||
let bridged = stencil_bridges(&refs, spec).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
(bridged, style_of(&top), roots)
|
||||
};
|
||||
let summary = |id: Option<NodeId>, original: Option<NodeId>| {
|
||||
json!({
|
||||
"id": id.map(|i| i.0), "islands": bridged.islands, "bridges": bridged.bridges, "unbridged": bridged.unbridged,
|
||||
"original": original.map(|i| i.0),
|
||||
})
|
||||
};
|
||||
if bridged.islands == 0 {
|
||||
let mut r = summary(None, None);
|
||||
// The warnings are written as `EngineError::Other` so that the i18n message scan
|
||||
// (`untranslated_report`) finds them.
|
||||
r["warning"] = json!(EngineError::Other("The selected art has no islands to bridge".into()).to_string());
|
||||
return Ok(r);
|
||||
}
|
||||
if bridged.bridges == 0 {
|
||||
let mut r = summary(None, None);
|
||||
r["warning"] = json!(EngineError::Other("No bridge of this width fits across the selected art".into()).to_string());
|
||||
return Ok(r);
|
||||
}
|
||||
let path = bridged.path.clone();
|
||||
let (id, original) = s.edit("Stencil Bridges", |d, sel| {
|
||||
let mut made = shape_node(d, path, None);
|
||||
style.apply(&mut made);
|
||||
let mut taken = vec![];
|
||||
let id = d.wrap(&roots, |nodes| {
|
||||
taken = nodes;
|
||||
made
|
||||
})?;
|
||||
let original = if keep { Some(hide_originals(d, taken)?) } else { None };
|
||||
sel.set([id]);
|
||||
Ok((id, original))
|
||||
})?;
|
||||
let mut r = summary(Some(id), original);
|
||||
if bridged.unbridged > 0 {
|
||||
r["warning"] = json!(EngineError::Other("Some islands could not be joined to the area around the art".into()).to_string());
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The leaf shapes of `n` as [`pf_leaves`] gives them, leaving out hidden objects and the members
|
||||
/// of hidden groups.
|
||||
fn shown_leaves(d: &Document, n: &Node, out: &mut Vec<Node>) {
|
||||
if !n.visible {
|
||||
return;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } if !n.is_compound_shape() => {
|
||||
for c in children {
|
||||
shown_leaves(d, c, out);
|
||||
}
|
||||
}
|
||||
_ => pf_leaves(d, n, out),
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `nodes` in a new hidden layer above the others, in their order, and return its id.
|
||||
fn hide_originals(d: &mut Document, nodes: Vec<Arc<Node>>) -> Result<NodeId> {
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
d.walk(|n| {
|
||||
if n.is_layer() {
|
||||
taken.extend(n.name.clone());
|
||||
}
|
||||
});
|
||||
let name = std::iter::once(ORIGINAL_LAYER.to_string())
|
||||
.chain((2..).map(|i| format!("{ORIGINAL_LAYER} {i}")))
|
||||
.find(|name| !taken.contains(name))
|
||||
.unwrap_or_else(|| ORIGINAL_LAYER.to_string());
|
||||
let layer = d.add_layer(Some(&name));
|
||||
for (i, n) in nodes.into_iter().enumerate() {
|
||||
d.insert(Some(layer), i, Arc::unwrap_or_clone(n))?;
|
||||
}
|
||||
d.node_mut(layer).ok_or(EngineError::NoNode(layer))?.visible = false;
|
||||
Ok(layer)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Tests for `addon.cutting.bridges`: bridges cut out of the selected art keep a stencil's islands
|
||||
//! attached.
|
||||
|
||||
use kurbo::Shape;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
use super::super::super::super::pathops::node_path;
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
/// `outer` less each of `holes` (x, y, width, height), as one compound path, selected.
|
||||
fn shape_with_holes(s: &mut Session, outer: (f64, f64, f64, f64), holes: &[(f64, f64, f64, f64)]) -> NodeId {
|
||||
let mut ids = vec![rect(s, outer.0, outer.1, outer.2, outer.3)];
|
||||
for h in holes {
|
||||
ids.push(rect(s, h.0, h.1, h.2, h.3));
|
||||
}
|
||||
select(s, &ids);
|
||||
let r = s.execute("object.pathfinder.minusFront", &json!({})).unwrap();
|
||||
let id = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
select(s, &[id]);
|
||||
id
|
||||
}
|
||||
|
||||
/// A square O: 100 pt across at (100, 100), with bars 20 pt thick.
|
||||
fn letter_o(s: &mut Session) -> NodeId {
|
||||
shape_with_holes(s, (100.0, 100.0, 100.0, 100.0), &[(120.0, 120.0, 60.0, 60.0)])
|
||||
}
|
||||
|
||||
fn id_of(r: &Value) -> NodeId {
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
||||
s.doc().unwrap().doc.node(id).cloned().unwrap()
|
||||
}
|
||||
|
||||
/// Is `(x, y)` inside the area object `id` fills?
|
||||
fn fills(s: &Session, id: NodeId, x: f64, y: f64) -> bool {
|
||||
let (path, rule) = node_path(&node(s, id)).unwrap();
|
||||
let w = path.to_bezpath().winding(kurbo::Point::new(x, y));
|
||||
match rule {
|
||||
vectorcraft_geom::FillRule::EvenOdd => w % 2 != 0,
|
||||
vectorcraft_geom::FillRule::NonZero => w != 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn undo_steps(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_o_gets_a_bridge_and_the_original_goes_to_a_hidden_layer() {
|
||||
let mut s = session();
|
||||
let o = letter_o(&mut s);
|
||||
let fill = node(&s, o).appearance.fill_paint();
|
||||
let steps = undo_steps(&s);
|
||||
let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
|
||||
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(1), json!(1), json!(0)));
|
||||
assert!(r.get("warning").is_none(), "{r}");
|
||||
let made = id_of(&r);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![made]);
|
||||
assert_eq!(undo_steps(&s), steps + 1, "one undo step");
|
||||
// The bridge crosses the top bar in the middle; the rest of the O stays, painted as it was.
|
||||
assert!(!fills(&s, made, 150.0, 110.0));
|
||||
assert!(fills(&s, made, 130.0, 110.0) && fills(&s, made, 150.0, 190.0) && fills(&s, made, 110.0, 150.0));
|
||||
assert_eq!(node(&s, made).appearance.fill_paint(), fill);
|
||||
// The original, unchanged, is on a new hidden layer.
|
||||
let layer = NodeId(r["original"].as_u64().unwrap());
|
||||
let l = node(&s, layer);
|
||||
assert_eq!((l.name.as_deref(), l.visible), (Some("Stencil Original"), false));
|
||||
let held = s.doc().unwrap().doc.children(Some(layer)).map(|c| c.iter().map(|n| n.id).collect::<Vec<_>>());
|
||||
assert_eq!(held, Some(vec![o]));
|
||||
// The island is joined to the sheet: the result has none left.
|
||||
let again = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
|
||||
assert_eq!(again["islands"], json!(0));
|
||||
assert!(again["warning"].as_str().is_some_and(|w| w.contains("no islands")), "{again}");
|
||||
assert_eq!(undo_steps(&s), steps + 1, "no islands: no undo step");
|
||||
// Undo puts the O back where it was and takes the layer away.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.node(layer).is_none() && d.node(made).is_none() && d.node(o).is_some_and(|n| n.visible));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_original_off_replaces_the_art() {
|
||||
let mut s = session();
|
||||
let o = letter_o(&mut s);
|
||||
let layers = s.doc().unwrap().doc.layers.len();
|
||||
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "keepOriginal": false})).unwrap();
|
||||
assert_eq!(r["original"], Value::Null);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.node(o).is_none() && d.node(id_of(&r)).is_some());
|
||||
assert_eq!(d.layers.len(), layers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bridge_is_as_wide_as_asked() {
|
||||
for width in [2.0, 8.0] {
|
||||
let mut s = session();
|
||||
letter_o(&mut s);
|
||||
let r = s.execute("addon.cutting.bridges", &json!({"width": width})).unwrap();
|
||||
let made = id_of(&r);
|
||||
let half = width / 2.0;
|
||||
assert!(!fills(&s, made, 150.0 - half * 0.9, 110.0) && !fills(&s, made, 150.0 + half * 0.9, 110.0), "{width}");
|
||||
assert!(fills(&s, made, 150.0 - half * 1.2, 110.0) && fills(&s, made, 150.0 + half * 1.2, 110.0), "{width}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_width_takes_units() {
|
||||
let mut s = session();
|
||||
letter_o(&mut s);
|
||||
let r = s.execute("addon.cutting.bridges", &json!({"width": "2 mm"})).unwrap();
|
||||
let made = id_of(&r);
|
||||
// 2 mm is about 5.67 pt.
|
||||
assert!(!fills(&s, made, 152.5, 110.0) && fills(&s, made, 154.0, 110.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shortest_crossing_takes_the_thinnest_bar() {
|
||||
let mut s = session();
|
||||
// The left bar is 5 pt thick, the others 20.
|
||||
shape_with_holes(&mut s, (100.0, 100.0, 100.0, 100.0), &[(105.0, 120.0, 75.0, 60.0)]);
|
||||
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "direction": "shortest"})).unwrap();
|
||||
let made = id_of(&r);
|
||||
assert!(!fills(&s, made, 102.5, 150.0), "the bridge crosses the thin bar");
|
||||
assert!(fills(&s, made, 150.0, 110.0) && fills(&s, made, 190.0, 150.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_bridges_cross_the_sides() {
|
||||
let mut s = session();
|
||||
letter_o(&mut s);
|
||||
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "direction": "horizontal"})).unwrap();
|
||||
let made = id_of(&r);
|
||||
assert!(!fills(&s, made, 110.0, 150.0) || !fills(&s, made, 190.0, 150.0));
|
||||
assert!(fills(&s, made, 150.0, 110.0) && fills(&s, made, 150.0, 190.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_with_two_holes_gets_a_bridge_for_each() {
|
||||
let mut s = session();
|
||||
shape_with_holes(&mut s, (100.0, 100.0, 100.0, 50.0), &[(110.0, 110.0, 30.0, 30.0), (160.0, 110.0, 30.0, 30.0)]);
|
||||
let r = s.execute("addon.cutting.bridges", &json!({"width": 3, "count": 2})).unwrap();
|
||||
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(2), json!(4), json!(0)));
|
||||
let made = id_of(&r);
|
||||
for x in [125.0, 175.0] {
|
||||
assert!(!fills(&s, made, x, 105.0) && !fills(&s, made, x, 145.0), "{x}: a bridge above and below each hole");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_is_bridged_by_its_glyph_outlines_and_stays_type() {
|
||||
let mut s = session();
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "O", "size": 144})).unwrap()["id"].as_u64().unwrap());
|
||||
select(&mut s, &[t]);
|
||||
let r = s.execute("addon.cutting.bridges", &json!({"width": 3})).unwrap();
|
||||
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(1), json!(1), json!(0)), "{r}");
|
||||
assert!(matches!(node(&s, id_of(&r)).kind, NodeKind::Path { .. } | NodeKind::Compound { .. }));
|
||||
// The type itself is not converted: it is on the hidden layer, still type.
|
||||
assert!(matches!(node(&s, t).kind, NodeKind::Text(_)));
|
||||
assert!(!node(&s, NodeId(r["original"].as_u64().unwrap())).visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_result_is_painted_as_the_topmost_object_with_outlines() {
|
||||
let mut s = session();
|
||||
let o = letter_o(&mut s);
|
||||
// Type holding only a space draws no outlines, so it does not set the paint, though it is on top.
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": " ", "size": 24})).unwrap()["id"].as_u64().unwrap());
|
||||
select(&mut s, &[o]);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let red = node(&s, o).appearance.fill_paint();
|
||||
select(&mut s, &[o, t]);
|
||||
let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
|
||||
assert_eq!(node(&s, id_of(&r)).appearance.fill_paint(), red);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_members_of_a_group_are_left_out() {
|
||||
let mut s = session();
|
||||
let o = letter_o(&mut s);
|
||||
// A hidden square that would fill the O's hole, grouped with it.
|
||||
let cover = rect(&mut s, 120.0, 120.0, 60.0, 60.0);
|
||||
select(&mut s, &[o, cover]);
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
select(&mut s, &[cover]);
|
||||
s.execute("object.hide", &json!({})).unwrap();
|
||||
select(&mut s, &[g]);
|
||||
let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
|
||||
assert_eq!((r["islands"].clone(), r["bridges"].clone()), (json!(1), json!(1)), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_without_islands_changes_nothing() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
select(&mut s, &[a]);
|
||||
let (steps, count) = (undo_steps(&s), s.doc().unwrap().doc.node_count());
|
||||
let r = s.execute("addon.cutting.bridges", &json!({})).unwrap();
|
||||
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["id"].clone()), (json!(0), json!(0), Value::Null));
|
||||
assert!(r["warning"].as_str().is_some(), "{r}");
|
||||
assert_eq!((undo_steps(&s), s.doc().unwrap().doc.node_count()), (steps, count));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_island_too_narrow_for_the_width_is_reported() {
|
||||
let mut s = session();
|
||||
shape_with_holes(&mut s, (100.0, 100.0, 100.0, 100.0), &[(149.0, 120.0, 2.0, 60.0)]);
|
||||
let steps = undo_steps(&s);
|
||||
let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
|
||||
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(1), json!(0), json!(1)));
|
||||
assert!(r["warning"].as_str().is_some_and(|w| w.contains("No bridge")), "{r}");
|
||||
assert_eq!(undo_steps(&s), steps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn islands_left_without_a_bridge_are_reported_beside_the_ones_bridged() {
|
||||
let mut s = session();
|
||||
// A wide hole on the left and a 2 pt slot on the right.
|
||||
shape_with_holes(&mut s, (100.0, 100.0, 200.0, 100.0), &[(120.0, 120.0, 60.0, 60.0), (249.0, 120.0, 2.0, 60.0)]);
|
||||
let steps = undo_steps(&s);
|
||||
let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
|
||||
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(2), json!(1), json!(1)));
|
||||
assert!(r["warning"].as_str().is_some_and(|w| w.contains("could not be joined")), "{r}");
|
||||
assert_eq!(undo_steps(&s), steps + 1);
|
||||
assert!(!fills(&s, id_of(&r), 150.0, 110.0), "the wide hole is bridged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_run_keeps_its_original_on_a_layer_of_its_own() {
|
||||
let mut s = session();
|
||||
letter_o(&mut s);
|
||||
let first = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
|
||||
shape_with_holes(&mut s, (300.0, 100.0, 100.0, 100.0), &[(320.0, 120.0, 60.0, 60.0)]);
|
||||
let second = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
|
||||
let name = |r: &Value| node(&s, NodeId(r["original"].as_u64().unwrap())).name;
|
||||
assert_eq!((name(&first).as_deref(), name(&second).as_deref()), (Some("Stencil Original"), Some("Stencil Original 2")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_refused() {
|
||||
let mut s = session();
|
||||
letter_o(&mut s);
|
||||
let steps = undo_steps(&s);
|
||||
for (key, p) in [
|
||||
("width", json!({"width": 0})),
|
||||
("width", json!({"width": 5000})),
|
||||
("width", json!({"width": "wide"})),
|
||||
("count", json!({"count": 0})),
|
||||
("count", json!({"count": 1.5})),
|
||||
("count", json!({"count": 17})),
|
||||
("count", json!({"count": "two"})),
|
||||
("direction", json!({"direction": "diagonal"})),
|
||||
("direction", json!({"direction": 90})),
|
||||
("keepOriginal", json!({"keepOriginal": "yes"})),
|
||||
] {
|
||||
let e = s.execute("addon.cutting.bridges", &p).unwrap_err().to_string();
|
||||
assert!(e.contains(key), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(undo_steps(&s), steps);
|
||||
assert!(s.execute("addon.cutting.bridges", &json!({"width": 4})).is_ok(), "good params run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_objects_without_outlines_stay_where_they_are() {
|
||||
for keep in [true, false] {
|
||||
let mut s = session();
|
||||
let o = letter_o(&mut s);
|
||||
// Type holding only a space has no outlines.
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": " ", "size": 24})).unwrap()["id"].as_u64().unwrap());
|
||||
let layer = |s: &Session, id: NodeId| s.doc().unwrap().doc.layer_of(id);
|
||||
let before = layer(&s, t);
|
||||
select(&mut s, &[o, t]);
|
||||
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "keepOriginal": keep})).unwrap();
|
||||
assert_eq!(r["bridges"], json!(1), "{r}");
|
||||
assert!(node(&s, t).visible && layer(&s, t) == before, "keep {keep}: the type stays where it was");
|
||||
if keep {
|
||||
let held =
|
||||
s.doc().unwrap().doc.children(Some(NodeId(r["original"].as_u64().unwrap()))).map(|c| c.iter().map(|n| n.id).collect::<Vec<_>>());
|
||||
assert_eq!(held, Some(vec![o]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_result_takes_the_place_of_the_topmost_selected_object() {
|
||||
let mut s = session();
|
||||
let o = letter_o(&mut s);
|
||||
let between = rect(&mut s, 400.0, 100.0, 50.0, 50.0);
|
||||
let o2 = shape_with_holes(&mut s, (500.0, 100.0, 100.0, 100.0), &[(520.0, 120.0, 60.0, 60.0)]);
|
||||
let above = rect(&mut s, 400.0, 300.0, 50.0, 50.0);
|
||||
select(&mut s, &[o, o2]);
|
||||
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "keepOriginal": false})).unwrap();
|
||||
let made = id_of(&r);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let order: Vec<NodeId> = d.children(d.layer_of(made)).unwrap().iter().map(|n| n.id).collect();
|
||||
let at = |id: NodeId| order.iter().position(|i| *i == id).unwrap();
|
||||
assert!(at(between) < at(made) && at(made) < at(above), "{order:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_result_is_one_path_when_one_outline_is_left() {
|
||||
for (count, plain) in [(1, true), (2, false)] {
|
||||
let mut s = session();
|
||||
letter_o(&mut s);
|
||||
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "count": count})).unwrap();
|
||||
let kind = node(&s, id_of(&r)).kind;
|
||||
// One bridge leaves a C, one outline; two leave two pieces.
|
||||
assert_eq!(matches!(kind, NodeKind::Path { .. }), plain, "count {count}");
|
||||
assert_eq!(matches!(kind, NodeKind::Compound { .. }), !plain, "count {count}");
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,8 @@ use super::opacitymask::percent;
|
||||
use super::paint::paint_from;
|
||||
use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs};
|
||||
use super::{
|
||||
CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, id_param, ids_param, index, leaf_targets,
|
||||
ok, paint_targets, str_param, switch_or, targets,
|
||||
CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, ids_param, index, leaf_targets,
|
||||
object_param, ok, paint_targets, str_param, switch_or, targets,
|
||||
};
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -726,8 +726,8 @@ fn clear(n: &mut Node) {
|
||||
|
||||
fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "appearance.transfer";
|
||||
let source = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
|
||||
let target = id_param(p, "target").ok_or_else(|| bad(C, "missing `target` id"))?;
|
||||
let source = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?;
|
||||
let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing `target` id"))?;
|
||||
if source == target {
|
||||
return Err(bad(C, "`source` and `target` are the same object"));
|
||||
}
|
||||
@@ -1201,7 +1201,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Sample enabled attributes with transform-aware character dimensions and atomic validation.
|
||||
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "appearance.copyFrom";
|
||||
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
|
||||
let clicked = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?;
|
||||
let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?);
|
||||
let defaults_only = switch_or(p, "defaultsOnly", false, C)?;
|
||||
if defaults_only && (reverse || append || p.get("ids").is_some()) {
|
||||
@@ -1512,6 +1512,9 @@ impl Picked {
|
||||
stroke_width: st.stroke_width,
|
||||
overprint_fill: st.overprint_fill,
|
||||
overprint_stroke: st.overprint_stroke,
|
||||
// The space before a line's first character belongs to where the character is,
|
||||
// not to how it looks: a sample from one doesn't move the target's (#5111).
|
||||
kerning_before: st.kerning_before,
|
||||
..cs.clone()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ use std::collections::HashSet;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_brush::{self as brush, ArtBrush, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation};
|
||||
use vectorcraft_brush::{
|
||||
self as brush, ArtBrush, Bristle, BristleShape, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation,
|
||||
};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, PatternDef, Symbol};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, SubPath, shapes};
|
||||
@@ -169,6 +171,7 @@ pub struct BuiltinBrushLibrary {
|
||||
pub const BUILTIN: &[BuiltinBrushLibrary] = &[
|
||||
BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons },
|
||||
BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs },
|
||||
BuiltinBrushLibrary { id: "bristle-brushes", name: "Bristle Brushes", make: bristle_brushes },
|
||||
BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders },
|
||||
BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes },
|
||||
BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails },
|
||||
@@ -290,6 +293,31 @@ fn calligraphic_nibs() -> BrushLibrary {
|
||||
built("Calligraphic Nibs", Document::new(100.0, 100.0), brushes)
|
||||
}
|
||||
|
||||
/// A bristle brush: `shape`, then size (pt), bristle length, density, thickness, paint opacity
|
||||
/// and stiffness (%).
|
||||
fn bristle(name: &str, shape: BristleShape, [size, length, density, thickness, opacity, stiffness]: [f64; 6]) -> Brush {
|
||||
Brush { name: name.into(), kind: BrushKind::Bristle(Bristle { shape, size, length, density, thickness, opacity, stiffness }) }
|
||||
}
|
||||
|
||||
/// Bristle Brushes: one bristle brush for each tip shape, from a fine liner to wide fans for
|
||||
/// blending and texture.
|
||||
fn bristle_brushes() -> BrushLibrary {
|
||||
use BristleShape::*;
|
||||
let brushes = vec![
|
||||
bristle("Fine Liner", RoundPoint, [2.0, 140.0, 30.0, 30.0, 90.0, 75.0]),
|
||||
bristle("Pointed Flat", FlatPoint, [5.0, 120.0, 55.0, 35.0, 80.0, 60.0]),
|
||||
bristle("Stubby Round", RoundBlunt, [8.0, 60.0, 70.0, 50.0, 85.0, 80.0]),
|
||||
bristle("Dry Scrub", FlatBlunt, [10.0, 40.0, 25.0, 60.0, 60.0, 95.0]),
|
||||
bristle("Soft Dome", RoundCurve, [12.0, 160.0, 85.0, 20.0, 45.0, 25.0]),
|
||||
bristle("Filbert", FlatCurve, [9.0, 110.0, 65.0, 40.0, 70.0, 45.0]),
|
||||
bristle("Angled Round", RoundAngle, [7.0, 130.0, 50.0, 35.0, 75.0, 55.0]),
|
||||
bristle("Angled Flat", FlatAngle, [11.0, 100.0, 60.0, 45.0, 80.0, 65.0]),
|
||||
bristle("Fan Blender", RoundFan, [16.0, 220.0, 40.0, 15.0, 35.0, 20.0]),
|
||||
bristle("Grass Fan", FlatFan, [20.0, 260.0, 15.0, 25.0, 65.0, 35.0]),
|
||||
];
|
||||
built("Bristle Brushes", Document::new(100.0, 100.0), brushes)
|
||||
}
|
||||
|
||||
fn border(name: &str, side: Node, corner: Option<Node>, fit: PatternFit) -> Brush {
|
||||
let kind = BrushKind::Pattern(PatternBrush { side, outer_corner: corner.clone(), inner_corner: corner, fit, ..Default::default() });
|
||||
Brush { name: name.into(), kind }
|
||||
|
||||
@@ -36,7 +36,7 @@ use brushes::{
|
||||
brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_place_art,
|
||||
brush_remove, brush_replace_art, brush_selection_art, brush_set_current, brush_unused, expand_brush,
|
||||
};
|
||||
pub(crate) use dynamic::{edit_part, part_only, selected_part};
|
||||
pub(crate) use dynamic::{edit_part, fit_parts, part_only, selected_part};
|
||||
use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance};
|
||||
use symbols::selected_instances;
|
||||
pub(crate) use symbols::{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! appearance of objects in the symbol's art and stays linked to the symbol
|
||||
//! (`symbol.instance.setAppearance`, `symbol.instance.reset`). The changes live on the instance
|
||||
//! ([`vectorcraft_doc::symbol_overrides`]); editing the symbol keeps those whose object is still
|
||||
//! there ([`fit_overrides`]).
|
||||
//! there and draws them ([`fit_overrides`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -27,7 +27,7 @@ pub(super) fn type_id(dynamic: bool) -> &'static str {
|
||||
}
|
||||
|
||||
/// Fit the appearance changes of instances `ids` to their symbols: an instance of a static (or
|
||||
/// missing) symbol keeps none, an instance of a dynamic one those naming an object of its art.
|
||||
/// missing) symbol keeps none, an instance of a dynamic one those its art still draws ([`fit_parts`]).
|
||||
pub(super) fn fit_overrides(d: &mut Document, ids: &[NodeId]) {
|
||||
for id in ids {
|
||||
let art = match d.node(*id) {
|
||||
@@ -40,7 +40,7 @@ pub(super) fn fit_overrides(d: &mut Document, ids: &[NodeId]) {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let mut e = n.extras_mut();
|
||||
match art {
|
||||
Some(art) => symbol_overrides::retain_parts(&mut e.symbol_overrides, &art),
|
||||
Some(art) => fit_parts(&mut e.symbol_overrides, &art),
|
||||
None => e.symbol_overrides.clear(),
|
||||
}
|
||||
}
|
||||
@@ -177,17 +177,42 @@ fn check_paint_kinds(p: &Value, art: &Node, path: &[u32], cmd: &str) -> Result<(
|
||||
fn solid_fill_check(p: &Value, art: &Node, path: &[u32], fill: Option<&vectorcraft_color::Paint>, cmd: &str) -> Result<()> {
|
||||
let asked = p.get("fill").is_some_and(|v| !v.is_null());
|
||||
let is_instance = symbol_overrides::part(art, path).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. }));
|
||||
let draws_nothing = |f: &vectorcraft_color::Paint| match f {
|
||||
vectorcraft_color::Paint::Gradient(_) => true,
|
||||
vectorcraft_color::Paint::Pattern { .. } => true,
|
||||
vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => false,
|
||||
};
|
||||
if asked && is_instance && fill.is_some_and(draws_nothing) {
|
||||
if asked && is_instance && fill.is_some_and(|f| !stains(f)) {
|
||||
return Err(bad(cmd, "fill on a symbol instance must be a solid color or none"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Can a symbol instance's fill be `f`? An instance stains its art with a solid fill only.
|
||||
fn stains(f: &vectorcraft_color::Paint) -> bool {
|
||||
match f {
|
||||
vectorcraft_color::Paint::Gradient(_) => false,
|
||||
vectorcraft_color::Paint::Pattern { .. } => false,
|
||||
vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the appearance changes `overrides` that name an object of `art` (a symbol's new art), each
|
||||
/// without a fill or stroke that object does not draw ([`paint_refusal`], [`stains`]); a change
|
||||
/// left with nothing in it is dropped.
|
||||
pub(crate) fn fit_parts(overrides: &mut Vec<SymbolOverride>, art: &Node) {
|
||||
symbol_overrides::retain_parts(overrides, art);
|
||||
for o in overrides.iter_mut() {
|
||||
let Some(n) = symbol_overrides::part(art, &o.path) else { continue };
|
||||
let instance = matches!(n.kind, NodeKind::SymbolInstance { .. });
|
||||
if paint_refusal(n, "fill", FIT).is_some() || (instance && o.fill.as_ref().is_some_and(|f| !stains(f))) {
|
||||
o.fill = None;
|
||||
}
|
||||
if paint_refusal(n, "stroke", FIT).is_some() {
|
||||
o.stroke = None;
|
||||
}
|
||||
}
|
||||
overrides.retain(|o| !o.is_empty());
|
||||
}
|
||||
|
||||
/// The command name [`fit_parts`] gives [`paint_refusal`], whose refusal it does not show.
|
||||
const FIT: &str = "symbol.update";
|
||||
|
||||
/// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or
|
||||
/// an object of `paint.setFill`'s paint params sets it.
|
||||
fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<Option<vectorcraft_color::Paint>>> {
|
||||
|
||||
@@ -29,8 +29,8 @@ use super::edit::{outermost, selected_roots};
|
||||
use super::paint::paint_from;
|
||||
use super::pathops::{Beside, shape_node};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, named_ids,
|
||||
number_or, point_param, str_param, switch_or,
|
||||
CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, named_ids, number_or,
|
||||
object_param, point_param, str_param, switch_or,
|
||||
};
|
||||
|
||||
mod live_paint;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{
|
||||
Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point,
|
||||
Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, id_param, ids_json,
|
||||
is_live_paint, json, number_or, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
|
||||
Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint,
|
||||
json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
|
||||
};
|
||||
|
||||
/// `options` as stored on a Live Paint group: none for the defaults.
|
||||
@@ -303,7 +303,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
crate::cmd::paint::check_keep_model(p, C)?;
|
||||
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
|
||||
let group = id_param(p, "group");
|
||||
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 {
|
||||
@@ -367,7 +367,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "livePaint.strokeEdge";
|
||||
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
|
||||
let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?;
|
||||
let g = object_param(p, "group", C)?.ok_or_else(|| bad(C, "missing group"))?;
|
||||
crate::cmd::paint::check_keep_model(p, C)?;
|
||||
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone());
|
||||
let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?;
|
||||
@@ -390,7 +390,10 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = id_param(p, "group").or_else(|| id_param(p, "id")).ok_or_else(|| bad("livePaint.info", "missing group"))?;
|
||||
let g = match object_param(p, "group", "livePaint.info")? {
|
||||
Some(g) => g,
|
||||
None => object_param(p, "id", "livePaint.info")?.ok_or_else(|| bad("livePaint.info", "missing group"))?,
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad("livePaint.info", format!("{g} is not a Live Paint group")))?;
|
||||
let faces: Vec<Value> = b::faces(n)
|
||||
|
||||
@@ -783,12 +783,14 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of
|
||||
/// each paragraph's direction (left, right for Hebrew and Arabic), Japanese punctuation composed
|
||||
/// with Line-end Punctuation Half Width and Standard burasagari. Imported text keeps its own.
|
||||
/// with Line-end Punctuation Half Width and Standard burasagari, and Korean words kept whole
|
||||
/// (Korean Line Break Anywhere off). Imported text keeps its own.
|
||||
pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
|
||||
vectorcraft_doc::ParaStyle {
|
||||
justify: vectorcraft_doc::Justify::Auto,
|
||||
mojikumi: vectorcraft_doc::Mojikumi::LineEndHalf,
|
||||
burasagari: vectorcraft_doc::Burasagari::Standard,
|
||||
korean_line_break_anywhere: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project
|
||||
|
||||
use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots};
|
||||
use super::{
|
||||
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param,
|
||||
matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets,
|
||||
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, matrix_param,
|
||||
named_id, named_ids, number_or, object_param, ok, point_indices, point_param, str_param, switch_or, targets,
|
||||
};
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -305,7 +305,7 @@ fn same_points(a: Option<&WidthProfile>, b: Option<&WidthProfile>) -> bool {
|
||||
|
||||
fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.widthPoint.set";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let t = f64_req(p, "t", C)?.clamp(0.0, 1.0);
|
||||
let (left, right) = (f64_req(p, "left", C)?, f64_req(p, "right", C)?);
|
||||
if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 {
|
||||
@@ -362,7 +362,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.widthPoint.copy";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let t = f64_req(p, "t", C)?.clamp(0.0, 1.0);
|
||||
// When the profile comes out as it was (the copy replaces a side of a discontinuous point that has
|
||||
// its widths already), the document and its undo history stay as they are.
|
||||
@@ -382,7 +382,7 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.widthPoint.remove";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let indices = match (p.get("indices"), p.get("index")) {
|
||||
(Some(a), _) => point_indices(a),
|
||||
(None, Some(i)) => point_indices(&json!([i])),
|
||||
|
||||
@@ -18,8 +18,8 @@ use super::create::{add_look, insert_look};
|
||||
use super::newart::NewArt;
|
||||
use super::pathops::{Beside, remove_each};
|
||||
use super::{
|
||||
CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, id_param, index, number_or, ok, str_param,
|
||||
switch_or,
|
||||
CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, index, number_or, object_param, ok,
|
||||
str_param, switch_or,
|
||||
};
|
||||
use crate::EngineError;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::common::{MAX_INPUT, path_kind, path_mut, point_entry, usize_opt, usize_req, within_input_bound};
|
||||
use super::{
|
||||
Anchor, BTreeSet, Beside, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Point, Result, Selection, Session, Value, Vec2,
|
||||
add_look, bad, check_numbers, f64_or, id_param, json, number_or, ok, po, str_param, switch_or,
|
||||
add_look, bad, check_numbers, f64_or, json, number_or, object_param, ok, po, str_param, switch_or,
|
||||
};
|
||||
|
||||
// ---------- Curvature ----------
|
||||
@@ -41,7 +41,7 @@ enum CurvatureOp {
|
||||
pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
|
||||
const C: &str = "path.curvatureEdit";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
check_numbers(p, &["x", "y", "t"], C)?;
|
||||
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
@@ -106,7 +106,7 @@ pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.removeAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let curvature = switch_or(p, "curvature", false, C)?;
|
||||
@@ -220,7 +220,7 @@ fn remove_anchors_of(
|
||||
|
||||
pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.convertAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let smooth = str_param(p, "to") == Some("smooth");
|
||||
@@ -257,7 +257,7 @@ pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub(super) fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.reshapeSegment";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let seg = usize_req(p, "segment", C)?;
|
||||
let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95);
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::common::{
|
||||
};
|
||||
use super::{
|
||||
Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath,
|
||||
Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, id_param, json, number_or, po,
|
||||
Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po,
|
||||
};
|
||||
|
||||
/// Split a filled path into connected pieces (each outer contour with its holes).
|
||||
@@ -170,7 +170,7 @@ pub(in super::super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn(
|
||||
|
||||
pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.split";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let anchor = usize_opt(p, "anchor", C)?;
|
||||
let seg = usize_opt(p, "segment", C)?;
|
||||
|
||||
@@ -4,7 +4,8 @@ use super::common::{
|
||||
};
|
||||
use super::{
|
||||
Anchor, Appearance, BTreeSet, BoolOp, Cap, Color, Document, EngineError, FillRule, Join, NewArt, Node, NodeId, NodeKind, Paint, PathData, Point,
|
||||
Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, str_param, switch_or,
|
||||
Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, insert_look, json, number_or, object_param, po, remove_each, str_param,
|
||||
switch_or,
|
||||
};
|
||||
|
||||
// ---------- Pencil / Paintbrush ----------
|
||||
@@ -158,7 +159,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let keep_selected = switch_or(p, "keepSelected", true, C)?;
|
||||
let filled = switch_or(p, "fill", false, C)?;
|
||||
if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) {
|
||||
let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?;
|
||||
let id = object_param(ext, "id", C)?.ok_or_else(|| bad(C, "extend needs id"))?;
|
||||
let at_start = str_param(ext, "end") == Some("start");
|
||||
let (pd, _) = path_of(&s.doc()?.doc, id)?;
|
||||
let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?;
|
||||
@@ -179,7 +180,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// open subpath, that subpath starting at the end the stroke ends on, it ends at its end).
|
||||
let connect = match p.get("connect").filter(|v| !v.is_null()) {
|
||||
Some(cn) => {
|
||||
let other = id_param(cn, "id").ok_or_else(|| bad(C, "connect needs id"))?;
|
||||
let other = object_param(cn, "id", C)?.ok_or_else(|| bad(C, "connect needs id"))?;
|
||||
if other == id {
|
||||
return Err(bad(C, "connect needs a path other than the one extended"));
|
||||
}
|
||||
@@ -299,7 +300,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "id": id.0 }));
|
||||
}
|
||||
if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) {
|
||||
let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?;
|
||||
let id = object_param(rd, "id", C)?.ok_or_else(|| bad(C, "redraw needs id"))?;
|
||||
let within = number_or(rd, "within", 12.0, C)?;
|
||||
if !within.is_finite() || within < 0.0 {
|
||||
return Err(bad(C, "redraw within must be a finite distance of 0 or more"));
|
||||
|
||||
@@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
use super::clipboard::SwatchChoices;
|
||||
use super::{
|
||||
CommandSpec, Result, Session, always, bad, can_clear_history, can_redo, can_undo, check_numbers, checked_ids_param, clock_date, cmd, date_param,
|
||||
f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, index,
|
||||
number_or, ok, point_param, str_param, switch_or,
|
||||
f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, number_or,
|
||||
object_param, ok, point_param, str_param, switch_or,
|
||||
};
|
||||
use crate::{Clipboard, EngineError, inspect};
|
||||
|
||||
@@ -267,7 +267,7 @@ fn doc_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
|
||||
let id = object_param(p, "id", "document.node")?.ok_or_else(|| bad("document.node", "missing id"))?;
|
||||
let mut opts = slice_opts(p, "document.node")?;
|
||||
let summary = switch_or(p, "summary", false, "document.node")?;
|
||||
if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
//! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`.
|
||||
//! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`.
|
||||
//! - `metafile`: EMF and WMF export, open and place.
|
||||
//! - `hpgl`: HP-GL export options and plots.
|
||||
//! - `importer`: files that importer plug-ins open (`document.open {importer}`, and extensions no
|
||||
//! built-in format reads).
|
||||
//! - `mesh3d`: 3D assets as OBJ, glTF, USDA and USDZ mesh files (`assets.export3D`).
|
||||
@@ -40,6 +41,7 @@ mod load;
|
||||
mod mesh3d;
|
||||
pub(crate) use load::{MAX_RASTER_ALLOC, size_note};
|
||||
mod heifread;
|
||||
mod hpgl;
|
||||
mod jp2read;
|
||||
mod metafile;
|
||||
mod naming;
|
||||
|
||||
@@ -536,6 +536,7 @@ fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document, def
|
||||
| "dxf"
|
||||
| "emf"
|
||||
| "wmf"
|
||||
| "hpgl"
|
||||
| "ai"
|
||||
)
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -464,7 +464,7 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
}
|
||||
// Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so
|
||||
// themselves: SVG only when it outlines or embeds fonts).
|
||||
if (f.raster || matches!(f.id, "eps" | "emf" | "wmf"))
|
||||
if (f.raster || matches!(f.id, "eps" | "emf" | "wmf" | "hpgl"))
|
||||
&& let Some(w) = crate::cmd::fonts::substitution_warning(doc)
|
||||
{
|
||||
enc.warnings.push(w);
|
||||
@@ -503,7 +503,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
let doc = &*doc.without_edit_modes();
|
||||
let n = doc.artboards.len();
|
||||
// SVG reads its own `useArtboards` (an SVG option).
|
||||
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
|
||||
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf" | "hpgl") { use_artboards(p)? } else { None };
|
||||
// `rect`: that area of the page, as if it were the only artboard (the document is not changed).
|
||||
if let Some(rect) = page_rect(f, p)? {
|
||||
let mut q = super::export::without_artboards(p);
|
||||
@@ -536,6 +536,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
"eps" => return super::eps::encode(doc, p),
|
||||
"emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf),
|
||||
"wmf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Wmf),
|
||||
"hpgl" => return super::hpgl::encode(doc, p, use_artboards),
|
||||
"ai" => return super::save::encode_ai(C, f, doc, p),
|
||||
"pdf" => {
|
||||
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::super::unique_name;
|
||||
use super::{
|
||||
Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, json, metafile, native, pdf, screens, svg, text, unsupported,
|
||||
Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, hpgl, json, metafile, native, pdf, screens, svg, text,
|
||||
unsupported,
|
||||
};
|
||||
|
||||
/// One option a format's encoder reads from the export params.
|
||||
@@ -479,8 +480,17 @@ pub const FORMATS: &[Format] = &[
|
||||
},
|
||||
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
// HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths; exports don't write them.
|
||||
reader("hpgl", "HP-GL", &["plt", "hpgl", "hgl", "hpg"], "application/vnd.hp-hpgl", false),
|
||||
// HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths and written as pen moves.
|
||||
Format {
|
||||
id: "hpgl",
|
||||
label: "HP-GL",
|
||||
extensions: &["plt", "hpgl", "hgl", "hpg"],
|
||||
mime: "application/vnd.hp-hpgl",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: false,
|
||||
options: hpgl::OPTIONS,
|
||||
},
|
||||
Format {
|
||||
id: "tga",
|
||||
label: "Targa",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! HP-GL for every path that writes plot files (`vectorcraft_cad::hpgl`): `document.export {format:
|
||||
//! hpgl}` and Export As (one plot of an artboard, or one per artboard with Use Artboards), with the
|
||||
//! options parsed from the params.
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_cad::hpgl::write::{DEFAULT_TOLERANCE, MAX_PENS, TOLERANCE_RANGE};
|
||||
use vectorcraft_cad::hpgl::{HpglOptions, Origin, Version};
|
||||
use vectorcraft_doc::Document;
|
||||
|
||||
use super::super::{Result, bad, number_or, switch_or, text_param};
|
||||
use super::{ArtboardPick, Encoded, FormatOption};
|
||||
|
||||
const C: &str = "document.export";
|
||||
|
||||
/// The options `document.formats` lists for HP-GL.
|
||||
pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
super::ARTBOARD,
|
||||
super::ARTBOARDS,
|
||||
super::RANGE,
|
||||
super::USE_ARTBOARDS,
|
||||
FormatOption {
|
||||
name: "hpglVersion",
|
||||
ty: "string",
|
||||
default: "\"hpgl\"",
|
||||
description: "hpgl (IN, SP, PU and PD only; a coordinate outside -32,768 to 32,767 gets a warning) or hpgl2 (also the pens' colors, NP and PC, and each pen's paths as one encoded polyline, PE)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "curveTolerance",
|
||||
ty: "number",
|
||||
default: "0.1",
|
||||
description: "how far the straight moves a curve becomes may lie from it, in points (0.01 to 10)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "pens",
|
||||
ty: "array",
|
||||
default: "null",
|
||||
description: "the pen table, pen 1's color first: up to 255 \"#rrggbb\" colors, each path drawn with the pen of the nearest color; null: a pen for each color in the order the art first draws in it, numbered alike in every file of one export",
|
||||
},
|
||||
FormatOption {
|
||||
name: "origin",
|
||||
ty: "string",
|
||||
default: "\"bottomLeft\"",
|
||||
description: "bottomLeft or center: where (0, 0) lies on the artboard (or the art's bounds)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "sortPaths",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "draw each pen's next path from the end nearest where the pen stopped, instead of in paint order",
|
||||
},
|
||||
FormatOption {
|
||||
name: "insideFirst",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "draw a path only after the pen's paths that lie inside it (a letter's counter before the letter, a hole before the part), the paths otherwise in paint order; with sortPaths, the nearest of the paths free to draw comes next",
|
||||
},
|
||||
];
|
||||
|
||||
/// The HP-GL options in `p` (the region is set per plot).
|
||||
fn parse(p: &Value) -> Result<HpglOptions> {
|
||||
let mut o = HpglOptions::default();
|
||||
if let Some(s) = text_param(p, "hpglVersion", C)? {
|
||||
let key = "hpglVersion";
|
||||
o.version = Version::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Version::ALL.map(Version::id).join(", "))))?;
|
||||
}
|
||||
let x = number_or(p, "curveTolerance", DEFAULT_TOLERANCE, C)?;
|
||||
if !TOLERANCE_RANGE.contains(&x) {
|
||||
let (key, min, max) = ("curveTolerance", TOLERANCE_RANGE.start(), TOLERANCE_RANGE.end());
|
||||
return Err(bad(C, format!("`{key}` must be between {min} and {max} (got {x})")));
|
||||
}
|
||||
o.tolerance = x;
|
||||
match p.get("pens") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(v) => {
|
||||
let colors: Option<Vec<[u8; 3]>> = v.as_array().filter(|a| (1..=MAX_PENS).contains(&a.len())).and_then(|a| a.iter().map(hex).collect());
|
||||
o.pens = colors.ok_or_else(|| bad(C, "`pens` must be an array of 1 to 255 #rrggbb colors"))?;
|
||||
}
|
||||
}
|
||||
if let Some(s) = text_param(p, "origin", C)? {
|
||||
let key = "origin";
|
||||
o.origin = Origin::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Origin::ALL.map(Origin::id).join(", "))))?;
|
||||
}
|
||||
o.sort = switch_or(p, "sortPaths", false, C)?;
|
||||
o.inside_first = switch_or(p, "insideFirst", false, C)?;
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// A `#rrggbb` color.
|
||||
fn hex(v: &Value) -> Option<[u8; 3]> {
|
||||
let s = v.as_str()?.strip_prefix('#').filter(|s| s.len() == 6 && s.bytes().all(|b| b.is_ascii_hexdigit()))?;
|
||||
let byte = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
Some([byte(0)?, byte(2)?, byte(4)?])
|
||||
}
|
||||
|
||||
/// Encode `doc` as HP-GL: a plot of the chosen artboard (default the first), or with
|
||||
/// `use_artboards` one plot per chosen artboard (default all), with one pen numbering for them all.
|
||||
pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) -> Result<Encoded> {
|
||||
let opts = parse(p)?;
|
||||
let f = super::format("hpgl").ok_or_else(|| bad(C, "no HP-GL format"))?;
|
||||
let pick: ArtboardPick = super::encode::options(f, p)?;
|
||||
let n = doc.artboards.len();
|
||||
let boards = match use_artboards {
|
||||
Some(true) => pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()),
|
||||
_ => vec![pick.one(n).map_err(|e| bad(C, e))?],
|
||||
};
|
||||
let regions = boards
|
||||
.iter()
|
||||
.map(|&b| doc.artboards.get(b).map(|a| a.rect).ok_or_else(|| bad(C, format!("no artboard {}", b + 1))))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let plots = vectorcraft_cad::hpgl::export_regions(doc, &opts, ®ions).map_err(|e| bad(C, e))?;
|
||||
let mut enc = Encoded::default();
|
||||
for (b, out) in boards.into_iter().zip(plots) {
|
||||
enc.files.push((Some(b), out.bytes));
|
||||
for w in out.warnings {
|
||||
if !enc.warnings.contains(&w) {
|
||||
enc.warnings.push(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
if enc.files.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let regions: Vec<vectorcraft_geom::Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
|
||||
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));
|
||||
Ok(enc)
|
||||
}
|
||||
@@ -885,7 +885,13 @@ impl SaveJob {
|
||||
/// folder?, warnings, files?: [{name, dataBase64}] (every file, when there are several),
|
||||
/// linked?: [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, files?:
|
||||
/// [path…], linked?: [path…]}`.
|
||||
pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
|
||||
pub fn write(&self, write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
|
||||
self.write_unless(|_| None, write)
|
||||
}
|
||||
|
||||
/// [`SaveJob::write`], once `stop` has seen the paths of every file it writes to a path, in
|
||||
/// order: when `stop` returns a value, nothing is written and that value is returned.
|
||||
pub fn write_unless(&self, stop: impl FnOnce(&[&str]) -> Option<Value>, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
|
||||
let (plan, doc) = (&self.plan, &*self.doc);
|
||||
let mut enc = self.encode()?;
|
||||
let warnings = std::mem::take(&mut enc.warnings);
|
||||
@@ -899,6 +905,9 @@ impl SaveJob {
|
||||
return Ok(out);
|
||||
};
|
||||
let files = enc.named(doc, path);
|
||||
if let Some(stopped) = stop(&files.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>()) {
|
||||
return Ok(stopped);
|
||||
}
|
||||
for (p, bytes) in &files {
|
||||
write(p, bytes)?;
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ pub(super) fn name_without_device(name: &str) -> String {
|
||||
pub(crate) const ROW_KEYS: [&str; 7] = ["format", "scale", "ppi", "width", "height", "suffix", "folder"];
|
||||
|
||||
/// The writable formats a format row can't name.
|
||||
pub(crate) const UNWRITTEN_FORMATS: [&str; 5] = ["template", "txt", "dxf", "emf", "wmf"];
|
||||
pub(crate) const UNWRITTEN_FORMATS: [&str; 6] = ["template", "txt", "dxf", "emf", "wmf", "hpgl"];
|
||||
|
||||
/// One format row: what to write, at which size, with which suffix, into which sub-folder.
|
||||
struct Row {
|
||||
|
||||
@@ -431,6 +431,7 @@ fn export_extensions_name_every_writable_format() {
|
||||
"eps",
|
||||
"emf",
|
||||
"wmf",
|
||||
"plt",
|
||||
"tga",
|
||||
"psd"
|
||||
]
|
||||
@@ -466,6 +467,7 @@ fn formats_query_lists_readers_writers_and_options() {
|
||||
"eps",
|
||||
"emf",
|
||||
"wmf",
|
||||
"hpgl",
|
||||
"tga",
|
||||
"psd"
|
||||
]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! HP-GL plotter files through the commands: `document.open` by extension or by content, File ›
|
||||
//! Place as one group of paths, the dialogs' filters and the format list, and files that aren't
|
||||
//! HP-GL refused with a message.
|
||||
//! Place as one group of paths, the dialogs' filters and the format list, files that aren't
|
||||
//! HP-GL refused with a message, and `document.export` of one artboard, each or the art, with its
|
||||
//! options checked.
|
||||
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
use super::*;
|
||||
@@ -100,5 +101,131 @@ fn the_dialogs_and_the_format_list_name_hpgl() {
|
||||
assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter));
|
||||
let r = Session::new().execute("document.formats", &json!({})).unwrap();
|
||||
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "hpgl").unwrap().clone();
|
||||
assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false)));
|
||||
assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(true), Some(false)));
|
||||
for o in ["useArtboards", "hpglVersion", "curveTolerance", "pens", "origin", "sortPaths", "insideFirst"] {
|
||||
assert!(f["options"].get(o).is_some(), "{o}: {f}");
|
||||
}
|
||||
assert_eq!(writable_format(None, Some("x/part.plt")).unwrap().id, "hpgl");
|
||||
assert_eq!(writable_format(None, Some("x/part.hpgl")).unwrap().id, "hpgl");
|
||||
}
|
||||
|
||||
fn bytes(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||||
}
|
||||
|
||||
/// Two 100 pt artboards side by side, a 30 pt square on each, the second one red.
|
||||
fn two_boards() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2})).unwrap();
|
||||
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
|
||||
s.execute("paint.setFill", &json!({"none": true})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
let red = s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||||
s.execute("paint.setStroke", &json!({"ids": [red], "color": "#ff0000"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_writes_a_plot_of_one_artboard_or_each() {
|
||||
let mut s = two_boards();
|
||||
let r = s.execute("document.export", &json!({"format": "hpgl"})).unwrap();
|
||||
assert_eq!(r["format"], "hpgl");
|
||||
// The first artboard's square, 1016 units to the inch from its bottom-left corner.
|
||||
assert_eq!(String::from_utf8(bytes(&r)).unwrap(), "IN;PU;SP1;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;");
|
||||
let second = String::from_utf8(bytes(&s.execute("document.export", &json!({"format": "hpgl", "artboard": 1})).unwrap())).unwrap();
|
||||
assert_eq!(second, "IN;PU;SP1;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;", "its own square, its own pen 1");
|
||||
assert_eq!(bytes(&s.execute("document.serialize", &json!({"format": "hpgl"})).unwrap()), bytes(&r));
|
||||
let r = s.execute("document.export", &json!({"format": "hpgl", "useArtboards": true})).unwrap();
|
||||
let files = r["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
assert!(files[1]["name"].as_str().unwrap().ends_with("-Artboard-2.plt"), "{files:?}");
|
||||
// One pen numbering for every file: the red square is pen 2 in the second.
|
||||
assert_eq!(String::from_utf8(bytes(&files[1])).unwrap(), "IN;PU;SP2;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;");
|
||||
// The bounds of the art: both squares, the second pen red, read back as two paths.
|
||||
let art = bytes(&s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false, "hpglVersion": "hpgl2"})).unwrap());
|
||||
assert!(art.starts_with(b"IN;NP4;PC1,0,0,0;PC2,255,0,0;"), "{:?}", String::from_utf8_lossy(&art));
|
||||
let back = Session::new().execute("document.open", &json!({"name": "art.plt", "dataBase64": b64(&art)})).unwrap();
|
||||
assert_eq!(back["format"], "hpgl", "{back}");
|
||||
// One file holds one artboard.
|
||||
assert!(s.execute("document.export", &json!({"format": "hpgl", "artboards": [0, 1]})).is_err());
|
||||
}
|
||||
|
||||
/// A plot read back opens with the same squares: as wide as the art drew them, a point at most
|
||||
/// apart from the 30 pt sides plus the 0.35 mm pen.
|
||||
#[test]
|
||||
fn a_plot_reads_back_as_its_art() {
|
||||
let mut s = two_boards();
|
||||
let plot = bytes(&s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).unwrap());
|
||||
let mut back = Session::new();
|
||||
back.execute("document.open", &json!({"name": "art.plt", "dataBase64": b64(&plot)})).unwrap();
|
||||
let doc = &back.doc().unwrap().doc;
|
||||
let mut paths = 0;
|
||||
doc.walk(|n| paths += usize::from(matches!(n.kind, NodeKind::Path { .. })));
|
||||
assert_eq!(paths, 2);
|
||||
let board = doc.artboards[0].rect;
|
||||
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
|
||||
let pen = 0.35 * 72.0 / 25.4;
|
||||
assert!((board.width() - (second + 30.0 + pen)).abs() < 0.1, "{board:?}");
|
||||
assert!((board.height() - (30.0 + pen)).abs() < 0.1, "{board:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_options_are_checked() {
|
||||
let mut s = two_boards();
|
||||
let refused = |s: &mut Session, p: Value| s.execute("document.export", &p).expect_err("refused").to_string();
|
||||
let cases = [
|
||||
(json!({"format": "hpgl", "hpglVersion": "hpgl3"}), "`hpglVersion` must be one of hpgl, hpgl2"),
|
||||
(json!({"format": "hpgl", "hpglVersion": 2}), "`hpglVersion` must be a string"),
|
||||
(json!({"format": "hpgl", "curveTolerance": 0}), "`curveTolerance` must be between 0.01 and 10 (got 0)"),
|
||||
(json!({"format": "hpgl", "curveTolerance": "fine"}), "`curveTolerance` must be a number"),
|
||||
(json!({"format": "hpgl", "pens": []}), "`pens` must be an array of 1 to 255 #rrggbb colors"),
|
||||
(json!({"format": "hpgl", "pens": ["#ff0000", "red"]}), "`pens` must be an array of 1 to 255 #rrggbb colors"),
|
||||
(json!({"format": "hpgl", "pens": "#ff0000"}), "`pens` must be an array of 1 to 255 #rrggbb colors"),
|
||||
(json!({"format": "hpgl", "origin": "top"}), "`origin` must be one of bottomLeft, center"),
|
||||
(json!({"format": "hpgl", "sortPaths": "yes"}), "`sortPaths` must be true or false"),
|
||||
(json!({"format": "hpgl", "insideFirst": 1}), "`insideFirst` must be true or false"),
|
||||
];
|
||||
for (p, want) in cases {
|
||||
let e = refused(&mut s, p.clone());
|
||||
assert!(e.contains(want), "{p}: {e}");
|
||||
}
|
||||
let many: Vec<String> = (0..256).map(|i| format!("#{:02x}0000", i % 256)).collect();
|
||||
assert!(refused(&mut s, json!({"format": "hpgl", "pens": many})).contains("`pens`"));
|
||||
// Given options take effect: the center as the origin, a pen table (black is pen 2), and the
|
||||
// square starting at its corner nearest the origin.
|
||||
let p = json!({"format": "hpgl", "origin": "CENTER", "pens": ["#ff0000", "#000000"], "curveTolerance": 10, "sortPaths": true});
|
||||
let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap();
|
||||
assert_eq!(plot, "IN;PU;SP2;PU-141,141;PD-564,141,-564,564,-141,564,-141,141;PU;SP0;");
|
||||
}
|
||||
|
||||
/// `insideFirst` draws the square inside the other one first, though it was made last.
|
||||
#[test]
|
||||
fn inside_first_draws_the_inner_square_first() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"none": true})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 30, "y": 30, "width": 20, "height": 20})).unwrap();
|
||||
let starts = |s: &mut Session, p: Value| {
|
||||
let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap();
|
||||
(plot.find("PU141,1270;").unwrap(), plot.find("PU423,988;").unwrap())
|
||||
};
|
||||
let (outer, inner) = starts(&mut s, json!({"format": "hpgl"}));
|
||||
assert!(outer < inner);
|
||||
let (outer, inner) = starts(&mut s, json!({"format": "hpgl", "insideFirst": true}));
|
||||
assert!(inner < outer);
|
||||
}
|
||||
|
||||
/// Export Selection plots the selected square alone (the red one, made last), from the
|
||||
/// bottom-left corner of its bounds.
|
||||
#[test]
|
||||
fn export_selection_writes_a_plot_of_the_selection() {
|
||||
let mut s = two_boards();
|
||||
let r = s.execute("document.exportSelection", &json!({"format": "hpgl"})).unwrap();
|
||||
assert_eq!(r["format"], "hpgl");
|
||||
let plot = String::from_utf8(bytes(&r)).unwrap();
|
||||
assert_eq!(plot.matches("PD").count(), 1, "{plot}");
|
||||
assert!(plot.starts_with("IN;PU;SP1;PU7,430;PD"), "{plot}");
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use super::{
|
||||
CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, cmd, color_value, f64_or, has_doc,
|
||||
has_selection, id_param, ids_param, index, ok, str_param, switch_or,
|
||||
has_selection, ids_param, index, object_param, ok, str_param, switch_or,
|
||||
};
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -426,7 +426,7 @@ fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The Layers panel's target circle.
|
||||
fn target(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.target";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
if !st.doc.is_editable(id) {
|
||||
@@ -594,7 +594,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.setCurrent";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let st = s.doc_mut()?;
|
||||
let layer = st.doc.layer_containing(id).ok_or(EngineError::NoNode(id))?;
|
||||
st.active_layer = Some(layer);
|
||||
@@ -700,7 +700,7 @@ fn in_isolation(doc: &Document, isolation: Option<NodeId>, id: NodeId) -> bool {
|
||||
/// The selection column: select a row's art.
|
||||
fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.selectAll";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let add = switch_or(p, "add", false, C)?;
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
@@ -747,9 +747,12 @@ enum Place {
|
||||
/// Drag rows in the Layers panel.
|
||||
fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.move";
|
||||
let mut ids = ids_param(p, "ids", C)?.or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?;
|
||||
let mut ids = match ids_param(p, "ids", C)? {
|
||||
Some(ids) => ids,
|
||||
None => vec![object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing ids"))?],
|
||||
};
|
||||
ids.truncate(MAX_ROWS);
|
||||
let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?;
|
||||
let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing target"))?;
|
||||
let place = match str_param(p, "place").unwrap_or("inside") {
|
||||
"above" => Place::Above,
|
||||
"below" => Place::Below,
|
||||
@@ -831,8 +834,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "node.move";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let parent = id_param(p, "parent");
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let parent = object_param(p, "parent", C)?;
|
||||
let index = p.get("index").and_then(Value::as_u64).map_or(usize::MAX, index);
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
|
||||
@@ -21,7 +21,7 @@ use super::expand::gradient_sampler;
|
||||
use super::pathops::Beside;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_param, f64_or, f64_req, finite_numbers, has_doc, has_multi,
|
||||
has_selection, id_param, named_ids, number_or, ok, str_param, switch_or, text_param,
|
||||
has_selection, named_ids, number_or, object_param, ok, str_param, switch_or, text_param,
|
||||
};
|
||||
|
||||
mod blend;
|
||||
|
||||
@@ -324,7 +324,11 @@ pub(super) fn spine_remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if anchors.contains(&index) {
|
||||
return Err(bad(C, "a key object sits on that point"));
|
||||
}
|
||||
if sp.anchors.len() <= 2 {
|
||||
// A closed spine pinned open keeps a copy of its first point at its end (#6809), so the
|
||||
// points left must be two apart, not only two in number.
|
||||
let mut rest = sp.anchors.iter().enumerate().filter(|(i, _)| *i != index).map(|(_, a)| a.p);
|
||||
let first = rest.next();
|
||||
if sp.anchors.len() <= 2 || first.is_none_or(|f| rest.all(|q| q.distance(f) < 0.01)) {
|
||||
return Err(bad(C, "a spine keeps at least two points"));
|
||||
}
|
||||
sp.anchors.remove(index);
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wr
|
||||
use super::mesh::{handle_param, index_param, whole};
|
||||
use super::{
|
||||
Affine, Appearance, Arc, Beside, Color, Document, EngineError, EnvelopeKind, EnvelopeMap, EnvelopeOptions, Node, NodeId, NodeKind, Paint,
|
||||
PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, id_param, json, live, ok, switch_or, text_param,
|
||||
PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, json, live, object_param, ok, switch_or, text_param,
|
||||
};
|
||||
|
||||
// ---------- Envelope ----------
|
||||
@@ -419,7 +419,7 @@ pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub(super) fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.envelope.setMeshPoint";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let index = index_param(p, "index", C)?;
|
||||
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
let handle = handle_param(p, C)?;
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::common::{fix_ids, ids_json, is_mesh, replace_with, roots_param, selec
|
||||
use super::envelope::{outline_of, rows_cols};
|
||||
use super::{
|
||||
Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeId, NodeKind, Paint, Point, Result, Session, Value, bad,
|
||||
check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, id_param, json, live, ok, str_param,
|
||||
check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, json, live, object_param, ok, str_param,
|
||||
};
|
||||
|
||||
// ---------- Gradient mesh ----------
|
||||
@@ -115,7 +115,7 @@ fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols:
|
||||
/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh
|
||||
/// envelope's grid too (see [`edit_grid`]).
|
||||
fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
|
||||
let id = object_param(p, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?;
|
||||
s.edit(label, move |d, _| edit_grid(d, id, cmd, envelopes, f))
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ const MAX_POINTS: usize = 1 << 20;
|
||||
/// points}…]}`. Each mesh comes once, with its points in order and without repeats.
|
||||
fn point_items(p: &Value, cmd: &str) -> Result<Vec<(NodeId, BTreeSet<usize>)>> {
|
||||
let one = |q: &Value| -> Result<(NodeId, BTreeSet<usize>)> {
|
||||
let id = id_param(q, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
|
||||
let id = object_param(q, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?;
|
||||
let points = match q.get("points") {
|
||||
None | Some(Value::Null) => BTreeSet::from([index_param(q, "index", cmd)?]),
|
||||
Some(Value::Array(v)) if v.len() <= MAX_POINTS => v
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@ use super::draw2::usize_opt;
|
||||
use super::pathops::Beside;
|
||||
use super::{
|
||||
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selected_path,
|
||||
has_selection, id_param, ids_param, index, number_or, ok, str_param, switch_or, targets,
|
||||
has_selection, ids_param, index, number_or, object_param, ok, str_param, switch_or, targets,
|
||||
};
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -168,7 +168,7 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result<
|
||||
}
|
||||
|
||||
fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
|
||||
let id = object_param(p, "id", "path.appendAnchor")?.ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
|
||||
let a = anchor_from_json(p, "path.appendAnchor")?;
|
||||
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
|
||||
s.edit("Pen", |d, sel| {
|
||||
@@ -206,7 +206,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// incoming handle moves as Move Direction Handle moves it: the outgoing handle turns with it only
|
||||
/// on a smooth anchor, so the first segment of a path that starts at a corner keeps its shape.
|
||||
fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
|
||||
let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?;
|
||||
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
|
||||
let handle = |key: &str| match p.get(key).filter(|h| !h.is_null()) {
|
||||
None => Ok(None),
|
||||
@@ -303,7 +303,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
|
||||
}
|
||||
|
||||
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
|
||||
let id = object_param(p, "id", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
|
||||
let out = str_param(p, "which") != Some("in");
|
||||
@@ -321,7 +321,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// `path.moveAnchor`: one anchor to (x, y), its handles keeping their offsets (the Anchor Point
|
||||
/// tool's Shift-drag).
|
||||
fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
|
||||
let id = object_param(p, "id", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?;
|
||||
let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?);
|
||||
@@ -341,7 +341,7 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?;
|
||||
let id = object_param(p, "id", "path.setAnchors")?.ok_or_else(|| bad("path.setAnchors", "missing id"))?;
|
||||
let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?;
|
||||
let data = PathData::new(
|
||||
subs.iter()
|
||||
@@ -1562,7 +1562,7 @@ fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usiz
|
||||
}
|
||||
|
||||
fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?;
|
||||
let id = object_param(p, "id", "path.reshape")?.ok_or_else(|| bad("path.reshape", "missing id"))?;
|
||||
let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?);
|
||||
check_numbers(p, &["dx", "dy", "tol", "radius"], "path.reshape")?;
|
||||
let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
@@ -1684,7 +1684,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
|
||||
let id = object_param(p, "id", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?;
|
||||
let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0);
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"addon.color.strokeToFill",
|
||||
"addon.color.swatchLegend",
|
||||
"addon.contour.set",
|
||||
"addon.cutting.bridges",
|
||||
"addon.image.removeBackground",
|
||||
"addon.pathtools.addPoints",
|
||||
"addon.pathtools.close",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -13,8 +13,8 @@ use vectorcraft_doc::{Appearance, CellAlign, CellVAlign, Document, Node, NodeKin
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::{
|
||||
CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, f64_req, has_doc, has_selection, id_param, ids_param, index, number_or, switch_or,
|
||||
targets,
|
||||
CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, f64_req, has_doc, has_selection, ids_param, index, number_or, object_param,
|
||||
switch_or, targets,
|
||||
};
|
||||
|
||||
/// The most slices one Divide Slices makes.
|
||||
@@ -680,7 +680,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn set_rect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.slice.setRect";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let r = rect_param(p, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
if d.slice(id).is_none() {
|
||||
|
||||
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy to User Defined",
|
||||
["Window", "Swatch Libraries"],
|
||||
None,
|
||||
"{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path (left out when it is outside the read roots), copied: false when nothing was written}",
|
||||
"{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}",
|
||||
has_user_folder,
|
||||
copy_to_user
|
||||
),
|
||||
@@ -368,9 +368,7 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
.ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?;
|
||||
let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) {
|
||||
Some(p) => {
|
||||
// The file's bytes are read: outside the request's read roots, the error names no
|
||||
// path. A document's swatches (the arm below) are written from the loaded library,
|
||||
// and the document is not read again.
|
||||
// Outside the request's read roots: the error names no path.
|
||||
if !readable(&p) {
|
||||
return Err(bad(cmd, "the library's file is outside the read roots"));
|
||||
}
|
||||
@@ -471,34 +469,19 @@ fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// The file name a concealed library or path is shown by: the last part of `path` as this platform
|
||||
/// splits a path, so that outside Windows a `\` stays part of the name. Another platform's absolute
|
||||
/// path ([`crate::file_access::written_elsewhere`]), and a path this platform gives no file name (one
|
||||
/// that ends in `..`), are split after the last `/` or `\` instead. `path` itself when it has no such part.
|
||||
pub fn concealed_file_name(path: &str) -> String {
|
||||
let native = (!crate::file_access::written_elsewhere(path)).then(|| std::path::Path::new(path).file_name()).flatten();
|
||||
native.map_or_else(|| path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(), |n| n.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without
|
||||
/// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is
|
||||
/// never readable under a read root.
|
||||
/// roots in force.
|
||||
fn readable(path: &str) -> bool {
|
||||
!crate::file_access::written_elsewhere(path) && crate::file_access::check_read(path).is_ok()
|
||||
crate::file_access::check_read(path).is_ok()
|
||||
}
|
||||
|
||||
/// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each
|
||||
/// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is
|
||||
/// named. The file name is [`concealed_file_name`], and a path saved as another platform's absolute
|
||||
/// path counts as one the request may not read. The ids are those of the
|
||||
/// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers
|
||||
/// to). When another id already is or shows that id, the next free
|
||||
/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. A library
|
||||
/// keeps the id it was first shown under for the rest of the session, so an id a client was given
|
||||
/// finds the same library after another one with the same file name is loaded; only a library that
|
||||
/// shows that id itself (one loaded later without a file under that name) moves it to the next free
|
||||
/// one. Libraries shown for the first time are numbered the session's first, in the sorted order of
|
||||
/// their full ids, then `others`, in the same order. Without roots in force, none.
|
||||
/// named. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then
|
||||
/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free
|
||||
/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. The
|
||||
/// session's libraries are numbered first, in the sorted order of their full ids, so a library shows
|
||||
/// the same id whatever `others` holds; then `others`, in the same order. Without roots in force, none.
|
||||
pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = String>) -> Vec<(String, String)> {
|
||||
if crate::file_access::current().is_none() {
|
||||
return vec![];
|
||||
@@ -510,7 +493,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin
|
||||
let mut taken = BTreeSet::new();
|
||||
for ids in [session, others] {
|
||||
for id in ids {
|
||||
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (concealed_file_name(path), path));
|
||||
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (file_name(path), path));
|
||||
match name {
|
||||
Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)),
|
||||
_ => {
|
||||
@@ -519,32 +502,16 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut assigned = s.shown_library_ids.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut kept = vec![None; hidden.len()];
|
||||
for (slot, (id, _)) in kept.iter_mut().zip(&hidden) {
|
||||
if let Some(shown) = assigned.get(id).filter(|shown| !taken.contains(*shown)) {
|
||||
taken.insert(shown.clone());
|
||||
*slot = Some(shown.clone());
|
||||
}
|
||||
}
|
||||
// A new id is never one given to another library earlier in the session.
|
||||
let mut given: BTreeSet<String> = assigned.values().cloned().collect();
|
||||
hidden
|
||||
.into_iter()
|
||||
.zip(kept)
|
||||
.map(|((id, name), kept)| {
|
||||
let shown = kept.unwrap_or_else(|| {
|
||||
let mut shown = format!("loaded/{name}");
|
||||
let mut n = 2;
|
||||
while taken.contains(&shown) || given.contains(&shown) {
|
||||
shown = format!("loaded/{name} ({n})");
|
||||
n += 1;
|
||||
}
|
||||
taken.insert(shown.clone());
|
||||
given.insert(shown.clone());
|
||||
assigned.insert(id.clone(), shown.clone());
|
||||
shown
|
||||
});
|
||||
.map(|(id, name)| {
|
||||
let mut shown = format!("loaded/{name}");
|
||||
let mut n = 2;
|
||||
while taken.contains(&shown) {
|
||||
shown = format!("loaded/{name} ({n})");
|
||||
n += 1;
|
||||
}
|
||||
taken.insert(shown.clone());
|
||||
(id, shown)
|
||||
})
|
||||
.collect()
|
||||
@@ -779,12 +746,8 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"user" => (info, lib, false),
|
||||
_ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))),
|
||||
};
|
||||
let mut out = json!({"library": info.id, "name": info.name, "count": lib.len(), "copied": copied});
|
||||
// As the saves with `user: true`: no `path` when the request may not read the file.
|
||||
if let Some(path) = s.swatch_libraries.shown_path(&info.id) {
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
Ok(out)
|
||||
let path = s.swatch_libraries.shown_path(&info.id);
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied}))
|
||||
}
|
||||
|
||||
/// The library swatches `names` stand for, each once, with the colour group it goes into (`None`:
|
||||
|
||||
@@ -19,7 +19,7 @@ use vectorcraft_doc::symbol_overrides::SymbolOverride;
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection, SymbolEdit, SymbolRegistration};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use super::brushsym::{natural_size, place_xf};
|
||||
use super::brushsym::{fit_parts, natural_size, place_xf};
|
||||
use super::{EngineError, Result, Session, bad, number_or, object_param, ok, str_param};
|
||||
|
||||
/// The name of the undo step that enters symbol editing mode.
|
||||
@@ -425,8 +425,8 @@ fn each_changed_instance(n: &mut Node, name: &str, f: &mut dyn FnMut(&mut Vec<Sy
|
||||
}
|
||||
|
||||
/// Each instance's appearance changes to objects of symbol `name`'s art follow those objects
|
||||
/// through an edit of the editing layer (see [`follow_paths`]), and keep only the changes to
|
||||
/// objects the art still has. That covers the instances on the layers and in the art of the
|
||||
/// through an edit of the editing layer (see [`follow_paths`]), and keep only the changes the art
|
||||
/// still draws ([`fit_parts`]). That covers the instances on the layers and in the art of the
|
||||
/// document's other symbols, graph designs, pattern tiles (the tiles pattern editing's Cancel puts
|
||||
/// back included) and brushes, opacity-mask art included. The editing layer holds no instance of
|
||||
/// the symbol, since [`sync`] refuses one.
|
||||
@@ -435,7 +435,7 @@ fn follow_objects(d: &mut Document, name: &str, old: (&[Arc<Node>], bool), new:
|
||||
let mut follow = |o: &mut Vec<SymbolOverride>| {
|
||||
follow_paths(o, old, new);
|
||||
match &art {
|
||||
Some(art) => vectorcraft_doc::symbol_overrides::retain_parts(o, art),
|
||||
Some(art) => fit_parts(o, art),
|
||||
None => o.clear(),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Tab Stops",
|
||||
["Window", "Tabs"],
|
||||
None,
|
||||
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept",
|
||||
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, or no more than each targeted paragraph already holds, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept",
|
||||
has_selection,
|
||||
set
|
||||
),
|
||||
@@ -38,10 +38,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn parse_stops(p: &Value) -> Result<Vec<TabStop>> {
|
||||
const C: &str = "text.tabs.set";
|
||||
let arr = p.get("stops").and_then(Value::as_array).ok_or_else(|| bad(C, "missing stops"))?;
|
||||
let max = TabStop::MAX_STOPS;
|
||||
if arr.len() > max {
|
||||
return Err(bad(C, format!("at most {max} tab stops")));
|
||||
}
|
||||
let mut stops = vec![];
|
||||
for s in arr {
|
||||
let position = s.get("position").and_then(Value::as_f64).ok_or_else(|| bad(C, "each stop needs a position"))?;
|
||||
@@ -90,10 +86,39 @@ fn parse_stops(p: &Value) -> Result<Vec<TabStop>> {
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// More than the limit is taken only when every targeted paragraph already holds at least as
|
||||
// many stops, so a paragraph that holds more can still lose them one at a time. The supplied
|
||||
// list is counted before it is read.
|
||||
let max = TabStop::MAX_STOPS;
|
||||
let supplied = p.get("stops").and_then(Value::as_array).map_or(0, Vec::len);
|
||||
if supplied > max && supplied > fewest_held(s, p)? {
|
||||
return Err(bad("text.tabs.set", format!("at most {max} tab stops")));
|
||||
}
|
||||
let stops = parse_stops(p)?;
|
||||
apply(s, p, stops, "Tab Stops")
|
||||
}
|
||||
|
||||
/// The fewest tab stops a paragraph that `text.tabs.set` with `p` targets holds (0 with none).
|
||||
fn fewest_held(s: &Session, p: &Value) -> Result<usize> {
|
||||
let ids = text_targets(s, p, "text.tabs.set")?;
|
||||
let range = TextRange::parse(p, "text.tabs.set")?;
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut fewest: Option<usize> = None;
|
||||
for id in &ids {
|
||||
if let Some(NodeKind::Text(t)) = doc.node(*id).map(|node| &node.kind) {
|
||||
let held = match para_span(range, t) {
|
||||
None => t.para_styles().map(|pa| pa.tabs.len()).min(),
|
||||
Some(r) => r.map(|i| t.para_at(i).tabs.len()).min(),
|
||||
};
|
||||
fewest = match (fewest, held) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(a, b) => a.or(b),
|
||||
};
|
||||
}
|
||||
}
|
||||
Ok(fewest.unwrap_or(0))
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, p: &Value, stops: Vec<TabStop>, label: &str) -> Result<Value> {
|
||||
let ids = text_targets(s, p, "text.tabs.set")?;
|
||||
let range = TextRange::parse(p, "text.tabs.set")?;
|
||||
@@ -159,14 +184,52 @@ mod tests {
|
||||
let err = |stops: Value| super::parse_stops(&json!({ "stops": stops })).unwrap_err().to_string();
|
||||
assert!(err(json!([{"position": 40, "leader": "123456789"}])).contains("`leader` must be at most 8 characters"));
|
||||
assert!(err(json!([{"position": 40, "alignOn": ",."}])).contains("`alignOn` must be one character"));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb"})).unwrap();
|
||||
let many: Vec<Value> = (0..257).map(|i| json!({"position": i})).collect();
|
||||
assert!(err(Value::from(many.clone())).contains("at most 256 tab stops"));
|
||||
let ok = super::parse_stops(&json!({ "stops": &many[..256] })).unwrap();
|
||||
assert_eq!(ok.len(), 256);
|
||||
let e = s.execute("text.tabs.set", &json!({ "stops": many.clone() })).unwrap_err().to_string();
|
||||
assert!(e.contains("at most 256 tab stops"), "{e}");
|
||||
// The supplied list is counted, not the stops left once those at one position merge.
|
||||
let same: Vec<Value> = (0..257).map(|_| json!({"position": 5})).collect();
|
||||
assert!(s.execute("text.tabs.set", &json!({ "stops": same })).unwrap_err().to_string().contains("at most 256 tab stops"));
|
||||
assert_eq!(s.execute("text.tabs.set", &json!({ "stops": &many[..256] })).unwrap()["stops"], json!(256));
|
||||
let ok = super::parse_stops(&json!({"stops": [{"position": 0, "leader": "12345678", "alignOn": "€"}]})).unwrap();
|
||||
assert_eq!((ok[0].leader.as_str(), ok[0].align_on), ("12345678", '€'));
|
||||
}
|
||||
|
||||
/// A paragraph that already holds more than 256 stops (from a file) can lose them one at a
|
||||
/// time: a list no longer than every targeted paragraph's own is taken, a longer one is not, and
|
||||
/// a paragraph with fewer stops targeted beside it keeps the limit.
|
||||
#[test]
|
||||
fn stops_can_be_removed_from_a_paragraph_holding_more_than_the_limit() {
|
||||
use vectorcraft_doc::{NodeId, NodeKind, TabAlign, TabStop};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb\nc\td"})).unwrap()["id"].as_u64().unwrap());
|
||||
let held: Vec<TabStop> =
|
||||
(0..300).map(|i| TabStop { position: f64::from(i), align: TabAlign::Left, leader: String::new(), align_on: '.' }).collect();
|
||||
s.edit("Load", |d, _| {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.edit_paras(None, |pa| pa.tabs = held.clone());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let list = |n: i32| json!({ "stops": (0..n).map(|i| json!({"position": i})).collect::<Vec<_>>() });
|
||||
assert!(s.execute("text.tabs.set", &list(301)).unwrap_err().to_string().contains("at most 256 tab stops"));
|
||||
assert_eq!(s.execute("text.tabs.set", &list(299)).unwrap()["stops"], json!(299));
|
||||
assert_eq!(s.execute("text.tabs.get", &json!({})).unwrap()["stops"].as_array().unwrap().len(), 299);
|
||||
// The second paragraph (bytes 4 on) now holds 2 stops: 298 for both paragraphs is refused,
|
||||
// and 298 for the first alone is taken.
|
||||
s.execute("text.tabs.set", &json!({"stops": [{"position": 1}, {"position": 2}], "start": 4, "end": 4})).unwrap();
|
||||
let mut both = list(298);
|
||||
assert!(s.execute("text.tabs.set", &both).is_err());
|
||||
both["start"] = json!(0);
|
||||
both["end"] = json!(0);
|
||||
assert_eq!(s.execute("text.tabs.set", &both).unwrap()["stops"], json!(298));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_get_clear_tabs() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -11,8 +11,8 @@ use super::menucmds::whole_or;
|
||||
use super::stroke::StrokeChange;
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param,
|
||||
index, number_or, point_param, str_param, switch_or, targets, text_param,
|
||||
CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, index,
|
||||
number_or, object_param, point_param, str_param, switch_or, targets, text_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Step Type",
|
||||
[],
|
||||
None,
|
||||
"{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}",
|
||||
"{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the space after the character before it, or at the start of a line (of the text, a paragraph, or after a forced line break) the space before the character after it (kerningBefore, as text.setRangeStyle), and nothing at an empty line's start; leading its paragraph), else every selected type object → {ids}",
|
||||
has_doc,
|
||||
type_step
|
||||
),
|
||||
@@ -148,7 +148,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reset Tracking and Kerning",
|
||||
[],
|
||||
Some("Cmd+Alt+Q"),
|
||||
"{ids?: [id…]} set tracking to 0 and clear manual kerning without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning of the character before it), else in every selected type object, as one undo step → {ids}",
|
||||
"{ids?: [id…]} set tracking to 0 and clear manual kerning (kerning and kerningBefore) without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning there, as type.step steps it), else in every selected type object, as one undo step → {ids}",
|
||||
has_selection,
|
||||
reset_tracking
|
||||
),
|
||||
@@ -272,7 +272,7 @@ fn range_of(p: &Value, len: usize, cmd: &str) -> Result<(usize, usize)> {
|
||||
|
||||
fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.editRange";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let t = text_ref(s, id)?;
|
||||
let len = edit::runs_len(&t.runs);
|
||||
let (a, b) = range_of(p, len, C)?;
|
||||
@@ -337,7 +337,7 @@ fn clear_param(s: &Session, p: &Value, id: NodeId, c: &str) -> Result<Vec<NodeId
|
||||
|
||||
fn start_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.startList";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let t = text_ref(s, id)?;
|
||||
let len = edit::runs_len(&t.runs);
|
||||
let at = offset_or(p, "at", len, C)?.min(len);
|
||||
@@ -365,7 +365,7 @@ fn start_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?;
|
||||
let id = object_param(p, "id", "text.getRange")?.ok_or_else(|| bad("text.getRange", "missing `id`"))?;
|
||||
if switch_or(p, "story", false, "text.getRange")? {
|
||||
return get_story(s, p, id);
|
||||
}
|
||||
@@ -430,6 +430,8 @@ pub(crate) struct CharChange {
|
||||
leading: Option<Option<f64>>,
|
||||
tracking: Option<f64>,
|
||||
kerning: Option<Option<f64>>,
|
||||
/// The space before a character that starts a line by itself (`kerningBefore`).
|
||||
kerning_before: Option<f64>,
|
||||
optical_kerning: Option<bool>,
|
||||
baseline_shift: Option<f64>,
|
||||
h_scale: Option<f64>,
|
||||
@@ -691,7 +693,7 @@ fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result<Option<Option<f64>>> {
|
||||
impl CharChange {
|
||||
/// `setup` gives the superscript, subscript and small caps proportions.
|
||||
pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result<Self> {
|
||||
check_numbers(p, &["size", "tracking", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?;
|
||||
check_numbers(p, &["size", "tracking", "kerningBefore", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?;
|
||||
check_switches(
|
||||
p,
|
||||
&["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "standardVerticalRoman", "noBreak", "kashidas"],
|
||||
@@ -720,6 +722,7 @@ impl CharChange {
|
||||
Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") || v.eq_ignore_ascii_case("auto") => Some(None),
|
||||
Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(cmd, "kerning must be a number, \"auto\", or \"optical\""))?)),
|
||||
},
|
||||
kerning_before: num("kerningBefore"),
|
||||
optical_kerning: match p.get("kerning") {
|
||||
Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") => Some(true),
|
||||
Some(Value::String(v)) if v.eq_ignore_ascii_case("auto") => Some(false),
|
||||
@@ -777,6 +780,7 @@ impl CharChange {
|
||||
&& self.leading.is_none()
|
||||
&& self.tracking.is_none()
|
||||
&& self.kerning.is_none()
|
||||
&& self.kerning_before.is_none()
|
||||
&& self.optical_kerning.is_none()
|
||||
&& self.baseline_shift.is_none()
|
||||
&& self.h_scale.is_none()
|
||||
@@ -883,6 +887,9 @@ impl CharChange {
|
||||
if let Some(v) = self.kerning {
|
||||
st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0));
|
||||
}
|
||||
if let Some(v) = self.kerning_before {
|
||||
st.kerning_before = v.clamp(-1000.0, 10000.0);
|
||||
}
|
||||
if let Some(v) = self.optical_kerning {
|
||||
st.optical_kerning = v;
|
||||
}
|
||||
@@ -1254,7 +1261,7 @@ fn drawn_glyph(
|
||||
|
||||
fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setGlyphAlternate";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let gid = match p.get("gid") {
|
||||
None => return Err(bad(C, "missing `gid` (a glyph id, or null for the default glyph)")),
|
||||
Some(Value::Null) => None,
|
||||
@@ -1344,7 +1351,7 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn glyph_alternates(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.glyphAlternates";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let t = text_ref(s, id)?;
|
||||
let (ch, bytes, face) = glyph_target(&t, p, C)?;
|
||||
let default = face.glyph_for(ch);
|
||||
@@ -1359,7 +1366,7 @@ fn glyph_alternates(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.touchType";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
check_numbers(p, &["start"], C)?;
|
||||
let start = byte_param(p, "start").ok_or_else(|| bad(C, "missing `start` (the byte offset of a character)"))?;
|
||||
if !["dx", "dy", "hScale", "vScale", "rotation"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
@@ -1585,7 +1592,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.createInPath";
|
||||
let pid = id_param(p, "path").ok_or_else(|| bad(C, "missing `path` (a path object id)"))?;
|
||||
let pid = object_param(p, "path", C)?.ok_or_else(|| bad(C, "missing `path` (a path object id)"))?;
|
||||
let on_path = match str_param(p, "mode").unwrap_or("area") {
|
||||
"area" => false,
|
||||
"onPath" | "path" => true,
|
||||
@@ -1694,17 +1701,16 @@ pub(crate) fn editing_story(s: &Session) -> Vec<NodeId> {
|
||||
s.active().map(|st| super::threads::story_of(&st.doc, id)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A text range a type shortcut changes: its type object, its bytes, and the caret's byte when it
|
||||
/// came from a caret ([`type_ranges`]).
|
||||
pub(crate) type TypeRange = (NodeId, usize, usize, Option<usize>);
|
||||
|
||||
/// The text ranges a type shortcut changes: the range of `id`, else all the text of the type
|
||||
/// objects in `ids`, else the text the Type tool has selected (all the text of every frame of the
|
||||
/// story after Select All in threaded text, [`editing_story`]), else all the text of every selected
|
||||
/// type object. At a caret, `at_caret` gives the range from the plain text and the caret's byte
|
||||
/// (None: nothing changes there). Each range comes with whether it came from a caret.
|
||||
pub(crate) fn type_ranges(
|
||||
s: &mut Session,
|
||||
p: &Value,
|
||||
c: &str,
|
||||
at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>,
|
||||
) -> Result<Vec<(NodeId, usize, usize, bool)>> {
|
||||
/// (None: nothing changes there). Each range comes with the caret's byte when it came from a caret.
|
||||
pub(crate) fn type_ranges(s: &mut Session, p: &Value, c: &str, at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>) -> Result<Vec<TypeRange>> {
|
||||
// (text, its range; None: all of it).
|
||||
let targets: Vec<(NodeId, Option<(usize, usize)>)> = match super::object_param(p, "id", c)? {
|
||||
// An object that isn't type is refused as `ids` of it is.
|
||||
@@ -1728,10 +1734,10 @@ pub(crate) fn type_ranges(
|
||||
let text = text_ref(s, id)?.plain_text();
|
||||
let (a, b) = range.map_or((0, text.len()), |(a, b)| (a.min(text.len()), b.min(text.len())));
|
||||
let (a, b, caret) = if a < b {
|
||||
(a, b, false)
|
||||
(a, b, None)
|
||||
} else {
|
||||
match at_caret(&text, a) {
|
||||
Some((a, b)) => (a, b, true),
|
||||
Some((x, y)) => (x, y, Some(a)),
|
||||
None => continue,
|
||||
}
|
||||
};
|
||||
@@ -1763,24 +1769,31 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Step::Tracking | Step::Kerning => prefs.tracking_increment,
|
||||
Step::BaselineShift => prefs.baseline_shift_increment,
|
||||
};
|
||||
// The ranges stepped. At a caret: kerning is the space after the character before it, leading
|
||||
// the paragraph's; the rest wait for a selection.
|
||||
// The ranges stepped. At a caret: kerning is the space after the character before it, or at
|
||||
// the start of a line the space before the character after it (#5111), leading the
|
||||
// paragraph's; the rest wait for a selection.
|
||||
let ranges = type_ranges(s, p, C, |text, a| match step {
|
||||
Step::Kerning => Some((edit::prev_char(text, a), a)),
|
||||
Step::Kerning => edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)),
|
||||
Step::Leading => {
|
||||
let para = edit::paragraph_at(text, a);
|
||||
Some((para.start, para.end))
|
||||
}
|
||||
_ => None,
|
||||
})?;
|
||||
// At a caret, the kerning stepped: (whether it is the space before the character, its value).
|
||||
let mut caret_kerning = Vec::with_capacity(ranges.len());
|
||||
for &(id, a, b, caret) in &ranges {
|
||||
let value = if matches!(step, Step::Kerning) && caret {
|
||||
let t = text_ref(s, id)?;
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
Some(effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b))
|
||||
} else {
|
||||
None
|
||||
let value = match caret {
|
||||
Some(c) if matches!(step, Step::Kerning) => {
|
||||
let t = text_ref(s, id)?;
|
||||
if edit::caret_kerning_target(&t.plain_text(), c).is_some_and(|(_, before)| before) {
|
||||
Some((true, edit::style_at(&t.runs, a + 1).kerning_before))
|
||||
} else {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
Some((false, effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b)))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
caret_kerning.push(value);
|
||||
}
|
||||
@@ -1829,10 +1842,13 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.leading = Some(super::typecmd::local_type_value(t, leading * points + inc, (0.1, 5000.0), false, C)?);
|
||||
}
|
||||
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
|
||||
Step::Kerning => {
|
||||
let current = caret_kerning.get(range_index).copied().flatten().unwrap_or_else(|| st.kerning.unwrap_or(0.0));
|
||||
st.kerning = Some((current + inc).clamp(-1000.0, 10000.0));
|
||||
}
|
||||
Step::Kerning => match caret_kerning.get(range_index).copied().flatten() {
|
||||
Some((true, current)) => st.kerning_before = (current + inc).clamp(-1000.0, 10000.0),
|
||||
caret => {
|
||||
let current = caret.map_or_else(|| st.kerning.unwrap_or(0.0), |(_, v)| v);
|
||||
st.kerning = Some((current + inc).clamp(-1000.0, 10000.0));
|
||||
}
|
||||
},
|
||||
Step::BaselineShift => {
|
||||
st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?;
|
||||
}
|
||||
@@ -1849,17 +1865,22 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Cmd/Ctrl+Alt+Q: tracking 0 and manual kerning cleared in the type objects in
|
||||
/// `ids`, else in the Type tool's selected text, else in every selected type object. At a caret,
|
||||
/// only the kerning of the character before it changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns there.
|
||||
/// The automatic method remains selected.
|
||||
/// only the kerning there changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns
|
||||
/// there: of the character before it, or at the start of a line the space before the character
|
||||
/// after it ([`edit::caret_kerning_target`]). The automatic method remains selected.
|
||||
fn reset_tracking(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?;
|
||||
let ranges = type_ranges(s, p, "type.resetTracking", |text, a| edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)))?;
|
||||
if !ranges.is_empty() {
|
||||
s.edit("Reset Tracking and Kerning", |d, _| {
|
||||
for &(id, a, b, caret) in &ranges {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
edit::style_range(&mut t.runs, a, b, |st| {
|
||||
st.kerning = None;
|
||||
if !caret {
|
||||
let before = caret.and_then(|c| edit::caret_kerning_target(&t.plain_text(), c)).map(|(_, before)| before);
|
||||
edit::style_range(&mut t.runs, a, b, |st| match before {
|
||||
Some(true) => st.kerning_before = 0.0,
|
||||
Some(false) => st.kerning = None,
|
||||
None => {
|
||||
st.kerning = None;
|
||||
st.kerning_before = 0.0;
|
||||
st.tracking = 0.0;
|
||||
}
|
||||
});
|
||||
@@ -1975,7 +1996,7 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.discardEmpty";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
if edit::runs_len(&text_ref(s, id)?.runs) > 0 {
|
||||
return Ok(json!({"removed": false}));
|
||||
}
|
||||
|
||||
@@ -188,8 +188,10 @@ fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> {
|
||||
/// from a style instead leaves the text's own value alone.
|
||||
pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
|
||||
let mut m = to_attrs(v);
|
||||
// A chosen glyph belongs to one character, not to a reusable character style.
|
||||
// A chosen glyph belongs to one character, and the space before a line's first character to
|
||||
// where it stands (#5111), not to a reusable character style.
|
||||
m.remove("glyphAlternate");
|
||||
m.remove("kerningBefore");
|
||||
for (k, x) in [
|
||||
("font_version", json!(v.font_version)),
|
||||
("fontAxes", json!(v.font_axes)),
|
||||
@@ -243,6 +245,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
|
||||
("kinsoku_type", json!(v.kinsoku_type)),
|
||||
("roman_hanging", json!(v.roman_hanging)),
|
||||
("kashida", json!(v.kashida)),
|
||||
("korean_line_break_anywhere", json!(v.korean_line_break_anywhere)),
|
||||
] {
|
||||
m.insert(k.into(), x);
|
||||
}
|
||||
@@ -530,6 +533,12 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
|
||||
if m.contains_key("glyphAlternate") {
|
||||
return Err(bad(&c, "`glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it)"));
|
||||
}
|
||||
if m.contains_key("kerningBefore") {
|
||||
return Err(bad(
|
||||
&c,
|
||||
"`kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it)",
|
||||
));
|
||||
}
|
||||
let mut st = with_attrs(&CharStyle::default(), m, &c)?;
|
||||
let changed = |key: &str| m.contains_key(key) && kept.and_then(|k| k.get(key)) != m.get(key);
|
||||
for (key, value, lo, hi) in
|
||||
|
||||
@@ -1336,7 +1336,8 @@ fn para_style_redefined_to_the_defaults_updates_its_paragraphs() {
|
||||
|
||||
/// The same for character styles: Auto kerning and the Roman baseline alignment, which the
|
||||
/// run's JSON leaves out, are captured and reach the style's users. A glyph choice belongs to
|
||||
/// one character, so it is not a reusable style attribute.
|
||||
/// one character, and the space before a line's first character to where it stands (#5111), so
|
||||
/// neither is a reusable style attribute.
|
||||
#[test]
|
||||
fn char_style_redefined_to_the_defaults_updates_its_text() {
|
||||
use vectorcraft_doc::CharAlign;
|
||||
@@ -1356,6 +1357,7 @@ fn char_style_redefined_to_the_defaults_updates_its_text() {
|
||||
assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3]));
|
||||
let mut reusable = settable::<CharStyle>();
|
||||
reusable.remove("glyphAlternate");
|
||||
reusable.remove("kerningBefore");
|
||||
assert_eq!(defined(&s, 0), reusable, "Redefine captures every reusable attribute");
|
||||
s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap();
|
||||
assert_eq!(defined(&s, 1), reusable, "New captures every reusable attribute");
|
||||
|
||||
@@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, PathData, Vec2};
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::{length_given, number_given, shape_node};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok,
|
||||
str_param, switch_or, targets, text_param,
|
||||
CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, number_or, object_param,
|
||||
ok, str_param, switch_or, targets, text_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -1273,7 +1273,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let mut items = Vec::with_capacity(raw.len());
|
||||
for it in raw {
|
||||
let id = id_param(it, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let id = object_param(it, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let mut refs = super::select::checked_refs(it.get("anchors"), C)?;
|
||||
refs.sort_unstable();
|
||||
refs.dedup();
|
||||
@@ -1304,7 +1304,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.reshapePath";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let mut refs = super::select::checked_refs(p.get("anchors"), C)?;
|
||||
refs.sort_unstable();
|
||||
refs.dedup();
|
||||
@@ -1335,7 +1335,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setPathHandle";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?;
|
||||
let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in");
|
||||
|
||||
@@ -397,6 +397,49 @@ fn roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on() {
|
||||
assert!(text(&s).para_at(1).roman_hanging);
|
||||
}
|
||||
|
||||
/// Paragraph panel menu › Korean Line Break Anywhere: new type has it off and saves that;
|
||||
/// `text.setFormat {koreanLineBreakAnywhere}` sets it per paragraph in one undo step and refuses a
|
||||
/// value that is not true or false, a paragraph saved before the option opens with it on, and
|
||||
/// paragraph styles carry it. `text.setRangeStyle`, a character command, does not take it.
|
||||
#[test]
|
||||
fn korean_line_break_anywhere_is_set_per_paragraph_and_saved_only_when_off() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "가나다\n라마"})).unwrap()["id"].as_u64().unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
assert!(!text(&s).para_at(0).korean_line_break_anywhere, "new type has it off");
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["korean_line_break_anywhere"], false);
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
// Only the first paragraph (the caret's range).
|
||||
s.execute("text.setFormat", &json!({"koreanLineBreakAnywhere": true, "start": 0, "end": 0})).unwrap();
|
||||
assert!(text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("korean_line_break_anywhere").is_none());
|
||||
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap();
|
||||
assert!(back.korean_line_break_anywhere);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!text(&s).para_at(0).korean_line_break_anywhere);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(text(&s).para_at(0).korean_line_break_anywhere);
|
||||
// Saved before the option: on, the line breaks it was laid out with.
|
||||
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap();
|
||||
assert!(old.korean_line_break_anywhere);
|
||||
assert!(s.execute("text.setFormat", &json!({"ids": [id], "koreanLineBreakAnywhere": "yes"})).is_err());
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": id, "koreanLineBreakAnywhere": false})).is_err());
|
||||
assert!(text(&s).para_at(0).korean_line_break_anywhere, "a refused call changes nothing");
|
||||
// A paragraph style carries it.
|
||||
s.execute("paraStyle.new", &json!({"name": "Anywhere", "attrs": {"korean_line_break_anywhere": true}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Anywhere", "id": id})).unwrap();
|
||||
assert!(text(&s).para_at(1).korean_line_break_anywhere);
|
||||
s.execute("paraStyle.new", &json!({"name": "Words", "attrs": {"korean_line_break_anywhere": false}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Words", "id": id})).unwrap();
|
||||
assert!(!text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere);
|
||||
}
|
||||
|
||||
/// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one
|
||||
/// undo step, the paragraph is saved with it only when it is not None and opens with it, an
|
||||
/// unknown value changes nothing, and paragraph styles carry it.
|
||||
|
||||
@@ -311,7 +311,10 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let mut b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() };
|
||||
// The frame is worked out in line space, where the lines are; vertical type turns it
|
||||
// into text space with the lines.
|
||||
let text_bounds = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() };
|
||||
let mut b = lay.line_xf.inverse().transform_rect_bbox(text_bounds);
|
||||
// Across, the frame spans the lines as they are aligned: from their starts to their ends
|
||||
// without the tracking after their last characters (`LineInfo::trailing`), not to the
|
||||
// ink, which tracking can pull past an advance. Area type aligns them alike.
|
||||
@@ -332,7 +335,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0),
|
||||
_ => (0.0, slack),
|
||||
};
|
||||
let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0));
|
||||
let frame = lay.line_xf.transform_rect_bbox(Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0)));
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(frame) };
|
||||
refresh_bounds(t);
|
||||
}
|
||||
@@ -368,6 +371,8 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Point type is in no thread: its first paragraph starts in it.
|
||||
t.first_para_continues = false;
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
// The lines are in line space; the frame's bounds are taken there too.
|
||||
let fb = lay.line_xf.inverse().transform_rect_bbox(fb);
|
||||
let plain = t.plain_text();
|
||||
let baseline = lay.lines.first().map(|l| l.baseline).unwrap_or(fb.y0 + t.first_style().size * 0.8);
|
||||
// Soft wraps → explicit breaks (replace the space before the wrap when there is one).
|
||||
@@ -414,7 +419,10 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// converting back to area type does not turn it on again.
|
||||
t.dynamic_text = false;
|
||||
t.kind = TextKind::Point;
|
||||
t.xf *= Affine::translate((ox, baseline));
|
||||
// The origin is in the area type's line space; point type's line space puts its own
|
||||
// origin there (vertical point type turns its lines about a different point).
|
||||
let point_xf = if lay.vertical { vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).line_xf } else { Affine::IDENTITY };
|
||||
t.xf *= lay.line_xf * Affine::translate((ox, baseline)) * point_xf.inverse();
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -8,7 +8,7 @@ use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::select::selectable_ids;
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, id_param, str_param};
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, object_param, str_param};
|
||||
|
||||
/// Magic Wand panel settings. Tolerances: colours as 0–255 RGB distance, stroke weight in
|
||||
/// points, opacity in percent.
|
||||
@@ -152,7 +152,7 @@ fn paintable(doc: &Document) -> Vec<NodeId> {
|
||||
}
|
||||
|
||||
fn magic_wand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("select.magicWand", "missing id"))?;
|
||||
let id = object_param(p, "id", "select.magicWand")?.ok_or_else(|| bad("select.magicWand", "missing id"))?;
|
||||
let st = s.doc()?;
|
||||
let reference = st.doc.node(id).ok_or(EngineError::NoNode(id))?.clone();
|
||||
let w = s.menu.wand.clone();
|
||||
|
||||
@@ -215,20 +215,6 @@ pub fn check_read(path: &str) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Read))
|
||||
}
|
||||
|
||||
/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one
|
||||
/// that starts with a drive (`C:`) or a `\`? [`check_read`] reads it as a name in the working folder.
|
||||
pub fn written_elsewhere(path: &str) -> bool {
|
||||
let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic());
|
||||
!cfg!(windows) && foreign && current().is_some()
|
||||
}
|
||||
|
||||
/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`,
|
||||
/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`,
|
||||
/// `destinationFolder`)?
|
||||
pub fn is_path_key(key: &str) -> bool {
|
||||
matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end))
|
||||
}
|
||||
|
||||
/// May `path` be written here? Always, without roots in force.
|
||||
pub fn check_write(path: &str) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Write))?;
|
||||
|
||||
@@ -151,9 +151,6 @@ pub struct Session {
|
||||
pub symbol_libraries: cmd::symbollib::Libraries,
|
||||
/// User Defined and loaded brush libraries (Window → Brush Libraries); not saved.
|
||||
pub brush_libraries: cmd::brushlib::Libraries,
|
||||
/// The id each loaded library outside a request's read roots has been shown under, by its own id
|
||||
/// ([`cmd::swatchlib::concealed_library_ids`]), kept for the session.
|
||||
shown_library_ids: std::sync::Mutex<std::collections::BTreeMap<String, String>>,
|
||||
/// The Libraries panel's libraries of graphics, colours and text styles (`library.*`).
|
||||
pub libraries: cmd::library::Libraries,
|
||||
/// Where the saved PDF presets are kept, one file each; `None`: only for the session
|
||||
|
||||
@@ -88,6 +88,13 @@ impl SourceMap {
|
||||
let name = self.files.get(seg.file).cloned().unwrap_or_else(|| Arc::from(""));
|
||||
(seg.file, name, seg.line.saturating_add(line - seg.out_line), column)
|
||||
}
|
||||
|
||||
/// The memory the map holds, in bytes: its lists and its file names, each name counted in full
|
||||
/// even when another map shares it.
|
||||
pub(crate) fn bytes(&self) -> usize {
|
||||
let names: usize = self.files.iter().map(|f| f.len() + 2 * size_of::<usize>()).sum();
|
||||
self.files.capacity() * size_of::<Arc<str>>() + self.segments.capacity() * size_of::<Segment>() + names
|
||||
}
|
||||
}
|
||||
|
||||
/// How a preprocessor failure ends the run.
|
||||
|
||||
+145
-28
@@ -221,12 +221,69 @@ fn current(link: &Link) -> std::result::Result<Rc<Host>, HostError> {
|
||||
}
|
||||
|
||||
/// A named engine: its interpreter, with the globals its runs left (`None` while a run has it),
|
||||
/// the link its host functions use, and its heap after its last run.
|
||||
/// the link its host functions use, its heap after its last run, and the source maps of the code
|
||||
/// its earlier runs compiled.
|
||||
struct Engine {
|
||||
name: String,
|
||||
realm: Option<Realm>,
|
||||
link: Link,
|
||||
heap: usize,
|
||||
maps: Rc<RefCell<KeptMaps>>,
|
||||
}
|
||||
|
||||
/// A named engine taken for a run: its realm (`None` for an engine made now), its link and its
|
||||
/// kept source maps.
|
||||
type TakenEngine = (Option<Realm>, Link, Rc<RefCell<KeptMaps>>);
|
||||
|
||||
/// The most bytes of source maps ([`include::SourceMap::bytes`]) a named engine keeps from its
|
||||
/// earlier runs.
|
||||
pub const KEPT_MAP_BYTES: usize = 8 << 20;
|
||||
|
||||
/// A source map an engine kept from an earlier run: the realm's tag for the source
|
||||
/// ([`Realm::error_tag`]), the map, and for the run's own script, that run's name, which stands for
|
||||
/// a script given as text.
|
||||
struct KeptMap {
|
||||
tag: u32,
|
||||
map: include::SourceMap,
|
||||
script: Option<Arc<str>>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
/// The source maps a named engine kept from its earlier runs, by tag, oldest first, in at most
|
||||
/// [`KEPT_MAP_BYTES`]: the oldest go first to make room, and a map larger than that is not kept.
|
||||
#[derive(Default)]
|
||||
struct KeptMaps {
|
||||
maps: std::collections::VecDeque<KeptMap>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl KeptMaps {
|
||||
fn keep(&mut self, tag: u32, map: include::SourceMap, script: Option<Arc<str>>) {
|
||||
let bytes = map.bytes() + script.as_ref().map_or(0, |s| s.len());
|
||||
let limit = kept_map_bytes();
|
||||
if bytes > limit || self.maps.back().is_some_and(|m| m.tag >= tag) {
|
||||
return;
|
||||
}
|
||||
while self.bytes + bytes > limit {
|
||||
let Some(old) = self.maps.pop_front() else { break };
|
||||
self.bytes -= old.bytes;
|
||||
}
|
||||
self.bytes += bytes;
|
||||
self.maps.push_back(KeptMap { tag, map, script, bytes });
|
||||
}
|
||||
|
||||
fn get(&self, tag: u32) -> Option<&KeptMap> {
|
||||
self.maps.binary_search_by_key(&tag, |m| m.tag).ok().and_then(|i| self.maps.get(i))
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of source maps a named engine keeps.
|
||||
fn kept_map_bytes() -> usize {
|
||||
#[cfg(test)]
|
||||
if let Some(n) = TEST_KEPT_MAP_BYTES.with(Cell::get) {
|
||||
return n;
|
||||
}
|
||||
KEPT_MAP_BYTES
|
||||
}
|
||||
|
||||
/// The named engines of one thread's sessions, by their key, each in the order they were made.
|
||||
@@ -665,6 +722,14 @@ struct Host {
|
||||
/// The file, line and column of each error `$.evalFile` raised for a file it could not run, in
|
||||
/// order: the error's mark ([`HostError::Marked`]) is its index.
|
||||
eval_failures: RefCell<Vec<(Arc<str>, u32, u32)>>,
|
||||
/// The realm's tag for the script ([`Realm::script_tag`]) once the run has started it, and
|
||||
/// [`vectorcraft_js::UNKNOWN_SOURCE`] before.
|
||||
script_tag: Cell<u32>,
|
||||
/// The run's name as its report gives it, which [`Host::name`] shortens for console lines.
|
||||
report_name: Arc<str>,
|
||||
/// The source maps the named engine of the run kept from its earlier runs; `None` for a run
|
||||
/// without one.
|
||||
kept_maps: Option<Rc<RefCell<KeptMaps>>>,
|
||||
/// When the run started, where the platform has a clock.
|
||||
started: Option<std::time::Instant>,
|
||||
/// The time since [`Host::started`] at which a `$.sleep` ends.
|
||||
@@ -740,6 +805,23 @@ pub(crate) fn slowest(times: &HashMap<&'static str, (u64, u128, u128)>) -> Vec<c
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Gives the named engine of the run the source maps of the code the run compiled: its script
|
||||
/// and each file `$.evalFile` gave. A run that never started its script compiled nothing.
|
||||
fn keep_maps(&self) {
|
||||
let (Some(kept), tag) = (&self.kept_maps, self.script_tag.get()) else { return };
|
||||
if tag == vectorcraft_js::UNKNOWN_SOURCE {
|
||||
return;
|
||||
}
|
||||
let mut kept = kept.borrow_mut();
|
||||
kept.keep(tag, std::mem::take(&mut *self.script_map.borrow_mut()), Some(self.report_name.clone()));
|
||||
for (n, map) in std::mem::take(&mut *self.eval_maps.borrow_mut()).into_iter().enumerate() {
|
||||
match u32::try_from(n + 1).ok().and_then(|n| tag.checked_add(n)).filter(|t| *t != vectorcraft_js::UNKNOWN_SOURCE) {
|
||||
Some(t) => kept.keep(t, map, None),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a line to the run's console and, during a slice, to the session's.
|
||||
fn line(&self, entry: console::Entry, cut: bool) {
|
||||
let line = console::ConsoleLine { run: self.run, name: self.name.clone(), entry };
|
||||
@@ -1358,34 +1440,55 @@ fn preprocessor_error(f: include::Failure) -> (RunError, Option<Arc<str>>) {
|
||||
(RunError { kind, message, line: f.line, column: f.column }, file)
|
||||
}
|
||||
|
||||
/// `e`, which the realm raised in source `tag` ([`Realm::error_source`]) with host error mark `mark`
|
||||
/// ([`Realm::error_mark`]), at the line and column of the file it is in, with that file's name
|
||||
/// unless it is the script's own.
|
||||
fn located(host: &Host, tag: u32, mark: Option<u32>, e: &RunError) -> (RunError, Option<Arc<str>>) {
|
||||
let map = |m: &include::SourceMap, own: bool| {
|
||||
/// `e`, the error `realm` raised in the run of `host`, at the line and column of the file it is in,
|
||||
/// with that file's name unless it is the run's own script.
|
||||
fn located(host: &Host, realm: &Realm, e: &RunError) -> (RunError, Option<Arc<str>>) {
|
||||
let map = |m: &include::SourceMap| {
|
||||
let (index, file, line, column) = m.locate(e.line, e.column);
|
||||
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() };
|
||||
(RunError { kind: e.kind.clone(), message, line, column }, (!own || index > 0).then_some(file))
|
||||
(RunError { kind: e.kind.clone(), message, line, column }, index, file)
|
||||
};
|
||||
// An error `$.evalFile` raised for a file it could not run, and no script caught, is at that
|
||||
// file's place.
|
||||
if let Some((file, line, column)) = mark.and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) {
|
||||
if let Some((file, line, column)) = realm.error_mark().and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) {
|
||||
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() };
|
||||
return (RunError { kind: e.kind.clone(), message, line, column }, Some(file));
|
||||
}
|
||||
if e.line == 0 {
|
||||
return (e.clone(), None);
|
||||
}
|
||||
// An error in code from another source, such as a function an earlier run of a named engine
|
||||
// defined, has no place in this run's files, so it is given without one.
|
||||
// An error in code from a source whose map is not at hand, such as code that `eval` compiled,
|
||||
// is given without a place.
|
||||
let unplaced = || (RunError { kind: e.kind.clone(), message: e.message.clone(), line: 0, column: 0 }, None);
|
||||
match tag {
|
||||
0 => map(&host.script_map.borrow(), true),
|
||||
match realm.error_source() {
|
||||
0 => {
|
||||
let (e, index, file) = map(&host.script_map.borrow());
|
||||
(e, (index > 0).then_some(file))
|
||||
}
|
||||
n if n != vectorcraft_js::UNKNOWN_SOURCE => match host.eval_maps.borrow().get(n as usize - 1) {
|
||||
Some(m) => map(m, false),
|
||||
Some(m) => {
|
||||
let (e, _, file) = map(m);
|
||||
(e, Some(file))
|
||||
}
|
||||
None => unplaced(),
|
||||
},
|
||||
_ => unplaced(),
|
||||
// Code an earlier run of the named engine compiled, such as a function it defined, is at
|
||||
// its place in that run's files: the run's script, a file it included, or a file
|
||||
// `$.evalFile` gave. A script given as text is named by that run's name.
|
||||
_ => {
|
||||
let kept = host.kept_maps.as_ref().map(|k| k.borrow());
|
||||
match kept.as_ref().and_then(|k| k.get(realm.error_tag())) {
|
||||
Some(kept) => {
|
||||
let (e, index, file) = map(&kept.map);
|
||||
let file = match &kept.script {
|
||||
Some(name) if index == 0 && file.is_empty() => name.clone(),
|
||||
_ => file,
|
||||
};
|
||||
(e, Some(file))
|
||||
}
|
||||
None => unplaced(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,17 +1541,17 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The realm of the named engine `name` for a run, with the link its host functions use: the
|
||||
/// realm its last run left, or `None` for an engine made now. → the kind and text of the
|
||||
/// failure when the engine cannot be had.
|
||||
fn take_engine(&mut self, name: &str) -> std::result::Result<(Option<Realm>, Link), (include::FailKind, String)> {
|
||||
/// The realm of the named engine `name` for a run, with the link its host functions use and the
|
||||
/// source maps it kept: the realm its last run left, or `None` for an engine made now. → the
|
||||
/// kind and text of the failure when the engine cannot be had.
|
||||
fn take_engine(&mut self, name: &str) -> std::result::Result<TakenEngine, (include::FailKind, String)> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Err((include::FailKind::Error, NO_ENGINES_ON_WEB.to_string()));
|
||||
}
|
||||
with_engines(&self.script_engines, |engines| {
|
||||
if let Some(e) = engines.iter_mut().find(|e| e.name == name) {
|
||||
let realm = e.realm.take().ok_or((include::FailKind::Error, ENGINE_BUSY.to_string()))?;
|
||||
return Ok((Some(realm), e.link.clone()));
|
||||
return Ok((Some(realm), e.link.clone(), e.maps.clone()));
|
||||
}
|
||||
if engines.len() >= MAX_ENGINES {
|
||||
return Err((
|
||||
@@ -1457,15 +1560,20 @@ impl Session {
|
||||
));
|
||||
}
|
||||
let link: Link = Rc::new(RefCell::new(None));
|
||||
engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0 });
|
||||
Ok((None, link))
|
||||
let maps = Rc::new(RefCell::new(KeptMaps::default()));
|
||||
engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0, maps: maps.clone() });
|
||||
Ok((None, link, maps))
|
||||
})
|
||||
}
|
||||
|
||||
/// The end of a run in engine `engine` (`None` for a realm that is not kept): the link no longer
|
||||
/// holds the run, and the engine keeps `realm`, or is dropped without one.
|
||||
/// holds the run, and the engine keeps `realm`, with the source maps of the code the run
|
||||
/// compiled, or is dropped without one.
|
||||
fn return_engine(&self, engine: Option<&str>, link: &Link, realm: Option<Realm>) {
|
||||
let host = link.borrow_mut().take();
|
||||
if let (Some(h), Some(_)) = (&host, &realm) {
|
||||
h.keep_maps();
|
||||
}
|
||||
drop(host);
|
||||
let Some(name) = engine else { return };
|
||||
let mut realm = realm;
|
||||
@@ -1668,9 +1776,9 @@ impl Session {
|
||||
};
|
||||
let heap_bytes = if kept.is_some() { engine_heap() } else { HEAP_BYTES };
|
||||
let limits = Limits { fuel: request.fuel, heap_bytes, ..Limits::default() };
|
||||
let (engine, realm, link) = match kept {
|
||||
None => (None, None, Rc::new(RefCell::new(None))),
|
||||
Some((engine, (realm, link))) => (Some(engine), realm, link),
|
||||
let (engine, realm, link, kept_maps) = match kept {
|
||||
None => (None, None, Rc::new(RefCell::new(None)), None),
|
||||
Some((engine, (realm, link, maps))) => (Some(engine), realm, link, Some(maps)),
|
||||
};
|
||||
// A realm made here gets the script extensions, `$` and the dialogs; a kept one has them
|
||||
// from its first run, and they act for the run that calls them.
|
||||
@@ -1716,6 +1824,9 @@ impl Session {
|
||||
script_map: RefCell::default(),
|
||||
eval_maps: RefCell::default(),
|
||||
eval_failures: RefCell::default(),
|
||||
script_tag: Cell::new(vectorcraft_js::UNKNOWN_SOURCE),
|
||||
report_name: Arc::from(name.as_str()),
|
||||
kept_maps,
|
||||
started: clock(),
|
||||
wake_at: Cell::new(None),
|
||||
can_wait: realm.wait_flag(),
|
||||
@@ -1752,8 +1863,12 @@ impl Session {
|
||||
.and_then(|()| if fresh { realm.define_host_object("$", dollar_object(&link, info, &localization)) } else { Ok(()) })
|
||||
.and_then(|()| if fresh { dialogs::install(&mut realm, &link) } else { Ok(()) })
|
||||
.and_then(|()| if fresh { files::install(&mut realm, &link) } else { realm.reset_folder_current() })
|
||||
.and_then(|()| realm.start(&expanded.source))
|
||||
.map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e))
|
||||
.and_then(|()| {
|
||||
let r = realm.start(&expanded.source);
|
||||
host.script_tag.set(realm.script_tag());
|
||||
r
|
||||
})
|
||||
.map_err(|e| located(&host, &realm, &e))
|
||||
}
|
||||
};
|
||||
if let Err((e, file)) = started {
|
||||
@@ -1832,7 +1947,7 @@ impl Session {
|
||||
fn script_failed(&mut self, job: Job, e: &RunError) -> Result<Value> {
|
||||
job.host.dialogs.stopped();
|
||||
let fuel_left = job.realm.fuel_left();
|
||||
let (e, file) = located(&job.host, job.realm.error_source(), job.realm.error_mark(), e);
|
||||
let (e, file) = located(&job.host, &job.realm, e);
|
||||
let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left));
|
||||
close_run(self, &job.host, &mut report, ending(&e.kind));
|
||||
self.end_job(job, e.kind == RunErrorKind::Internal);
|
||||
@@ -2127,6 +2242,8 @@ thread_local! {
|
||||
pub(crate) static TEST_GROWTH: Cell<Option<u64>> = const { Cell::new(None) };
|
||||
/// Tests: the heap of a named engine in place of [`ENGINE_HEAP_BYTES`].
|
||||
pub(crate) static TEST_ENGINE_HEAP: Cell<Option<usize>> = const { Cell::new(None) };
|
||||
/// Tests: the bytes of source maps a named engine keeps, in place of [`KEPT_MAP_BYTES`].
|
||||
pub(crate) static TEST_KEPT_MAP_BYTES: Cell<Option<usize>> = const { Cell::new(None) };
|
||||
/// Tests: the `JSINCLUDE` folders in place of the environment's.
|
||||
pub(crate) static TEST_JSINCLUDE: RefCell<Option<Vec<std::path::PathBuf>>> = const { RefCell::new(None) };
|
||||
/// Tests: the time since a run started, in place of its clock's.
|
||||
|
||||
@@ -80,7 +80,6 @@ impl Session {
|
||||
style_libraries: Default::default(),
|
||||
symbol_libraries: Default::default(),
|
||||
brush_libraries: Default::default(),
|
||||
shown_library_ids: Default::default(),
|
||||
libraries: Default::default(),
|
||||
pdf_preset_dir: None,
|
||||
dictionary_file: None,
|
||||
|
||||
@@ -20,6 +20,7 @@ mod brushlib;
|
||||
mod brushsym;
|
||||
mod build;
|
||||
mod cancel;
|
||||
mod caret_kerning;
|
||||
mod charstroke;
|
||||
pub(super) mod clip;
|
||||
mod clipboard;
|
||||
|
||||
@@ -485,7 +485,7 @@ fn making_and_restyling_type_reports_its_overflow() {
|
||||
}
|
||||
|
||||
/// The formats whose exports draw type.
|
||||
const DRAWING_FORMATS: [&str; 8] = ["pdf", "eps", "svg", "png", "jpg", "tiff", "emf", "wmf"];
|
||||
const DRAWING_FORMATS: [&str; 9] = ["pdf", "eps", "svg", "png", "jpg", "tiff", "emf", "wmf", "hpgl"];
|
||||
|
||||
/// The warnings of `document.export {format, …options}` about type that doesn't fit its frame.
|
||||
fn export_overflow(s: &mut Session, format: &str, options: Value) -> Vec<String> {
|
||||
|
||||
@@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
let list = run(&mut s, "brush.libraries", json!({}));
|
||||
let libs = list["libraries"].as_array().unwrap();
|
||||
let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]);
|
||||
assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "bristle-brushes", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]);
|
||||
assert!(libs.iter().all(|l| l["category"] == "builtIn"));
|
||||
let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"}));
|
||||
assert_eq!(
|
||||
@@ -117,13 +117,13 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
trails["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(),
|
||||
["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"]
|
||||
);
|
||||
assert_eq!(libs[4]["count"].as_u64(), Some(4));
|
||||
assert_eq!(libs[5]["count"].as_u64(), Some(4));
|
||||
let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"}));
|
||||
assert_eq!(
|
||||
beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(),
|
||||
["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"]
|
||||
);
|
||||
assert_eq!(libs[5]["count"].as_u64(), Some(4));
|
||||
assert_eq!(libs[6]["count"].as_u64(), Some(4));
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
||||
let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap();
|
||||
let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]}));
|
||||
@@ -132,6 +132,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
for (id, kind) in [
|
||||
("art-ribbons", "art"),
|
||||
("calligraphic-nibs", "calligraphic"),
|
||||
("bristle-brushes", "bristle"),
|
||||
("pattern-borders", "pattern"),
|
||||
("scatter-shapes", "scatter"),
|
||||
("game-trails", "art"),
|
||||
@@ -165,6 +166,77 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err());
|
||||
}
|
||||
|
||||
/// Bristle Brushes: one brush per tip shape, every value inside the range the brush keeps (a value
|
||||
/// outside would be clamped without notice), names no other library or default brush uses; added
|
||||
/// in one step, not twice, and saved as a library they reopen unchanged.
|
||||
#[test]
|
||||
fn the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges() {
|
||||
use vectorcraft_brush::{Bristle, BristleShape};
|
||||
let mut s = session();
|
||||
let (_, lib) = cmd::brushlib::library(&s, "bristle-brushes").unwrap();
|
||||
assert_eq!(lib.name, "Bristle Brushes");
|
||||
let names: Vec<String> = lib.brushes.iter().map(|b| b.name.clone()).collect();
|
||||
assert!((8..=10).contains(&names.len()), "{names:?}");
|
||||
let within = |v: f64, (lo, hi): (f64, f64)| (lo..=hi).contains(&v);
|
||||
let mut shapes = vec![];
|
||||
for b in &lib.brushes {
|
||||
let BrushKind::Bristle(x) = &b.kind else { panic!("{} is not a bristle brush", b.name) };
|
||||
assert!(within(x.size, Bristle::SIZE_RANGE) && within(x.length, Bristle::LENGTH_RANGE), "{}", b.name);
|
||||
for v in [x.density, x.thickness, x.opacity, x.stiffness] {
|
||||
assert!(within(v, Bristle::PERCENT_RANGE), "{}: {v}", b.name);
|
||||
}
|
||||
let mut kept = b.clone();
|
||||
kept.sanitize();
|
||||
assert_eq!(&kept, b, "sanitizing changes {}", b.name);
|
||||
assert!(b.name.chars().count() <= Bristle::MAX_NAME_CHARS, "{}", b.name);
|
||||
if !shapes.contains(&x.shape) {
|
||||
shapes.push(x.shape);
|
||||
}
|
||||
}
|
||||
let every = [
|
||||
BristleShape::RoundPoint,
|
||||
BristleShape::FlatPoint,
|
||||
BristleShape::RoundBlunt,
|
||||
BristleShape::FlatBlunt,
|
||||
BristleShape::RoundCurve,
|
||||
BristleShape::FlatCurve,
|
||||
BristleShape::RoundAngle,
|
||||
BristleShape::FlatAngle,
|
||||
BristleShape::RoundFan,
|
||||
BristleShape::FlatFan,
|
||||
];
|
||||
assert!(every.iter().all(|x| shapes.contains(x)), "{shapes:?}");
|
||||
// Names of its own: no default brush's, no other built-in library's, none twice.
|
||||
let defaults: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).collect();
|
||||
let others: Vec<String> = cmd::brushlib::BUILTIN
|
||||
.iter()
|
||||
.filter(|b| b.id != "bristle-brushes")
|
||||
.flat_map(|b| cmd::brushlib::library(&s, b.id).unwrap().1.brushes.iter().map(|x| x.name.clone()).collect::<Vec<_>>())
|
||||
.collect();
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
assert!(!defaults.contains(&n.as_str()) && !others.contains(n) && !names[..i].contains(n), "{n}");
|
||||
}
|
||||
|
||||
// Added to a document in one undo step; adding them again adds nothing.
|
||||
let before = (undo_len(&s), brushes(&s));
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "Bristle Brushes"}));
|
||||
assert_eq!(strs(&r["added"]), names.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
assert_eq!(undo_len(&s), before.0 + 1);
|
||||
assert_eq!(brushes(&s), [before.1.clone(), names.clone()].concat());
|
||||
for b in &lib.brushes {
|
||||
assert_eq!(&brush(&s, &b.name), b);
|
||||
}
|
||||
let again = undo_len(&s);
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "bristle-brushes"}));
|
||||
assert_eq!((strs(&r["added"]).len(), strs(&r["existing"]).len(), undo_len(&s)), (0, names.len(), again));
|
||||
// Saved as a library and loaded back: the same brushes, in the same order.
|
||||
let saved = run(&mut s, "brush.saveLibrary", json!({ "names": names, "name": "Bristles" }));
|
||||
let b64 = saved["dataBase64"].as_str().unwrap().to_string();
|
||||
let r = run(&mut s, "brush.loadLibrary", json!({"dataBase64": b64, "name": "Bristles.vectorcraft"}));
|
||||
let (_, reopened) = cmd::brushlib::library(&s, r["library"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(reopened.brushes, lib.brushes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_library_reopens_and_its_brushes_join_another_document_in_one_step() {
|
||||
let dir = folder("user");
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Kerning at the Type tool's caret at the start of a line (#5111): with no character on the line
|
||||
//! before the caret, `type.step`, `type.resetTracking` and the Kerning field set the space before
|
||||
//! the character after it (`kerningBefore`), as one undo step, and the character moves.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{CharStyle, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, t: &str) -> NodeId {
|
||||
NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn obj(s: &Session, id: NodeId) -> TextObject {
|
||||
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(id).map(|n| &n.kind) else { panic!("not text") };
|
||||
(**t).clone()
|
||||
}
|
||||
|
||||
fn style_at(s: &Session, id: NodeId, byte: usize) -> CharStyle {
|
||||
vectorcraft_text::edit::style_at(&obj(s, id).runs, byte + 1)
|
||||
}
|
||||
|
||||
/// Where glyph `byte` is drawn along its line, in text space.
|
||||
fn drawn_x(s: &Session, id: NodeId, byte: usize) -> f64 {
|
||||
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(s, id));
|
||||
l.glyphs.iter().find(|g| g.byte == byte).unwrap().xf.as_coeffs()[4]
|
||||
}
|
||||
|
||||
/// The Type tool editing `id` (made by [`text`]) with its caret at byte `caret`.
|
||||
fn caret_in(s: &mut Session, id: NodeId, caret: usize) -> ViewInfo {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, 102.0, 95.0), v).unwrap();
|
||||
}
|
||||
assert_eq!(s.tool_options()["editing"], json!(id.0), "editing the text");
|
||||
s.set_tool_option("select", &json!({"start": caret, "end": caret}));
|
||||
v
|
||||
}
|
||||
|
||||
fn step_at(s: &mut Session, id: NodeId, caret: usize, by: i32) -> serde_json::Value {
|
||||
s.execute("type.step", &json!({"id": id.0, "start": caret, "end": caret, "attribute": "kerning", "by": by})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_at_the_start_of_the_text_moves_the_first_character() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "AV");
|
||||
let inc = s.prefs.tracking_increment;
|
||||
let (a, v) = (drawn_x(&s, id, 0), drawn_x(&s, id, 1));
|
||||
assert_eq!(step_at(&mut s, id, 0, 1)["ids"], json!([id.0]));
|
||||
let st = style_at(&s, id, 0);
|
||||
assert_eq!((st.kerning_before, st.kerning), (inc, None), "the space before A, not after it");
|
||||
// A step of `inc` thousandths of the 20 pt em moves both letters along the line.
|
||||
let moved = inc / 1000.0 * 20.0;
|
||||
assert!((drawn_x(&s, id, 0) - a - moved).abs() < 1e-6 && (drawn_x(&s, id, 1) - v - moved).abs() < 1e-6);
|
||||
// A negative step goes back towards the line's start, past it.
|
||||
step_at(&mut s, id, 0, -3);
|
||||
assert_eq!(style_at(&s, id, 0).kerning_before, -2.0 * inc);
|
||||
assert!((drawn_x(&s, id, 0) - a + 2.0 * moved).abs() < 1e-6);
|
||||
// One undo step each.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(style_at(&s, id, 0).kerning_before, inc);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(style_at(&s, id, 0).kerning_before, 0.0);
|
||||
assert!((drawn_x(&s, id, 0) - a).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_a_line_a_step_still_kerns_the_character_before_the_caret() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "AV");
|
||||
step_at(&mut s, id, 1, 1);
|
||||
let (a, v) = (style_at(&s, id, 0), style_at(&s, id, 1));
|
||||
assert_eq!((a.kerning_before, v.kerning_before, v.kerning), (0.0, 0.0, None));
|
||||
assert!(a.kerning.is_some(), "the pair after A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_start_of_a_paragraph_or_after_a_forced_line_break_kerns_the_character_after_it() {
|
||||
let mut s = session();
|
||||
let inc = s.prefs.tracking_increment;
|
||||
let id = text(&mut s, "One\nTwo");
|
||||
let t = drawn_x(&s, id, 4);
|
||||
step_at(&mut s, id, 4, 1);
|
||||
assert_eq!((style_at(&s, id, 3).kerning, style_at(&s, id, 4).kerning_before), (None, inc), "not the line break's kerning");
|
||||
assert!(drawn_x(&s, id, 4) > t, "T moves");
|
||||
let forced = text(&mut s, "One\u{2028}Two");
|
||||
let after = "One\u{2028}".len();
|
||||
step_at(&mut s, forced, after, 1);
|
||||
assert_eq!((style_at(&s, forced, 3).kerning, style_at(&s, forced, after).kerning_before), (None, inc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_line_has_nothing_to_kern() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "One\n\nTwo");
|
||||
let before = obj(&s, id).runs;
|
||||
assert_eq!(step_at(&mut s, id, 4, 1)["ids"], json!([]));
|
||||
assert_eq!(obj(&s, id).runs, before);
|
||||
// Nor does the end of text after a line break.
|
||||
let end = text(&mut s, "One\n");
|
||||
assert_eq!(step_at(&mut s, end, 4, 1)["ids"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_at_the_start_of_a_line_clears_the_space_before_and_a_full_reset_clears_it_too() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "AV");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": -40, "tracking": 100})).unwrap();
|
||||
step_at(&mut s, id, 0, 2);
|
||||
assert_ne!(style_at(&s, id, 0).kerning_before, 0.0);
|
||||
// At the caret before A only the space before A goes; A keeps its kerning after it.
|
||||
caret_in(&mut s, id, 0);
|
||||
s.execute("type.resetTracking", &json!({})).unwrap();
|
||||
let st = style_at(&s, id, 0);
|
||||
assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, Some(-40.0), 100.0));
|
||||
step_at(&mut s, id, 0, 2);
|
||||
s.execute("type.resetTracking", &json!({"ids": [id.0]})).unwrap();
|
||||
let st = style_at(&s, id, 0);
|
||||
assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, None, 0.0), "all manual kerning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_range_style_sets_the_space_before() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "AV");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 50})).unwrap();
|
||||
assert_eq!(style_at(&s, id, 0).kerning_before, 50.0);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 20000})).unwrap();
|
||||
assert_eq!(style_at(&s, id, 0).kerning_before, 10000.0, "kerning's limits");
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": "auto"})).is_err());
|
||||
}
|
||||
|
||||
/// The Type tool: Alt+← at the caret before the first character kerns it, and Alt+→ back.
|
||||
#[test]
|
||||
fn alt_arrows_at_the_caret_before_the_first_character_kern_it() {
|
||||
let mut s = session();
|
||||
let inc = s.prefs.tracking_increment;
|
||||
let id = text(&mut s, "Hello");
|
||||
let v = caret_in(&mut s, id, 0);
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
s.tool_key(ToolKey::Left, alt, v).unwrap();
|
||||
assert_eq!((style_at(&s, id, 0).kerning_before, style_at(&s, id, 0).kerning), (-inc, None));
|
||||
s.tool_key(ToolKey::Right, alt, v).unwrap();
|
||||
s.tool_key(ToolKey::Right, alt, v).unwrap();
|
||||
assert_eq!(style_at(&s, id, 0).kerning_before, inc);
|
||||
}
|
||||
|
||||
/// The Eyedropper copies how type looks, not where a line starts: a sample from a first character
|
||||
/// with a space before it leaves the target's first character where it was.
|
||||
#[test]
|
||||
fn the_eyedropper_keeps_the_targets_space_before() {
|
||||
let mut s = session();
|
||||
let src = text(&mut s, "Source");
|
||||
s.execute("text.setRangeStyle", &json!({"id": src.0, "start": 0, "end": 1, "kerningBefore": 300, "tracking": 50})).unwrap();
|
||||
let dst = text(&mut s, "Target");
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
|
||||
let st = style_at(&s, dst, 0);
|
||||
assert_eq!((st.tracking, st.kerning_before), (50.0, 0.0), "the tracking goes, the space before doesn't");
|
||||
}
|
||||
|
||||
/// A character style captured from a line's first character leaves its space before out, and a
|
||||
/// style can't be given one: it would space the first character of every paragraph set in it.
|
||||
#[test]
|
||||
fn a_character_style_leaves_the_space_before_out() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "Source");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": 300, "tracking": 50})).unwrap();
|
||||
s.execute("charStyle.new", &json!({"name": "Body", "id": id.0})).unwrap();
|
||||
let attrs = s.doc().unwrap().doc.char_styles[0].attrs.clone();
|
||||
assert_eq!(attrs.get("tracking"), Some(&json!(50.0)));
|
||||
assert!(!attrs.contains_key("kerningBefore"), "{attrs:?}");
|
||||
let err = s.execute("charStyle.new", &json!({"name": "Spaced", "attrs": {"kerningBefore": 100}})).unwrap_err();
|
||||
assert!(err.to_string().contains("kerningBefore"), "{err}");
|
||||
}
|
||||
@@ -432,6 +432,30 @@ fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() {
|
||||
assert_eq!(overrides(&s, inst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_the_symbol_drops_paints_its_new_objects_do_not_draw() {
|
||||
let mut s = session();
|
||||
let inst = pair(&mut s, "dynamic");
|
||||
let p = json!({"id": inst.0, "path": [1], "fill": "#ff0000", "stroke": "#ff0000", "opacity": 50});
|
||||
s.execute("symbol.instance.setAppearance", &p).unwrap();
|
||||
// The second object is now an image, which draws no fill or stroke of its own.
|
||||
let a = square(&mut s, 10.0);
|
||||
place_image(&mut s);
|
||||
let image = top(&s);
|
||||
s.execute("symbol.update", &json!({"name": "Pair", "ids": [a.0, image.0]})).unwrap();
|
||||
let o = node(&s, inst).extras().symbol_overrides.clone();
|
||||
assert_eq!(o.len(), 1, "the opacity change stays: {o:?}");
|
||||
assert_eq!((&o[0].fill, &o[0].stroke, o[0].opacity), (&None, &None, Some(0.5)));
|
||||
// A change of paint alone goes with its paint: the first object is now a blend.
|
||||
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [0], "stroke": "#ff0000"})).unwrap();
|
||||
let (b, c) = (square(&mut s, 10.0), square(&mut s, 60.0));
|
||||
let blend = id(&s.execute("object.blend.make", &json!({"ids": [b.0, c.0], "steps": 1})).unwrap());
|
||||
let d = square(&mut s, 120.0);
|
||||
s.execute("symbol.update", &json!({"name": "Pair", "ids": [blend.0, d.0]})).unwrap();
|
||||
let o = node(&s, inst).extras().symbol_overrides.clone();
|
||||
assert_eq!(o.iter().map(|o| o.path.clone()).collect::<Vec<_>>(), [vec![1]], "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_symbol_drops_the_changes_made_for_the_old_symbol() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -40,7 +40,16 @@ fn built_in_presets_are_the_gradient_libraries_by_theme_with_unique_names() {
|
||||
libraries.dedup();
|
||||
assert_eq!(
|
||||
libraries,
|
||||
["Spectrum Gradients", "Sky Gradients", "Neutral Gradients", "Organic Fades", "Duotone Gradients", "Radial Glows", "Metallic Gradients"]
|
||||
[
|
||||
"Spectrum Gradients",
|
||||
"Sky Gradients",
|
||||
"Neutral Gradients",
|
||||
"Organic Fades",
|
||||
"Holographic",
|
||||
"Duotone Gradients",
|
||||
"Radial Glows",
|
||||
"Metallic Gradients"
|
||||
]
|
||||
);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
assert!(names(&list).iter().all(|n| seen.insert(n.to_lowercase())), "a name names one preset");
|
||||
@@ -82,6 +91,42 @@ fn an_organic_fade_applies_with_its_method_midpoints_and_opacities() {
|
||||
assert_eq!(row["gradient"]["method"], "perceptual");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_holographic_foil_applies_in_place_as_one_undo_step() {
|
||||
let (mut s, id) = session_with(json!({"gradient": {"angle": 30}}));
|
||||
let before = paint(&s, id, false);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let veil = s.gradient_preset("prism veil").unwrap();
|
||||
assert_eq!((veil.library, veil.gradient.method), (Some("Holographic"), GradientMethod::Perceptual));
|
||||
let r = s.execute("gradient.presets.apply", &json!({"name": "prism veil"})).unwrap();
|
||||
assert_eq!(r["name"].as_str(), Some("Prism Veil"));
|
||||
let g = paint(&s, id, false);
|
||||
assert_eq!((g.gradient.kind, g.gradient.method, &g.gradient.stops), (GradientKind::Linear, GradientMethod::Perceptual, &veil.gradient.stops));
|
||||
assert_eq!(g.gradient.stops.len(), 6);
|
||||
assert_eq!(g.angle, before.angle, "the placement stays");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(s.gradient_preset_name(&g.gradient).as_deref(), Some("Prism Veil"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(paint(&s, id, false), before);
|
||||
}
|
||||
|
||||
/// A preset saved as "Mint Halo" before Holographic existed stays listed under a free name.
|
||||
#[test]
|
||||
fn a_saved_preset_named_like_a_holographic_glow_gets_a_free_name() {
|
||||
let (mut s, id) = session_with(json!({"gradient": {"kind": "radial"}}));
|
||||
let halo = s.gradient_preset("Mint Halo").unwrap().gradient;
|
||||
let mut own = paint(&s, id, false).gradient;
|
||||
own.dither = true;
|
||||
let mut prefs = s.prefs.clone();
|
||||
prefs.gradient_presets = vec![crate::cmd::GradientPreset { name: "MINT HALO".into(), gradient: own.clone() }];
|
||||
s.apply_prefs(prefs);
|
||||
assert_eq!(s.prefs.gradient_presets.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["MINT HALO 2"]);
|
||||
s.execute("gradient.presets.apply", &json!({"name": "mint halo 2"})).unwrap();
|
||||
assert_eq!(paint(&s, id, false).gradient, own);
|
||||
s.execute("gradient.presets.apply", &json!({"name": "Mint Halo"})).unwrap();
|
||||
assert_eq!(paint(&s, id, false).gradient.stops, halo.stops);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preset_applies_to_the_stroke_and_to_a_solid_paint() {
|
||||
let (mut s, id) = session_with(json!({"color": "#336699"}));
|
||||
|
||||
@@ -214,72 +214,26 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() {
|
||||
let mine = format!("loaded/{}", text(&inside));
|
||||
let copied = confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": mine})));
|
||||
assert_eq!((copied["library"].as_str(), copied["copied"].as_bool()), (Some("user/Kit.vcswatches"), Some(true)), "{copied}");
|
||||
assert_eq!(copied.get("path"), None, "{copied}");
|
||||
assert_eq!(copied["path"], Value::Null, "{copied}");
|
||||
let saved = confine(roots.as_ref(), || run(&mut s, "swatch.library.save", json!({"user": true, "name": "Mine"})));
|
||||
assert_eq!((saved["library"].as_str(), saved.get("path")), (Some("user/Mine.vcswatches"), None), "{saved}");
|
||||
for reply in [&added, &copied, &saved] {
|
||||
assert!(!holds(reply, &base.join("a")) && !holds(reply, &base.join("user")), "{reply}");
|
||||
}
|
||||
// A library loaded from data under the file name keeps its id; the concealed one shown under that
|
||||
// id takes the next free one, the other keeps its id, and no two rows share an id.
|
||||
// A library loaded from data under the file name keeps its id; each concealed one takes the next
|
||||
// free one, so no two rows share an id.
|
||||
let data = run(&mut s, "swatch.library.save", json!({}))["data"].as_str().unwrap().to_string();
|
||||
run(&mut s, "swatch.library.load", json!({"data": data, "name": "Kit.vcswatches"}));
|
||||
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
|
||||
let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect();
|
||||
assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches")], "{list}");
|
||||
assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches")], "{list}");
|
||||
// Copied into the user library folder, it is still found by that id, which no other library takes.
|
||||
confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": "loaded/Kit.vcswatches"})));
|
||||
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
|
||||
let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect();
|
||||
assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)")], "{list}");
|
||||
assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)")], "{list}");
|
||||
// The person sees the folder.
|
||||
let saved = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Theirs"}));
|
||||
assert_eq!(saved["path"], json!(text(&base.join("user").join("Theirs.vcswatches"))));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Outside Windows a `\` is part of a file name: a concealed library saved at `<folder>/weird\name.gpl`
|
||||
/// is shown under its whole file name. Another platform's absolute path is still named by the part
|
||||
/// after its last `\`.
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn a_concealed_library_keeps_a_backslash_in_its_file_name() {
|
||||
let base = folder("backslash-name");
|
||||
let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap();
|
||||
let native = format!("loaded/{}/weird\\name.gpl", text(&base.join("a")));
|
||||
let foreign = r"loaded/C:\Users\someone\other.gpl".to_string();
|
||||
let s = Session::new();
|
||||
let mut hidden = confine(roots.as_ref(), || cmd::swatchlib::concealed_library_ids(&s, [native.clone(), foreign.clone()]));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
hidden.sort();
|
||||
let mut want = vec![(foreign, "loaded/other.gpl".to_string()), (native, "loaded/weird\\name.gpl".to_string())];
|
||||
want.sort();
|
||||
assert_eq!(hidden, want);
|
||||
}
|
||||
|
||||
/// A concealed library keeps the id a client was shown when another library with the same file name,
|
||||
/// whose full id sorts before it, is loaded later: the id still finds the first library.
|
||||
#[test]
|
||||
fn a_concealed_library_keeps_its_id_after_another_with_its_file_name_loads() {
|
||||
let base = folder("keeps-id");
|
||||
let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap();
|
||||
let (first, later) = (base.join("b").join("Kit.vcswatches"), base.join("a").join("Kit.vcswatches"));
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 100, "height": 100}));
|
||||
(SWATCHES.add)(&mut s, "First");
|
||||
run(&mut s, "swatch.library.save", json!({"path": text(&first), "name": "Kit"}));
|
||||
(SWATCHES.add)(&mut s, "Second");
|
||||
run(&mut s, "swatch.library.save", json!({"path": text(&later), "name": "Kit"}));
|
||||
let shown = "loaded/Kit.vcswatches";
|
||||
let count = run(&mut s, "swatch.library.load", json!({"path": text(&first)}))["count"].clone();
|
||||
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
|
||||
assert_eq!(loaded(&list), [(json!(shown), Value::Null, count)], "{list}");
|
||||
run(&mut s, "swatch.library.load", json!({"path": text(&later)}));
|
||||
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
|
||||
let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect();
|
||||
assert_eq!(ids, [json!(shown), json!("loaded/Kit.vcswatches (2)")], "{list}");
|
||||
let theirs = run(&mut s, "swatch.library.get", json!({"library": format!("loaded/{}", text(&first))}));
|
||||
let got = confine(roots.as_ref(), || run(&mut s, "swatch.library.get", json!({"library": shown})));
|
||||
assert_eq!((&got["id"], &got["swatches"]), (&json!(shown), &theirs["swatches"]), "the first library: {got}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
@@ -424,6 +424,93 @@ fn blend_replace_spine_follows_path() {
|
||||
assert!(first.distance(Point::new(100.0, 300.0)) < 1.0, "{first:?}");
|
||||
}
|
||||
|
||||
/// #6809: after Replace Spine, moving a key object moves the drawn key and its end of the spine,
|
||||
/// not only the key's selection outline.
|
||||
#[test]
|
||||
fn moving_a_key_of_a_replaced_spine_blend_moves_what_is_drawn() {
|
||||
let mut s = session();
|
||||
let (a, _, g) = blend(&mut s, json!({"steps": 5}));
|
||||
let arc = id_of(&s.execute("path.create", &json!({"d": "M100 300 C100 245 145 200 200 200 C255 200 300 245 300 300"})).unwrap());
|
||||
sel(&mut s, &[g, arc]);
|
||||
s.execute("object.blend.replaceSpine", &json!({})).unwrap();
|
||||
let center = |n: &Node| n.geometric_bounds().unwrap().center();
|
||||
let before = steps_of(&s, g);
|
||||
assert!(center(&before[0]).distance(Point::new(100.0, 300.0)) < 1e-6);
|
||||
// Direct Selection picks the key inside the blend; the drag moves it.
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.move", &json!({"dx": -50, "dy": 40})).unwrap();
|
||||
let key = center(&node(&s, a));
|
||||
assert!(key.distance(Point::new(50.0, 340.0)) < 1e-6, "{key:?}");
|
||||
let after = steps_of(&s, g);
|
||||
assert_eq!(after.len(), before.len());
|
||||
let drawn = center(&after[0]);
|
||||
assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}");
|
||||
assert!(center(&after[1]).distance(center(&before[1])) > 1.0, "the first step follows the moved key");
|
||||
let last = center(&after[after.len() - 1]);
|
||||
assert!(last.distance(Point::new(300.0, 300.0)) < 1e-6, "the other key stays put: {last:?}");
|
||||
// Undo puts the key back on the spine as Replace Spine left it.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let back = steps_of(&s, g);
|
||||
assert!(center(&back[0]).distance(Point::new(100.0, 300.0)) < 1e-6);
|
||||
assert!(center(&back[1]).distance(center(&before[1])) < 1e-6);
|
||||
}
|
||||
|
||||
/// #6809 on a closed spine, where both end keys start on its first point: moving the first key
|
||||
/// moves its end of the spine without dragging the last key's end along.
|
||||
#[test]
|
||||
fn moving_the_first_key_of_a_closed_replaced_spine_blend_moves_what_is_drawn() {
|
||||
let mut s = session();
|
||||
let (a, _, g) = blend(&mut s, json!({"steps": 5}));
|
||||
let square = id_of(&s.execute("path.create", &json!({"d": "M100 100 L300 100 L300 300 L100 300 Z"})).unwrap());
|
||||
sel(&mut s, &[g, square]);
|
||||
s.execute("object.blend.replaceSpine", &json!({})).unwrap();
|
||||
let center = |n: &Node| n.geometric_bounds().unwrap().center();
|
||||
let before = steps_of(&s, g);
|
||||
assert!(center(&before[0]).distance(Point::new(100.0, 100.0)) < 1e-6);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.move", &json!({"dx": -50, "dy": -40})).unwrap();
|
||||
let key = center(&node(&s, a));
|
||||
assert!(key.distance(Point::new(50.0, 60.0)) < 1e-6, "{key:?}");
|
||||
let after = steps_of(&s, g);
|
||||
assert_eq!(after.len(), before.len());
|
||||
let drawn = center(&after[0]);
|
||||
assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}");
|
||||
assert!(center(&after[1]).distance(center(&before[1])) > 1.0, "the first step follows the moved key");
|
||||
let last = center(&after[after.len() - 1]);
|
||||
assert!(last.distance(Point::new(100.0, 100.0)) < 1e-6, "the other key stays put: {last:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let back = steps_of(&s, g);
|
||||
assert!(center(&back[0]).distance(Point::new(100.0, 100.0)) < 1e-6);
|
||||
assert!(center(&back[1]).distance(center(&before[1])) < 1e-6);
|
||||
}
|
||||
|
||||
/// #6809 on a closed spine pinned by a spine edit: the end keys get points of their own, so
|
||||
/// moving the first key afterwards still moves only its end.
|
||||
#[test]
|
||||
fn moving_the_first_key_of_a_pinned_closed_spine_blend_moves_what_is_drawn() {
|
||||
let mut s = session();
|
||||
let (a, _, g) = blend(&mut s, json!({"steps": 5}));
|
||||
let square = id_of(&s.execute("path.create", &json!({"d": "M100 100 L300 100 L300 300 L100 300 Z"})).unwrap());
|
||||
sel(&mut s, &[g, square]);
|
||||
s.execute("object.blend.replaceSpine", &json!({})).unwrap();
|
||||
// Dragging a corner away from the keys pins the spine to them.
|
||||
s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 2, "x": 320, "y": 320})).unwrap();
|
||||
let NodeKind::Blend { spec, .. } = node(&s, g).kind else { panic!() };
|
||||
assert_eq!(spec.key_anchors.len(), 2, "{:?}", spec.key_anchors);
|
||||
let center = |n: &Node| n.geometric_bounds().unwrap().center();
|
||||
let before = steps_of(&s, g);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.move", &json!({"dx": -50, "dy": -40})).unwrap();
|
||||
let key = center(&node(&s, a));
|
||||
assert!(key.distance(Point::new(50.0, 60.0)) < 1e-6, "{key:?}");
|
||||
let after = steps_of(&s, g);
|
||||
assert_eq!(after.len(), before.len());
|
||||
let drawn = center(&after[0]);
|
||||
assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}");
|
||||
let last = center(&after[after.len() - 1]);
|
||||
assert!(last.distance(Point::new(100.0, 100.0)) < 1e-6, "the other key stays put: {last:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_align_to_path_rotates_steps() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -400,6 +400,90 @@ fn stroke_set_refuses_an_unknown_key() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An object id given as something other than a whole number (`"5"`, `-1`) is refused as such,
|
||||
/// naming its key, not as a missing id; an id left out is still a missing one.
|
||||
#[test]
|
||||
fn a_malformed_object_id_is_not_reported_as_missing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let before = undo(&s);
|
||||
let points = json!([[0, 0], [10, 10], [20, 0]]);
|
||||
for bad in [json!("5"), json!(-1)] {
|
||||
let id = || json!({ "id": bad });
|
||||
let with = |extra: serde_json::Value| {
|
||||
let mut p = id();
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
for (cmd, p, key) in [
|
||||
("stroke.widthPoint.set", id(), "id"),
|
||||
("stroke.widthPoint.copy", id(), "id"),
|
||||
("stroke.widthPoint.remove", id(), "id"),
|
||||
("path.curvatureEdit", id(), "id"),
|
||||
("path.removeAnchor", id(), "id"),
|
||||
("path.convertAnchor", id(), "id"),
|
||||
("path.reshapeSegment", id(), "id"),
|
||||
("path.split", id(), "id"),
|
||||
("path.appendAnchor", id(), "id"),
|
||||
("path.close", id(), "id"),
|
||||
("path.setHandle", id(), "id"),
|
||||
("path.moveAnchor", id(), "id"),
|
||||
("path.setAnchors", id(), "id"),
|
||||
("path.reshape", id(), "id"),
|
||||
("path.insertAnchor", id(), "id"),
|
||||
("path.freehand", json!({"points": points, "extend": {"id": bad}}), "id"),
|
||||
("path.freehand", json!({"points": points, "redraw": {"id": bad}}), "id"),
|
||||
("document.node", id(), "id"),
|
||||
("layer.target", id(), "id"),
|
||||
("layer.setCurrent", id(), "id"),
|
||||
("layer.selectAll", id(), "id"),
|
||||
("layer.move", with(json!({"target": 1})), "id"),
|
||||
("layer.move", json!({"ids": [1], "target": bad}), "target"),
|
||||
("node.move", id(), "id"),
|
||||
("node.move", json!({"id": 1, "parent": bad}), "parent"),
|
||||
("text.reshapeArea", with(json!({"anchors": []})), "id"),
|
||||
("text.reshapeArea", json!({"items": [{"id": bad, "anchors": []}]}), "id"),
|
||||
("text.reshapePath", id(), "id"),
|
||||
("text.setPathHandle", id(), "id"),
|
||||
("text.editRange", id(), "id"),
|
||||
("text.startList", id(), "id"),
|
||||
("text.getRange", id(), "id"),
|
||||
("text.setGlyphAlternate", id(), "id"),
|
||||
("text.glyphAlternates", id(), "id"),
|
||||
("text.touchType", id(), "id"),
|
||||
("text.discardEmpty", id(), "id"),
|
||||
("text.createInPath", json!({"path": bad}), "path"),
|
||||
("object.envelope.setMeshPoint", id(), "id"),
|
||||
("object.mesh.setPointColor", with(json!({"index": 0})), "id"),
|
||||
("object.mesh.movePoint", with(json!({"index": 0, "x": 0, "y": 0})), "id"),
|
||||
("object.mesh.movePoints", json!({"items": [{"id": bad, "index": 0}]}), "id"),
|
||||
("object.mesh.addLine", with(json!({"x": 0, "y": 0})), "id"),
|
||||
("object.mesh.deletePoint", with(json!({"index": 0})), "id"),
|
||||
("object.slice.setRect", id(), "id"),
|
||||
("appearance.transfer", json!({"source": bad, "target": 1}), "source"),
|
||||
("appearance.transfer", json!({"source": 1, "target": bad}), "target"),
|
||||
("appearance.copyFrom", json!({"source": bad}), "source"),
|
||||
("livePaint.strokeEdge", json!({"point": [0, 0], "group": bad}), "group"),
|
||||
("livePaint.fill", json!({"point": [0, 0], "group": bad}), "group"),
|
||||
("livePaint.info", json!({"group": bad}), "group"),
|
||||
("livePaint.info", id(), "id"),
|
||||
("select.magicWand", id(), "id"),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
// `layer.move` checks an `id` that stands in for `ids` before it runs.
|
||||
let named = e.contains(&format!("`{key}` must be a whole number")) || (cmd == "layer.move" && e.contains("invalid object id"));
|
||||
assert!(named, "{cmd} {p}: {e}");
|
||||
}
|
||||
}
|
||||
for cmd in ["path.close", "layer.target", "text.reshapePath", "object.mesh.movePoint", "select.magicWand"] {
|
||||
let e = s.execute(cmd, &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("missing"), "{cmd}: {e}");
|
||||
}
|
||||
assert_eq!(undo(&s), before);
|
||||
}
|
||||
|
||||
/// A command that takes the keys of one that isn't checked yet isn't checked either.
|
||||
#[test]
|
||||
fn a_reference_to_an_unchecked_command_is_unchecked() {
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::script::{PANIC_IN_SLICE, TEST_ENGINE_HEAP, engines_on_this_thread, releases_kept_for};
|
||||
use crate::script::{PANIC_IN_SLICE, TEST_ENGINE_HEAP, TEST_KEPT_MAP_BYTES, engines_on_this_thread, releases_kept_for};
|
||||
use crate::*;
|
||||
|
||||
fn run(s: &mut Session, source: &str) -> Result<Value> {
|
||||
@@ -119,11 +119,115 @@ fn only_the_script_itself_names_its_engine() {
|
||||
assert_eq!(report["line"], 2, "{report}");
|
||||
assert_eq!(names(&mut s), ["e"]);
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
// An error in a function an earlier run defined has no place in the run that calls it.
|
||||
// An error in a function an earlier run defined is at that run's line; a script given as text
|
||||
// is named by its run's name.
|
||||
run(&mut s, "#targetengine e\n\n\n\nfunction bad() { throw new Error('kept'); }").unwrap();
|
||||
assert!(run(&mut s, "#targetengine e\nbad();").is_err());
|
||||
let e = run(&mut s, "#targetengine e\nbad();").unwrap_err().to_string();
|
||||
let report = s.script_report().unwrap().clone();
|
||||
assert!(report["line"].as_u64().is_none_or(|l| l == 0), "{report}");
|
||||
assert_eq!((report["name"].as_str(), report["file"].as_str(), report["line"].as_u64()), (Some("t.js"), Some("t.js"), Some(5)), "{report}");
|
||||
let column = report["column"].as_u64().unwrap();
|
||||
assert_eq!(error_lines(&report), [format!("t.js: t.js:5:{column}: Error: kept")]);
|
||||
assert!(e.contains(&format!("line 5, column {column} of t.js")), "{e}");
|
||||
}
|
||||
|
||||
/// The console error lines of a run's report.
|
||||
fn error_lines(report: &Value) -> Vec<String> {
|
||||
let lines = report["console"]["lines"].as_array().unwrap();
|
||||
lines.iter().filter_map(Value::as_str).filter(|l| l.contains("Error: ")).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// The file, line and column of the last run's error.
|
||||
fn place(s: &Session) -> (Option<String>, u64, u64) {
|
||||
let r = s.script_report().unwrap();
|
||||
(r["file"].as_str().map(str::to_string), r["line"].as_u64().unwrap(), r["column"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// An error in code an earlier run of a named engine compiled is at the place of that code: in that
|
||||
/// run's script, a file it included or a file `$.evalFile` gave, while the report and the console
|
||||
/// line keep the calling run's name.
|
||||
#[test]
|
||||
fn an_error_in_code_an_earlier_run_kept_is_at_that_runs_file_and_line() {
|
||||
let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-places-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
std::fs::write(base.join("inc.jsxinc"), "\nfunction fromInclude() { throw new Error('inc'); }").unwrap();
|
||||
std::fs::write(base.join("ev.jsxinc"), "\n\n\nfunction fromEval() { throw new Error('ev'); }").unwrap();
|
||||
let def = base.join("def.jsx");
|
||||
let text = "#targetengine e\n#include \"inc.jsxinc\"\n$.evalFile('ev.jsxinc');\nfunction fromMain() {\n throw new Error('main');\n}\n\
|
||||
function spin() {\n while (true) {}\n}\nfunction badEval() {\n eval('var = 1');\n}";
|
||||
std::fs::write(&def, text).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("script.run", &json!({"path": def.to_string_lossy()})).unwrap();
|
||||
let call = |s: &mut Session, code: &str| {
|
||||
let e = s.execute("script.run", &json!({"source": format!("#targetengine e\n\n{code}"), "name": "caller.js"}));
|
||||
assert!(e.is_err(), "{code}");
|
||||
};
|
||||
let shown = def.to_string_lossy().to_string();
|
||||
let ends = |p: &(Option<String>, u64, u64), file: &str, line: u64| p.0.as_deref().is_some_and(|f| f.ends_with(file)) && p.1 == line && p.2 > 0;
|
||||
call(&mut s, "fromMain();");
|
||||
let p = place(&s);
|
||||
assert!(ends(&p, "def.jsx", 5), "{p:?}");
|
||||
let report = s.script_report().unwrap().clone();
|
||||
assert_eq!(report["name"], "caller.js");
|
||||
assert_eq!(error_lines(&report), [format!("caller.js: {}:5:{}: Error: main", p.0.as_deref().unwrap(), p.2)]);
|
||||
call(&mut s, "fromInclude();");
|
||||
assert!(ends(&place(&s), "inc.jsxinc", 2), "{:?}", place(&s));
|
||||
call(&mut s, "fromEval();");
|
||||
assert!(ends(&place(&s), "ev.jsxinc", 4), "{:?}", place(&s));
|
||||
call(&mut s, "badEval();");
|
||||
assert!(ends(&place(&s), "def.jsx", 11), "a syntax error eval raises is at its call: {:?}", place(&s));
|
||||
let e = s.execute("script.run", &json!({"source": "#targetengine e\nspin();", "name": "caller.js", "maxFuel": 100_000}));
|
||||
assert!(e.is_err());
|
||||
assert_eq!(s.script_report().unwrap()["kind"], "limit");
|
||||
assert!(ends(&place(&s), "def.jsx", 8), "the fuel ran out in spin: {:?}", place(&s));
|
||||
// A caught error thrown again is where that throw is, in the calling run.
|
||||
call(&mut s, "try { fromMain(); } catch (x) {\n throw x;\n}");
|
||||
assert_eq!(place(&s), (None, 4, 3));
|
||||
// A run that fails before it starts compiles nothing, and the kept places stay.
|
||||
assert!(run(&mut s, "#targetengine e\n#include \"missing.jsxinc\"").is_err());
|
||||
call(&mut s, "fromMain();");
|
||||
assert!(ends(&place(&s), "def.jsx", 5), "{:?}", place(&s));
|
||||
assert!(place(&s).0.is_some_and(|f| f == shown));
|
||||
// A dropped engine takes its places with it: the new one has its own.
|
||||
s.execute("script.engine.drop", &json!({"name": "e"})).unwrap();
|
||||
run(&mut s, "#targetengine e\nfunction fromMain() { throw new Error('new'); }").unwrap();
|
||||
call(&mut s, "fromMain();");
|
||||
assert_eq!(place(&s).0.as_deref(), Some("t.js"));
|
||||
assert_eq!(place(&s).1, 2);
|
||||
// The name stands for the earlier script as that run's report gave it, not as console lines
|
||||
// show it.
|
||||
s.execute("script.run", &json!({"source": "#targetengine e\nfunction named() { throw 1; }", "name": "a\u{1}b.js"})).unwrap();
|
||||
call(&mut s, "named();");
|
||||
assert_eq!(place(&s).0.as_deref(), Some("a\u{1}b.js"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A named engine keeps the places of its earlier runs' code within its budget, the oldest going
|
||||
/// first, and the code still runs once its place is gone.
|
||||
#[test]
|
||||
fn an_engine_keeps_the_newest_places_within_its_budget() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "#targetengine e\nfunction a() { throw new Error('a'); }").unwrap();
|
||||
TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(300)));
|
||||
run(&mut s, "#targetengine e\n\nfunction b() { throw new Error('b'); }").unwrap();
|
||||
for _ in 0..20 {
|
||||
run(&mut s, "#targetengine e\nvar x = 1;").unwrap();
|
||||
}
|
||||
run(&mut s, "#targetengine e\n\n\nfunction c() { throw new Error('c'); }").unwrap();
|
||||
// Each run's own map takes the room of the one before, so c is called first.
|
||||
assert!(run(&mut s, "#targetengine e\nc();").is_err());
|
||||
let c_place = place(&s);
|
||||
let a = run(&mut s, "#targetengine e\na();").map(drop);
|
||||
let a_place = place(&s);
|
||||
TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(1)));
|
||||
run(&mut s, "#targetengine e\n\n\n\nfunction d() { throw new Error('d'); }").unwrap();
|
||||
assert!(run(&mut s, "#targetengine e\nd();").is_err());
|
||||
let d_place = place(&s);
|
||||
TEST_KEPT_MAP_BYTES.with(|b| b.set(None));
|
||||
assert!(a.is_err());
|
||||
assert_eq!((a_place.0, a_place.1, a_place.2), (None, 0, 0), "a's place went to make room");
|
||||
assert_eq!((c_place.0.as_deref(), c_place.1), (Some("t.js"), 4));
|
||||
assert_eq!((d_place.0, d_place.1, d_place.2), (None, 0, 0), "a map larger than the budget is not kept");
|
||||
}
|
||||
|
||||
/// A `$.evalFile` an earlier run kept reads files the way the run that calls it does: relative to
|
||||
|
||||
@@ -671,6 +671,44 @@ fn organic_fades_add_with_their_method_midpoints_and_opacities_in_rgb_and_cmyk()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holographic_glows_add_whole_in_rgb_and_cmyk_as_one_undo_step() {
|
||||
let lib = vectorcraft_color::libraries::builtin_library("holographic").unwrap();
|
||||
let Paint::Gradient(want) = &lib.swatch("Mint Halo").unwrap().paint else { panic!("Mint Halo is a gradient") };
|
||||
for mode in ["rgb", "cmyk"] {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": mode}));
|
||||
let list = run(&mut s, "swatch.library.list", json!({}));
|
||||
let row = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "holographic").unwrap().clone();
|
||||
assert_eq!((row["name"].as_str(), row["category"].as_str(), row["count"].as_u64()), (Some("Holographic"), Some("gradients"), Some(14)));
|
||||
let got = run(&mut s, "swatch.library.get", json!({"library": "holographic"}));
|
||||
assert!(got.to_string().contains("Mint Halo"), "{mode}: get lists Mint Halo");
|
||||
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone();
|
||||
let node = vectorcraft_doc::NodeId(id.as_u64().unwrap());
|
||||
let before = undo_len(&s);
|
||||
let r = run(&mut s, "swatch.library.add", json!({"library": "holographic", "names": ["Mint Halo"], "apply": "fill"}));
|
||||
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Mint Halo"]), json!("Mint Halo")), "{mode}");
|
||||
assert_eq!(undo_len(&s), before + 1, "{mode}: added and applied as one step");
|
||||
// The swatch comes as the library has it: kind, stops (RGB colors, opacities, midpoints) and method.
|
||||
let Paint::Gradient(added) = &doc(&s).swatch("Mint Halo").unwrap().paint else { panic!("{mode}: a gradient swatch") };
|
||||
assert_eq!(added.gradient, want.gradient, "{mode}");
|
||||
assert!(added.gradient.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{mode}: the stops stay RGB");
|
||||
assert_eq!(added.gradient.stops.last().map(|st| st.opacity), Some(0.0), "{mode}: clear at the edge");
|
||||
let Paint::Gradient(fill) = doc(&s).node(node).unwrap().appearance.fill_paint() else { panic!("{mode}: a gradient fill") };
|
||||
assert_eq!((fill.gradient.kind, fill.gradient.method), (want.gradient.kind, want.gradient.method), "{mode}");
|
||||
assert_eq!(fill.gradient.stops, want.gradient.stops, "{mode}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(doc(&s).swatch("Mint Halo").is_none(), "{mode}: undo removes the add");
|
||||
assert!(!matches!(doc(&s).node(node).unwrap().appearance.fill_paint(), Paint::Gradient(_)), "{mode}: and the fill");
|
||||
// A document swatch already named Mint Halo stays; the glow comes in under a number.
|
||||
run(&mut s, "swatch.new", json!({"name": "Mint Halo", "color": "#123456"}));
|
||||
let r = run(&mut s, "swatch.library.add", json!({"library": "holographic", "names": ["Mint Halo"]}));
|
||||
assert_eq!(r["added"], json!(["Mint Halo 2"]), "{mode}");
|
||||
assert!(doc(&s).swatch("Mint Halo").unwrap().paint.color().is_some(), "{mode}: the document's swatch is kept");
|
||||
assert!(matches!(&doc(&s).swatch("Mint Halo 2").unwrap().paint, Paint::Gradient(g) if g.gradient == want.gradient));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_libraries_bring_their_patterns_and_add_each_once() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -475,6 +475,7 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() {
|
||||
("text.setFormat", json!({"ids": [id.0], "hyphenate": "false"}), "hyphenate"),
|
||||
("text.setFormat", json!({"ids": [id.0], "hyphenateCapitalized": 0}), "hyphenateCapitalized"),
|
||||
("text.setFormat", json!({"ids": [id.0], "romanHangingPunctuation": "true"}), "romanHangingPunctuation"),
|
||||
("text.setFormat", json!({"ids": [id.0], "koreanLineBreakAnywhere": 1}), "koreanLineBreakAnywhere"),
|
||||
("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"),
|
||||
("type.opticalMarginAlignment", json!({"on": "true"}), "on"),
|
||||
("type.systemLayout", json!({"on": "true"}), "on"),
|
||||
@@ -2523,6 +2524,68 @@ fn converting_tracked_type_between_point_and_area_keeps_it_in_place() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal and vertical type keep every line where it is when converted from point to area
|
||||
/// type and back, and from area to point type and back, however the lines are aligned. Vertical
|
||||
/// lines run down their columns, and each next column sits to the left of the one before it.
|
||||
#[test]
|
||||
fn converting_vertical_type_between_point_and_area_keeps_it_in_place() {
|
||||
for vertical in [false, true] {
|
||||
for j in ["left", "center", "right"] {
|
||||
for text in ["Wave", "Wave\nWaves"] {
|
||||
let mut s = session();
|
||||
let r = s.execute("text.create", &json!({"x": 300, "y": 40, "text": text, "size": 12, "vertical": vertical})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap();
|
||||
let before = line_starts_doc(&obj(&s, id));
|
||||
assert_eq!(before.len(), text.lines().count());
|
||||
if vertical && before.len() > 1 {
|
||||
assert!(before[1].x < before[0].x, "{j}: the second column is to the left: {before:?}");
|
||||
}
|
||||
for round in 0..2 {
|
||||
s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
assert!(matches!(t.kind, TextKind::Area { .. }));
|
||||
let area = line_starts_doc(&t);
|
||||
assert_eq!(area.len(), before.len(), "vertical {vertical}, {j}, {text:?}: no rewrap");
|
||||
for (a, b) in area.iter().zip(&before) {
|
||||
assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, {text:?}, round {round}, area: {before:?} → {area:?}");
|
||||
}
|
||||
s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
assert!(matches!(t.kind, TextKind::Point));
|
||||
let point = line_starts_doc(&t);
|
||||
for (a, b) in point.iter().zip(&before) {
|
||||
assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, {text:?}, round {round}, point: {before:?} → {point:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Area type to point type and back.
|
||||
let mut s = session();
|
||||
let r = s
|
||||
.execute(
|
||||
"text.create",
|
||||
&json!({"x": 300, "y": 40, "text": "Wave\nWaves", "size": 12, "vertical": vertical, "area": {"width": 120, "height": 120}}),
|
||||
)
|
||||
.unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap();
|
||||
let before = line_starts_doc(&obj(&s, id));
|
||||
assert_eq!(before.len(), 2);
|
||||
s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap();
|
||||
let point = line_starts_doc(&obj(&s, id));
|
||||
for (a, b) in point.iter().zip(&before) {
|
||||
assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, from area, point: {before:?} → {point:?}");
|
||||
}
|
||||
s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap();
|
||||
let area = line_starts_doc(&obj(&s, id));
|
||||
assert_eq!(area.len(), 2);
|
||||
for (a, b) in area.iter().zip(&before) {
|
||||
assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, from area, area again: {before:?} → {area:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_area_to_point_keeps_a_deeper_list_item_in_place() {
|
||||
let mut s = session();
|
||||
|
||||
Reference in New Issue
Block a user