mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 03:48:01 +00:00
Merge main into fix/ai-writer-object-limit-2
This commit is contained in:
Generated
+1
@@ -6384,6 +6384,7 @@ dependencies = [
|
||||
name = "vectorcraft-js"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"proptest",
|
||||
"unicode-general-category",
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
- Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged.
|
||||
|
||||
- Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged.
|
||||
- UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged.
|
||||
|
||||
- PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document.
|
||||
- PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned.
|
||||
|
||||
@@ -168,6 +168,11 @@ fn same_lasting(a: &serde_json::Value, b: &serde_json::Value) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fields that a read fills in from other fields when the file lacks them
|
||||
/// (`vectorcraft_ui_egui::layout_migration`): `help_bar` follows `status_bar`. A save writes them
|
||||
/// whenever the file lacks them, so the next read does not fill them in again from a changed field.
|
||||
const DERIVED: &[&str] = &["help_bar"];
|
||||
|
||||
/// `on_disk` with what changed from `base` to `mine` put over it: each top-level field of the UI
|
||||
/// state, and each engine preference (`engine_prefs`) on its own, so two windows that change
|
||||
/// different settings keep both. A field both changed takes this window's value (it saves last).
|
||||
@@ -219,6 +224,13 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in DERIVED {
|
||||
if !out.contains_key(*key)
|
||||
&& let Some(v) = mine.get(*key)
|
||||
{
|
||||
out.insert((*key).to_string(), v.clone());
|
||||
}
|
||||
}
|
||||
if use_mine {
|
||||
match mine.get("action_set") {
|
||||
Some(v) => {
|
||||
@@ -383,6 +395,20 @@ mod tests {
|
||||
assert!(!same_lasting(&base, &mine));
|
||||
}
|
||||
|
||||
/// A file from before the Help Bar setting: the read shows the Help Bar with the status bar.
|
||||
/// After the status bar is hidden and the Help Bar left showing, the save writes `help_bar`,
|
||||
/// so the next read does not hide it with the status bar. A value on disk is kept.
|
||||
#[test]
|
||||
fn a_help_bar_setting_the_file_lacks_is_saved() {
|
||||
let base = json!({"status_bar": true, "help_bar": true});
|
||||
let mine = json!({"status_bar": false, "help_bar": true});
|
||||
let on_disk = json!({"status_bar": true});
|
||||
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"status_bar": false, "help_bar": true}));
|
||||
// Another window saved its own Help Bar setting: it stays.
|
||||
let on_disk = json!({"status_bar": true, "help_bar": false});
|
||||
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"status_bar": false, "help_bar": false}));
|
||||
}
|
||||
|
||||
/// One window deleted the first Actions set and saved; this one made the second set current.
|
||||
/// The saved index follows that set to its place in the merged list.
|
||||
#[test]
|
||||
|
||||
@@ -1227,10 +1227,10 @@ impl<'a> Scene<'a> {
|
||||
let Some(&first) = run.first() else { continue };
|
||||
let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte);
|
||||
let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo);
|
||||
// A right-to-left paragraph's marker is drawn with its tab first and its number's
|
||||
// period at the number's left; the TEXT entity holds the characters in that order.
|
||||
// A right-to-left paragraph's marker is drawn with its number's period at the
|
||||
// number's left; the TEXT entity holds the characters in that order.
|
||||
let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| match m.text.strip_suffix('.') {
|
||||
Some(number) if m.tab == m.glyphs.start => format!(".{number}"),
|
||||
Some(number) if layout.glyphs.get(m.tab).is_some_and(|g| g.rtl) => format!(".{number}"),
|
||||
_ => m.text.clone(),
|
||||
});
|
||||
let Some(s) = marker.as_deref().or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue };
|
||||
|
||||
+21
-4
@@ -1076,15 +1076,15 @@ fn a_bullet_is_text_of_its_own_before_its_paragraph() {
|
||||
|
||||
/// A numbered list paragraph's number and period are a TEXT entity of their own where they are
|
||||
/// drawn, in the order they are drawn: in a right-to-left paragraph the period comes first, at the
|
||||
/// number's left.
|
||||
/// number's left, with the marker aligned left or right.
|
||||
#[test]
|
||||
fn a_number_is_text_of_its_own_before_its_paragraph() {
|
||||
use vectorcraft_doc::text::{NumberStyle, ParaList};
|
||||
let numbered = |text: &str, count: u32| {
|
||||
use vectorcraft_doc::text::{ListAlign, NumberStyle, ParaList};
|
||||
let numbered_aligned = |text: &str, count: u32, align: ListAlign| {
|
||||
let mut d = text_doc(text, 20.0);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.para.list = Some(ParaList::numbered(NumberStyle::Arabic));
|
||||
t.para.list = Some(ParaList::numbered(NumberStyle::Arabic).aligned(align));
|
||||
t.para.left_indent = 30.0;
|
||||
t.para.first_line_indent = -30.0;
|
||||
t.list_count = count;
|
||||
@@ -1092,10 +1092,27 @@ fn a_number_is_text_of_its_own_before_its_paragraph() {
|
||||
let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
entities(&out).iter().map(|e| (get(e, 1).map(str::to_string), all(e, 10)[0])).collect::<Vec<_>>()
|
||||
};
|
||||
let numbered = |text: &str, count: u32| numbered_aligned(text, count, ListAlign::Left);
|
||||
assert_eq!(numbered("Item", 11), [(Some("12.".to_string()), 10.0), (Some("Item".to_string()), 40.0)]);
|
||||
let rtl = numbered("שלום", 11);
|
||||
assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::<Vec<_>>(), ["שלום", ".12"]);
|
||||
assert!(rtl[1].1 > rtl[0].1, "the number right of the text: {rtl:?}");
|
||||
// Aligned right, the number keeps its order and moves right, against the text.
|
||||
let right = numbered_aligned("Item", 11, ListAlign::Right);
|
||||
assert_eq!((right[0].0.as_deref(), right[1].clone()), (Some("12."), (Some("Item".to_string()), 40.0)));
|
||||
assert!(right[0].1 > 10.0 && right[0].1 < 40.0, "{right:?}");
|
||||
let rtl = numbered_aligned("שלום", 11, ListAlign::Right);
|
||||
assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::<Vec<_>>(), ["שלום", ".12"]);
|
||||
// A mixed paragraph: the period follows the paragraph's direction, not the script of the
|
||||
// words it holds, with either alignment.
|
||||
let marker = |text: &str, align: ListAlign| {
|
||||
let out = numbered_aligned(text, 11, align);
|
||||
out.into_iter().filter_map(|(s, _)| s).find(|s| s.contains("12")).unwrap_or_default()
|
||||
};
|
||||
for align in [ListAlign::Left, ListAlign::Right] {
|
||||
assert_eq!(marker("Item שלום", align), "12.", "{align:?}");
|
||||
assert_eq!(marker("שלום Item", align), ".12", "{align:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Area type that Shrink Text to Fit scales down is written at the height it draws at.
|
||||
|
||||
+69
-14
@@ -19,7 +19,7 @@ use std::f64::consts::PI;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_geom::kurbo::ParamCurveArclen;
|
||||
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv};
|
||||
use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
|
||||
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile};
|
||||
@@ -904,9 +904,12 @@ pub struct Spine {
|
||||
cum: Vec<f64>,
|
||||
anchors: Vec<f64>,
|
||||
closed: bool,
|
||||
path: PathData,
|
||||
}
|
||||
|
||||
impl Spine {
|
||||
/// Build a spine from the first subpath of `path`: flatten it to a polyline, record cumulative
|
||||
/// arc lengths, and the arc length at each anchor.
|
||||
pub fn new(path: &PathData) -> Option<Self> {
|
||||
let sp = path.subpaths.first()?;
|
||||
let mut pts: Vec<Point> = Vec::new();
|
||||
@@ -943,8 +946,9 @@ impl Spine {
|
||||
if pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some(Self { pts, cum, anchors, closed: sp.closed })
|
||||
Some(Self { pts, cum, anchors, closed: sp.closed, path: path.clone() })
|
||||
}
|
||||
/// The spine's flattened length in arc length.
|
||||
pub fn length(&self) -> f64 {
|
||||
*self.cum.last().unwrap_or(&0.0)
|
||||
}
|
||||
@@ -1002,20 +1006,32 @@ impl Spine {
|
||||
pub fn at(&self, f: f64) -> (Point, f64) {
|
||||
self.at_length(f.clamp(0.0, 1.0) * self.length())
|
||||
}
|
||||
/// Point and tangent angle (radians) at arc length `s` (clamped to the spine).
|
||||
/// Point and tangent angle (radians) at arc length `s` (clamped to the spine). The point and
|
||||
/// tangent come from the original curve at that arc length, not from the flattened polyline: a
|
||||
/// flattened chord's direction is piecewise constant, so steps oriented to a curved spine with
|
||||
/// `Align to Path` jump between chords instead of following the curve smoothly.
|
||||
pub fn at_length(&self, s: f64) -> (Point, f64) {
|
||||
let last = self.pts.len().saturating_sub(2);
|
||||
let s = if s.is_finite() { s.clamp(0.0, self.length()) } else { 0.0 };
|
||||
let i = match self.cum.binary_search_by(|c| c.total_cmp(&s)) {
|
||||
Ok(i) => i.min(last),
|
||||
Err(i) => i.saturating_sub(1).min(last),
|
||||
};
|
||||
let (Some(p0), Some(p1), Some(c0), Some(c1)) = (self.pts.get(i), self.pts.get(i + 1), self.cum.get(i), self.cum.get(i + 1)) else {
|
||||
return (self.pts.first().copied().unwrap_or_default(), 0.0);
|
||||
};
|
||||
let u = ((s - c0) / (c1 - c0).max(1e-12)).clamp(0.0, 1.0);
|
||||
let d = *p1 - *p0;
|
||||
(p0.lerp(*p1, u), d.y.atan2(d.x))
|
||||
let Some(sp) = self.path.subpaths.first() else { return (self.pts.first().copied().unwrap_or_default(), 0.0) };
|
||||
let n = sp.segment_count();
|
||||
let mut start = 0.0;
|
||||
for i in 0..n {
|
||||
let c = sp.segment(i);
|
||||
let end = self.anchor_length(i + 1).unwrap_or(self.length());
|
||||
if s <= end || i + 1 == n {
|
||||
let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9);
|
||||
let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0);
|
||||
let e = c.eval(t);
|
||||
let tv = c.deriv().eval(t).to_vec2();
|
||||
// Where a handle sits on its anchor the derivative vanishes there: take the
|
||||
// direction between points a little before and after instead.
|
||||
let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv };
|
||||
return (Point::new(e.x, e.y), tv.y.atan2(tv.x));
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
// Only a spine without segments gets here: the loop returns at the last one.
|
||||
(self.pts.first().copied().unwrap_or_default(), 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1446,6 +1462,45 @@ mod tests {
|
||||
assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientations_follow_a_curved_spine_smoothly() {
|
||||
// A curved spine: from (0, 0) with handles (+50, +50) / (-50, -50) to (100, 0) with handles
|
||||
// (+50, -50) / (-50, +50) — the classic up-bow arc.
|
||||
let spine = PathData::single(SubPath::new(
|
||||
vec![Anchor::smooth(Point::new(0.0, 0.0), Point::new(50.0, 50.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(50.0, -50.0))],
|
||||
false,
|
||||
));
|
||||
let sp = Spine::new(&spine).unwrap();
|
||||
// Many steps along the bow: each step's tangent comes from the curve at its arc length,
|
||||
// not from a flattened chord — so the orientations must sweep the true angle, smoothly.
|
||||
let len = sp.length();
|
||||
let mut prev: Option<f64> = None;
|
||||
for i in 1..=200 {
|
||||
let (pt, ang) = sp.at_length(len * i as f64 / 201.0);
|
||||
if let Some(p) = prev {
|
||||
let step = (ang - p).abs().min(2.0 * std::f64::consts::PI - (ang - p).abs());
|
||||
assert!(step < 0.1, "tangent step {} is too big at {} ({}, {})", step, i, pt.x, pt.y);
|
||||
}
|
||||
prev = Some(ang);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the spine starts at an anchor whose handle sits on it, the first step still faces along
|
||||
/// the curve (towards the next handle), not along a direction of zero length.
|
||||
#[test]
|
||||
fn a_handle_on_its_anchor_still_gives_the_curve_direction() {
|
||||
let spine = PathData::single(SubPath::new(
|
||||
vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(150.0, -50.0))],
|
||||
false,
|
||||
));
|
||||
let sp = Spine::new(&spine).unwrap();
|
||||
let (p, angle) = sp.at_length(0.0);
|
||||
assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6, "{p:?}");
|
||||
assert!((angle - std::f64::consts::FRAC_PI_4).abs() < 0.05, "faces the in handle at (50, 50): {angle}");
|
||||
let (_, end) = sp.at_length(sp.length());
|
||||
assert!(end.is_finite(), "{end}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn easing_keeps_the_ends_and_stays_monotone() {
|
||||
use crate::live::{BlendEase, BlendEaseType as K};
|
||||
|
||||
@@ -321,6 +321,20 @@ pub struct SeriesPaint {
|
||||
/// Opacity (0..1) of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stroke_opacity: Option<f32>,
|
||||
/// Blend mode of the topmost fill of each path directly in the series group, as [`Self::fill_opacity`]. Absent: Normal.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fill_blend: Option<vectorcraft_color::BlendMode>,
|
||||
/// Blend mode of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`]. Absent:
|
||||
/// Normal.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stroke_blend: Option<vectorcraft_color::BlendMode>,
|
||||
/// Object opacity (0..1) of each path directly in the series group, taken from the object opacity set on one of them.
|
||||
/// Absent: fully opaque.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub opacity: Option<f32>,
|
||||
/// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blend: Option<vectorcraft_color::BlendMode>,
|
||||
}
|
||||
|
||||
/// Graph data and options. `rows` are categories (one per row of the Graph Data window), each
|
||||
|
||||
+22
-10
@@ -437,7 +437,7 @@ pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec<NodeI
|
||||
/// whole, and an unlocked layer, group, blend or envelope in Edit Contents is searched inside.
|
||||
fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, out: &mut Vec<NodeId>) {
|
||||
let Some(children) = parent.children() else { return };
|
||||
if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
|
||||
if parent.clips() && !edits_inside(parent, opt.scope) && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
|
||||
return;
|
||||
}
|
||||
for i in hit_order(parent, children, p) {
|
||||
@@ -653,6 +653,15 @@ fn clip_outline_hit(parent: &Node, clip: &Node, p: Point, opt: HitOptions, symbo
|
||||
clip_outline(clip, p, opt)
|
||||
}
|
||||
|
||||
/// Does isolation mode (`scope`) edit what `parent` holds: is `parent` the isolated container, or
|
||||
/// the parent of the single object isolated? Then the selection tools hit the art that the
|
||||
/// clipping mask of `parent` hides, and the Unlock submenu lists it (#3748). The reference
|
||||
/// app's help says that the parts outside a clipping mask can still be edited
|
||||
/// (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-clipping-masks.html>).
|
||||
fn edits_inside(parent: &Node, scope: Option<NodeId>) -> bool {
|
||||
scope.is_some_and(|s| s == parent.id || parent.children().is_some_and(|c| c.iter().any(|c| c.id == s)))
|
||||
}
|
||||
|
||||
fn hit_children(
|
||||
parent: &Node,
|
||||
p: Point,
|
||||
@@ -670,8 +679,10 @@ fn hit_children(
|
||||
chain.push(clip.id);
|
||||
return Some(Hit { leaf: clip.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 });
|
||||
}
|
||||
// Elsewhere a clip group (or a layer with a clipping mask) only hits inside it.
|
||||
if !clip_contains(clip, p) {
|
||||
// Elsewhere a clip group (or a layer with a clipping mask) only hits inside it, except for
|
||||
// the selection tools in isolation mode within it.
|
||||
let edited = opt.clip_outlines && editable_only && edits_inside(parent, opt.scope);
|
||||
if !edited && !clip_contains(clip, p) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -1205,7 +1216,9 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
|
||||
/// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured
|
||||
/// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a
|
||||
/// clipping mask counts only when the marquee meets the region its clipping path clips to, and then
|
||||
/// only within that region's bounds.
|
||||
/// only within that region's bounds. In isolation mode the clipping mask of the isolated container,
|
||||
/// or of the parent of the single object isolated, does not limit the marquee, as it does not limit
|
||||
/// the selection tools' clicks.
|
||||
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
|
||||
marquee_with(doc, r, scope, leaves, false)
|
||||
}
|
||||
@@ -1223,16 +1236,15 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool, wh
|
||||
// Isolated art whose layer, or a group around it, was locked or hidden afterwards: the
|
||||
// marquee touches none of it, as clicks don't (#5309).
|
||||
Some(s) if !doc.is_editable(s.id) => vec![],
|
||||
Some(s) => match (s.children(), marquee_in(s, r)) {
|
||||
(Some(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),
|
||||
(Some(_), None) => vec![],
|
||||
Some(s) => match s.children() {
|
||||
Some(c) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),
|
||||
// A single object isolated: only it.
|
||||
(None, _) => doc
|
||||
None => doc
|
||||
.parent_of(s.id)
|
||||
.and_then(|p| doc.node(p))
|
||||
.and_then(|p| Some((p.children()?, marquee_in(p, r)?)))
|
||||
.and_then(|p| p.children())
|
||||
.into_iter()
|
||||
.flat_map(|(c, r)| c.iter().filter(|c| c.id == s.id).map(move |c| (c, r)))
|
||||
.flat_map(|c| c.iter().filter(|c| c.id == s.id).map(|c| (c, r)))
|
||||
.collect(),
|
||||
},
|
||||
// The objects of the layers, looking through sublayers (hidden, locked and template ones
|
||||
|
||||
@@ -255,7 +255,9 @@ fn a_selected_objects_hidden_art_on_a_preview_off_layer_counts_on_its_outline()
|
||||
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
|
||||
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
|
||||
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
|
||||
/// Group Selection's leaves and in isolation mode.
|
||||
/// Group Selection's leaves. In isolation mode (#3748) the isolated group's own mask does not
|
||||
/// limit the marquee, as it does not limit clicks: with the group or the art isolated, the
|
||||
/// marquee takes the hidden art.
|
||||
#[test]
|
||||
fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
@@ -270,8 +272,9 @@ fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() {
|
||||
let hidden = Rect::new(150.0, -10.0, 210.0, 50.0);
|
||||
assert!(marquee(&d, hidden, None, false).is_empty());
|
||||
assert!(marquee(&d, hidden, None, true).is_empty());
|
||||
assert!(marquee(&d, hidden, Some(g), false).is_empty());
|
||||
assert!(marquee(&d, hidden, Some(art_id), false).is_empty());
|
||||
assert_eq!(marquee(&d, hidden, Some(g), false), vec![art_id]);
|
||||
assert_eq!(marquee(&d, hidden, Some(g), true), vec![art_id]);
|
||||
assert_eq!(marquee(&d, hidden, Some(art_id), false), vec![art_id]);
|
||||
// This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge.
|
||||
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]);
|
||||
// This marquee crosses the shown half's top edge and goes on into the hidden half.
|
||||
@@ -682,6 +685,82 @@ fn select_behind_takes_a_layer_masks_outline_first() {
|
||||
assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]);
|
||||
}
|
||||
|
||||
/// #3748: in isolation mode the selection tools hit the art that the isolated clip group (or
|
||||
/// the isolated layer with a clipping mask) hides outside its mask, and the art when it is the
|
||||
/// single object isolated; the mask's outline is still hit first. Outside isolation mode, and
|
||||
/// for the tools that act on what is drawn, the hidden art is not hit. The Unlock submenu
|
||||
/// lists the hidden art when it is locked.
|
||||
#[test]
|
||||
fn isolation_reaches_the_art_a_clipping_mask_hides() {
|
||||
let hidden = Point::new(170.0, 90.0);
|
||||
for layer in [false, true] {
|
||||
let (mut d, g, clip, art) = masked_square(layer);
|
||||
let isolated = HitOptions { scope: Some(g), ..SELECTING };
|
||||
assert!(hit_test(&d, hidden, SELECTING).is_none(), "layer {layer}: not isolated");
|
||||
let h = hit_test(&d, hidden, isolated);
|
||||
assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(g)))), Some((art, art)), "layer {layer}");
|
||||
assert_eq!(objects_at(&d, hidden, SELECTING, Some(g)), vec![art], "layer {layer}");
|
||||
assert_eq!(hit_test(&d, Point::new(140.0, 90.0), isolated).map(|h| h.leaf), Some(clip), "layer {layer}: the outline first");
|
||||
let one = HitOptions { scope: Some(art), ..SELECTING };
|
||||
assert_eq!(hit_test(&d, hidden, one).map(|h| h.leaf), Some(art), "layer {layer}: the art isolated");
|
||||
assert!(hit_test(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }).is_none(), "layer {layer}: drawn art only");
|
||||
assert!(hit_test_visible(&d, hidden, isolated).is_none(), "layer {layer}: sampling");
|
||||
d.node_mut(art).unwrap().locked = true;
|
||||
assert_eq!(locked_objects_at(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }), vec![art], "layer {layer}");
|
||||
assert!(locked_objects_at(&d, hidden, HitOptions::default()).is_empty(), "layer {layer}: not isolated");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3748, nested clip groups: with the outer clip group isolated, a click or a marquee takes
|
||||
/// the art its own mask hides, while the mask of a clip group inside it still limits that
|
||||
/// group's art. With the inner clip group isolated, or its art, the inner mask does not.
|
||||
#[test]
|
||||
fn isolation_reaches_hidden_art_only_for_the_isolated_clip_group() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (outer, outer_clip, outer_art, inner, inner_clip, inner_art) =
|
||||
(d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
let mask = |id, r| {
|
||||
let mut path = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
if let NodeKind::Path { clipping, .. } = &mut path.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
Arc::new(path)
|
||||
};
|
||||
let art = |id, r| Arc::new(Node::path(id, shapes::rectangle(r), Appearance::default_art()));
|
||||
let inner_group = Node::new(
|
||||
inner,
|
||||
NodeKind::Group {
|
||||
children: vec![mask(inner_clip, Rect::new(60.0, 60.0, 120.0, 120.0)), art(inner_art, Rect::new(40.0, 40.0, 160.0, 160.0))],
|
||||
clip: true,
|
||||
},
|
||||
);
|
||||
let children =
|
||||
vec![mask(outer_clip, Rect::new(20.0, 20.0, 180.0, 180.0)), art(outer_art, Rect::new(190.0, 20.0, 260.0, 180.0)), Arc::new(inner_group)];
|
||||
d.insert(Some(l), 0, Node::new(outer, NodeKind::Group { children, clip: true })).unwrap();
|
||||
// `outer_hidden` is outside the outer mask, on the outer group's own art; `inner_hidden` is
|
||||
// inside the outer mask and outside the inner one, on the inner group's art.
|
||||
let (outer_hidden, inner_hidden, shown) = (Point::new(220.0, 100.0), Point::new(150.0, 100.0), Point::new(90.0, 90.0));
|
||||
let leaf = |p, scope| hit_test(&d, p, HitOptions { scope, ..SELECTING }).map(|h| h.leaf);
|
||||
assert_eq!((leaf(outer_hidden, None), leaf(inner_hidden, None)), (None, None), "not isolated");
|
||||
assert_eq!(leaf(outer_hidden, Some(outer)), Some(outer_art), "outer isolated: its own hidden art");
|
||||
assert_eq!(leaf(inner_hidden, Some(outer)), None, "outer isolated: the inner mask still clips");
|
||||
let h = hit_test(&d, shown, HitOptions { scope: Some(outer), ..SELECTING });
|
||||
assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(outer)))), Some((inner_art, inner)), "outer isolated: shown art");
|
||||
assert_eq!(leaf(inner_hidden, Some(inner)), Some(inner_art), "inner isolated");
|
||||
assert_eq!(leaf(outer_hidden, Some(inner)), None, "inner isolated: the outer group's art");
|
||||
assert_eq!(leaf(inner_hidden, Some(inner_art)), Some(inner_art), "the inner art isolated");
|
||||
// These marquees cross the right edge of the outer art (outside the outer mask) and of the
|
||||
// inner art (inside the outer mask, outside the inner one).
|
||||
let (outer_edge, inner_edge) = (Rect::new(255.0, 95.0, 265.0, 105.0), Rect::new(155.0, 95.0, 165.0, 105.0));
|
||||
assert_eq!(marquee(&d, outer_edge, Some(outer), false), vec![outer_art]);
|
||||
assert!(marquee(&d, inner_edge, Some(outer), false).is_empty());
|
||||
assert!(marquee(&d, inner_edge, Some(outer), true).is_empty());
|
||||
assert_eq!(marquee(&d, inner_edge, Some(inner), false), vec![inner_art]);
|
||||
assert!(marquee(&d, inner_edge, None, false).is_empty());
|
||||
assert!(marquee(&d, outer_edge, None, false).is_empty());
|
||||
}
|
||||
|
||||
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
|
||||
/// sublayer; hidden, locked and template sublayers block both.
|
||||
#[test]
|
||||
|
||||
@@ -572,7 +572,8 @@ mod tests {
|
||||
place(&mut g, &symbols);
|
||||
g.transform(Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]), false);
|
||||
let rec = g.extras().objects_on_path.as_deref().cloned().unwrap();
|
||||
assert_eq!((rec.spacing, rec.start), (Some(40.0), 20.0));
|
||||
// The spine measures arc length along the curve, so within rounding of the exact values.
|
||||
assert!(rec.spacing.is_some_and(|d| (d - 40.0).abs() < 1e-9) && (rec.start - 20.0).abs() < 1e-9, "{rec:?}");
|
||||
place(&mut g, &symbols);
|
||||
let x = |id| g.children().and_then(|c| c.iter().find(|c| c.id == id).cloned()).unwrap().geometric_bounds().unwrap().center().x;
|
||||
assert!((x(NodeId(2)) - 20.0).abs() < 1e-9 && (x(NodeId(3)) - 60.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3)));
|
||||
|
||||
+15
-2
@@ -26,8 +26,11 @@ pub use para_style::{
|
||||
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection,
|
||||
ParaStyle, SingleWordJustify,
|
||||
};
|
||||
pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef};
|
||||
pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop};
|
||||
pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef, TypeAntiAlias};
|
||||
pub use tabs_lists::{
|
||||
Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, LIST_LEVEL_INDENT, LIST_LEVELS, ListAlign, ListCounts, NumberStyle, ParaList, TabAlign,
|
||||
TabStop,
|
||||
};
|
||||
pub use wrap::{resolve_wrap, wrap_work};
|
||||
|
||||
/// A text object. `runs` split into paragraphs at `\n`.
|
||||
@@ -83,6 +86,11 @@ pub struct TextObject {
|
||||
/// Off for new type and for documents from before it, and saved only when on.
|
||||
#[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub optical_margin: bool,
|
||||
/// Character panel › Anti-aliasing Method: how the type's edges are drawn as pixels. Each
|
||||
/// frame of a thread keeps its own method. Sharp for new type and for documents from before it,
|
||||
/// and saved only when it is another method.
|
||||
#[serde(default, rename = "antiAlias", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub anti_alias: TypeAntiAlias,
|
||||
/// Character panel menu › System Layout, copied from the document: when on, each character
|
||||
/// that takes space advances by a whole number of points. Off by default, and saved only when
|
||||
/// on. The document setting is [`crate::DocSetup::system_layout`].
|
||||
@@ -101,6 +109,11 @@ pub struct TextObject {
|
||||
/// file saves the count only when it is not 0.
|
||||
#[serde(default, rename = "listCount", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub list_count: u32,
|
||||
/// Threaded text: as [`Self::list_count`], the counts the earlier frames carry into this frame
|
||||
/// at list levels 2 on, level 2 first ([`ListCounts::from_parts`]). Empty when they are all 0,
|
||||
/// and the file saves them only when not empty. A file's longer list opens with the first 8.
|
||||
#[serde(default, rename = "listLevelCounts", skip_serializing_if = "Vec::is_empty", deserialize_with = "tabs_lists::read_level_counts")]
|
||||
pub list_level_counts: Vec<u32>,
|
||||
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
|
||||
#[serde(skip)]
|
||||
pub cached_bounds: Option<Rect>,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::area::{AreaOptions, PathAlign, PathEffect};
|
||||
use super::char_style::CharStyle;
|
||||
use super::para_style::ParaStyle;
|
||||
use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end};
|
||||
use super::tabs_lists::ParaList;
|
||||
use super::runs::{RubySpan, TextKind, TextRun, TypeAntiAlias, move_anchors, reverse_from_end};
|
||||
use super::tabs_lists::{ListCounts, ParaList};
|
||||
use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2};
|
||||
|
||||
impl TextObject {
|
||||
@@ -90,13 +90,24 @@ impl TextObject {
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
anti_alias: TypeAntiAlias::Sharp,
|
||||
system_layout: false,
|
||||
fractional_widths: true,
|
||||
list_count: 0,
|
||||
list_level_counts: Vec::new(),
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The list counts the story's earlier frames carry into this frame at each level
|
||||
/// ([`Self::list_count`] and [`Self::list_level_counts`]).
|
||||
pub fn list_counts(&self) -> ListCounts {
|
||||
ListCounts::from_parts(self.list_count, &self.list_level_counts)
|
||||
}
|
||||
/// Set the list counts the story's earlier frames carry into this frame ([`Self::list_counts`]).
|
||||
pub fn set_list_counts(&mut self, counts: ListCounts) {
|
||||
(self.list_count, self.list_level_counts) = counts.parts();
|
||||
}
|
||||
pub fn plain_text(&self) -> String {
|
||||
self.runs.iter().map(|r| r.text.as_str()).collect()
|
||||
}
|
||||
|
||||
@@ -258,6 +258,12 @@ impl ParaStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// The left indent the layout sets the paragraph at: its left indent, and for a paragraph in a
|
||||
/// list at a level past the first, [`ParaList::level_indent`] more.
|
||||
pub fn laid_out_left_indent(&self) -> f64 {
|
||||
self.left_indent + self.list.map_or(0.0, |l| l.level_indent())
|
||||
}
|
||||
|
||||
/// The leading of characters styled `st` in this paragraph, as the layout sets it: their own
|
||||
/// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their
|
||||
/// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is
|
||||
|
||||
@@ -144,6 +144,44 @@ pub(super) fn reverse_from_end(path: &mut PathData) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The anti-aliasing method of a type object (Character panel › Anti-aliasing Method): how the
|
||||
/// edges of its characters are drawn when the type is drawn as pixels (the canvas, raster exports,
|
||||
/// rasterizing). SVG, PDF and EPS export ignore it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TypeAntiAlias {
|
||||
/// Hard edges: a pixel takes the type's color where the type covers at least half of it, and
|
||||
/// nothing elsewhere.
|
||||
None,
|
||||
/// Each edge pixel shows the share of it the type covers.
|
||||
#[default]
|
||||
Sharp,
|
||||
/// An edge pixel covered by a share `c` shows `2c - 0.5`, between 0 and 1: edges about half as
|
||||
/// wide as Sharp's.
|
||||
Crisp,
|
||||
/// An edge pixel covered by a share `c` shows `2c`, at most 1: heavier type.
|
||||
Strong,
|
||||
}
|
||||
|
||||
impl TypeAntiAlias {
|
||||
pub const ALL: [Self; 4] = [Self::None, Self::Sharp, Self::Crisp, Self::Strong];
|
||||
|
||||
/// The id used in command params and files (`none`, `sharp`, `crisp`, `strong`).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Sharp => "sharp",
|
||||
Self::Crisp => "crisp",
|
||||
Self::Strong => "strong",
|
||||
}
|
||||
}
|
||||
|
||||
/// The method an id names.
|
||||
pub fn from_id(id: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|a| a.id() == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where ruby (furigana) text sits: above its base text in horizontal type, right of it in
|
||||
/// vertical type (the Japanese convention), or below (left in vertical type).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -155,14 +155,29 @@ fn roman(mut n: u32) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// Where a list's marker stands before the first line's text ([`ParaList::align`]): the Bullets
|
||||
/// and Numbering dialog's Alignment.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ListAlign {
|
||||
/// The marker starts where the first line starts: at the left indent plus the first-line
|
||||
/// indent.
|
||||
#[default]
|
||||
Left,
|
||||
/// The marker ends where the first line's text starts, against the text.
|
||||
Right,
|
||||
}
|
||||
|
||||
/// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the
|
||||
/// list's marker before the paragraph's first line, in the font, size and color of the paragraph's
|
||||
/// first character without its baseline shift, rotation, superscript or subscript, tracking, manual
|
||||
/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after
|
||||
/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a
|
||||
/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves
|
||||
/// out as their defaults, and a build ignores the fields it doesn't know.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
/// it starts at the next tab stop, the left indent counting as a stop. The marker stands at the
|
||||
/// first line's start, or against the text with [`ListAlign::Right`]. A paragraph at a level past
|
||||
/// the first ([`Self::level`]) is set [`LIST_LEVEL_INDENT`] further in for each level. The marker
|
||||
/// is not a character of the text, and an empty paragraph has none. A saved list reads the fields
|
||||
/// it leaves out as their defaults, and a build ignores the fields it doesn't know.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ParaList {
|
||||
/// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for
|
||||
@@ -172,17 +187,127 @@ pub struct ParaList {
|
||||
/// numbered list.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub number: Option<NumberStyle>,
|
||||
/// Where the marker stands before the first line's text. The file saves it only when it is
|
||||
/// [`ListAlign::Right`].
|
||||
#[serde(skip_serializing_if = "crate::skip::is_default")]
|
||||
pub align: ListAlign,
|
||||
/// The paragraph's level in the list, 1 to [`LIST_LEVELS`] (Tab and Shift+Tab at the start of
|
||||
/// the paragraph, Bullets and Numbering…'s Level): each level counts its own numbers
|
||||
/// ([`Self::next_marker`]). A value outside that range is read as the nearest level
|
||||
/// ([`Self::depth`]). The file saves it only when it is not 1.
|
||||
#[serde(skip_serializing_if = "is_first_level", deserialize_with = "read_level")]
|
||||
pub level: u8,
|
||||
}
|
||||
|
||||
impl Default for ParaList {
|
||||
/// A bulleted list with a dot, its marker aligned left, at level 1.
|
||||
fn default() -> Self {
|
||||
Self { bullet: Bullet::default(), number: None, align: ListAlign::default(), level: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `level` the first list level, which the file does not save?
|
||||
fn is_first_level(level: &u8) -> bool {
|
||||
*level == 1
|
||||
}
|
||||
|
||||
/// Reads a saved list level, a whole number, as the nearest of 1 to [`LIST_LEVELS`], so a level
|
||||
/// such as -1 or 300 opens the file at level 1 or 9. Anything else is an error.
|
||||
fn read_level<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u8, D::Error> {
|
||||
let level = match serde_json::Value::deserialize(d)? {
|
||||
serde_json::Value::Number(n) if n.is_i64() || n.is_u64() => n.as_i64().map_or(LIST_LEVELS as i64, |n| n.clamp(1, LIST_LEVELS as i64)),
|
||||
_ => return Err(serde::de::Error::custom("a list level must be a whole number")),
|
||||
};
|
||||
Ok(level as u8)
|
||||
}
|
||||
|
||||
/// Reads a type object's saved counts at list levels 2 on ([`ListCounts::from_parts`]), keeping
|
||||
/// the first `LIST_LEVELS - 1` and skipping the rest of a longer list without storing it.
|
||||
pub(super) fn read_level_counts<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<u32>, D::Error> {
|
||||
struct Counts;
|
||||
impl<'de> serde::de::Visitor<'de> for Counts {
|
||||
type Value = Vec<u32>;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a list of list counts")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u32>, A::Error> {
|
||||
let mut out = Vec::new();
|
||||
while out.len() < LIST_LEVELS - 1 {
|
||||
match seq.next_element::<u32>()? {
|
||||
Some(c) => out.push(c),
|
||||
None => return Ok(out),
|
||||
}
|
||||
}
|
||||
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
d.deserialize_seq(Counts)
|
||||
}
|
||||
|
||||
/// How many levels a list has (Bullets and Numbering…'s Level: 1 to 9).
|
||||
pub const LIST_LEVELS: usize = 9;
|
||||
|
||||
/// How much further in (pt) the layout sets a list paragraph for each level past the first: its
|
||||
/// left indent counts that much more, and the marker and the text move with it.
|
||||
pub const LIST_LEVEL_INDENT: f64 = 18.0;
|
||||
|
||||
/// The numbered paragraphs a story's list has counted at each level, level 1 first
|
||||
/// ([`ParaList::next_marker`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ListCounts(pub [u32; LIST_LEVELS]);
|
||||
|
||||
impl ListCounts {
|
||||
/// The counts with `first` at level 1 and `deeper` at levels 2 on, a missing level at 0, as a
|
||||
/// type object saves them ([`TextObject::list_count`], [`TextObject::list_level_counts`]).
|
||||
pub fn from_parts(first: u32, deeper: &[u32]) -> Self {
|
||||
let mut counts = [0; LIST_LEVELS];
|
||||
counts[0] = first;
|
||||
for (c, &d) in counts[1..].iter_mut().zip(deeper) {
|
||||
*c = d;
|
||||
}
|
||||
Self(counts)
|
||||
}
|
||||
|
||||
/// The count at level 1, and the counts at levels 2 on without the zeros that end them.
|
||||
pub fn parts(&self) -> (u32, Vec<u32>) {
|
||||
let deeper = &self.0[1..];
|
||||
let used = deeper.iter().rposition(|&c| c != 0).map_or(0, |i| i + 1);
|
||||
(self.0[0], deeper[..used].to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
impl ParaList {
|
||||
/// A bulleted list with `bullet`.
|
||||
pub fn bulleted(bullet: Bullet) -> Self {
|
||||
Self { bullet, number: None }
|
||||
Self { bullet, ..Self::default() }
|
||||
}
|
||||
|
||||
/// A numbered list with `style`.
|
||||
pub fn numbered(style: NumberStyle) -> Self {
|
||||
Self { bullet: Bullet::default(), number: Some(style) }
|
||||
Self { number: Some(style), ..Self::default() }
|
||||
}
|
||||
|
||||
/// This list with its marker at `align`.
|
||||
pub fn aligned(self, align: ListAlign) -> Self {
|
||||
Self { align, ..self }
|
||||
}
|
||||
|
||||
/// This list at level `level` (1 to [`LIST_LEVELS`]).
|
||||
pub fn at_level(self, level: u8) -> Self {
|
||||
Self { level, ..self }
|
||||
}
|
||||
|
||||
/// The list's level, 1 to [`LIST_LEVELS`]: [`Self::level`], a value outside the range taken
|
||||
/// as the nearest level.
|
||||
pub fn depth(&self) -> usize {
|
||||
usize::from(self.level).clamp(1, LIST_LEVELS)
|
||||
}
|
||||
|
||||
/// How much further in (pt) than its left indent the layout sets a paragraph of this list:
|
||||
/// [`LIST_LEVEL_INDENT`] for each level past the first.
|
||||
pub fn level_indent(&self) -> f64 {
|
||||
(self.depth() - 1) as f64 * LIST_LEVEL_INDENT
|
||||
}
|
||||
|
||||
/// The characters of the marker the layout draws before the paragraph: the bullet, or for a
|
||||
@@ -195,18 +320,26 @@ impl ParaList {
|
||||
}
|
||||
|
||||
/// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and
|
||||
/// which holds no text when `empty`, with `count` the numbered paragraphs counted before it.
|
||||
/// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has
|
||||
/// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the
|
||||
/// count as it is; a numbered paragraph adds one to the count and is numbered with it.
|
||||
pub fn next_marker(list: Option<ParaList>, empty: bool, count: &mut u32) -> Option<String> {
|
||||
/// which holds no text when `empty`, with `counts` the numbered paragraphs counted before it at
|
||||
/// each level. A paragraph in no list sets every count back to 0 and has no marker; an empty
|
||||
/// paragraph has none and leaves the counts as they are. A paragraph with text sets the counts
|
||||
/// of the levels deeper than its own back to 0, so a deeper paragraph after it counts from 1
|
||||
/// again; a bulleted one has its bullet and leaves the count of its level as it is, and a
|
||||
/// numbered one adds one to the count of its level and is numbered with it. A paragraph back
|
||||
/// at a shallower level then goes on with that level's count.
|
||||
pub fn next_marker(list: Option<ParaList>, empty: bool, counts: &mut ListCounts) -> Option<String> {
|
||||
let Some(list) = list else {
|
||||
*count = 0;
|
||||
*counts = ListCounts::default();
|
||||
return None;
|
||||
};
|
||||
if empty {
|
||||
return None;
|
||||
}
|
||||
let level = list.depth() - 1;
|
||||
for deeper in &mut counts.0[level + 1..] {
|
||||
*deeper = 0;
|
||||
}
|
||||
let count = &mut counts.0[level];
|
||||
if list.number.is_some() {
|
||||
*count = count.saturating_add(1);
|
||||
}
|
||||
|
||||
@@ -194,13 +194,13 @@ fn number_styles_write_their_numbers_and_roll_over() {
|
||||
assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV.");
|
||||
assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}");
|
||||
let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot)));
|
||||
let mut count = 0;
|
||||
let mut count = ListCounts::default();
|
||||
let markers: Vec<Option<String>> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)]
|
||||
.iter()
|
||||
.map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count))
|
||||
.collect();
|
||||
assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]);
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(count.parts(), (1, vec![]));
|
||||
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
|
||||
assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash));
|
||||
assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits));
|
||||
@@ -211,6 +211,20 @@ fn number_styles_write_their_numbers_and_roll_over() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A list's marker alignment: Left by default and not saved, Right saved as `"align": "right"`;
|
||||
/// a list saved without it reads as Left.
|
||||
#[test]
|
||||
fn a_list_saves_its_marker_alignment_only_when_it_is_right() {
|
||||
let right = ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right);
|
||||
assert_eq!(ParaList::bulleted(Bullet::Dot).align, ListAlign::Left);
|
||||
assert_eq!(serde_json::to_value(right).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"}));
|
||||
assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"}));
|
||||
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
|
||||
assert_eq!(read(serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"})), right);
|
||||
assert_eq!(read(serde_json::json!({"bullet": "square"})).align, ListAlign::Left);
|
||||
assert!(serde_json::from_value::<ParaList>(serde_json::json!({"align": "center"})).is_err());
|
||||
}
|
||||
|
||||
/// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph
|
||||
/// start a numbered list, and "*", "+", "#" and ">" and a space start a bulleted list with the
|
||||
/// filled dot, the hollow circle, the filled square and the dash. Other text, the same characters
|
||||
@@ -240,6 +254,70 @@ fn typed_list_starts_are_found_at_a_paragraphs_start() {
|
||||
assert_eq!(t.typed_list_at(19), Some((16, letters)));
|
||||
}
|
||||
|
||||
/// List levels: each level counts its own numbers, a deeper paragraph starts its count at 1,
|
||||
/// a paragraph back at a shallower level goes on with that level's count, and a paragraph in
|
||||
/// no list starts every level again. A level past the first sets the paragraph 18 pt further
|
||||
/// in; the file saves the level only when it is not 1, and a level outside 1 to 9 reads as the
|
||||
/// nearest one. A type object's counts save level 1 and the deeper levels apart.
|
||||
#[test]
|
||||
fn list_levels_count_their_own_numbers_and_save_only_past_the_first() {
|
||||
let num = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level));
|
||||
let dot = |level| Some(ParaList::bulleted(Bullet::Dot).at_level(level));
|
||||
let mut counts = ListCounts::default();
|
||||
let markers: Vec<Option<String>> = [
|
||||
(num(1), false),
|
||||
(num(2), false),
|
||||
(num(2), false),
|
||||
(num(3), false),
|
||||
(num(2), true),
|
||||
(num(2), false),
|
||||
(num(1), false),
|
||||
(num(2), false),
|
||||
(dot(1), false),
|
||||
(num(2), false),
|
||||
(num(1), false),
|
||||
(None, false),
|
||||
(num(2), false),
|
||||
]
|
||||
.iter()
|
||||
.map(|&(l, empty)| ParaList::next_marker(l, empty, &mut counts))
|
||||
.collect();
|
||||
let want = ["1.", "1.", "2.", "1.", "", "3.", "2.", "1.", "\u{2022}", "1.", "3.", "", "1."];
|
||||
assert_eq!(markers.iter().map(|m| m.as_deref().unwrap_or("")).collect::<Vec<_>>(), want);
|
||||
assert_eq!(counts, ListCounts([0, 1, 0, 0, 0, 0, 0, 0, 0]));
|
||||
assert_eq!((ParaList::default().level, ParaList::bulleted(Bullet::Dot).depth()), (1, 1));
|
||||
assert_eq!([0, 1, 4, 9, 12].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).depth()), [1, 1, 4, 9, 9]);
|
||||
assert_eq!([1, 2, 9].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).level_indent()), [0.0, 18.0, 144.0]);
|
||||
let para = ParaStyle { left_indent: 18.0, list: dot(3), ..Default::default() };
|
||||
assert_eq!((para.laid_out_left_indent(), ParaStyle { list: None, ..para.clone() }.laid_out_left_indent()), (54.0, 18.0));
|
||||
assert_eq!(serde_json::to_value(dot(1)).unwrap(), serde_json::json!({"bullet": "dot"}));
|
||||
assert_eq!(serde_json::to_value(num(4)).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "level": 4}));
|
||||
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
|
||||
assert_eq!((read(serde_json::json!({"bullet": "dash"})).level, read(serde_json::json!({"level": 4}))), (1, dot(4).unwrap()));
|
||||
assert!(serde_json::from_value::<ParaList>(serde_json::json!({"level": "two"})).is_err());
|
||||
assert!(serde_json::from_value::<ParaList>(serde_json::json!({"level": 2.5})).is_err());
|
||||
let levels = [serde_json::json!(0), serde_json::json!(-1), serde_json::json!(12), serde_json::json!(300), serde_json::json!(u64::MAX)];
|
||||
assert_eq!(
|
||||
levels.map(|l| read(serde_json::json!({ "level": l })).level),
|
||||
[1, 1, 9, 9, 9],
|
||||
"a saved level outside 1 to 9 reads as the nearest one"
|
||||
);
|
||||
let deep = ListCounts([5, 0, 2, 0, 0, 0, 0, 0, 0]);
|
||||
assert_eq!(deep.parts(), (5, vec![0, 2]));
|
||||
assert_eq!((ListCounts::from_parts(5, &[0, 2]), ListCounts::default().parts()), (deep, (0, vec![])));
|
||||
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
|
||||
t.set_list_counts(deep);
|
||||
assert_eq!((t.list_count, t.list_level_counts.as_slice(), t.list_counts()), (5, &[0, 2][..], deep));
|
||||
let saved = serde_json::to_value(&t).unwrap();
|
||||
assert_eq!((&saved["listCount"], &saved["listLevelCounts"]), (&serde_json::json!(5), &serde_json::json!([0, 2])));
|
||||
t.set_list_counts(ListCounts::default());
|
||||
assert!(serde_json::to_value(&t).unwrap().get("listLevelCounts").is_none(), "no counts past level 1 are saved without them");
|
||||
let mut saved = serde_json::to_value(&t).unwrap();
|
||||
saved["listLevelCounts"] = serde_json::json!((1..=20).collect::<Vec<u32>>());
|
||||
let opened = serde_json::from_value::<TextObject>(saved).unwrap();
|
||||
assert_eq!(opened.list_level_counts, (1..=8).collect::<Vec<u32>>(), "a file keeps only the counts of levels 2 to 9");
|
||||
}
|
||||
|
||||
/// In a threaded frame whose first paragraph continues from the frame before, the text at the
|
||||
/// frame's start is not the start of a paragraph, so typing a list's start there starts nothing.
|
||||
/// The paragraphs after it still start lists.
|
||||
|
||||
@@ -210,6 +210,177 @@ fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bo
|
||||
assert_eq!(baked(&fitted, distort()).1.geom, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = |kind| GradientPaint::new(Gradient::new(kind, stops.clone()));
|
||||
// A ramp across the square and one down it, so both axes of the box map show.
|
||||
let across = GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None };
|
||||
let down = GradientGeom { start: Point::new(60.0, 10.0), end: Point::new(60.0, 75.0), aspect: 1.0, focal: None };
|
||||
let placed = |geom| Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp(GradientKind::Linear) }));
|
||||
let fitted = Paint::Gradient(Box::new(ramp(GradientKind::Linear)));
|
||||
let square = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let baked = |fill: &Paint, e: Effect| {
|
||||
let mut n = Node::path(NodeId(50), shapes::rectangle(square), Appearance::basic(fill.clone(), Paint::None, 0.0));
|
||||
n.appearance.effects.push(e);
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
let m = d.node(NodeId(50)).unwrap();
|
||||
match m.appearance.fill_paint() {
|
||||
Paint::Gradient(g) => (m.geometric_bounds().unwrap(), (*g).clone()),
|
||||
p => panic!("a gradient: {p:?}"),
|
||||
}
|
||||
};
|
||||
// Where the box map from the square to `to` takes `p`.
|
||||
let follow = |p: Point, to: Rect| Point::new(to.x0 + p.x / 100.0 * to.width(), to.y0 + p.y / 100.0 * to.height());
|
||||
let styles = [
|
||||
"arc",
|
||||
"arcLower",
|
||||
"arcUpper",
|
||||
"arch",
|
||||
"bulge",
|
||||
"shellLower",
|
||||
"shellUpper",
|
||||
"flag",
|
||||
"wave",
|
||||
"fish",
|
||||
"rise",
|
||||
"fisheye",
|
||||
"inflate",
|
||||
"squeeze",
|
||||
"twist",
|
||||
];
|
||||
// The square's warped bounds, from `warp_point` on 401 points along each side: the square's
|
||||
// corners are (-1, -1) and (1, 1) in the warp's box coordinates, and a vertical warp swaps
|
||||
// the axes.
|
||||
let warped_bounds = |style: &str, p: &serde_json::Value| {
|
||||
let Some(style) = crate::WarpStyle::from_id(style) else { panic!("warp.{style}: a style") };
|
||||
let share = |key: &str, default: f64| p.get(key).and_then(serde_json::Value::as_f64).unwrap_or(default) / 100.0;
|
||||
let vertical = p.get("orientation").and_then(serde_json::Value::as_str) == Some("vertical");
|
||||
let (bend, dh, dv) = (share("bend", 50.0), share("horizontal", 0.0), share("vertical", 0.0));
|
||||
let mut r: Option<Rect> = None;
|
||||
for k in 0..=400 {
|
||||
let s = f64::from(k) / 200.0 - 1.0;
|
||||
for (x, y) in [(s, -1.0), (s, 1.0), (-1.0, s), (1.0, s)] {
|
||||
let (x2, y2) = if vertical {
|
||||
let (a, b) = crate::warp_point(style, bend, dh, dv, 1.0, y, x);
|
||||
(b, a)
|
||||
} else {
|
||||
crate::warp_point(style, bend, dh, dv, 1.0, x, y)
|
||||
};
|
||||
let q = Point::new(50.0 + x2 * 50.0, 50.0 + y2 * 50.0);
|
||||
r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q)));
|
||||
}
|
||||
}
|
||||
r.unwrap_or(square)
|
||||
};
|
||||
let near =
|
||||
|a: Rect, b: Rect, tol: f64| (a.x0 - b.x0).abs() < tol && (a.y0 - b.y0).abs() < tol && (a.x1 - b.x1).abs() < tol && (a.y1 - b.y1).abs() < tol;
|
||||
let mut moved = 0;
|
||||
for style in styles {
|
||||
for params in
|
||||
[json!({"bend": 30.0}), json!({"bend": -40.0, "orientation": "vertical"}), json!({"bend": 20.0, "horizontal": 30.0, "vertical": -20.0})]
|
||||
{
|
||||
// A warp that bends the square's sides inward keeps its bounds (within 0.5 pt), and the
|
||||
// gradient stays with them.
|
||||
let expected = warped_bounds(style, ¶ms);
|
||||
let moves = !near(expected, square, 0.5);
|
||||
moved += usize::from(moves);
|
||||
for linear in [across, down] {
|
||||
let what = format!("warp.{style} {params} {linear:?}");
|
||||
let (b, g) = baked(&placed(linear), fx(&format!("warp.{style}"), params.clone()));
|
||||
assert!(near(b, expected, 0.1), "{what}: bounds {b:?}, want {expected:?}");
|
||||
let geom = g.geom.unwrap();
|
||||
let shifted = geom.start.distance(linear.start).max(geom.end.distance(linear.end));
|
||||
assert_eq!(shifted > 0.1, moves, "{what}: the gradient moves {shifted} with bounds {b:?}");
|
||||
let want = GradientGeom { start: follow(linear.start, b), end: follow(linear.end, b), ..linear };
|
||||
assert_geom_near(geom, want, &what);
|
||||
}
|
||||
let what = format!("warp.{style} {params}");
|
||||
// A fitted gradient stays fitted, and fits the warped outline.
|
||||
assert_eq!(baked(&fitted, fx(&format!("warp.{style}"), params)).1.geom, None, "{what}");
|
||||
}
|
||||
}
|
||||
assert_eq!(moved, 39, "the warps that move the bounds, of 45");
|
||||
// With no bend and no distortion the gradient stays where it is.
|
||||
let (_, g) = baked(&placed(across), fx("warp.arc", json!({"bend": 0.0})));
|
||||
assert_geom_near(g.geom.unwrap(), across, "no bend");
|
||||
}
|
||||
|
||||
/// A warp with no distortion and no bend (or a bend Arc takes as none) leaves a placed gradient
|
||||
/// exactly where it was, even where warping the outline moves its bounds by a rounding error.
|
||||
#[test]
|
||||
fn a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let geom = GradientGeom { start: Point::new(13.7, 31.3), end: Point::new(91.1, 47.9), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) }));
|
||||
let mut drift = 0;
|
||||
let cases = crate::WARP_STYLES.map(|(s, _)| (s, 0.0));
|
||||
for (k, (style, bend)) in cases.into_iter().chain([("arc", 1e-5), ("arc", -1e-5)]).enumerate() {
|
||||
let k = k as f64;
|
||||
let oval = Rect::new(10.1 + k * 0.37, 20.3 - k * 1.13, 97.7 + k * 3.31, 71.9 + k * 0.71);
|
||||
let mut n = Node::path(NodeId(50), shapes::ellipse(oval), Appearance::basic(placed.clone(), Paint::None, 0.0));
|
||||
n.appearance.effects.push(fx(&format!("warp.{style}"), json!({ "bend": bend })));
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
let m = d.node(NodeId(50)).unwrap();
|
||||
drift += usize::from(m.geometric_bounds() != shapes::ellipse(oval).bounds());
|
||||
let Paint::Gradient(g) = m.appearance.fill_paint() else { panic!("warp.{style} {bend}: a gradient") };
|
||||
assert_eq!(g.geom, Some(geom), "warp.{style} {bend}");
|
||||
}
|
||||
assert!(drift > 0, "the warped outlines' bounds move by a rounding error");
|
||||
// Another style with as small a bend still moves the outline, and the gradient follows it.
|
||||
let mut n = Node::path(NodeId(50), shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(placed, Paint::None, 0.0));
|
||||
n.appearance.effects.push(fx("warp.rise", json!({"bend": 1e-5})));
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
let Paint::Gradient(g) = d.node(NodeId(50)).unwrap().appearance.fill_paint() else { panic!("warp.rise: a gradient") };
|
||||
assert_ne!(g.geom, Some(geom), "warp.rise 1e-5");
|
||||
}
|
||||
|
||||
/// A Warp effect on a fill or a stroke itself moves and scales its placed gradient from the
|
||||
/// path's bounds before the warp to its bounds after it (a stroke: its stroke-inflated bounds).
|
||||
#[test]
|
||||
fn a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
let geom = GradientGeom { start: Point::new(20.0, 60.0), end: Point::new(80.0, 60.0), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
|
||||
let square = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let width = 4.0;
|
||||
// Where the box map from `from` to `to` takes `p`.
|
||||
let follow = |p: Point, from: Rect, to: Rect| {
|
||||
Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height())
|
||||
};
|
||||
let pad = |r: Rect| r.inflate(width / 2.0, width / 2.0);
|
||||
for style in ["arc", "rise", "inflate"] {
|
||||
for stroke in [false, true] {
|
||||
let what = format!("warp.{style} on the {}", if stroke { "stroke" } else { "fill" });
|
||||
let mut ap =
|
||||
if stroke { Appearance::basic(Paint::None, placed.clone(), width) } else { Appearance::basic(placed.clone(), Paint::None, 0.0) };
|
||||
let Some(item) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_)) == stroke) else { panic!("{what}: the item") };
|
||||
item.effects_mut().push(fx(&format!("warp.{style}"), json!({"bend": 40.0})));
|
||||
let n = Node::path(NodeId(50), shapes::rectangle(square), ap);
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
let mut found = vec![];
|
||||
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
|
||||
for item in &c.appearance.items {
|
||||
if let Paint::Gradient(g) = item.paint() {
|
||||
found.push((c.geometric_bounds().unwrap(), g.geom.unwrap()));
|
||||
}
|
||||
}
|
||||
});
|
||||
assert!(!found.is_empty(), "{what}: the gradient is kept");
|
||||
for (b, g) in found {
|
||||
assert_ne!(b, square, "{what}: the warp changes the bounds");
|
||||
let (from, to) = if stroke { (pad(square), pad(b)) } else { (square, b) };
|
||||
let want = GradientGeom { start: follow(geom.start, from, to), end: follow(geom.end, from, to), ..geom };
|
||||
assert_geom_near(g, want, &what);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_effects_split_into_one_path_per_item() {
|
||||
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||
|
||||
@@ -111,8 +111,7 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
}
|
||||
}
|
||||
|
||||
const WARP_DOC: &str =
|
||||
"{bend: % (-100..100, 50), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}";
|
||||
const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}";
|
||||
|
||||
const DT: &[&str] = &["Effect", "Distort & Transform"];
|
||||
|
||||
|
||||
@@ -175,9 +175,10 @@ pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: R
|
||||
}
|
||||
|
||||
/// How placed gradients follow geometry effects. The Transform effect without copies maps them as
|
||||
/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort moves
|
||||
/// and scales them from the object's bounds before the effect to its bounds after it. For the
|
||||
/// other geometry effects it is empty. It does not change gradients fitted to the object.
|
||||
/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort and
|
||||
/// the Warp effects move and scale them from the object's bounds before the effect to its bounds
|
||||
/// after it. For the other geometry effects it is empty. It does not change gradients fitted to
|
||||
/// the object.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Carry(Vec<CarryStep>);
|
||||
|
||||
@@ -193,9 +194,10 @@ enum CarryStep {
|
||||
|
||||
impl Carry {
|
||||
/// Does effect `id` with merged params `p` on geometry with reference box `b` (`seed` for
|
||||
/// Random) move placed gradients? Free Distort does unless it leaves every corner in place;
|
||||
/// the Transform effect does without copies (with copies the original stays where it is, and
|
||||
/// `crate::reshape` gives each copy its own gradients).
|
||||
/// Random) move placed gradients? Free Distort does unless it leaves every corner in place, a
|
||||
/// Warp effect unless it has no bend and no distortion, and the Transform effect does without
|
||||
/// copies (with copies the original stays where it is, and `crate::reshape` gives each copy
|
||||
/// its own gradients).
|
||||
pub(crate) fn follows(id: &str, p: &Value, b: Rect, seed: u64) -> bool {
|
||||
match id {
|
||||
"distort.transform" => {
|
||||
@@ -209,10 +211,17 @@ impl Carry {
|
||||
[(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1)].iter().zip(&corners).all(|(a, c)| c.distance((*a).into()) <= tol);
|
||||
!still && corners.iter().all(|c| c.x.is_finite() && c.y.is_finite())
|
||||
}
|
||||
_ if id.starts_with("warp.") => crate::warp::bends(id, p),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does effect `id` move placed gradients from each path's bounds before it to its bounds
|
||||
/// after it, rather than by one map for the whole object?
|
||||
pub(crate) fn by_bounds(id: &str) -> bool {
|
||||
id == "distort.freeDistort" || id.starts_with("warp.")
|
||||
}
|
||||
|
||||
/// How placed gradients follow effect `id` (as [`Self::follows`]) on an object whose geometric
|
||||
/// bounds are `from` before the effect and `to` after it; empty when they stay in place.
|
||||
pub(crate) fn of(id: &str, p: &Value, b: Rect, from: Option<Rect>, to: Option<Rect>, seed: u64) -> Carry {
|
||||
|
||||
@@ -312,7 +312,8 @@ fn compound_shape_area_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNod
|
||||
Some(po::boolean_n(&refs, |inside| {
|
||||
let (mut start, mut area) = (0, false);
|
||||
for (k, (op, count)) in members.iter().enumerate() {
|
||||
let here = inside.get(start..start + count).is_some_and(|flags| flags.contains(&true));
|
||||
// `inside` is ascending: the first leaf at or after `start` tells whether one of this member's is in it.
|
||||
let here = inside.get(inside.partition_point(|&i| i < start)).is_some_and(|&i| i < start + count);
|
||||
start += count;
|
||||
area = if k == 0 { here } else { op.apply(area, here) };
|
||||
}
|
||||
|
||||
@@ -349,6 +349,42 @@ fn shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform() {
|
||||
assert!((out.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-6, "the art moves");
|
||||
}
|
||||
|
||||
/// Under a Warp effect, the gradient placed on a symbol instance's art's own fill follows the art's
|
||||
/// bounds, and the gradient placed on a compound shape follows its area's bounds, each from before
|
||||
/// the warp to after it, in the art Pathfinder takes.
|
||||
#[test]
|
||||
fn shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
let geom = GradientGeom { start: (2.0, 3.0).into(), end: (9.0, 3.0).into(), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
|
||||
let follow = |p: Point, from: Rect, to: Rect| {
|
||||
Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height())
|
||||
};
|
||||
let check = |out: &Node, from: Rect, what: &str| {
|
||||
let to = out.geometric_bounds().unwrap();
|
||||
assert_ne!(from, to, "{what}: the warp changes the bounds");
|
||||
let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("{what}: a gradient: {:?}", out.appearance) };
|
||||
let got = g.geom.unwrap();
|
||||
let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to));
|
||||
assert!(got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6, "{what}: {got:?}, want {s:?} {e:?}");
|
||||
};
|
||||
for style in ["arc", "rise", "bulge"] {
|
||||
let warp = || Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true };
|
||||
let mut art = Node::group(NodeId(20), vec![sq(21, 0.0, Color::BLACK)]);
|
||||
art.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone())));
|
||||
let hook = move |_: &Node| Some(art.clone());
|
||||
let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY });
|
||||
instance.appearance.effects.push(warp());
|
||||
check(&shape_art(&instance, Some(&hook)).unwrap(), Rect::new(0.0, 0.0, 10.0, 10.0), &format!("warp.{style} on a symbol instance"));
|
||||
let mut shape = compound_shape(ShapeMode::Add);
|
||||
shape.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into();
|
||||
shape.appearance.effects.push(warp());
|
||||
check(&shape_art(&shape, None).unwrap(), Rect::new(0.0, 0.0, 15.0, 10.0), &format!("warp.{style} on a compound shape"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
@@ -378,6 +414,118 @@ fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths()
|
||||
assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}");
|
||||
}
|
||||
|
||||
/// A group's Warp effect moves and scales the gradient placed on each of its paths from that path's
|
||||
/// bounds before the warp to its bounds after it, not by the group's bounds.
|
||||
#[test]
|
||||
fn a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
let geom = |x: f64| GradientGeom { start: (x + 2.0, 3.0).into(), end: (x + 8.0, 3.0).into(), aspect: 1.0, focal: None };
|
||||
let placed = |x: f64| Paint::Gradient(Box::new(GradientPaint { geom: Some(geom(x)), ..ramp.clone() }));
|
||||
let square = |id: u64, x: f64| {
|
||||
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(placed(x), Paint::None, 0.0)))
|
||||
};
|
||||
for style in ["arc", "bulge", "rise", "twist"] {
|
||||
let mut g = Node::group(NodeId(10), vec![square(1, 0.0), square(2, 20.0)]);
|
||||
g.appearance.effects.push(Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true });
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
let mut moved = false;
|
||||
for (i, x) in [0.0, 20.0].into_iter().enumerate() {
|
||||
let m = &art.children().unwrap()[i];
|
||||
let b = m.geometric_bounds().unwrap();
|
||||
let follow = |p: Point| Point::new(b.x0 + (p.x - x) / 10.0 * b.width(), b.y0 + p.y / 10.0 * b.height());
|
||||
let Paint::Gradient(gp) = m.appearance.fill_paint() else { panic!("a gradient") };
|
||||
let got = gp.geom.unwrap();
|
||||
let want = geom(x);
|
||||
assert!(
|
||||
got.start.distance(follow(want.start)) < 1e-6 && got.end.distance(follow(want.end)) < 1e-6,
|
||||
"warp.{style} path {i}: {got:?} in {b:?}"
|
||||
);
|
||||
moved |= got.start.distance(want.start) > 0.1;
|
||||
}
|
||||
assert!(moved, "warp.{style} moves a gradient");
|
||||
}
|
||||
}
|
||||
|
||||
/// Under a group's Warp effect, the group's own placed gradients follow the group's bounds, a
|
||||
/// nested group's own placed gradients follow the nested group's bounds, and a compound path's
|
||||
/// placed gradients follow the compound path's bounds, each from before the warp to after it (a
|
||||
/// stroke's: those bounds inflated by the stroke).
|
||||
#[test]
|
||||
fn a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
let geom = GradientGeom { start: (12.0, 4.0).into(), end: (26.0, 4.0).into(), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
|
||||
let own_fill = |n: &mut Node| {
|
||||
n.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone())));
|
||||
n.appearance.items.push(AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(placed.clone(), 3.0)));
|
||||
};
|
||||
let follow = |p: Point, from: Rect, to: Rect| {
|
||||
Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height())
|
||||
};
|
||||
for style in ["arc", "bulge", "rise", "flag"] {
|
||||
let mut inner = Node::group(NodeId(5), vec![sq(1, 0.0, Color::BLACK)]);
|
||||
own_fill(&mut inner);
|
||||
let ring = vec![
|
||||
Arc::new(Node::path(NodeId(6), shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 14.0)), Appearance::default())),
|
||||
Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(23.0, 3.0, 27.0, 7.0)), Appearance::default())),
|
||||
];
|
||||
let mut compound = Node::new(NodeId(3), NodeKind::Compound { children: ring, rule: FillRule::EvenOdd });
|
||||
compound.appearance = Appearance::basic(placed.clone(), placed.clone(), 2.0).into();
|
||||
let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(compound), sq(2, 40.0, Color::BLACK)]);
|
||||
own_fill(&mut g);
|
||||
let mut before = std::collections::HashMap::new();
|
||||
g.walk(&mut |c: &Node| {
|
||||
before.insert(c.id, c.geometric_bounds().unwrap());
|
||||
});
|
||||
g.appearance.effects.push(Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true });
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
// The bounds after the warp of the group, the nested group and the compound path (the paths
|
||||
// a group's own fill paints carry the group's id).
|
||||
let mut after = std::collections::HashMap::new();
|
||||
art.walk(&mut |c: &Node| {
|
||||
if matches!(c.kind, NodeKind::Group { .. } | NodeKind::Compound { .. }) {
|
||||
after.entry(c.id).or_insert_with(|| c.geometric_bounds().unwrap());
|
||||
}
|
||||
});
|
||||
let mut checked = vec![];
|
||||
art.walk(&mut |c: &Node| {
|
||||
if ![NodeId(3), NodeId(5), NodeId(10)].contains(&c.id) {
|
||||
return;
|
||||
}
|
||||
for item in &c.appearance.items {
|
||||
let Paint::Gradient(gp) = item.paint() else { continue };
|
||||
let (from, to) = (before[&c.id], after[&c.id]);
|
||||
assert_ne!(from, to, "warp.{style} {:?}: the warp changes the bounds", c.id);
|
||||
let stroke = match item {
|
||||
AppearanceItem::Stroke(s) => Some(s),
|
||||
AppearanceItem::Fill(_) => None,
|
||||
};
|
||||
let (from, to) = match stroke {
|
||||
Some(s) => (s.paint_bounds(from), s.paint_bounds(to)),
|
||||
None => (from, to),
|
||||
};
|
||||
let got = gp.geom.unwrap();
|
||||
let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to));
|
||||
assert!(
|
||||
got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6,
|
||||
"warp.{style} {:?} (stroke: {}): {got:?}, want {s:?} {e:?} ({from:?} to {to:?})",
|
||||
c.id,
|
||||
stroke.is_some()
|
||||
);
|
||||
checked.push((c.id, stroke.is_some()));
|
||||
}
|
||||
});
|
||||
checked.sort();
|
||||
checked.dedup();
|
||||
let want: Vec<_> = [NodeId(3), NodeId(5), NodeId(10)].into_iter().flat_map(|id| [(id, false), (id, true)]).collect();
|
||||
assert_eq!(checked, want, "warp.{style}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A group nested in a group with a Transform effect or Free Distort keeps its own placed gradient
|
||||
/// on its members: the Transform's map moves it, and Free Distort moves and scales it from the
|
||||
/// nested group's bounds before the effect to its bounds after it.
|
||||
|
||||
@@ -80,8 +80,8 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects};
|
||||
pub use reshape::{
|
||||
SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies, has_transform_options,
|
||||
image_paint, needs_outline, outline_art, outline_text, reshape,
|
||||
SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies,
|
||||
has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
|
||||
};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
|
||||
@@ -133,7 +133,13 @@ fn gives_shape(e: &Effect) -> bool {
|
||||
/// Does `item`, a fill or stroke of an image, paint something: does it paint and carry an effect
|
||||
/// that gives it a shape ([`gives_shape`])?
|
||||
fn shapes_image(item: &AppearanceItem) -> bool {
|
||||
crate::paints(item) && item.effects().iter().any(gives_shape)
|
||||
crate::paints(item) && gives_image_shape(item.effects())
|
||||
}
|
||||
|
||||
/// Do `effects`, those of an image's fill or stroke, give that fill or stroke a shape to paint
|
||||
/// ([`gives_shape`])?
|
||||
pub fn gives_image_shape(effects: &[Effect]) -> bool {
|
||||
effects.iter().any(gives_shape)
|
||||
}
|
||||
|
||||
/// The fills and strokes of `items` (an image's own) that paint something ([`shapes_image`]), each
|
||||
@@ -652,12 +658,13 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
for e in fx {
|
||||
let Some(bounds) = art.geometric_bounds() else { break };
|
||||
let params = merged_params(&e.id, &e.params);
|
||||
// Placed gradients follow a Transform effect and Free Distort ([`crate::Carry`]): under
|
||||
// Free Distort (`boxes`) each path's move and scale from its bounds before the effect to
|
||||
// its bounds after it. Under that and the other effects, the placed gradients of type's
|
||||
// outlines in `art` (`outline_text`) move and scale with the outlines' bounds.
|
||||
// Placed gradients follow a Transform effect, Free Distort and the Warp effects
|
||||
// ([`crate::Carry`]): under Free Distort and Warp (`boxes`) each path's move and scale from
|
||||
// its bounds before the effect to its bounds after it. Under those and the other effects,
|
||||
// the placed gradients of type's outlines in `art` (`outline_text`) move and scale with
|
||||
// the outlines' bounds.
|
||||
let follows = crate::Carry::follows(&e.id, ¶ms, bounds, seed);
|
||||
let boxes = follows && e.id == "distort.freeDistort";
|
||||
let boxes = follows && crate::Carry::by_bounds(&e.id);
|
||||
let mut outlines = vec![];
|
||||
if !follows || boxes {
|
||||
type_outline_bounds(art, &mut outlines);
|
||||
@@ -728,8 +735,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
rebase_type_outlines(art, &mut outlines.into_iter());
|
||||
}
|
||||
// What paints the art as a whole, and the own fills and strokes of the groups inside it,
|
||||
// follow their bounds under Free Distort (they stay when no path changed them). Each stays
|
||||
// when a path under it that its bounds measure kept its shape (past the budget).
|
||||
// follow their bounds under Free Distort and Warp (they stay when no path changed them).
|
||||
// Each stays when a path under it that its bounds measure kept its shape (past the budget).
|
||||
let mut after = vec![];
|
||||
let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1;
|
||||
if !groups.is_empty() {
|
||||
|
||||
@@ -57,16 +57,27 @@ fn numbers(v: &str) -> Option<Vec<f64>> {
|
||||
/// Filter `xml` as it is drawn and written ([`rebuilt`]) when it passes [`check`], `None` when it
|
||||
/// doesn't. Remembered for the filters seen last, so drawing does not check a filter again each time.
|
||||
fn checked(xml: &str) -> Option<Arc<str>> {
|
||||
use std::hash::BuildHasher;
|
||||
type Seen = (std::collections::hash_map::RandomState, std::collections::HashMap<(u64, usize), Option<Arc<str>>>, usize);
|
||||
static SEEN: std::sync::OnceLock<std::sync::Mutex<Seen>> = std::sync::OnceLock::new();
|
||||
let seen = SEEN.get_or_init(Default::default);
|
||||
remembered(&SEEN, xml, |xml| checked_form(xml).ok().map(|(_, form)| Arc::from(form)))
|
||||
}
|
||||
|
||||
/// The results [`remembered`] keeps: the hash's seed, the results by key, and their size in bytes.
|
||||
type Seen = (std::collections::hash_map::RandomState, std::collections::HashMap<(u64, usize), Option<Arc<str>>>, usize);
|
||||
|
||||
/// `make(xml)`, remembered in `seen` for the filters seen last.
|
||||
fn remembered(
|
||||
seen: &'static std::sync::OnceLock<std::sync::Mutex<Seen>>,
|
||||
xml: &str,
|
||||
make: impl FnOnce(&str) -> Option<Arc<str>>,
|
||||
) -> Option<Arc<str>> {
|
||||
use std::hash::BuildHasher;
|
||||
let seen = seen.get_or_init(Default::default);
|
||||
// Keyed by a hash that a file can't aim at (randomly seeded) and the length.
|
||||
let key = seen.lock().ok().map(|s| (s.0.hash_one(xml), xml.len()));
|
||||
if let Some(hit) = key.and_then(|k| seen.lock().ok().and_then(|s| s.1.get(&k).cloned())) {
|
||||
return hit;
|
||||
}
|
||||
let form: Option<Arc<str>> = checked_form(xml).ok().map(|(_, form)| Arc::from(form));
|
||||
let form = make(xml);
|
||||
if let (Some(k), Ok(mut s)) = (key, seen.lock())
|
||||
&& !s.1.contains_key(&k)
|
||||
{
|
||||
@@ -83,7 +94,7 @@ fn checked(xml: &str) -> Option<Arc<str>> {
|
||||
form
|
||||
}
|
||||
|
||||
/// The most check results [`checked`] keeps, and the most bytes of filters it keeps.
|
||||
/// The most results [`remembered`] keeps in one place, and the most bytes of filters it keeps there.
|
||||
const MAX_CHECKED: usize = 65_536;
|
||||
const MAX_CHECKED_BYTES: usize = 32 << 20;
|
||||
|
||||
@@ -279,9 +290,30 @@ const LATER_BLEND_MODES: [&str; 11] =
|
||||
/// The `feComposite` operators of SVG 1.1.
|
||||
const SVG11_OPERATORS: [&str; 6] = ["over", "in", "out", "atop", "xor", "arithmetic"];
|
||||
|
||||
/// The longest form SVG output writes ([`svg11`]), leaving room within [`MAX_DRAWN_XML`] for the
|
||||
/// region and id written into it.
|
||||
const MAX_WRITTEN_XML: usize = MAX_DRAWN_XML - 1024;
|
||||
/// The longest id SVG output gives a filter it writes: a unique-id prefix of up to 13 characters,
|
||||
/// `filter-` and a counter of up to 20 digits, with room to spare.
|
||||
const WRITTEN_ID_ROOM: usize = 64;
|
||||
|
||||
/// The longest text `{}` writes for a finite `f64`: a sign, `0.`, 307 zeros and 17 digits, as for
|
||||
/// `-f64::MIN_POSITIVE`.
|
||||
const MAX_NUMBER_TEXT: usize = 327;
|
||||
|
||||
/// The most the four region attributes written into a filter ([`SvgFilterFx::written_xml`]) add to
|
||||
/// it: each of `x`, `y`, `width` and `height` as ` height="…"` with the longest number.
|
||||
const REGION_ROOM: usize = 4 * (" height=\"\"".len() + MAX_NUMBER_TEXT);
|
||||
|
||||
/// The longest form SVG output writes ([`svg11`]): room is left within [`SvgFilter::MAX_XML`], the
|
||||
/// longest filter SVG import reads, for the id written into it. Measured with [`written_len`].
|
||||
const MAX_WRITTEN_XML: usize = SvgFilter::MAX_XML - WRITTEN_ID_ROOM;
|
||||
|
||||
/// How long `s` is as SVG output can write it and import reads it back: SVG output with the Latin-1
|
||||
/// encoding writes each character past U+00FF as `&#x…;`, which import keeps as text, so the
|
||||
/// longer of the two lengths is counted for each such character.
|
||||
fn written_len(s: &str) -> usize {
|
||||
// `&#x`, one hex digit per four significant bits, and `;`.
|
||||
let reference = |c: u32| 4 + ((u32::BITS - c.leading_zeros()) as usize).div_ceil(4);
|
||||
s.chars().map(|c| if u32::from(c) < 0x100 { c.len_utf8() } else { reference(u32::from(c)) }).sum()
|
||||
}
|
||||
|
||||
/// Filter `xml` (a rebuilt one, [`rebuilt`]) as SVG 1.1 output writes it, in the form an SVG 1.1
|
||||
/// viewer draws as resvg draws `xml`:
|
||||
@@ -413,7 +445,7 @@ fn svg11(xml: &str) -> Option<String> {
|
||||
}
|
||||
out.push_str("</filter>");
|
||||
}
|
||||
(out.len() <= MAX_WRITTEN_XML).then_some(out)
|
||||
(written_len(&out) <= MAX_WRITTEN_XML).then_some(out)
|
||||
}
|
||||
|
||||
/// The result names of a filter's SVG 1.1 form ([`svg11`]): the names its elements use, and the
|
||||
@@ -1278,13 +1310,28 @@ impl SvgFilterFx {
|
||||
/// whose box in the document is `bounds`: in its SVG 1.1 form ([`svg11`]), `None` when SVG 1.1
|
||||
/// can't hold it ([`Self::writes_as_svg11`]).
|
||||
pub fn written_xml(&self, bounds: Rect) -> Option<String> {
|
||||
self.with_region(&svg11(&self.xml)?, (bounds - self.origin).scale_from_origin(1.0 / self.scale))
|
||||
let out = self.with_region(&self.svg11_form()?, (bounds - self.origin).scale_from_origin(1.0 / self.scale))?;
|
||||
(written_len(&out) <= MAX_WRITTEN_XML).then_some(out)
|
||||
}
|
||||
|
||||
/// Can SVG 1.1 output write the filter as a `<filter>` element ([`svg11`])? When it can't, SVG
|
||||
/// output treats it as it treats the other pixel effects.
|
||||
/// Can SVG 1.1 output write the filter as a `<filter>` element ([`svg11`]) that SVG import
|
||||
/// reads back? When it can't, SVG output treats it as it treats the other pixel effects.
|
||||
pub fn writes_as_svg11(&self) -> bool {
|
||||
svg11(&self.xml).is_some()
|
||||
self.svg11_form().is_some()
|
||||
}
|
||||
|
||||
/// The filter's SVG 1.1 form ([`svg11`]) when, with any region and id that SVG output writes
|
||||
/// into it, it is a filter SVG import reads ([`check`]): at most [`SvgFilter::MAX_XML`] bytes,
|
||||
/// [`MAX_PRIMITIVES`] primitives and [`MAX_DEPTH`] levels deep, and drawn. Remembered for the
|
||||
/// filters seen last, so export does not check a filter again for each object.
|
||||
fn svg11_form(&self) -> Option<Arc<str>> {
|
||||
static FORMS: std::sync::OnceLock<std::sync::Mutex<Seen>> = std::sync::OnceLock::new();
|
||||
// `measured` follows from the XML, so the XML is the whole key.
|
||||
let region = if self.measured { REGION_ROOM } else { 0 };
|
||||
remembered(&FORMS, &self.xml, |xml| {
|
||||
let form = svg11(xml)?;
|
||||
(written_len(&form) + region <= MAX_WRITTEN_XML && check(&form).is_ok()).then(|| Arc::from(form))
|
||||
})
|
||||
}
|
||||
|
||||
/// How finely the filter runs on a raster whose pixels are `space.px` points wide, for an
|
||||
|
||||
@@ -457,9 +457,48 @@ fn a_filter_at_the_size_limit_draws_with_its_measured_region() {
|
||||
assert_eq!(xml.len(), SvgFilter::MAX_XML);
|
||||
assert_eq!(check(&xml), Ok("r".into()));
|
||||
let f = filter(json!({ "xml": xml }));
|
||||
let written = f.written_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap();
|
||||
assert!(written.len() > SvgFilter::MAX_XML, "the region written out is longer: {} bytes", written.len());
|
||||
assert!(with_id(&written, DRAWN_ID).is_some(), "the drawn form is read");
|
||||
let drawn = f.drawn_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap();
|
||||
assert!(drawn.len() > SvgFilter::MAX_XML, "the region written out is longer: {} bytes", drawn.len());
|
||||
assert!(with_id(&drawn, DRAWN_ID).is_some(), "the drawn form is read");
|
||||
}
|
||||
|
||||
/// SVG output writes a filter live only when the form it writes, with its region and id, is one
|
||||
/// SVG import reads back: at most [`SvgFilter::MAX_XML`] bytes. A filter at the limit whose region
|
||||
/// is measured against the box, one at the limit whose id is a character long, and one whose
|
||||
/// shadows make its SVG 1.1 form longer are left to be written as other pixel effects are.
|
||||
#[test]
|
||||
fn svg_output_writes_a_filter_only_in_a_form_import_reads() {
|
||||
let at_limit = |open: &str, close: &str| {
|
||||
let xml = format!("{open}{}{close}", "a".repeat(SvgFilter::MAX_XML - open.len() - close.len()));
|
||||
assert_eq!((xml.len(), check(&xml).is_ok()), (SvgFilter::MAX_XML, true));
|
||||
filter(json!({ "xml": xml }))
|
||||
};
|
||||
let measured = at_limit(
|
||||
r#"<filter id="r" filterUnits="userSpaceOnUse" x="-1%" y="-1%" width="1%" height="1%"><feOffset result=""#,
|
||||
r#""/><feOffset/></filter>"#,
|
||||
);
|
||||
let short_id = at_limit(r#"<filter id="r"><feOffset result=""#, r#""/><feOffset/></filter>"#);
|
||||
let shadows = format!(r#"<filter id="s">{}<feOffset result="{}"/></filter>"#, "<feDropShadow/>".repeat(12), "a".repeat(SvgFilter::MAX_XML - 400));
|
||||
assert!(check(&shadows).is_ok() && shadows.len() <= SvgFilter::MAX_XML);
|
||||
for f in [measured, short_id, filter(json!({ "xml": shadows }))] {
|
||||
assert!(!f.writes_as_svg11() && f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).is_none(), "{} bytes", f.xml().len());
|
||||
}
|
||||
// Room for the id and the longest region: still written, and read back.
|
||||
let open = r#"<filter id="r" filterUnits="userSpaceOnUse" x="-1%" y="-1%" width="1%" height="1%"><feOffset result=""#;
|
||||
let close = r#""/><feOffset/></filter>"#;
|
||||
let xml = format!("{open}{}{close}", "a".repeat(MAX_WRITTEN_XML - REGION_ROOM - open.len() - close.len()));
|
||||
let f = filter(json!({ "xml": xml }));
|
||||
assert!(f.writes_as_svg11());
|
||||
let id = format!("u_vc_unique_-filter-{}", usize::MAX);
|
||||
let out = with_id(&f.written_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap(), &id).unwrap();
|
||||
assert!(out.len() <= SvgFilter::MAX_XML && check(&out).is_ok(), "{} bytes", out.len());
|
||||
// The longest numbers a region can take still fit.
|
||||
let far = f.written_xml(Rect::new(-f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::MAX / 4.0, f64::MAX / 4.0)).unwrap();
|
||||
assert!(with_id(&far, &id).is_some_and(|out| out.len() <= SvgFilter::MAX_XML));
|
||||
for v in [-f64::MIN_POSITIVE, -5e-324, -2.225073858507201e-308, f64::MIN, -1.0 / 3.0] {
|
||||
assert!(format!("{v}").len() <= MAX_NUMBER_TEXT, "{v:e}");
|
||||
}
|
||||
assert_eq!(format!("{}", -f64::MIN_POSITIVE).len(), MAX_NUMBER_TEXT);
|
||||
}
|
||||
|
||||
/// Only a type selector naming a filter or one of its elements, or `*`, lets a style sheet reach
|
||||
|
||||
@@ -180,6 +180,24 @@ fn warp_zero_bend_is_identity_ish() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `warp::bends` says a warp moves placed gradients exactly when the warp moves the outline:
|
||||
/// Arc leaves the outline as it is under a bend of 0.0001 % and moves it above that, and the other
|
||||
/// styles move it at any bend.
|
||||
#[test]
|
||||
fn warp_bends_when_the_warp_moves_the_outline() {
|
||||
// A star: Fisheye moves nothing on the edge of the box, where an ellipse or a rectangle lies.
|
||||
let oval = shapes::star(Point::new(51.7, 34.3), 40.9, 17.3, 5, 7.0);
|
||||
for (s, _) in WARP_STYLES {
|
||||
let id = format!("warp.{s}");
|
||||
let flat = run(&id, json!({"bend": 0}), &oval);
|
||||
for bend in [0.0, 1e-5, -1e-5, 0.9e-4, -0.9e-4, 1.1e-4, -1.1e-4] {
|
||||
let p = json!({ "bend": bend });
|
||||
let moved = run(&id, p.clone(), &oval) != flat;
|
||||
assert_eq!(crate::warp::bends(&id, &p), moved, "{id} at {bend} %");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_arc_bends_the_top_edge() {
|
||||
let out = run("warp.arc", json!({"bend": 50}), &square());
|
||||
|
||||
@@ -32,3 +32,14 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -
|
||||
let piece = b.width().hypot(b.height()) / 24.0;
|
||||
map_nonlinear_within(path, piece, max, f)
|
||||
}
|
||||
|
||||
/// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with
|
||||
/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the
|
||||
/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline`
|
||||
/// tests both sides of it).
|
||||
pub(crate) fn bends(id: &str, p: &Value) -> bool {
|
||||
let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0;
|
||||
let bend = share("bend", 50.0);
|
||||
let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 };
|
||||
bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0
|
||||
}
|
||||
|
||||
@@ -356,8 +356,16 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
})
|
||||
}
|
||||
|
||||
/// The command `id`: a built-in one, or a command an installed plug-in adds
|
||||
/// ([`plugin::command_specs`]).
|
||||
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
command_specs().iter().find(|c| c.id == id)
|
||||
command_specs().iter().find(|c| c.id == id).or_else(|| plugin::find_spec(id))
|
||||
}
|
||||
|
||||
/// Runs command `spec`'s implementation with `p`. A plug-in command's runs the plug-in
|
||||
/// ([`plugin::run_command`]), which needs the command's id.
|
||||
pub(crate) fn run_spec(s: &mut Session, spec: &CommandSpec, p: &Value) -> Result<Value> {
|
||||
if plugin::is_command_id(spec.id) { plugin::run_command(s, spec.id, p) } else { (spec.run)(s, p) }
|
||||
}
|
||||
|
||||
// ---------- param helpers ----------
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Fill",
|
||||
["Window", "Appearance"],
|
||||
Some("Cmd+/"),
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, true)
|
||||
),
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Stroke",
|
||||
["Window", "Appearance"],
|
||||
Some("Cmd+Alt+/"),
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, false)
|
||||
),
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clear Appearance",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
clear_appearance
|
||||
),
|
||||
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reduce to Basic Appearance",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
reduce_basic
|
||||
),
|
||||
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
set_item
|
||||
),
|
||||
@@ -90,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove Item",
|
||||
[],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
remove_item
|
||||
),
|
||||
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate Item",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
duplicate_item
|
||||
),
|
||||
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reorder Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}",
|
||||
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}",
|
||||
has_doc,
|
||||
move_item
|
||||
),
|
||||
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Eyedropper",
|
||||
[],
|
||||
None,
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}",
|
||||
has_doc,
|
||||
copy_from
|
||||
),
|
||||
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Show All Hidden Attributes",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
|
||||
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
|
||||
has_doc,
|
||||
show_all_hidden
|
||||
),
|
||||
@@ -162,7 +162,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Appearance",
|
||||
[],
|
||||
None,
|
||||
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {source, target}",
|
||||
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}",
|
||||
has_doc,
|
||||
transfer
|
||||
),
|
||||
@@ -628,25 +628,33 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
let object = super::graph::capture_series_object_opacity(d, &ids);
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Each of `ids` whose object opacity or blend mode is another in `d` than in `before`, for
|
||||
/// [`super::graph::capture_series_object_opacity`].
|
||||
fn object_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let of = |d: &vectorcraft_doc::Document, id: NodeId| d.node(id).map(|n| (n.opacity, n.blend));
|
||||
ids.iter().copied().filter(|id| of(before, *id) != of(d, *id)).collect()
|
||||
}
|
||||
|
||||
/// Each of `ids` with both kinds of row (fill, then stroke), for [`super::graph::capture_series_opacity`] after an
|
||||
/// edit that replaces whole stacks.
|
||||
fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> {
|
||||
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect()
|
||||
}
|
||||
|
||||
/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity in `d`
|
||||
/// than in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`].
|
||||
/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity or blend
|
||||
/// mode in `d` than in `before` (no row counts as opaque and Normal), for [`super::graph::capture_series_opacity`].
|
||||
fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> {
|
||||
let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| {
|
||||
d.node(id)
|
||||
.and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(AppearanceItem::opacity))
|
||||
.unwrap_or(1.0)
|
||||
.and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(|it| (it.opacity(), it.blend())))
|
||||
.unwrap_or((1.0, BlendMode::Normal))
|
||||
};
|
||||
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect()
|
||||
}
|
||||
@@ -666,9 +674,10 @@ fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator<Item = (N
|
||||
}
|
||||
|
||||
/// Store on the graph what an edit of `ids` (from `before` to `d`) leaves on the topmost visible fill and stroke of the
|
||||
/// series marks among them: a paint, weight or opacity that changed, and the paint (`paints`) and opacity (`opacities`) of
|
||||
/// the kinds of row the edit wrote, as (fill, stroke). With `top_only` the edit wrote the topmost row of each kind, which
|
||||
/// counts only when it is visible. Returns whether the stored paint changed.
|
||||
/// series marks among them: a paint, weight, opacity or blend mode that changed, and the paint (`paints`) and opacity and
|
||||
/// blend mode (`opacities`) of the kinds of row the edit wrote, as (fill, stroke). With `top_only` the edit wrote the
|
||||
/// topmost row of each kind, which counts only when it is visible. It also stores the marks' object opacity and blend mode
|
||||
/// when they changed, or on every mark when the edit wrote them (`object`). Returns whether the stored paint changed.
|
||||
pub(super) fn capture_series_writes(
|
||||
before: &vectorcraft_doc::Document,
|
||||
d: &mut vectorcraft_doc::Document,
|
||||
@@ -676,22 +685,29 @@ pub(super) fn capture_series_writes(
|
||||
paints: (bool, bool),
|
||||
opacities: (bool, bool),
|
||||
top_only: bool,
|
||||
object: bool,
|
||||
) -> bool {
|
||||
let top_shows = |d: &vectorcraft_doc::Document, id: NodeId, kind: bool| {
|
||||
let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]);
|
||||
items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(AppearanceItem::visible)
|
||||
};
|
||||
let written = |d: &vectorcraft_doc::Document, (fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> {
|
||||
let kinds = |(fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> {
|
||||
let rows = ids.iter().flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))]).flatten();
|
||||
painted_rows(d, rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind)))
|
||||
rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind)).collect()
|
||||
};
|
||||
let written = |d: &vectorcraft_doc::Document, rows: (bool, bool)| painted_rows(d, kinds(rows));
|
||||
let mut opacity_rows = top_opacity_changed(before, d, ids);
|
||||
// A written row that had paint before the edit counts too when the edit left it without paint: only a row without
|
||||
// paint both before and after keeps what the graph stores.
|
||||
opacity_rows.extend(painted_rows(before, kinds(opacities)));
|
||||
opacity_rows.extend(written(d, opacities));
|
||||
let mut rows = top_paint_changed(before, d, ids);
|
||||
rows.extend(written(d, paints));
|
||||
let kept = super::graph::capture_series_opacity(d, &opacity_rows);
|
||||
let painted = super::graph::capture_series_top_paints(d, &rows);
|
||||
kept || painted
|
||||
let objects = if object { ids.to_vec() } else { object_opacity_changed(before, d, ids) };
|
||||
let stored = super::graph::capture_series_object_opacity(d, &objects);
|
||||
kept || painted || stored
|
||||
}
|
||||
|
||||
/// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`.
|
||||
@@ -743,7 +759,8 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut rows = top_paint_changed(&before, d, &changed);
|
||||
rows.extend(painted_rows(d, both_kinds(&[target])));
|
||||
let painted = super::graph::capture_series_top_paints(d, &rows);
|
||||
Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted))
|
||||
let object = super::graph::capture_series_object_opacity(d, &changed);
|
||||
Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted || object))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
Ok(json!({ "source": source.0, "target": target.0 }))
|
||||
@@ -862,10 +879,10 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edited.push((*id, fill));
|
||||
}
|
||||
}
|
||||
// The graph keeps an opacity, paint or weight set on the topmost visible fill or stroke of its series art for when
|
||||
// it is drawn again, and hiding or showing a row changes which row that is.
|
||||
// The graph keeps an opacity, blend mode, paint or weight set on the topmost visible fill or stroke of its series art
|
||||
// for when it is drawn again, and hiding or showing a row changes which row that is.
|
||||
let mut opacities = top_opacity_changed(&before, d, &ids);
|
||||
if p.get("opacity").and_then(Value::as_f64).is_some() {
|
||||
if p.get("opacity").and_then(Value::as_f64).is_some() || blend.is_some() {
|
||||
opacities.extend(edited.iter().copied());
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &opacities);
|
||||
@@ -1347,7 +1364,10 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
rows.extend(painted_rows(d, written(paints)));
|
||||
let kept = super::graph::capture_series_opacity(d, &opacity_rows);
|
||||
let painted = super::graph::capture_series_top_paints(d, &rows);
|
||||
Ok(count_changed(d, &before, &targets) + usize::from(kept || painted))
|
||||
// A pick of the object's transparency writes it on every target.
|
||||
let objects = if attrs.appearance.transparency { targets.clone() } else { object_opacity_changed(&before, d, &targets) };
|
||||
let object = super::graph::capture_series_object_opacity(d, &objects);
|
||||
Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object))
|
||||
})?;
|
||||
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Symbol Instance Appearance",
|
||||
[],
|
||||
None,
|
||||
"{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol or a path naming no object → {id, overrides: how many objects the instance changes}",
|
||||
"{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol, a path naming no object, or a fill or stroke other than null for an object that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect), with nothing of the call applied; opacity, blend and null are taken for every object → {id, overrides: how many objects the instance changes}",
|
||||
has_selection,
|
||||
symbol_instance_set_appearance
|
||||
),
|
||||
|
||||
@@ -129,6 +129,64 @@ fn path_param(p: &Value, art: &Node, cmd: &str) -> Result<Vec<u32>> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// The refusal of `key` (`fill` or `stroke`) for `n`, an object of a dynamic symbol's art, when `n`
|
||||
/// does not draw that paint of its own; `None` when it does. Paths (not guides),
|
||||
/// compound paths, type, groups and layers draw them; a symbol instance draws its fill as a stain
|
||||
/// on its art ([`solid_fill_check`]) and no stroke; an image draws one only when that fill or
|
||||
/// stroke has a Convert to Shape or Outline Object effect.
|
||||
fn paint_refusal(n: &Node, key: &str, cmd: &str) -> Option<EngineError> {
|
||||
Some(match &n.kind {
|
||||
NodeKind::Path { guide: true, .. } => bad(cmd, format!("{key} cannot be set on a guide")),
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => return None,
|
||||
NodeKind::Image(_) => {
|
||||
let effects = if key == "fill" { n.appearance.fill().map(|f| &f.effects) } else { n.appearance.stroke().map(|s| &s.effects) };
|
||||
if effects.is_some_and(|e| vectorcraft_render::effects::gives_image_shape(e)) {
|
||||
return None;
|
||||
}
|
||||
bad(cmd, format!("{key} cannot be set on an image without a Convert to Shape or Outline Object effect on it"))
|
||||
}
|
||||
NodeKind::SymbolInstance { .. } if key == "fill" => return None,
|
||||
NodeKind::SymbolInstance { .. } => bad(cmd, "stroke cannot be set on a symbol instance"),
|
||||
NodeKind::Blend { .. } => bad(cmd, format!("{key} cannot be set on a blend")),
|
||||
NodeKind::Envelope { .. } => bad(cmd, format!("{key} cannot be set on an envelope")),
|
||||
NodeKind::Mesh(_) => bad(cmd, format!("{key} cannot be set on a gradient mesh")),
|
||||
NodeKind::Repeat(_) => bad(cmd, format!("{key} cannot be set on a Repeat")),
|
||||
NodeKind::PlacedDocument(_) => bad(cmd, format!("{key} cannot be set on a placed document")),
|
||||
NodeKind::Intertwine { .. } => bad(cmd, format!("{key} cannot be set on an Intertwine group")),
|
||||
})
|
||||
}
|
||||
|
||||
/// Refuse a `fill` or `stroke` other than null that the object at `path` of `art` does not draw
|
||||
/// ([`paint_refusal`]); null, which takes a change back, is taken for every object.
|
||||
fn check_paint_kinds(p: &Value, art: &Node, path: &[u32], cmd: &str) -> Result<()> {
|
||||
let Some(n) = symbol_overrides::part(art, path) else { return Ok(()) };
|
||||
for key in ["fill", "stroke"] {
|
||||
if p.get(key).is_some_and(|v| !v.is_null())
|
||||
&& let Some(e) = paint_refusal(n, key, cmd)
|
||||
{
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse `fill`, the paint `p` asks for the object at `path` of `art`, when that object is a
|
||||
/// symbol instance and `fill` is a gradient or a pattern: an instance stains its art with a solid
|
||||
/// fill only, so such a fill draws nothing.
|
||||
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) {
|
||||
return Err(bad(cmd, "fill on a symbol instance must be a solid color or none"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `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>>> {
|
||||
@@ -170,9 +228,11 @@ pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Resu
|
||||
}
|
||||
let (id, art) = dynamic_instance(s, p, C)?;
|
||||
let path = path_param(p, &art, C)?;
|
||||
check_paint_kinds(p, &art, &path, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned());
|
||||
let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?;
|
||||
solid_fill_check(p, &art, &path, new.fill.as_ref(), C)?;
|
||||
let count = edit_part(s, id, &path, "Symbol Instance Appearance", |o| *o = new)?;
|
||||
Ok(json!({ "id": id.0, "overrides": count }))
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
@@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make and Expand",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{…as imageTrace.make, byColor?: false (each color's paths in a group of their own, as imageTrace.expand)} same as imageTrace.make, then expand → {id, paths, anchors, colors}",
|
||||
"{…as imageTrace.make, byColor?: false (each color's paths in a group of their own, as imageTrace.expand)} same as imageTrace.make, then expand → {id, paths, anchors, colors, warnings: [text…] (as imageTrace.make)}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, true)
|
||||
),
|
||||
|
||||
@@ -712,7 +712,8 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
|
||||
_ => trace_now(&inputs, &trace, &bytes, &|_, _| true)?,
|
||||
};
|
||||
let TraceInputs { target, image: img, .. } = inputs;
|
||||
let warnings = trace_warnings((img.width, img.height), raster, res.worked_at);
|
||||
let mut warnings = keep_warnings(&trace, &res);
|
||||
warnings.extend(trace_warnings((img.width, img.height), raster, res.worked_at));
|
||||
// Pixel space of the decoded raster → the image object's pixel space → document.
|
||||
let sx = img.width.max(1) as f64 / raster.0.max(1) as f64;
|
||||
let sy = img.height.max(1) as f64 / raster.1.max(1) as f64;
|
||||
@@ -824,6 +825,30 @@ pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32), worked: Optio
|
||||
out
|
||||
}
|
||||
|
||||
/// What a trace with Keep Colors tells the user: that nothing was traced because no color the
|
||||
/// trace found matches a listed one, because Ignore Color left out the ones that match, or because
|
||||
/// the ones that match produce no paths; each listed color that matches none of them; and that Flat
|
||||
/// Logo mode does not use the list.
|
||||
pub(crate) fn keep_warnings(params: &tr::TraceParams, res: &tr::TraceResult) -> Vec<String> {
|
||||
if params.keep_colors.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
if params.mode == tr::Mode::Logo {
|
||||
return vec!["Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds.".to_string()];
|
||||
}
|
||||
if res.paths.is_empty() && res.keep_unmatched.len() == params.keep_colors.len() {
|
||||
return vec![format!("Nothing was traced: none of the colors the trace found matches keepColors ({}).", params.keep_colors.join(", "))];
|
||||
}
|
||||
let mut out = vec![];
|
||||
if res.paths.is_empty() && res.keep_ignored {
|
||||
out.push("Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string());
|
||||
} else if res.paths.is_empty() {
|
||||
out.push("Nothing was traced: the colors that match keepColors produce no paths.".to_string());
|
||||
}
|
||||
out.extend(res.keep_unmatched.iter().map(|c| format!("keepColors: none of the colors the trace found matches {c}, so nothing of it is traced.")));
|
||||
out
|
||||
}
|
||||
|
||||
fn trace_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let v: Vec<NodeId> = root_ids(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(is_trace_group)).collect();
|
||||
@@ -1069,7 +1094,25 @@ mod color_group_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod trace_limit_tests {
|
||||
use super::{MAX_TRACE_ANCHORS, trace_warnings};
|
||||
use super::{MAX_TRACE_ANCHORS, keep_warnings, trace_warnings};
|
||||
use vectorcraft_trace as tr;
|
||||
|
||||
#[test]
|
||||
fn keep_warnings_name_matched_colors_that_produce_no_paths() {
|
||||
let params = tr::TraceParams { keep_colors: vec!["#ff0000".into()], ..tr::TraceParams::default() };
|
||||
// A listed color was traced, and produced no paths: the palette lists no color without paths.
|
||||
let traced = tr::TraceResult::default();
|
||||
let none = vec!["Nothing was traced: the colors that match keepColors produce no paths.".to_string()];
|
||||
assert_eq!(keep_warnings(¶ms, &traced), none);
|
||||
// With Ignore Color, too: Ignore Color did not leave that color out.
|
||||
let ignoring = tr::TraceParams { ignore_white: true, ..params.clone() };
|
||||
assert_eq!(keep_warnings(&ignoring, &traced), none);
|
||||
// Ignore Color left out every color that matches: none was traced.
|
||||
assert_eq!(
|
||||
keep_warnings(&ignoring, &tr::TraceResult { keep_ignored: true, ..tr::TraceResult::default() }),
|
||||
vec!["Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_warnings_name_a_large_or_scaled_down_image() {
|
||||
|
||||
@@ -71,24 +71,39 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
/// other open documents (or an earlier active document) defined. Run after each command and when
|
||||
/// another document takes the front, so an undo, a revert, an opened or closed file or a document
|
||||
/// switch draws type in the active document's own composite fonts, and type naming one it does
|
||||
/// not define shows as missing. Whether a composite font was added, changed or removed.
|
||||
pub(crate) fn sync(s: &mut Session) -> bool {
|
||||
/// not define shows as missing. The lowercased names of the composite fonts added, changed or
|
||||
/// removed.
|
||||
pub(crate) fn sync(s: &mut Session) -> Vec<String> {
|
||||
let db = FontDb::global();
|
||||
let mut names = Vec::new();
|
||||
let mut changed = false;
|
||||
let mut changed = Vec::new();
|
||||
for f in s.active().map(|d| d.doc.composite_fonts.to_vec()).unwrap_or_default() {
|
||||
changed |= db.set_composite_font(&f);
|
||||
names.push(f.name.to_lowercase());
|
||||
let name = f.name.to_lowercase();
|
||||
if db.set_composite_font(&f) {
|
||||
changed.push(name.clone());
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
let others = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase()));
|
||||
let stale: Vec<String> = s.composite_names.iter().cloned().chain(others).filter(|n| !names.contains(n)).collect();
|
||||
for gone in stale {
|
||||
changed |= db.remove_composite_font(&gone);
|
||||
if db.remove_composite_font(&gone) {
|
||||
changed.push(gone);
|
||||
}
|
||||
}
|
||||
s.composite_names_registered.extend(names.iter().chain(&changed).cloned());
|
||||
s.composite_names = names;
|
||||
changed
|
||||
}
|
||||
|
||||
/// The lowercased names of the composite fonts the open documents define, the font database holds
|
||||
/// and the session has registered before: the names type may have been laid out in.
|
||||
pub(crate) fn names_in_use(s: &Session) -> std::collections::HashSet<String> {
|
||||
let defined = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase()));
|
||||
let held = FontDb::global().composite_font_names().into_iter().map(|n| n.to_lowercase());
|
||||
defined.chain(held).chain(s.composite_names_registered.iter().cloned()).collect()
|
||||
}
|
||||
|
||||
/// Leave out a file's unusable composite fonts before registering and measuring its type.
|
||||
pub(crate) fn filter_loaded(doc: &mut vectorcraft_doc::Document) {
|
||||
let db = FontDb::global();
|
||||
@@ -172,6 +187,9 @@ fn replace(s: &mut Session, label: &str, after: Vec<CompositeFont>) -> Result<()
|
||||
}
|
||||
let changed: Vec<String> =
|
||||
before.iter().filter(|b| !after.contains(b)).chain(after.iter().filter(|a| !before.contains(a))).map(|f| f.name.to_lowercase()).collect();
|
||||
// A refused or panicked edit leaves the document without the new fonts: `sync` forgets a name
|
||||
// listed here that the document doesn't define.
|
||||
s.composite_names.extend(after.iter().map(|f| f.name.to_lowercase()));
|
||||
let db = FontDb::global();
|
||||
for f in &before {
|
||||
if !after.iter().any(|a| CompositeFont::same_name(&a.name, &f.name)) {
|
||||
|
||||
@@ -395,6 +395,150 @@ fn opened_document_measures_missing_composite_font_as_missing() {
|
||||
assert_eq!(actual.cached_bounds, Some(expected));
|
||||
}
|
||||
|
||||
/// The native file of a document whose composite font `name` sets Roman at full size, with type
|
||||
/// "Ab12" set in it → (the file, the type's id, its width).
|
||||
fn full_roman_file(name: &str) -> (Vec<u8>, u64, f64) {
|
||||
let mut s = session();
|
||||
let mut full = font(name);
|
||||
full["parts"]["roman"]["size"] = json!(100);
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": name})).unwrap()["id"].as_u64().unwrap();
|
||||
let width = text(&s, id).cached_bounds.unwrap().width();
|
||||
(vectorcraft_format::save_file(&s.doc().unwrap().doc), id, width)
|
||||
}
|
||||
|
||||
/// Type in a missing font that no composite font is named after keeps the bounds it was measured
|
||||
/// with when its file was read: opening the document does not lay it out again.
|
||||
#[test]
|
||||
fn opening_leaves_type_in_an_unrelated_missing_font_as_measured() {
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Unrelated")]})).unwrap();
|
||||
let id =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "No Such Family CF"})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
let kept = vectorcraft_geom::Rect::new(1.0, 2.0, 3.0, 4.0);
|
||||
if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind {
|
||||
t.cached_bounds = Some(kept);
|
||||
}
|
||||
s.add_document(doc, None);
|
||||
assert_eq!(text(&s, id).cached_bounds, Some(kept));
|
||||
}
|
||||
|
||||
/// Text holding an inline graphic whose symbol's art is type in a composite font is measured with
|
||||
/// the graphic at the size the opened document's own definition gives it.
|
||||
#[test]
|
||||
fn opening_measures_inline_graphics_of_composite_type_in_its_own_definition() {
|
||||
let mut s = session();
|
||||
let mut full = font("CF Inline");
|
||||
full["parts"]["roman"]["size"] = json!(100);
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap();
|
||||
let art = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Inline"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("symbol.new", &json!({"name": "CF Inline Mark", "ids": [art]})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "x", "size": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "CF Inline Mark"})).unwrap();
|
||||
let width = text(&s, id).cached_bounds.unwrap().width();
|
||||
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Inline")]})).unwrap();
|
||||
let file = crate::cmd::fileio::read_for_open("inline.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
|
||||
crate::cmd::fileio::open_read(&mut s, file).unwrap();
|
||||
assert!((text(&s, id).cached_bounds.unwrap().width() - width).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// Type read for opening while a composite font of its name was registered is measured again when
|
||||
/// it opens, even after the document that defined the font has closed in between.
|
||||
#[test]
|
||||
fn opening_measures_type_read_in_a_composite_font_closed_since() {
|
||||
let mut s = session();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Gone"})).unwrap()["id"].as_u64().unwrap();
|
||||
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Gone")]})).unwrap();
|
||||
let file = crate::cmd::fileio::read_for_open("gone.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
|
||||
assert!(s.close_document(1));
|
||||
crate::cmd::fileio::open_read(&mut s, file).unwrap();
|
||||
let actual = text(&s, id);
|
||||
let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
|
||||
assert_eq!(actual.cached_bounds, Some(expected));
|
||||
}
|
||||
|
||||
/// A file read for opening lays out its type in the composite fonts registered then (the front
|
||||
/// document's, which defines the name with Roman at half size); once the file opens, its type is
|
||||
/// measured again in its own.
|
||||
#[test]
|
||||
fn an_opened_file_measures_its_type_in_its_own_composite_fonts() {
|
||||
let (bytes, id, width) = full_roman_file("CF Opened");
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Opened")]})).unwrap();
|
||||
let file = crate::cmd::fileio::read_for_open("opened.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
|
||||
crate::cmd::fileio::open_read(&mut s, file).unwrap();
|
||||
let actual = text(&s, id);
|
||||
assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6);
|
||||
let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
|
||||
assert_eq!(actual.cached_bounds, Some(expected));
|
||||
}
|
||||
|
||||
/// A document replaced while in the back (File › Revert) is measured in its own composite fonts
|
||||
/// when it comes to the front, not in the front document's.
|
||||
#[test]
|
||||
fn a_document_replaced_in_the_back_is_measured_in_its_own_composite_fonts() {
|
||||
let (bytes, id, width) = full_roman_file("CF Replaced");
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap();
|
||||
assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
|
||||
assert!(s.set_active(0));
|
||||
let actual = text(&s, id);
|
||||
assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6);
|
||||
let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
|
||||
assert_eq!(actual.cached_bounds, Some(expected));
|
||||
}
|
||||
|
||||
/// So is one that comes to the front because the front document closes.
|
||||
#[test]
|
||||
fn a_document_replaced_in_the_back_is_measured_when_the_front_one_closes() {
|
||||
let (bytes, id, width) = full_roman_file("CF Closed");
|
||||
let mut s = session();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Closed")]})).unwrap();
|
||||
assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
|
||||
assert!(s.close_document(1));
|
||||
let actual = text(&s, id);
|
||||
assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6);
|
||||
let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
|
||||
assert_eq!(actual.cached_bounds, Some(expected));
|
||||
}
|
||||
|
||||
/// A failed batch takes back the composite fonts it set, in the font database too.
|
||||
#[test]
|
||||
fn a_failed_batch_takes_back_its_composite_fonts() {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut s = session();
|
||||
let steps = json!([
|
||||
{"command": "type.compositeFonts.set", "params": {"fonts": [font("CF Failed Batch")]}},
|
||||
{"command": "no.such.command"},
|
||||
]);
|
||||
assert!(s.execute("command.batch", &json!({"commands": steps})).is_err());
|
||||
assert!(names(&s).is_empty());
|
||||
assert!(db.composite_font("CF Failed Batch").is_none());
|
||||
}
|
||||
|
||||
/// A composite font whose edit is refused, because it makes selected type too wide for the canvas,
|
||||
/// is not left in the font database.
|
||||
#[test]
|
||||
fn a_refused_edit_takes_back_its_new_composite_font() {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut s = session();
|
||||
let x = crate::MAX_COORD - 1000.0;
|
||||
s.execute("text.create", &json!({"x": x, "y": 50, "text": "MMMMMMMM", "size": 100, "font": "CF Refused Edit"})).unwrap();
|
||||
let wide = json!({"name": "CF Refused Edit", "parts": {"kanji": {"font": "Source Sans 3", "size": 400}}});
|
||||
let e = s.execute("type.compositeFonts.set", &json!({"fonts": [wide]})).unwrap_err().to_string();
|
||||
assert!(e.contains("exceed the canvas"), "{e}");
|
||||
assert!(names(&s).is_empty());
|
||||
assert!(db.composite_font("CF Refused Edit").is_none());
|
||||
}
|
||||
|
||||
/// A loaded composite font cannot replace an installed family used by existing type.
|
||||
#[test]
|
||||
fn loaded_composite_font_cannot_take_an_installed_family_name() {
|
||||
|
||||
@@ -157,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Cut",
|
||||
["Edit"],
|
||||
Some("Cmd+X"),
|
||||
"{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut); refused while an object of a symbol instance's art is selected",
|
||||
"{selectedArtboards?: [n…], art?: bool, lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (as edit.copy) (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut); refused while an object of a symbol instance's art is selected",
|
||||
has_selection_guides_or_artboard_tool,
|
||||
cut
|
||||
),
|
||||
@@ -166,7 +166,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy",
|
||||
["Edit"],
|
||||
Some("Cmd+C"),
|
||||
"{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}",
|
||||
"{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}",
|
||||
has_selection_guides_or_artboard_tool,
|
||||
copy
|
||||
),
|
||||
@@ -946,15 +946,27 @@ fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {
|
||||
Ok((direct, roots))
|
||||
}
|
||||
|
||||
/// The params edit.copy and edit.cut pass on to `artboard.copy` and `artboard.cut`.
|
||||
fn artboard_clipboard_params(p: &Value) -> Value {
|
||||
p.get("lockedAndHidden").map_or_else(|| json!({}), |v| json!({ "lockedAndHidden": v }))
|
||||
/// Copy or Cut takes artboards: the Artboard tool is chosen, or the journal recorded the artboards
|
||||
/// it took then.
|
||||
fn takes_artboards(s: &Session, p: &Value) -> bool {
|
||||
s.tool_id() == "artboard" || p.get("selectedArtboards").is_some()
|
||||
}
|
||||
|
||||
/// Copy or Cut of artboards: `selectedArtboards`, `art` and `lockedAndHidden` of `p`, or their
|
||||
/// defaults from the Artboard tool, which the journal entry records.
|
||||
fn copy_artboards(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
|
||||
let given: serde_json::Map<String, Value> =
|
||||
["selectedArtboards", "art", "lockedAndHidden"].into_iter().filter_map(|key| p.get(key).map(|v| (key.to_string(), v.clone()))).collect();
|
||||
let given = Value::Object(given);
|
||||
super::panelcmds::boards_and_art_noted(s, &given, if cut { "edit.cut" } else { "edit.copy" })?;
|
||||
super::panelcmds::artboard_copy(s, &given, cut)
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
switch_or(p, "art", false, "edit.copy")?;
|
||||
// The Artboard tool copies its artboard (and the art on it).
|
||||
if s.tool_id() == "artboard" {
|
||||
return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), false);
|
||||
if takes_artboards(s, p) {
|
||||
return copy_artboards(s, p, false);
|
||||
}
|
||||
// With an object of a dynamic symbol instance's art selected, the instance is copied.
|
||||
let (direct, targets) = copy_cut_targets(s)?;
|
||||
@@ -994,8 +1006,9 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.tool_id() == "artboard" {
|
||||
return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), true);
|
||||
switch_or(p, "art", false, "edit.cut")?;
|
||||
if takes_artboards(s, p) {
|
||||
return copy_artboards(s, p, true);
|
||||
}
|
||||
refuse_symbol_part(s)?;
|
||||
// Ruler guides selected on their own: Copy takes them, Clear removes them.
|
||||
|
||||
@@ -470,7 +470,7 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
|
||||
/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its
|
||||
/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no
|
||||
/// visible geometry effect.
|
||||
fn reshaped(l: &Node) -> Option<Node> {
|
||||
pub(crate) fn reshaped(l: &Node) -> Option<Node> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry};
|
||||
if !has_geometry(&l.appearance.effects) {
|
||||
return None;
|
||||
@@ -490,7 +490,7 @@ fn reshaped(l: &Node) -> Option<Node> {
|
||||
/// outline, box and fill: when the fill has visible geometry effects, `l` with the outline they
|
||||
/// make of `path` as a plain path, that outline's box, and the fill with its gradient moved along
|
||||
/// with the outline.
|
||||
fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect, AppearanceItem)> {
|
||||
pub(crate) fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect, AppearanceItem)> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry};
|
||||
if !has_geometry(item.effects()) {
|
||||
return Some((l.clone(), path.clone(), bounds, item.clone()));
|
||||
@@ -996,10 +996,13 @@ fn moves_only(a: Affine) -> bool {
|
||||
(m0 - 1.0).abs() <= eps && m1.abs() <= eps && m2.abs() <= eps && (m3 - 1.0).abs() <= eps
|
||||
}
|
||||
|
||||
/// Is `e` a visible Transform effect with Random on (which takes its seed from the object's id)?
|
||||
pub(crate) fn seeded(e: &vectorcraft_doc::Effect) -> bool {
|
||||
e.visible && e.id == "distort.transform" && e.params.get("random").and_then(Value::as_bool) == Some(true)
|
||||
}
|
||||
|
||||
/// Does `n` have a visible Transform effect with Random on, its own or a fill's or stroke's?
|
||||
fn seeded_here(n: &Node) -> bool {
|
||||
let seeded =
|
||||
|e: &vectorcraft_doc::Effect| e.visible && e.id == "distort.transform" && e.params.get("random").and_then(Value::as_bool) == Some(true);
|
||||
n.appearance.effects.iter().any(seeded) || n.appearance.items.iter().any(|i| i.effects().iter().any(seeded))
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ const ONE_FILE_WARNING: &str = "each data set saves over the same file, which ke
|
||||
/// `path`; a Save step without a `path` when the document has a file of its own (saved, and not
|
||||
/// converted from an older version or restored by Data Recovery) and the step gives no `format` or
|
||||
/// the document's own; an Export for Screens step whose `folder`, given or from its
|
||||
/// `exportPreset`, names a folder, whatever the overrides.
|
||||
/// `exportPreset`, names a folder, when Override Export does not give each data set its own folder.
|
||||
/// Other steps return their data instead of writing a file.
|
||||
pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool {
|
||||
if p.get("source").and_then(Value::as_str) != Some("datasets") {
|
||||
@@ -150,8 +150,8 @@ pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool {
|
||||
"document.export" | "document.exportSelection" => !export && path,
|
||||
// The batch does not point these exports into `destinationFolder`.
|
||||
"document.exportPdf" | "document.exportEps" | "document.exportDxf" | "document.exportForWeb" | "document.exportForOffice" => path,
|
||||
// Override Export sets `path`, which Export for Screens does not read: its `folder` stays.
|
||||
"document.exportForScreens" => super::screens::with_preset(s, ¶ms).is_ok_and(|p| names(p.get("folder"))),
|
||||
// Override Export gives Export for Screens a folder for each data set; otherwise its `folder` stays.
|
||||
"document.exportForScreens" => !export && super::screens::with_preset(s, ¶ms).is_ok_and(|p| names(p.get("folder"))),
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
@@ -196,14 +196,29 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run:
|
||||
if files.is_empty() {
|
||||
return Err(bad(cmd, "no readable files in the folder"));
|
||||
}
|
||||
// Each file's name without its extension, through [`source_stem`] (`a..svg` gives `a`, the
|
||||
// folder Windows makes for `a.`, and `a\b.svg` keeps `a\b` where `\` is not a separator), with
|
||||
// `-2`, `-3`… after a name an earlier file used (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file
|
||||
// wrote ([`StemPicker`]), as data set names are numbered. A file's stem is picked once it is
|
||||
// open, so its artboard files count ([`dataset_output_paths`]). No output takes the name of a
|
||||
// source file in the destination folder, other than that file's own output, or of a subfolder
|
||||
// of the destination folder that holds source files.
|
||||
let steps = filter_steps(run.steps, override_open, false);
|
||||
let run = Run { steps: &steps, ..*run };
|
||||
let in_dest: Vec<Option<(String, bool)>> = files.iter().map(|path| name_in_folder(path, run.dest_folder)).collect();
|
||||
let mut picker =
|
||||
StemPicker { stems: std::collections::HashSet::new(), written: in_dest.iter().flatten().map(|(name, _)| name.clone()).collect() };
|
||||
let mut processed = Vec::new();
|
||||
let mut failed = 0usize;
|
||||
for path in &files {
|
||||
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("file");
|
||||
for (path, place) in files.iter().zip(&in_dest) {
|
||||
let ext = super::extension(path);
|
||||
match process_one_file(s, &run, path, stem, &ext) {
|
||||
let base = source_stem(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or(""));
|
||||
let own = place.as_ref().filter(|(_, own)| *own).map(|(name, _)| name.clone());
|
||||
let pick = |doc: &vectorcraft_doc::Document| {
|
||||
let outputs = dataset_output_extensions(&run, doc, &ext);
|
||||
picker.pick(&base, |stem| output_names(run.dest_folder, stem, &outputs, doc), own.as_deref())
|
||||
};
|
||||
match process_one_file(s, &run, path, &ext, pick) {
|
||||
Ok(row) => processed.push(row),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
@@ -260,6 +275,68 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
|
||||
Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false}))
|
||||
}
|
||||
|
||||
/// The output stem for a source file's name without its extension: without trailing periods and
|
||||
/// spaces and with `-` after a Windows device name ([`super::screens::name_without_device`]), and
|
||||
/// "file" when nothing is left. Other characters stay, so `a\b` and `a:b` keep their names on Linux
|
||||
/// and macOS.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn source_stem(stem: &str) -> String {
|
||||
let stem = super::screens::name_without_device(stem);
|
||||
if stem.is_empty() { "file".to_string() } else { stem }
|
||||
}
|
||||
|
||||
/// The names in lower case of the files and folders that a source with the stem `stem` writes
|
||||
/// into the destination folder ([`dataset_output_paths`]), artboard files included.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn output_names(folder: Option<&str>, stem: &str, outputs: &[DatasetOutput], doc: &vectorcraft_doc::Document) -> Vec<String> {
|
||||
dataset_output_paths(folder, stem, outputs, doc)
|
||||
.iter()
|
||||
.filter_map(|path| path.file_name().and_then(|name| name.to_str()).map(str::to_lowercase))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// When the file at `path` is inside `folder`: the name in lower case of the file, when it is
|
||||
/// directly inside, or of the subfolder of `folder` that holds it, and whether it is the file's own.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn name_in_folder(path: &str, folder: Option<&str>) -> Option<(String, bool)> {
|
||||
let folder = std::fs::canonicalize(folder?).ok()?;
|
||||
let path = std::fs::canonicalize(path).ok()?;
|
||||
let mut parts = path.strip_prefix(&folder).ok()?.components();
|
||||
let first = parts.next()?.as_os_str().to_str()?.to_lowercase();
|
||||
Some((first, parts.next().is_none()))
|
||||
}
|
||||
|
||||
/// Picks the stem of each folder source in turn: its name, with `-2`, `-3`… after it when an
|
||||
/// earlier source used it or when one of the files or the folder it writes has the name of a file
|
||||
/// or folder an earlier source wrote (`a.svg` writes the file `a.svg`, so the Export for Screens
|
||||
/// folder of `a.svg.svg` is `a.svg-2`) or a name in `written` from the start (the source files and
|
||||
/// subfolders in the destination folder) other than its own file's. Names are compared without case.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct StemPicker {
|
||||
stems: std::collections::HashSet<String>,
|
||||
written: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl StemPicker {
|
||||
/// `names` gives the names in lower case of what a stem writes; `own` is the source's own name.
|
||||
fn pick(&mut self, base: &str, names: impl Fn(&str) -> Vec<String>, own: Option<&str>) -> String {
|
||||
let mut name = base.to_string();
|
||||
let mut n = 1usize;
|
||||
loop {
|
||||
let lower = name.to_lowercase();
|
||||
let outputs = names(&name);
|
||||
if !self.stems.contains(&lower) && outputs.iter().all(|f| !self.written.contains(f) || own == Some(f.as_str())) {
|
||||
self.stems.insert(lower);
|
||||
self.written.extend(outputs);
|
||||
return name;
|
||||
}
|
||||
n += 1;
|
||||
name = format!("{base}-{n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop Open (and optionally Close) steps when the batch supplies the file itself.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec<Value> {
|
||||
@@ -279,12 +356,19 @@ fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec<Value>
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext: &str) -> Result<Value> {
|
||||
fn process_one_file(
|
||||
s: &mut Session,
|
||||
run: &Run<'_>,
|
||||
path: &str,
|
||||
ext: &str,
|
||||
stem_for: impl FnOnce(&vectorcraft_doc::Document) -> String,
|
||||
) -> Result<Value> {
|
||||
s.execute("document.open", &json!({"path": path}))?;
|
||||
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, ext);
|
||||
let stem = stem_for(&s.doc()?.doc);
|
||||
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, &stem, ext);
|
||||
let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?;
|
||||
// Folder sources open a file per pass: close after Save and Close / Folder.
|
||||
finish_destination(s, run, stem, ext, path, true)?;
|
||||
finish_destination(s, run, &stem, ext, path, true)?;
|
||||
Ok(json!({"path": path, "result": batch}))
|
||||
}
|
||||
|
||||
@@ -349,10 +433,12 @@ fn dataset_stems(names: &[String], doc_stem: &str, naming: &str, run: &Run<'_>,
|
||||
"fileNumber" => ordinal,
|
||||
"dataSetName" if !safe_data_set.is_empty() => safe_data_set,
|
||||
"dataSetName" => ordinal,
|
||||
_ => format!("{doc_stem}_{safe}"),
|
||||
// Without trailing periods and spaces, which Windows drops from a folder name, so the
|
||||
// Export for Screens folders of `Set.` and `Set` are numbered apart.
|
||||
_ => safe_file_name(&format!("{doc_stem}_{safe}")),
|
||||
}
|
||||
});
|
||||
let outputs = dataset_output_extensions(run, doc);
|
||||
let outputs = dataset_output_extensions(run, doc, "vectorcraft");
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
let mut reserved = std::collections::HashSet::new();
|
||||
let mut stems = Vec::with_capacity(bases.len());
|
||||
@@ -384,30 +470,39 @@ struct DatasetOutput {
|
||||
ext: String,
|
||||
artboards: bool,
|
||||
joiner: &'static str,
|
||||
/// An Export for Screens or Export Assets step writes into the folder `{stem}` instead.
|
||||
folder: bool,
|
||||
}
|
||||
|
||||
/// Lists step output extensions and reserves artboard file names for saves with any artboard or
|
||||
/// exports with multiple artboards. Per-artboard names count for vectorcraft, template, AI and AIT
|
||||
/// saves and SVG, SVGZ, EPS, raster, DXF, EMF, WMF and AI exports.
|
||||
/// saves and SVG, SVGZ, EPS, raster, DXF, EMF, WMF and AI exports. An Export for Screens or Export
|
||||
/// Assets step reserves the folder `{stem}` ([`rewrite_paths`]). A step without a format and the
|
||||
/// Folder destination write `default_ext` (`vectorcraft` when it is empty).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document) -> Vec<DatasetOutput> {
|
||||
fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document, default_ext: &str) -> Vec<DatasetOutput> {
|
||||
let default_ext = if default_ext.is_empty() { "vectorcraft" } else { default_ext };
|
||||
let mut outputs = Vec::new();
|
||||
if run.destination == "folder" {
|
||||
outputs.push(DatasetOutput { ext: "vectorcraft".into(), artboards: false, joiner: "-" });
|
||||
outputs.push(DatasetOutput { ext: default_ext.into(), artboards: false, joiner: "-", folder: false });
|
||||
}
|
||||
for step in run.steps {
|
||||
let id = step.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
if run.override_export && matches!(id, "document.exportForScreens" | "assets.export") {
|
||||
outputs.push(DatasetOutput { ext: String::new(), artboards: false, joiner: "-", folder: true });
|
||||
continue;
|
||||
}
|
||||
let writes = (run.override_save && matches!(id, "document.save" | "file.save" | "file.saveAs"))
|
||||
|| (run.override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection"));
|
||||
|| (run.override_export && matches!(id, "document.export" | "document.exportSelection"));
|
||||
if writes {
|
||||
let format = step.get("params").and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty());
|
||||
let ext = match format {
|
||||
Some(format) if super::format(format).is_some() => format_extension(format),
|
||||
Some(_) => continue,
|
||||
None => "vectorcraft".to_string(),
|
||||
None => default_ext.to_string(),
|
||||
};
|
||||
if safe_extension(&ext) {
|
||||
let format_id = format.and_then(super::format).map(|f| f.id).unwrap_or("vectorcraft");
|
||||
let format_id = super::format(format.unwrap_or(default_ext)).map(|f| f.id).unwrap_or("vectorcraft");
|
||||
let is_save = matches!(id, "document.save" | "file.save" | "file.saveAs");
|
||||
let artboards = if is_save {
|
||||
!doc.artboards.is_empty() && matches!(format_id, "vectorcraft" | "template" | "ai" | "ait")
|
||||
@@ -434,7 +529,7 @@ fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document) ->
|
||||
| "ai"
|
||||
)
|
||||
};
|
||||
outputs.push(DatasetOutput { ext, artboards, joiner: if format_id == "eps" { "_" } else { "-" } });
|
||||
outputs.push(DatasetOutput { ext, artboards, joiner: if format_id == "eps" { "_" } else { "-" }, folder: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -463,6 +558,10 @@ fn dataset_output_paths(folder: Option<&str>, stem: &str, outputs: &[DatasetOutp
|
||||
}
|
||||
let mut paths = Vec::new();
|
||||
for output in outputs {
|
||||
if output.folder {
|
||||
paths.extend(output_folder(folder, stem));
|
||||
continue;
|
||||
}
|
||||
if !safe_extension(&output.ext) || super::format(&output.ext).is_none() {
|
||||
continue;
|
||||
}
|
||||
@@ -497,6 +596,18 @@ fn output_path(folder: &str, stem: &str, ext: &str) -> Option<std::path::PathBuf
|
||||
(path.parent() == Some(Path::new(folder))).then_some(path)
|
||||
}
|
||||
|
||||
/// Returns the folder `stem` directly inside the destination folder, where an Export for Screens or
|
||||
/// Export Assets step writes, for a safe stem.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn output_folder(folder: &str, stem: &str) -> Option<std::path::PathBuf> {
|
||||
use std::path::Path;
|
||||
if stem.is_empty() || matches!(stem, "." | "..") || stem.contains(std::path::is_separator) {
|
||||
return None;
|
||||
}
|
||||
let path = Path::new(folder).join(stem);
|
||||
(path.parent() == Some(Path::new(folder))).then_some(path)
|
||||
}
|
||||
|
||||
/// Save when the extension is a Save format; otherwise export (PNG, JPEG…).
|
||||
/// `as_copy`: `file.saveCopy` so the open document keeps its path (data-set batches).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -513,7 +624,9 @@ fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result
|
||||
}
|
||||
|
||||
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on: `ext` is the
|
||||
/// extension of the step's `format` ([`format_extension`]), else `default_ext`.
|
||||
/// extension of the step's `format` ([`format_extension`]), else `default_ext`. Export for Screens
|
||||
/// and Export Assets steps get `dest_folder/{stem}` as their `folder`, one folder for each source
|
||||
/// file or data set, and name the files in it themselves.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn rewrite_paths(
|
||||
steps: &[Value],
|
||||
@@ -532,21 +645,26 @@ fn rewrite_paths(
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
let mut c = c.clone();
|
||||
let rewrite = (override_save && matches!(id, "document.save" | "file.save" | "file.saveAs"))
|
||||
|| (override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection"));
|
||||
|| (override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection" | "assets.export"));
|
||||
if !rewrite {
|
||||
return c;
|
||||
}
|
||||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
let mut params = params.as_object().cloned().unwrap_or_default();
|
||||
let ext = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) {
|
||||
Some(f) => format_extension(f),
|
||||
None => default_ext.to_string(),
|
||||
};
|
||||
if params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()).is_some_and(|f| super::format(f).is_none()) {
|
||||
return c;
|
||||
if matches!(id, "document.exportForScreens" | "assets.export") {
|
||||
let Some(dir) = output_folder(folder, stem) else { return c };
|
||||
params.insert("folder".into(), json!(dir.to_string_lossy()));
|
||||
} else {
|
||||
let ext = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) {
|
||||
Some(f) => format_extension(f),
|
||||
None => default_ext.to_string(),
|
||||
};
|
||||
if params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()).is_some_and(|f| super::format(f).is_none()) {
|
||||
return c;
|
||||
}
|
||||
let Some(file) = output_path(folder, stem, &ext) else { return c };
|
||||
params.insert("path".into(), json!(file.to_string_lossy()));
|
||||
}
|
||||
let Some(file) = output_path(folder, stem, &ext) else { return c };
|
||||
params.insert("path".into(), json!(file.to_string_lossy()));
|
||||
if let Some(o) = c.as_object_mut() {
|
||||
o.insert("params".into(), Value::Object(params));
|
||||
}
|
||||
@@ -723,6 +841,15 @@ mod tests {
|
||||
assert_eq!(file_names(&dest), ["a\\b.svg"], "{r}");
|
||||
}
|
||||
|
||||
/// A source stem keeps `\`, `:` and the other characters Windows does not allow in a name, which
|
||||
/// only reach the batch where the file system allows them, and loses trailing periods and spaces.
|
||||
#[test]
|
||||
fn source_stem_keeps_characters_the_file_system_allowed() {
|
||||
for (stem, want) in [("a\\b", "a\\b"), ("a:b", "a:b"), ("a.", "a"), ("a. ", "a"), ("CON", "CON-"), ("...", "file"), ("", "file")] {
|
||||
assert_eq!(source_stem(stem), want, "{stem:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_path_accepts_only_platform_file_name_separators() {
|
||||
let folder = tmp("output-path");
|
||||
@@ -806,6 +933,319 @@ mod tests {
|
||||
assert!(file_names(&dest).is_empty(), "{r}");
|
||||
}
|
||||
|
||||
/// Override Export writes an Export for Screens step's files into a folder for each source file
|
||||
/// inside the destination folder, under the names the export gives them, whether the step names
|
||||
/// a folder of its own, takes one from an export preset or names none. With Override Export off,
|
||||
/// the files go to the step's folder.
|
||||
#[test]
|
||||
fn batch_override_export_sends_export_for_screens_files_to_the_destination_folder() {
|
||||
let src = tmp("screens-src");
|
||||
write_svg(&src, "a.svg", "#f00");
|
||||
write_svg(&src, "b.svg", "#0f0");
|
||||
let own = tmp("screens-own");
|
||||
let svg = json!([{"format": "svg"}]);
|
||||
let preset = json!({"name": "Own Folder", "settings": {"formats": svg, "folder": own.to_string_lossy()}});
|
||||
let steps = [json!({"folder": own.to_string_lossy(), "formats": svg}), json!({"exportPreset": "Own Folder"}), json!({"formats": svg})];
|
||||
for (i, params) in steps.into_iter().enumerate() {
|
||||
let dest = tmp(&format!("screens-out-{i}"));
|
||||
let mut s = Session::new();
|
||||
s.execute("document.exportPresets.save", &preset).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "document.exportForScreens", "params": params}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{params}: {r}");
|
||||
assert_eq!(file_names(&dest), ["a", "b"], "{params}: {r}");
|
||||
for stem in ["a", "b"] {
|
||||
assert_eq!(file_names(&dest.join(stem)), ["Artboard-1.svg"], "{params}: {r}");
|
||||
}
|
||||
assert!(file_names(&own).is_empty(), "{params}: {r}");
|
||||
}
|
||||
let dest = tmp("screens-out-off");
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"overrideExport": false,
|
||||
"commands": [{"command": "document.exportForScreens", "params": {"folder": own.to_string_lossy(), "formats": svg}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&own), ["Artboard-1.svg"], "{r}");
|
||||
assert!(file_names(&dest).is_empty(), "{r}");
|
||||
}
|
||||
|
||||
/// Two source files with the same name in different subfolders get Export for Screens folders of
|
||||
/// their own: the second one's folder is numbered, as a repeated data set name is.
|
||||
#[test]
|
||||
fn batch_override_export_numbers_export_for_screens_folders_of_files_with_the_same_name() {
|
||||
let src = tmp("screens-same-src");
|
||||
std::fs::create_dir_all(src.join("sub")).unwrap();
|
||||
write_svg(&src, "a.svg", "#f00");
|
||||
write_svg(&src.join("sub"), "a.svg", "#0f0");
|
||||
let dest = tmp("screens-same-out");
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"includeSubdirs": true,
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
for folder in file_names(&dest) {
|
||||
println!("{folder}/: {:?}", file_names(&dest.join(&folder)));
|
||||
}
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["a", "a-2"], "{r}");
|
||||
for (folder, fill) in [("a", "#ff0000"), ("a-2", "#00ff00")] {
|
||||
assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}");
|
||||
let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap();
|
||||
assert!(svg.contains(fill), "{folder}: {svg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A source file whose name without its extension ends in a period (`a..svg`) gets the folder
|
||||
/// name without it, numbered after the folder of `a.svg`: Windows drops the period from a folder
|
||||
/// name, so `a.` and `a` would be one folder there.
|
||||
#[test]
|
||||
fn batch_override_export_numbers_folders_that_differ_by_a_trailing_period() {
|
||||
let src = tmp("screens-period-src");
|
||||
write_svg(&src, "a.svg", "#f00");
|
||||
write_svg(&src, "a..svg", "#0f0");
|
||||
let dest = tmp("screens-period-out");
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["a", "a-2"], "{r}");
|
||||
for folder in ["a", "a-2"] {
|
||||
assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A source file's Export for Screens folder does not take the name of a file the Folder
|
||||
/// destination saved for an earlier source: `a.svg` is saved as `a.svg`, so the folder of
|
||||
/// `a.svg.svg` is numbered.
|
||||
#[test]
|
||||
fn batch_override_export_numbers_folders_named_like_a_saved_file() {
|
||||
let src = tmp("screens-file-src");
|
||||
write_svg(&src, "a.svg", "#f00");
|
||||
write_svg(&src, "a.svg.svg", "#0f0");
|
||||
let dest = tmp("screens-file-out");
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["a", "a.svg", "a.svg-2", "a.svg-2.svg"], "{r}");
|
||||
for folder in ["a", "a.svg-2"] {
|
||||
assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With the destination folder the same as the source folder, a file's output does not replace
|
||||
/// another source file before Batch opens it: `a..svg` would be saved as `a.svg`, so it is saved
|
||||
/// as `a-2.svg`, and `a.svg` is saved over itself.
|
||||
#[test]
|
||||
fn batch_does_not_save_over_another_source_file_in_the_destination_folder() {
|
||||
let src = tmp("in-place");
|
||||
write_svg(&src, "a..svg", "#f00");
|
||||
write_svg(&src, "a.svg", "#0f0");
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destination": "folder",
|
||||
"destinationFolder": src.to_string_lossy(),
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&src), ["a-2.svg", "a..svg", "a.svg"], "{r}");
|
||||
for (file, fill) in [("a-2.svg", "#ff0000"), ("a.svg", "#00ff00")] {
|
||||
let svg = std::fs::read_to_string(src.join(file)).unwrap();
|
||||
assert!(svg.contains(fill), "{file}: {svg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With the destination folder the same as the source folder and `includeSubdirs`, an Export for
|
||||
/// Screens folder does not take the name of a subfolder that holds source files: the folder of
|
||||
/// `a.svg` is `a-2`, so `a/Artboard-1.svg` is opened as it was.
|
||||
#[test]
|
||||
fn batch_does_not_export_into_a_source_subfolder() {
|
||||
let src = tmp("in-place-sub");
|
||||
std::fs::create_dir_all(src.join("a")).unwrap();
|
||||
write_svg(&src, "a.svg", "#f00");
|
||||
write_svg(&src.join("a"), "Artboard-1.svg", "#0f0");
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"includeSubdirs": true,
|
||||
"destinationFolder": src.to_string_lossy(),
|
||||
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
let source = std::fs::read_to_string(src.join("a").join("Artboard-1.svg")).unwrap();
|
||||
assert!(source.contains("fill=\"#0f0\""), "{r}: {source}");
|
||||
let export = std::fs::read_to_string(src.join("a-2").join("Artboard-1.svg")).unwrap();
|
||||
assert!(export.contains("#ff0000"), "{r}: {export}");
|
||||
}
|
||||
|
||||
/// With the destination folder the same as the source folder, a save's artboard files do not
|
||||
/// replace another source file: `a.vectorcraft` would also write `a-Artboard-1.vectorcraft`, so
|
||||
/// it is saved as `a-2`.
|
||||
#[test]
|
||||
fn batch_does_not_save_artboard_files_over_another_source_file() {
|
||||
let src = tmp("in-place-artboards");
|
||||
let mut s = Session::new();
|
||||
for (name, width) in [("a.vectorcraft", 20), ("a-Artboard-1.vectorcraft", 30)] {
|
||||
s.execute("file.new", &json!({"width": width, "height": 20, "artboards": 1})).unwrap();
|
||||
s.execute("document.save", &json!({"path": src.join(name).to_string_lossy()})).unwrap();
|
||||
}
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destinationFolder": src.to_string_lossy(),
|
||||
"commands": [{"command": "document.save", "params": {"separateArtboards": true}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(
|
||||
file_names(&src),
|
||||
["a-2-Artboard-1.vectorcraft", "a-2.vectorcraft", "a-Artboard-1-Artboard-1.vectorcraft", "a-Artboard-1.vectorcraft", "a.vectorcraft"],
|
||||
"{r}"
|
||||
);
|
||||
s.execute("document.open", &json!({"path": src.join("a-Artboard-1.vectorcraft").to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.width(), 30.0, "{r}");
|
||||
}
|
||||
|
||||
/// Data sets whose names differ only by a trailing period (`a` and `a.`) get Export Assets
|
||||
/// folders of their own: the second one's folder is numbered.
|
||||
#[test]
|
||||
fn batch_override_export_numbers_data_set_folders_that_differ_by_a_trailing_period() {
|
||||
let dest = tmp("assets-period-out");
|
||||
let mut s = with_data_sets(&["a", "a."]);
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("assets.add", &json!({"ids": [id], "name": "Logo"})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "assets.export", "params": {"formats": [{"format": "svg"}]}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_a", "Untitled_a-2"], "{r}");
|
||||
for folder in ["Untitled_a", "Untitled_a-2"] {
|
||||
assert_eq!(file_names(&dest.join(folder)), ["Logo.svg"], "{r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two source files with the same name in different subfolders are saved under names of their
|
||||
/// own, by Override Save and by the Folder destination: the second one's name is numbered.
|
||||
#[test]
|
||||
fn batch_numbers_saved_files_of_source_files_with_the_same_name() {
|
||||
let src = tmp("save-same-src");
|
||||
std::fs::create_dir_all(src.join("sub")).unwrap();
|
||||
write_svg(&src, "a.svg", "#f00");
|
||||
write_svg(&src.join("sub"), "a.svg", "#0f0");
|
||||
let save = json!([{"command": "file.saveAs", "params": {"format": "svg"}}]);
|
||||
let unlock = json!([{"command": "object.unlockAll", "params": {}}]);
|
||||
for (tag, destination, commands) in [("save", "none", save), ("folder", "folder", unlock)] {
|
||||
let dest = tmp(&format!("save-same-{tag}"));
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"includeSubdirs": true,
|
||||
"destination": destination,
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": commands
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{tag}: {r}");
|
||||
assert_eq!(file_names(&dest), ["a-2.svg", "a.svg"], "{tag}: {r}");
|
||||
for (file, fill) in [("a.svg", "#ff0000"), ("a-2.svg", "#00ff00")] {
|
||||
let svg = std::fs::read_to_string(dest.join(file)).unwrap();
|
||||
assert!(svg.contains(fill), "{tag}: {file}: {svg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Override Export writes an Export Assets step's files into a folder for each data set inside
|
||||
/// the destination folder, under the assets' names.
|
||||
#[test]
|
||||
fn batch_override_export_sends_asset_export_files_to_the_destination_folder() {
|
||||
let own = tmp("assets-own");
|
||||
let dest = tmp("assets-out");
|
||||
let mut s = with_data_sets(&["a", "b"]);
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("assets.add", &json!({"ids": [id], "name": "Logo"})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "assets.export", "params": {"folder": own.to_string_lossy(), "formats": [{"format": "svg"}]}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_a", "Untitled_b"], "{r}");
|
||||
for stem in ["Untitled_a", "Untitled_b"] {
|
||||
assert_eq!(file_names(&dest.join(stem)), ["Logo.svg"], "{r}");
|
||||
}
|
||||
assert!(file_names(&own).is_empty(), "{r}");
|
||||
}
|
||||
|
||||
/// Override Save names each file with the extension of the format the step names, also over
|
||||
/// data sets.
|
||||
#[test]
|
||||
@@ -1258,7 +1698,7 @@ mod tests {
|
||||
assert!(!dataset_output_exists(
|
||||
Some(&folder),
|
||||
"../outside",
|
||||
&[DatasetOutput { ext: "svg".into(), artboards: false, joiner: "-" }],
|
||||
&[DatasetOutput { ext: "svg".into(), artboards: false, joiner: "-", folder: false }],
|
||||
&vectorcraft_doc::Document::new(100.0, 80.0),
|
||||
));
|
||||
}
|
||||
@@ -1525,7 +1965,15 @@ mod tests {
|
||||
assert!(!batch_overwrites_one_file(&saved, &save(Some("svg"))), "another format without a path: the data comes back");
|
||||
assert!(!with(json!({"commands": [{"command": "file.saveCopy", "params": {"folder": "/x"}}]})), "a folder alone: the data comes back");
|
||||
let screens = json!([{"command": "document.exportForScreens", "params": {"folder": "/x"}}]);
|
||||
assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})), "Override Export leaves its folder");
|
||||
assert!(
|
||||
!with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})),
|
||||
"Override Export gives each data set its own folder"
|
||||
);
|
||||
assert!(
|
||||
with(json!({"destination": "folder", "destinationFolder": "/out", "overrideExport": false, "commands": screens})),
|
||||
"without Override Export the step keeps its folder"
|
||||
);
|
||||
assert!(with(json!({"commands": screens})), "without a destination folder the step keeps its folder");
|
||||
assert!(!with(json!({"commands": [{"command": "document.exportForScreens", "params": {}}]})));
|
||||
let preset = json!([{"command": "document.exportForScreens", "params": {"exportPreset": "Logo Package"}}]);
|
||||
assert!(!with(json!({"commands": preset})), "a preset without a folder: the data comes back");
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Serialize Document",
|
||||
[],
|
||||
None,
|
||||
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
|
||||
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
|
||||
has_doc,
|
||||
export::serialize
|
||||
),
|
||||
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Selection…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export",
|
||||
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
|
||||
has_selection,
|
||||
export::export_selection
|
||||
),
|
||||
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Batch…",
|
||||
[],
|
||||
None,
|
||||
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)",
|
||||
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file or a folder of source files in destinationFolder), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-` and trailing periods and spaces are dropped; a name already used in the batch or whose output file or folder already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)",
|
||||
always,
|
||||
action_batch::action_batch
|
||||
),
|
||||
|
||||
@@ -21,14 +21,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export DXF",
|
||||
[],
|
||||
None,
|
||||
"{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}",
|
||||
"{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused",
|
||||
has_doc,
|
||||
export_dxf
|
||||
)]
|
||||
}
|
||||
|
||||
/// The DXF options `document.formats` lists.
|
||||
pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
pub(crate) const OPTIONS: &[FormatOption] = &[
|
||||
super::ARTBOARD,
|
||||
super::ARTBOARDS,
|
||||
super::RANGE,
|
||||
|
||||
@@ -32,14 +32,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export EPS",
|
||||
[],
|
||||
None,
|
||||
"{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art; none when no art shows) | false (one file, the bounding box the visible art, y up from the first artboard's bottom-left corner, even when artboards are named; the default when none are named), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (the artboards written; naming them without useArtboards works as useArtboards: true)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}",
|
||||
"{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art; none when no art shows) | false (one file, the bounding box the visible art, y up from the first artboard's bottom-left corner, even when artboards are named; the default when none are named), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (the artboards written; naming them without useArtboards works as useArtboards: true)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}. A key EPS doesn't take, and an unknown key inside flattener, are refused",
|
||||
has_doc,
|
||||
export_eps
|
||||
)]
|
||||
}
|
||||
|
||||
/// The EPS options `document.formats` lists.
|
||||
pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
pub(crate) const OPTIONS: &[FormatOption] = &[
|
||||
super::ARTBOARD,
|
||||
super::ARTBOARDS,
|
||||
super::RANGE,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -174,7 +174,13 @@ pub(super) fn file_part(s: &str) -> String {
|
||||
/// every platform. Spaces between the device name and the period are dropped (`CON .png` becomes
|
||||
/// `CON-.png`).
|
||||
pub(crate) fn safe_file_name(s: &str) -> String {
|
||||
let name = file_part(s);
|
||||
name_without_device(&file_part(s))
|
||||
}
|
||||
|
||||
/// [`safe_file_name`] without [`file_part`]: drops trailing periods and spaces and puts `-` after a
|
||||
/// Windows device name, and keeps every other character, so a source file name with `\` or `:` keeps
|
||||
/// it where the file system allows it.
|
||||
pub(super) fn name_without_device(name: &str) -> String {
|
||||
let name = name.trim_end_matches(['.', ' ']);
|
||||
let (base, rest) = name.split_at(name.find('.').unwrap_or(name.len()));
|
||||
// Windows ignores spaces before the extension, so `CON .png` is the CON device too.
|
||||
|
||||
@@ -445,6 +445,39 @@ fn a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles() {
|
||||
assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3)], "{r}");
|
||||
}
|
||||
|
||||
/// A `.ai` symbol library (symbols and their list, no layers) opens with its symbols, and Other
|
||||
/// Library… in the Symbols panel lists them in the list's order.
|
||||
#[test]
|
||||
fn a_symbol_library_in_a_ai_file_without_layers_loads_its_symbols() {
|
||||
let symbol = |name: &str, x: f64| {
|
||||
format!(
|
||||
"%AI14_BeginSymbol\n({name})\n0 A\n0 Xw\n0 Ae\nu\n0 1 1 0 1 0 0 Xa\n{x} 0 m\n{x} 4 L\n{} 4 L\n{} 0 L\nf\nU\n\
|
||||
%_/ArtDictionary :\n%_5 /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n",
|
||||
x + 4.0,
|
||||
x + 4.0
|
||||
)
|
||||
};
|
||||
let symbols = format!(
|
||||
"%AI5_Begin_NonPrinting\n{}{}{}%AI5_End_NonPrinting--\n%AI24_BeginSymbolList\n(Star)\n(Leaf)\n(Arrow)\n%AI24_EndSymbolList\n",
|
||||
symbol("Arrow", 0.0),
|
||||
symbol("Leaf", 10.0),
|
||||
symbol("Star", 20.0)
|
||||
);
|
||||
let data = ai::editing_data(200.0, 100.0, &symbols);
|
||||
let file = ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f");
|
||||
let mut s = Session::new();
|
||||
let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "shapes.ai"});
|
||||
let r = s.execute("symbol.loadLibrary", &p).unwrap();
|
||||
assert_eq!((&r["name"], &r["count"]), (&json!("shapes"), &json!(3)), "{r}");
|
||||
let lib = s.execute("symbol.library", &json!({"library": r["library"]})).unwrap();
|
||||
let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|g| g["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Star", "Leaf", "Arrow"], "{lib}");
|
||||
let r = open(&mut s, "shapes.ai", &file, json!({})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let names: Vec<&str> = doc.symbols.iter().map(|g| g.name.as_str()).collect();
|
||||
assert_eq!(names, ["Star", "Leaf", "Arrow"], "{r}");
|
||||
}
|
||||
|
||||
/// A 1-bit RGB image opens from the editing data in the colors its PDF part shows.
|
||||
#[test]
|
||||
fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() {
|
||||
|
||||
@@ -42,7 +42,10 @@ use data::{fmt_value, parse_csv, to_csv};
|
||||
use generate::generate;
|
||||
#[cfg(test)]
|
||||
use series::default_series_color;
|
||||
pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, shown_paint};
|
||||
pub(crate) use series::{
|
||||
capture_label_styles, capture_series_object_opacity, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets,
|
||||
series_object_opacity_differs, shown_paint,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use vectorcraft_color::BlendMode;
|
||||
|
||||
use super::axis::radar_axis;
|
||||
use super::labels::{Role, filled_lines_on, label_style};
|
||||
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad};
|
||||
@@ -26,26 +28,43 @@ pub(super) fn series_marks(g: &GraphSpec, i: usize, series_fill: bool, stroke: P
|
||||
(fill, stroke, width)
|
||||
}
|
||||
|
||||
/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity on their
|
||||
/// topmost fill and stroke. Other art, such as a graph design, keeps its own opacity. A value read from a file is
|
||||
/// held to 0..1, and one that is not a number is left out.
|
||||
/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity and blend mode on
|
||||
/// their topmost fill and stroke, and its kept object opacity and blend mode. Other art, such as a graph design, keeps its
|
||||
/// own. An opacity read from a file is held to 0..1, and one that is not a number is left out.
|
||||
pub(super) fn with_series_opacity(g: &GraphSpec, i: usize, mut items: Vec<Arc<Node>>) -> Vec<Arc<Node>> {
|
||||
let Some(slot) = g.series_paints.get(i) else { return items };
|
||||
let kept = |o: Option<f32>| o.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0));
|
||||
let (fill, stroke) = (kept(slot.fill_opacity), kept(slot.stroke_opacity));
|
||||
if fill.is_none() && stroke.is_none() {
|
||||
let (fill, stroke, object) = (kept(slot.fill_opacity), kept(slot.stroke_opacity), kept(slot.opacity));
|
||||
if fill.is_none() && stroke.is_none() && object.is_none() && slot.fill_blend.is_none() && slot.stroke_blend.is_none() && slot.blend.is_none() {
|
||||
return items;
|
||||
}
|
||||
for item in &mut items {
|
||||
if !matches!(item.kind, NodeKind::Path { .. }) {
|
||||
continue;
|
||||
}
|
||||
let ap = &mut Arc::make_mut(item).appearance;
|
||||
if let (Some(o), Some(f)) = (fill, ap.fill_mut()) {
|
||||
f.opacity = o;
|
||||
let n = Arc::make_mut(item);
|
||||
if let Some(o) = object {
|
||||
n.opacity = o;
|
||||
}
|
||||
if let (Some(o), Some(st)) = (stroke, ap.stroke_mut()) {
|
||||
st.opacity = o;
|
||||
if let Some(b) = slot.blend {
|
||||
n.blend = b;
|
||||
}
|
||||
let ap = &mut n.appearance;
|
||||
if let Some(f) = ap.fill_mut() {
|
||||
if let Some(o) = fill {
|
||||
f.opacity = o;
|
||||
}
|
||||
if let Some(b) = slot.fill_blend {
|
||||
f.blend = b;
|
||||
}
|
||||
}
|
||||
if let Some(st) = ap.stroke_mut() {
|
||||
if let Some(o) = stroke {
|
||||
st.opacity = o;
|
||||
}
|
||||
if let Some(b) = slot.stroke_blend {
|
||||
st.blend = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
items
|
||||
@@ -216,10 +235,10 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P
|
||||
changed
|
||||
}
|
||||
|
||||
/// Copy the opacity of the topmost visible fill (`true`) or stroke (`false`) of generated series art just edited (`edited`:
|
||||
/// each object with the kind of row) into that series' fill or stroke opacity; art left without a row of that kind
|
||||
/// keeps none. Called inside the same document edit, so both undo together. Returns whether a series' kept opacity
|
||||
/// changed.
|
||||
/// Copy the opacity and blend mode of the topmost visible fill (`true`) or stroke (`false`) of generated series art just
|
||||
/// edited (`edited`: each object with the kind of row) into that series' fill or stroke opacity and blend mode; art left
|
||||
/// without a row of that kind keeps neither. Called inside the same document edit, so both undo together. Returns
|
||||
/// whether a series' kept opacity or blend mode changed.
|
||||
pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool {
|
||||
if edited.is_empty() {
|
||||
return false;
|
||||
@@ -236,14 +255,15 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool
|
||||
let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?;
|
||||
let row = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill && it.visible());
|
||||
let opacity = row.map_or(1.0, vectorcraft_doc::AppearanceItem::opacity);
|
||||
Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity)))
|
||||
let blend = row.map_or(BlendMode::Normal, vectorcraft_doc::AppearanceItem::blend);
|
||||
Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity), (blend != BlendMode::Normal).then_some(blend)))
|
||||
})
|
||||
.collect();
|
||||
let mut changed = false;
|
||||
for (graph, index, fill, kept) in captures {
|
||||
for (graph, index, fill, kept, blend) in captures {
|
||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||||
// A fully opaque row on a series the graph keeps nothing for leaves the graph as it is.
|
||||
if kept.is_none() && spec.series_paints.len() <= index {
|
||||
// A fully opaque Normal row on a series the graph keeps nothing for leaves the graph as it is.
|
||||
if kept.is_none() && blend.is_none() && spec.series_paints.len() <= index {
|
||||
continue;
|
||||
}
|
||||
let was = spec.series_paints.clone();
|
||||
@@ -253,15 +273,73 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool
|
||||
}
|
||||
let Some(slot) = spec.series_paints.get_mut(index) else { continue };
|
||||
if fill {
|
||||
slot.fill_opacity = kept;
|
||||
(slot.fill_opacity, slot.fill_blend) = (kept, blend);
|
||||
} else {
|
||||
slot.stroke_opacity = kept;
|
||||
(slot.stroke_opacity, slot.stroke_blend) = (kept, blend);
|
||||
}
|
||||
changed |= spec.series_paints != was;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Copy the object opacity and blend mode of generated series art just edited (`edited`) into that series' object opacity
|
||||
/// and blend mode. Called inside the same document edit, so both undo together. Returns whether a series' kept object
|
||||
/// opacity or blend mode changed.
|
||||
pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId]) -> bool {
|
||||
let mut changed = false;
|
||||
for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) {
|
||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||||
// A fully opaque Normal object on a series the graph keeps nothing for leaves the graph as it is.
|
||||
if opacity.is_none() && blend.is_none() && spec.series_paints.len() <= index {
|
||||
continue;
|
||||
}
|
||||
let was = spec.series_paints.clone();
|
||||
// `index` is below the graph's series count (`series_members`).
|
||||
if spec.series_paints.len() <= index {
|
||||
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
|
||||
}
|
||||
let Some(slot) = spec.series_paints.get_mut(index) else { continue };
|
||||
(slot.opacity, slot.blend) = (opacity, blend);
|
||||
changed |= spec.series_paints != was;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Would [`capture_series_object_opacity`] change what the graph stores for `edited`? Reads the document without copying
|
||||
/// it.
|
||||
pub(crate) fn series_object_opacity_differs(doc: &Document, edited: &[NodeId]) -> bool {
|
||||
let mut after: HashMap<(NodeId, usize), (Option<f32>, Option<BlendMode>)> = HashMap::new();
|
||||
let stored = |graph: NodeId, index: usize| {
|
||||
let spec = doc.node(graph).and_then(|n| n.extras().graph.as_deref());
|
||||
spec.and_then(|g| g.series_paints.get(index)).map_or((None, None), |slot| (slot.opacity, slot.blend))
|
||||
};
|
||||
for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) {
|
||||
after.insert((graph, index), (opacity, blend));
|
||||
}
|
||||
after.into_iter().any(|((graph, index), pair)| stored(graph, index) != pair)
|
||||
}
|
||||
|
||||
/// The series and object opacity and blend mode (None for 100% and Normal) of each of `edited` that is a path directly in a
|
||||
/// series group, in order.
|
||||
fn object_opacity_captures(doc: &Document, edited: &[NodeId]) -> Vec<(NodeId, usize, Option<f32>, Option<BlendMode>)> {
|
||||
if edited.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let members = series_members(doc);
|
||||
if members.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
edited
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
// Only the paths the graph draws again, not the copy of a graph design.
|
||||
let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?;
|
||||
let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?;
|
||||
Some((r.graph, r.index, (node.opacity < 1.0).then_some(node.opacity), (node.blend != BlendMode::Normal).then_some(node.blend)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Graph Data › Transpose row/column: categories become series and series categories.
|
||||
pub(super) fn transpose(g: &mut GraphSpec) {
|
||||
let cells = g.cells();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind};
|
||||
|
||||
use crate::{NodeId, Session};
|
||||
@@ -347,14 +347,14 @@ fn clearing_a_series_marks_appearance_forgets_its_kept_opacity() {
|
||||
}
|
||||
|
||||
/// Removing the fill whose opacity the graph keeps takes the kept opacity away; an edit that leaves a mark opaque
|
||||
/// (a blend mode alone) keeps nothing for its series.
|
||||
/// and Normal (an opacity of 100% alone) keeps nothing for its series.
|
||||
#[test]
|
||||
fn removing_a_series_marks_fill_forgets_its_kept_opacity() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "blend": "multiply"})).unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 100})).unwrap();
|
||||
assert!(spec(&s, id).series_paints.is_empty());
|
||||
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4));
|
||||
@@ -503,6 +503,198 @@ fn a_stroke_opacity_on_a_series_line_survives_regeneration() {
|
||||
assert!(!after.is_empty() && after.iter().all(|m| m.appearance.stroke().unwrap().opacity == 0.6));
|
||||
}
|
||||
|
||||
/// Each mark of series `index`: its object opacity and blend mode, and the blend modes of its top fill and stroke.
|
||||
fn transparency_of(s: &Session, id: NodeId, index: u32) -> Vec<(f32, BlendMode, BlendMode, BlendMode)> {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let marks = series(&n, index).children().unwrap().to_vec();
|
||||
marks.iter().map(|m| (m.opacity, m.blend, m.appearance.fill().unwrap().blend, m.appearance.stroke().unwrap().blend)).collect()
|
||||
}
|
||||
|
||||
/// An object opacity and blend mode set on one series mark with no fill or stroke row picked are kept by the graph:
|
||||
/// every mark of that series has them after the graph is drawn again, through save and open, the other series stay
|
||||
/// opaque and Normal, 100% and Normal forget them, and undo takes them back.
|
||||
#[test]
|
||||
fn an_object_opacity_and_blend_mode_on_a_series_mark_survive_regeneration() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply)));
|
||||
let restored: GraphSpec = serde_json::from_value(serde_json::to_value(spec(&s, id)).unwrap()).unwrap();
|
||||
assert_eq!(restored, spec(&s, id));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
let normal = BlendMode::Normal;
|
||||
assert!(transparency_of(&s, id, 0).iter().all(|t| *t == (0.4, BlendMode::Multiply, normal, normal)), "{:?}", transparency_of(&s, id, 0));
|
||||
assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (1.0, normal, normal, normal)), "{:?}", transparency_of(&s, id, 1));
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
s.execute("transparency.set", &json!({"ids": [bar.0], "opacity": 100, "blend": "normal"})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.opacity, kept.blend), (None, None));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(spec(&s, id).series_paints.is_empty(), "undo takes back the edit and what the graph stored");
|
||||
}
|
||||
|
||||
/// `object.setProps` with the opacity a mark already shows replaces the opacity another mark of the series left on the
|
||||
/// graph, in one undo step, and an opacity on a graph design's copy in a series is not kept.
|
||||
#[test]
|
||||
fn an_object_opacity_a_series_mark_already_shows_replaces_the_kept_opacity() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 50})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5));
|
||||
s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 100})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].opacity, None);
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5));
|
||||
// Setting it again where the graph already stores it leaves the document and its history as they are.
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 50})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
save_design(&mut s, "Square");
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap();
|
||||
let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
|
||||
assert!(matches!(copy.kind, NodeKind::Group { .. }));
|
||||
s.execute("object.setProps", &json!({"id": copy.id.0, "opacity": 50})).unwrap();
|
||||
assert!(spec(&s, id).series_paints.iter().all(|p| p.opacity.is_none()));
|
||||
}
|
||||
|
||||
/// A blend mode set on the top fill or stroke row of one series mark is kept by the graph and drawn on every mark of
|
||||
/// the series again; Normal forgets it.
|
||||
#[test]
|
||||
fn a_row_blend_mode_on_a_series_mark_survives_regeneration() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let (fill, stroke) = (top_row(&s, marks[0], true), top_row(&s, marks[0], false));
|
||||
s.execute("transparency.set", &json!({"ids": [marks[0].0], "item": fill, "blend": "screen"})).unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [marks[1].0], "item": stroke, "blend": "darken"})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill_blend, kept.stroke_blend), (Some(BlendMode::Screen), Some(BlendMode::Darken)));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
let normal = BlendMode::Normal;
|
||||
assert!(transparency_of(&s, id, 0).iter().all(|t| *t == (1.0, normal, BlendMode::Screen, BlendMode::Darken)), "{:?}", transparency_of(&s, id, 0));
|
||||
assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (1.0, normal, normal, normal)), "{:?}", transparency_of(&s, id, 1));
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
s.execute("transparency.set", &json!({"ids": [bar.0], "item": top_row(&s, bar, true), "blend": "normal"})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_blend, None);
|
||||
}
|
||||
|
||||
/// The graph stores an object's or a row's opacity and blend mode as one pair from the mark just edited: setting only
|
||||
/// one half on another mark replaces both, either way round, and the redraw and undo follow the stored pair.
|
||||
#[test]
|
||||
fn setting_half_of_a_series_marks_transparency_stores_that_marks_whole_pair() {
|
||||
let normal = BlendMode::Normal;
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("transparency.set", &json!({"ids": [marks[0].0], "blend": "multiply"})).unwrap();
|
||||
s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 50})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.opacity, kept.blend), (Some(0.5), None));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(transparency_of(&s, id, 0).iter().all(|t| (t.0, t.1) == (0.5, normal)), "{:?}", transparency_of(&s, id, 0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.opacity, kept.blend), (None, Some(BlendMode::Multiply)));
|
||||
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 30})).unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [marks[1].0], "blend": "screen"})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.opacity, kept.blend), (None, Some(BlendMode::Screen)));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(transparency_of(&s, id, 0).iter().all(|t| (t.0, t.1) == (1.0, BlendMode::Screen)), "{:?}", transparency_of(&s, id, 0));
|
||||
|
||||
// A fill row: a blend alone on one mark replaces the opacity another mark's row left, and the other way round.
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "darken"})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill_opacity, kept.fill_blend), (None, Some(BlendMode::Darken)));
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 60})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.6), None));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill_opacity, kept.fill_blend), (None, Some(BlendMode::Darken)));
|
||||
}
|
||||
|
||||
/// Clear Appearance on a series mark sets its object opacity back to 100% and Normal, and the graph keeps neither.
|
||||
#[test]
|
||||
fn clearing_a_series_marks_appearance_forgets_its_kept_object_opacity() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap();
|
||||
s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.opacity, kept.blend), (None, None));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.4));
|
||||
}
|
||||
|
||||
/// An appearance moved or copied onto a mark of another series takes the source's object opacity and blend mode, and
|
||||
/// the graph keeps them for the target's series; a move leaves the source's series opaque and Normal.
|
||||
#[test]
|
||||
fn moving_an_appearance_onto_a_series_mark_keeps_its_object_opacity() {
|
||||
for copy in [false, true] {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap();
|
||||
s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": to.0, "copy": copy})).unwrap();
|
||||
let paints = spec(&s, id).series_paints;
|
||||
assert_eq!((paints[1].opacity, paints[1].blend), (Some(0.4), Some(BlendMode::Multiply)), "copy {copy}");
|
||||
let source = if copy { (Some(0.4), Some(BlendMode::Multiply)) } else { (None, None) };
|
||||
assert_eq!((paints[0].opacity, paints[0].blend), source, "copy {copy}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An Eyedropper pick of the object's transparency onto a series mark makes the graph keep the picked opacity and blend
|
||||
/// mode for that series, even an opaque Normal pick onto a mark that already shows it.
|
||||
#[test]
|
||||
fn an_eyedropper_pick_of_object_transparency_onto_a_series_mark_is_kept() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": marks[0].0, "ids": [to.0]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[1].clone();
|
||||
assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply)));
|
||||
// An opaque Normal source picked onto another opaque mark of series 0 replaces what series 0 stores.
|
||||
let opaque = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].id;
|
||||
s.execute("appearance.copyFrom", &json!({"source": opaque.0, "ids": [marks[1].0]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.opacity, kept.blend), (None, None));
|
||||
}
|
||||
|
||||
/// A graphic style made from an object at 50% Multiply, applied to a series mark, makes the graph keep the style's object
|
||||
/// opacity and blend mode; redefining the style updates them for a mark still linked to it.
|
||||
#[test]
|
||||
fn a_graphic_styles_object_opacity_on_a_series_mark_is_kept() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let (rect, _) = blue_style(&mut s, "Blue", true);
|
||||
s.execute("object.setProps", &json!({"id": rect.0, "opacity": 50, "blend": "multiply"})).unwrap();
|
||||
s.execute("graphicStyle.new", &json!({"id": rect.0, "name": "Faded"})).unwrap();
|
||||
s.execute("graphicStyle.apply", &json!({"name": "Faded", "ids": [marks[0].0]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.opacity, kept.blend), (Some(0.5), Some(BlendMode::Multiply)));
|
||||
s.execute("object.setProps", &json!({"id": rect.0, "opacity": 100, "blend": "normal"})).unwrap();
|
||||
s.execute("graphicStyle.redefine", &json!({"name": "Faded", "id": rect.0})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.opacity, kept.blend), (None, None));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5), "one undo takes back the redefinition and what the graph stored");
|
||||
|
||||
// An opaque Normal style applied to an opaque mark replaces the opacity another mark of the series left.
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 40})).unwrap();
|
||||
blue_style(&mut s, "Blue", true);
|
||||
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].opacity, None);
|
||||
}
|
||||
|
||||
/// The colors of series `index`'s marks: each mark's top fill and stroke paint and its stroke weight.
|
||||
fn marks_of(s: &Session, id: NodeId, index: u32) -> Vec<(Paint, Paint, f64)> {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
@@ -1010,19 +1202,46 @@ fn a_stroke_set_on_a_filled_line_band_does_not_repaint_it() {
|
||||
assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
|
||||
}
|
||||
|
||||
/// An opacity set on a Filled Lines band's stroke row is kept, without a stroke paint, and goes on the band's thin black
|
||||
/// outline when the graph is drawn again.
|
||||
/// An opacity and blend mode set on a Filled Lines band's stroke row are kept and drawn on its outline, which keeps its
|
||||
/// black paint and 0.25 pt weight.
|
||||
#[test]
|
||||
fn a_stroke_opacity_set_on_a_filled_line_band_goes_on_its_outline() {
|
||||
fn a_filled_line_bands_outline_keeps_a_stroke_opacity_and_blend_mode() {
|
||||
let (mut s, id, band) = band_graph();
|
||||
let row = top_row(&s, band, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 30})).unwrap();
|
||||
let kept = spec(&s, id).series_paints.first().cloned().unwrap_or_default();
|
||||
assert_eq!((kept.stroke.is_none(), kept.stroke_opacity), (true, Some(0.3)));
|
||||
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 50, "blend": "multiply"})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.stroke, kept.stroke_opacity, kept.stroke_blend), (None, Some(0.5), Some(BlendMode::Multiply)));
|
||||
let (_, outline, width) = redrawn_band(&mut s, id);
|
||||
assert_eq!((outline, width), (Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
|
||||
let band = series_parts(&s, id, 0)[0].clone();
|
||||
assert_eq!(band.appearance.stroke().unwrap().opacity, 0.3);
|
||||
let st = band.appearance.stroke().unwrap();
|
||||
assert_eq!((st.opacity, st.blend), (0.5, BlendMode::Multiply));
|
||||
}
|
||||
|
||||
/// A graphic style without a stroke applied to a marker whose stroke had paint replaces the stroke opacity and blend mode
|
||||
/// the series' Filled Lines band left: the band's outline is drawn opaque and Normal again. A marker that had no stroke
|
||||
/// before the style leaves them as they are.
|
||||
#[test]
|
||||
fn a_style_that_removes_a_marks_stroke_replaces_the_kept_stroke_opacity() {
|
||||
for stroked in [true, false] {
|
||||
let (mut s, id, band) = band_graph();
|
||||
let marker = series_parts(&s, id, 0)[1].id;
|
||||
if stroked {
|
||||
let row = top_row(&s, marker, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": row, "color": "#ff0000"})).unwrap();
|
||||
}
|
||||
let row = top_row(&s, band, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 40, "blend": "multiply"})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_opacity, Some(0.4));
|
||||
blue_style(&mut s, "Blue", false);
|
||||
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marker.0]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
let want = if stroked { (None, None) } else { (Some(0.4), Some(BlendMode::Multiply)) };
|
||||
assert_eq!((kept.stroke_opacity, kept.stroke_blend), want, "stroked {stroked}");
|
||||
redrawn_band(&mut s, id);
|
||||
let st = series_parts(&s, id, 0)[0].appearance.stroke().unwrap().clone();
|
||||
assert_eq!((Some(st.opacity).filter(|o| *o < 1.0), Some(st.blend).filter(|b| *b != BlendMode::Normal)), want, "stroked {stroked}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A Filled Lines band's stroke row removed with the Appearance panel or set to None with `paint.setStroke` leaves the
|
||||
@@ -1307,13 +1526,15 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() {
|
||||
s.execute("appearance.setItem", &json!({"ids": [line.id.0], "index": row, "color": "#0000ff"})).unwrap();
|
||||
assert!(spec(&s, id).series_paints[0].fill.is_none(), "a stroke edit on a line keeps no fill");
|
||||
|
||||
// A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it.
|
||||
// A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it, and
|
||||
// stores only the blend mode.
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
|
||||
let kept = spec(&s, id);
|
||||
let mut kept = spec(&s, id).series_paints;
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "multiply"})).unwrap();
|
||||
assert_eq!(spec(&s, id), kept);
|
||||
kept[0].fill_blend = Some(vectorcraft_color::BlendMode::Multiply);
|
||||
assert_eq!(spec(&s, id).series_paints, kept);
|
||||
}
|
||||
|
||||
/// A graphic style `name` made from a new rectangle with a blue fill at 50% opacity, and without a stroke row unless
|
||||
|
||||
@@ -330,7 +330,7 @@ fn raised_area(face: &PathData, i: usize, areas: &[Option<PathData>]) -> PathDat
|
||||
.filter(|a| a.bounds().is_some_and(|b| b.overlaps(near)))
|
||||
.map(|a| (a, FillRule::NonZero)),
|
||||
);
|
||||
let only = po::boolean_n(&paths, |inside| inside.first() == Some(&true) && inside.get(1) == Some(&true) && !inside.iter().skip(2).any(|b| *b));
|
||||
let only = po::boolean_n(&paths, |inside| inside == [0, 1]);
|
||||
if only.is_empty() {
|
||||
return face.clone();
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ pub(crate) fn whole_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result<
|
||||
/// Run a command's implementation without the enablement check or journaling.
|
||||
pub(crate) fn run_raw(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
let c = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
(c.run)(s, p)
|
||||
super::run_spec(s, c, p)
|
||||
}
|
||||
|
||||
/// Merge the undo entries pushed since the history had `from` entries into one labelled step.
|
||||
|
||||
@@ -251,7 +251,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Object Properties",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}",
|
||||
"{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
set_props
|
||||
),
|
||||
|
||||
@@ -65,7 +65,11 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) {
|
||||
// A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, so a value a mark
|
||||
// already has still replaces the one another mark left on the graph.
|
||||
let transparency = opacity.is_some() || blend.is_some();
|
||||
let stores = || transparency && crate::cmd::graph::series_object_opacity_differs(d, &ids);
|
||||
if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) && !stores() {
|
||||
// Hidden or locked objects can't stay selected.
|
||||
if hides {
|
||||
s.select(|d, sel| sel.deselect_uneditable(d))?;
|
||||
@@ -118,6 +122,9 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
}
|
||||
}
|
||||
if transparency {
|
||||
crate::cmd::graph::capture_series_object_opacity(d, &ids);
|
||||
}
|
||||
// Hidden or locked objects can't stay selected.
|
||||
if hides {
|
||||
sel.deselect_uneditable(d);
|
||||
|
||||
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Default Fill and Stroke",
|
||||
[],
|
||||
Some("D"),
|
||||
"{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
"{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
default_paint
|
||||
),
|
||||
@@ -73,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Transparency",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself)",
|
||||
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). On a path directly in a graph series, the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
transparency
|
||||
),
|
||||
@@ -855,7 +855,7 @@ fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.appearance.set_stroke(f);
|
||||
}
|
||||
// The swap writes the paint of the topmost fill and stroke, not their opacity.
|
||||
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true);
|
||||
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true, false);
|
||||
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept))
|
||||
})?;
|
||||
}
|
||||
@@ -884,7 +884,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => n.appearance = Appearance::default_art().into(),
|
||||
}
|
||||
}
|
||||
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false);
|
||||
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, false);
|
||||
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept))
|
||||
})?;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,7 +6,9 @@
|
||||
//! by `|`; a `?` marks an optional one, and a `:`, `=` or `(` starts what the entry says about it. An entry that starts
|
||||
//! with `…` stands for keys listed elsewhere: those of each command it names after `as` or `of`
|
||||
//! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't
|
||||
//! described key by key, so only the top level is checked.
|
||||
//! described key by key, so only the top level is checked, except in an export's options: those
|
||||
//! are checked against the options of the formats the command can write, in `marks`, `bleed` and
|
||||
//! the other objects too ([`check_export_options`]).
|
||||
//!
|
||||
//! [`Session::execute`] answers a key the description doesn't name with a parameter error
|
||||
//! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the
|
||||
@@ -500,9 +502,100 @@ pub const UNCHECKED: &[&str] = &[
|
||||
"webExport.settings",
|
||||
];
|
||||
|
||||
/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included, and
|
||||
/// [`check_effect_params`] for the effect `effect.apply` adds. A command the engine doesn't have
|
||||
/// passes: running it reports that.
|
||||
/// The object options of a PDF export, key by key: the Save PDF settings as a preset stores them,
|
||||
/// with the passwords it leaves out.
|
||||
fn pdf_option_tree() -> Value {
|
||||
let mut tree = serde_json::to_value(vectorcraft_pdf::PdfSettings::default()).unwrap_or_default();
|
||||
if let Some(security) = tree.get_mut("security").and_then(Value::as_object_mut) {
|
||||
security.extend(["openPassword", "permissionsPassword"].map(|k| (k.to_string(), Value::Null)));
|
||||
}
|
||||
tree
|
||||
}
|
||||
|
||||
/// `given`, the object at `within` (`marks.`), has only keys of `schema`, the objects inside it too.
|
||||
fn check_option_tree(id: &str, within: &str, given: &serde_json::Map<String, Value>, schema: &serde_json::Map<String, Value>) -> Result<()> {
|
||||
for (key, value) in given {
|
||||
match (schema.get(key), value) {
|
||||
(None, _) => {
|
||||
let keys: Vec<&str> = schema.keys().map(String::as_str).collect();
|
||||
let why = match nearest(key, &keys) {
|
||||
Some(near) => format!("did you mean `{within}{near}`?"),
|
||||
None => format!("`{}` takes: {}", within.trim_end_matches('.'), keys.join(", ")),
|
||||
};
|
||||
return Err(bad(id, format!("unknown parameter `{within}{key}`; {why}")));
|
||||
}
|
||||
(Some(Value::Object(inner)), Value::Object(value)) => check_option_tree(id, &format!("{within}{key}."), value, inner)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The options of an export command `id`. `document.export`, `document.exportSelection` and
|
||||
/// `document.serialize` take a key that some format reads (one object can carry several formats'
|
||||
/// options; `document.formats` lists them). `document.exportPdf`, `document.pdfSettings` and
|
||||
/// `pdf.preset.save` take a key that PDF reads, `document.exportEps` one that EPS reads and
|
||||
/// `document.exportDxf` one that DXF reads. Where a command takes `marks`, `bleed`, `flattener` or
|
||||
/// another object option of PDF or EPS, only the object's own keys pass. An SVG export's `svg`
|
||||
/// object is checked when it is read. Saved presets are not checked: one with a key that is no
|
||||
/// longer read keeps loading.
|
||||
fn check_export_options(id: &str, params: &Value) -> Result<()> {
|
||||
use super::fileio::{FORMATS, dxf, eps, pdf};
|
||||
let Value::Object(given) = params else { return Ok(()) };
|
||||
let mut keys: Vec<&str> = vec!["path", "format", "acknowledgeLoss"];
|
||||
let why_not = match id {
|
||||
"document.export" | "document.exportSelection" | "document.serialize" => {
|
||||
// `richBlack` is Appearance of Black's setting for a raster export, and `params` an
|
||||
// exporter plug-in's own.
|
||||
keys.extend(["includeArtboardFill", "selectedOnly", "richBlack", "params"]);
|
||||
keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name));
|
||||
"no format takes it"
|
||||
}
|
||||
"document.exportPdf" | "document.pdfSettings" | "pdf.preset.save" => {
|
||||
keys.push("includeArtboardFill");
|
||||
keys.extend(pdf::OPTIONS.iter().map(|o| o.name));
|
||||
match id {
|
||||
"document.pdfSettings" => keys.push("includeDocument"),
|
||||
"pdf.preset.save" => keys.extend(["name", "newName", "description"]),
|
||||
_ => {}
|
||||
}
|
||||
"PDF doesn't take it"
|
||||
}
|
||||
"document.exportEps" => {
|
||||
keys.push("includeArtboardFill");
|
||||
keys.extend(eps::OPTIONS.iter().map(|o| o.name));
|
||||
"EPS doesn't take it"
|
||||
}
|
||||
"document.exportDxf" => {
|
||||
keys.extend(dxf::OPTIONS.iter().map(|o| o.name));
|
||||
"DXF doesn't take it"
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let mut trees = pdf_option_tree();
|
||||
if let Some(tree) = trees.as_object_mut() {
|
||||
tree.insert("flattener".into(), serde_json::to_value(super::flatten::FlattenOptions::default()).unwrap_or_default());
|
||||
}
|
||||
for (key, value) in given {
|
||||
if !keys.contains(&key.as_str()) {
|
||||
return Err(bad(
|
||||
id,
|
||||
match nearest(key, &keys) {
|
||||
Some(near) => format!("unknown parameter `{key}`; did you mean `{near}`?"),
|
||||
None => format!("unknown parameter `{key}`; {why_not} (see document.formats)"),
|
||||
},
|
||||
));
|
||||
}
|
||||
if let (Some(Value::Object(schema)), Value::Object(value)) = (trees.get(key), value) {
|
||||
check_option_tree(id, &format!("{key}."), value, schema)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included,
|
||||
/// [`check_effect_params`] for the effect `effect.apply` adds and [`check_export_options`] for an
|
||||
/// export. A command the engine doesn't have passes: running it reports that.
|
||||
pub fn check_params(id: &str, params: &Value) -> Result<()> {
|
||||
check_params_in(id, params, false)
|
||||
}
|
||||
@@ -516,6 +609,7 @@ fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> {
|
||||
{
|
||||
check_effect_params(id, effect, given)?;
|
||||
}
|
||||
check_export_options(id, params)?;
|
||||
if UNCHECKED.contains(&id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::{BoolOp, PathfinderOp};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::expand::{fill_shape, reshaped, seeded};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, always, bad, bool_or, checked_ids_or_selection, cmd, has_doc, has_selected_path, has_selection,
|
||||
number_or, str_param, switch_or,
|
||||
@@ -891,24 +892,60 @@ pub(crate) fn outlined_stroke(
|
||||
|
||||
/// Path `l` with every stroke outlined, in appearance order: runs of fills stay on copies of
|
||||
/// the path, each stroke becomes its outline (or brush art), grouped when there are several.
|
||||
/// As on the canvas, the path's geometry effects ([`reshaped`]) shape the path first, and each
|
||||
/// stroke's own geometry effects then shape the path that stroke outlines; [`outlined_stroke`]
|
||||
/// gets the stroke without them. A fill with a random Transform effect paints the outline its
|
||||
/// geometry effects make of the path, on a copy of its own. A path whose Transform effect copies
|
||||
/// it with a gradient, or has Scale Strokes & Effects or Transform Patterns on, becomes the art the
|
||||
/// canvas draws for it, each copy with its own gradient, stroke width and pattern, and its strokes
|
||||
/// outlined before the art gets new ids. Random in a Transform effect takes its seed
|
||||
/// from the object's id, so these effects give the outline the values the canvas drew. When the
|
||||
/// geometry effects leave nothing to paint, an empty group takes the path's place.
|
||||
/// `None` when `l` has no stroke to outline.
|
||||
pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option<Node> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, is_geometry, reshape};
|
||||
if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) {
|
||||
return None;
|
||||
}
|
||||
if has_transform_art(l) {
|
||||
let mut art = reshape(Some(&*d), l, None)?;
|
||||
outline_member_strokes(d, brushes, &mut art);
|
||||
return Some(d.reid(&art));
|
||||
}
|
||||
let reshaped = reshaped(l);
|
||||
let l = reshaped.as_ref().unwrap_or(l);
|
||||
let (path, rule) = node_path(l)?;
|
||||
let ctx = GeomContext::of(l);
|
||||
// A fill belongs to the source path, whether open or closed. Converting its stroke
|
||||
// preserves that fill on a separate copy (including compound member geometry).
|
||||
let mut parts: Vec<Node> = vec![];
|
||||
let mut fills: Vec<AppearanceItem> = vec![];
|
||||
for item in &l.appearance.items {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => fills.push(item.clone()),
|
||||
AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => {
|
||||
let shaped = f.effects.iter().any(seeded).then(|| path.bounds().and_then(|b| fill_shape(l, item, &path, b))).flatten();
|
||||
let Some((shape, _, _, moved)) = shaped else {
|
||||
fills.push(item.clone());
|
||||
continue;
|
||||
};
|
||||
if !fills.is_empty() {
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut fills)));
|
||||
}
|
||||
let mut plain = moved;
|
||||
plain.effects_mut().retain(|e| !is_geometry(&e.id));
|
||||
parts.push(styled_copy(d, &shape, vec![plain]));
|
||||
}
|
||||
AppearanceItem::Stroke(st) if outlinable(st) => {
|
||||
if !fills.is_empty() {
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut fills)));
|
||||
}
|
||||
parts.extend(outlined_stroke(d, brushes, &path, rule, st));
|
||||
let mut plain = st.clone();
|
||||
plain.effects.retain(|e| !is_geometry(&e.id));
|
||||
let stroked = match path.bounds() {
|
||||
Some(b) if has_geometry(&st.effects) => apply_geometry_with(&st.effects, &path, b, &ctx.item(item)),
|
||||
_ => path.clone(),
|
||||
};
|
||||
parts.extend(outlined_stroke(d, brushes, &stroked, rule, &plain));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -916,9 +953,27 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
|
||||
if !fills.is_empty() {
|
||||
parts.push(styled_copy(d, l, fills));
|
||||
}
|
||||
let reshapes = reshaped.is_some()
|
||||
|| l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st) && has_geometry(&st.effects)));
|
||||
if parts.is_empty() && reshapes {
|
||||
// The geometry effects leave nothing to paint, as on the canvas: an empty group takes the path's place.
|
||||
parts.push(Node::group(d.alloc_id(), vec![]));
|
||||
}
|
||||
assemble(d, l, parts)
|
||||
}
|
||||
|
||||
/// Outline the strokes of every member of group `n`, however deep ([`outline_strokes`]).
|
||||
fn outline_member_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &mut Node) {
|
||||
let Some(children) = n.children_mut() else { return };
|
||||
for c in children.iter_mut() {
|
||||
if let Some(o) = outline_strokes(d, brushes, c) {
|
||||
*c = Arc::new(o);
|
||||
} else if c.children().is_some() {
|
||||
outline_member_strokes(d, brushes, Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain copy of path `l` (new ids, no transparency, name or mask) painted with `items`.
|
||||
/// The copy is shown and unlocked.
|
||||
pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec<AppearanceItem>) -> Node {
|
||||
|
||||
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save PDF Preset",
|
||||
[],
|
||||
None,
|
||||
"{name?: (default: a new \"PDF Preset N\"), newName?: rename it, description?, preset?: the preset to start from (default: the saved preset `name`, else VectorCraft Default), …options of document.exportPdf} create or change a saved preset (built-in presets are read-only; passwords are never stored) → {name, created, settings}",
|
||||
"{name?: (default: a new \"PDF Preset N\"), newName?: rename it, description?, preset?: the preset to start from (default: the saved preset `name`, else VectorCraft Default), …options of document.exportPdf} create or change a saved preset (built-in presets are read-only; passwords are never stored) → {name, created, settings}. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused",
|
||||
always,
|
||||
save
|
||||
),
|
||||
|
||||
+193
-11
@@ -20,6 +20,13 @@
|
||||
//! `doc.write` an output that changes the document is refused, and without `state` it neither
|
||||
//! reads nor writes the data it keeps with the document.
|
||||
//!
|
||||
//! A filter or a live effect may add menu commands of its own (its manifest's `commands`). Each
|
||||
//! is the command `plugin.<plug-in id>.<command id>` while the plug-in is installed
|
||||
//! ([`command_specs`], which [`super::find_command`] and the command listings read): under
|
||||
//! Object › Plug-ins › the plug-in's name (Effect › Plug-ins for a live effect's), journaled so
|
||||
//! actions record it, and run as a filter run of the plug-in ([`run_command`]) with the command's
|
||||
//! id in `_context`, its own checked parameters and the plug-in's permissions, as one undo step.
|
||||
//!
|
||||
//! A plug-in of API level 2 that lists `progress` may import the host's `vc_progress`,
|
||||
//! `vc_cancelled` and `vc_log` ([`vectorcraft_plugins::host`]): run inside a
|
||||
//! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its
|
||||
@@ -29,11 +36,11 @@
|
||||
//! `plugin.clearCache` empties the cache, `plugin.list` and `plugin.info` give its size
|
||||
//! (`cacheBytes`), replacing the plug-in keeps both and `plugin.remove` deletes them.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{Effect, Node};
|
||||
use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registry, safe_mode};
|
||||
use vectorcraft_plugins::{Budget, Command, Kind, Plugin, effect, export, objects, registry, safe_mode};
|
||||
|
||||
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or};
|
||||
|
||||
@@ -67,6 +74,18 @@ pub fn describe(p: &Plugin) -> Value {
|
||||
if m.budget == Budget::Heavy {
|
||||
v["budget"] = json!("heavy");
|
||||
}
|
||||
if !m.commands.is_empty() {
|
||||
let commands: Vec<Value> = m
|
||||
.commands
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let run = m.for_command(c);
|
||||
json!({"id": c.id, "command": command_id(&m.id, &c.id), "label": c.shown_label(), "params": run.params_doc(),
|
||||
"paramsSchema": serde_json::to_value(&run).ok().and_then(|v| v.get("params").cloned())})
|
||||
})
|
||||
.collect();
|
||||
v["commands"] = json!(commands);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -97,6 +116,8 @@ fn plugin_by_id(cmd: &str, p: &Value) -> Result<Arc<Plugin>> {
|
||||
/// like any other command.
|
||||
pub struct FilterRun {
|
||||
plugin: Arc<Plugin>,
|
||||
/// The plug-in command it runs (`plugin.<id>.<command>`), if it isn't `plugin.run`'s run.
|
||||
command: Option<Command>,
|
||||
input: Vec<u8>,
|
||||
params: Vec<u8>,
|
||||
/// The objects the plug-in is given, and the container new objects go in.
|
||||
@@ -138,13 +159,23 @@ impl FilterRun {
|
||||
));
|
||||
}
|
||||
}
|
||||
// Bad parameters fail before anything runs.
|
||||
let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?);
|
||||
Self::prepare_with(s, plugin, None, C, p)
|
||||
}
|
||||
|
||||
/// The run of `plugin`, or of its `command`, that command `c`'s params `p` ask for, on the
|
||||
/// active document. Bad parameters fail before anything runs.
|
||||
fn prepare_with(s: &Session, plugin: Arc<Plugin>, command: Option<Command>, c: &str, p: &Value) -> Result<FilterRun> {
|
||||
let m = plugin.manifest();
|
||||
let resolved = match &command {
|
||||
Some(cmd) => m.for_command(cmd).resolve_params(&plugin_params(p)),
|
||||
None => m.resolve_params(&plugin_params(p)),
|
||||
};
|
||||
let mut params = Value::Object(resolved.map_err(|e| bad(c, e.to_string()))?);
|
||||
let may = m.permissions();
|
||||
let st = s.doc()?;
|
||||
// While an object of a dynamic symbol instance's art is selected, the selection is refused,
|
||||
// as `selected_roots` refuses it.
|
||||
let selected = match ids_param(p, "ids", C)? {
|
||||
let selected = match ids_param(p, "ids", c)? {
|
||||
Some(ids) => ids,
|
||||
None => {
|
||||
super::edit::refuse_symbol_part(s)?;
|
||||
@@ -160,10 +191,15 @@ impl FilterRun {
|
||||
// What the plug-in keeps with the document, as it last wrote it.
|
||||
let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null);
|
||||
params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data});
|
||||
// A command run says which command it is.
|
||||
if let Some(cmd) = &command {
|
||||
params["_context"]["mode"] = json!("command");
|
||||
params["_context"]["command"] = json!(cmd.id);
|
||||
}
|
||||
let input = objects::encode_input(&st.doc, &inputs);
|
||||
let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?;
|
||||
let params = serde_json::to_vec(¶ms).map_err(|e| bad(c, e.to_string()))?;
|
||||
let document = (st.uid, st.revision);
|
||||
Ok(FilterRun { plugin, input, params, inputs, parent, document })
|
||||
Ok(FilterRun { plugin, command, input, params, inputs, parent, document })
|
||||
}
|
||||
|
||||
/// The plug-in, for its name and budget.
|
||||
@@ -214,7 +250,9 @@ impl FilterRun {
|
||||
}
|
||||
let may_write = may.contains(&Permission::DocWrite);
|
||||
let (inputs, parent) = (self.inputs, self.parent);
|
||||
let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| {
|
||||
// A command's undo step is named after the command.
|
||||
let label = self.command.as_ref().map_or_else(|| m.name.trim_end_matches('…').to_string(), Command::shown_label);
|
||||
let ids = s.edit(&label, |d, sel| {
|
||||
let before = (!may_write).then(|| d.layers.clone());
|
||||
let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?;
|
||||
if before.is_some_and(|b| b != d.layers) {
|
||||
@@ -226,7 +264,11 @@ impl FilterRun {
|
||||
sel.set(ids.iter().copied().filter(|&id| d.node(id).is_some_and(objects::selectable)));
|
||||
Ok(ids)
|
||||
})?;
|
||||
Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
let mut out = json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
|
||||
if let Some(cmd) = &self.command {
|
||||
out["command"] = json!(cmd.id);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +298,146 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
r.finish(s, &output)
|
||||
}
|
||||
|
||||
/// The command id of plug-in `plugin`'s command `command`: `plugin.<plugin>.<command>`.
|
||||
pub fn command_id(plugin: &str, command: &str) -> String {
|
||||
format!("{}{plugin}.{command}", vectorcraft_plugins::PREFIX)
|
||||
}
|
||||
|
||||
/// Is `id` shaped as a plug-in command's id (`plugin.<plug-in id>.<command id>`)? This module's
|
||||
/// own `plugin.*` commands have no dot after the prefix, and a command id has none.
|
||||
pub fn is_command_id(id: &str) -> bool {
|
||||
id.strip_prefix(vectorcraft_plugins::PREFIX).is_some_and(|rest| rest.contains('.'))
|
||||
}
|
||||
|
||||
/// The installed plug-in and its command that command id `id` names.
|
||||
pub fn command_of(id: &str) -> Option<(Arc<Plugin>, Command)> {
|
||||
let (plugin, command) = id.strip_prefix(vectorcraft_plugins::PREFIX)?.rsplit_once('.')?;
|
||||
let p = registry::get(plugin)?;
|
||||
let c = p.manifest().command(command)?.clone();
|
||||
Some((p, c))
|
||||
}
|
||||
|
||||
/// The plug-in commands of the installed plug-ins, the registry revision they were listed at, and
|
||||
/// a count that changes whenever the list does ([`commands_generation`]).
|
||||
struct Listed {
|
||||
revision: Option<u64>,
|
||||
generation: u64,
|
||||
specs: Vec<&'static CommandSpec>,
|
||||
}
|
||||
|
||||
static LISTED: RwLock<Listed> = RwLock::new(Listed { revision: None, generation: 0, specs: Vec::new() });
|
||||
|
||||
/// Every command spec made for a plug-in command, made once: specs hold static strings, so memory
|
||||
/// stays bounded by the plug-in commands ever installed.
|
||||
static MADE: Mutex<Vec<&'static CommandSpec>> = Mutex::new(Vec::new());
|
||||
|
||||
/// The commands the installed plug-ins add, in plug-in id order and then in each manifest's order.
|
||||
pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
let revision = registry::revision();
|
||||
if let Ok(l) = LISTED.read()
|
||||
&& l.revision == Some(revision)
|
||||
{
|
||||
return l.specs.clone();
|
||||
}
|
||||
let specs: Vec<&'static CommandSpec> =
|
||||
registry::list().iter().flat_map(|p| p.manifest().commands.iter().map(|c| spec_of(p, c)).collect::<Vec<_>>()).collect();
|
||||
// When a plug-in was installed or removed during the listing, the list may not match
|
||||
// `revision`. It is returned without being cached, and the next call lists again.
|
||||
if registry::revision() != revision {
|
||||
return specs;
|
||||
}
|
||||
let mut l = LISTED.write().unwrap_or_else(|e| e.into_inner());
|
||||
if l.specs.len() != specs.len() || l.specs.iter().zip(&specs).any(|(a, b)| !std::ptr::eq(*a, *b)) {
|
||||
l.generation += 1;
|
||||
}
|
||||
l.revision = Some(revision);
|
||||
l.specs.clone_from(&specs);
|
||||
specs
|
||||
}
|
||||
|
||||
/// Changes whenever the plug-in commands [`command_specs`] lists change, for caches built from
|
||||
/// them (the keyboard shortcuts' catalogue).
|
||||
pub fn commands_generation() -> u64 {
|
||||
command_specs();
|
||||
LISTED.read().map(|l| l.generation).unwrap_or_else(|e| e.into_inner().generation)
|
||||
}
|
||||
|
||||
/// The spec of installed plug-in command `id`.
|
||||
pub fn find_spec(id: &str) -> Option<&'static CommandSpec> {
|
||||
if !is_command_id(id) {
|
||||
return None;
|
||||
}
|
||||
command_specs().into_iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// The command spec of plug-in `p`'s command `c`: its id, its label as menus show it (escaped and
|
||||
/// capped), its menu (Object › Plug-ins › the plug-in's name, or Effect › Plug-ins › it for a live
|
||||
/// effect: never a menu the manifest names) and its params description.
|
||||
fn spec_of(p: &Plugin, c: &Command) -> &'static CommandSpec {
|
||||
let m = p.manifest();
|
||||
let id = command_id(&m.id, &c.id);
|
||||
let label = c.shown_label();
|
||||
let top = if m.kind == Kind::Effect { "Effect" } else { "Object" };
|
||||
let name = permissions::display_text(m.name.trim(), permissions::MAX_SHOWN_NAME);
|
||||
let mut fields =
|
||||
String::from("ids?: [object id] (left out or null: the selection), params?: {…} (its parameters; or give them as top-level keys)");
|
||||
let doc = m.for_command(c).params_doc();
|
||||
if let Some(own) = doc.strip_prefix('{').and_then(|d| d.strip_suffix('}')).filter(|d| !d.is_empty()) {
|
||||
fields.push_str(", ");
|
||||
fields.push_str(own);
|
||||
}
|
||||
let params = format!(
|
||||
"{{{fields}}} run “{label}”, a command of plug-in {}, on the selected paths and compound paths as plugin.run runs a filter, with _context.command {:?}: one undo step → {{plugin, command, ids}}",
|
||||
m.id, c.id
|
||||
);
|
||||
let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let same = |s: &&&'static CommandSpec| s.id == id && s.label == label && s.menu == [top, "Plug-ins", name.as_str()] && s.params == params;
|
||||
if let Some(s) = made.iter().find(same) {
|
||||
return s;
|
||||
}
|
||||
let menu: &'static [&'static str] = Box::leak(vec![top, "Plug-ins", registry::intern(&name)].into_boxed_slice());
|
||||
let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
|
||||
id: registry::intern(&id),
|
||||
label: registry::intern(&label),
|
||||
menu,
|
||||
shortcut: None,
|
||||
params: registry::intern(¶ms),
|
||||
enabled: has_doc,
|
||||
run: unrouted,
|
||||
journal: true,
|
||||
}));
|
||||
made.push(spec);
|
||||
spec
|
||||
}
|
||||
|
||||
/// A plug-in command's `run`: plug-in commands run through [`super::run_spec`], which passes
|
||||
/// [`run_command`] the command id.
|
||||
fn unrouted(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Err(EngineError::Internal { cmd: "plugin".into(), msg: "a plug-in command runs through Session::execute".into() })
|
||||
}
|
||||
|
||||
/// Runs plug-in command `id` (`plugin.<plug-in id>.<command id>`): a filter run of the plug-in on
|
||||
/// `ids` (else the selection) with the command's own parameters, checked before it runs, and
|
||||
/// `_context.command`, under the plug-in's permissions and budget, applied as one undo step named
|
||||
/// after the command → `{plugin, command, ids}`. A run that fails (a trap, an exhausted budget, an
|
||||
/// output it may not make) leaves the document as it was, and the error names the plug-in and the
|
||||
/// command.
|
||||
pub(crate) fn run_command(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
// An output handed in is for `plugin.run` alone.
|
||||
s.plugin_output = None;
|
||||
let (plugin, command) = command_of(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
let failed = {
|
||||
let (name, label) = (permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME), command.shown_label());
|
||||
move |e: EngineError| match e {
|
||||
EngineError::Cancelled => EngineError::Cancelled,
|
||||
e => EngineError::Other(format!("“{name}” › {label}: {e}")),
|
||||
}
|
||||
};
|
||||
let r = FilterRun::prepare_with(s, plugin, Some(command), id, p)?;
|
||||
let output = r.run().map_err(&failed)?;
|
||||
r.finish(s, &output).map_err(failed)
|
||||
}
|
||||
|
||||
/// Admits the effect records a filter returned for object `n` (written, its effects still its own)
|
||||
/// as `effect.apply` would apply them: each new record a known effect (one of ours, or an installed
|
||||
/// live-effect plug-in) with parameters it takes, filled in to their defaults. A record the object
|
||||
@@ -669,7 +851,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"List Plug-ins",
|
||||
[],
|
||||
None,
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
@@ -687,7 +869,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Run Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
|
||||
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
|
||||
has_doc,
|
||||
run
|
||||
),
|
||||
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Graphic Style",
|
||||
["Window", "Graphic Styles"],
|
||||
None,
|
||||
"{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows; exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}",
|
||||
"{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}",
|
||||
has_doc,
|
||||
style_apply
|
||||
),
|
||||
@@ -67,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Redefine Graphic Style",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {name}",
|
||||
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {name}",
|
||||
has_doc,
|
||||
style_redefine
|
||||
),
|
||||
@@ -349,14 +349,14 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The graph stores the rows of the kinds the style shows, now on top of each stack.
|
||||
let shows = |kind: bool| g.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible());
|
||||
let kinds = (shows(true), shows(false));
|
||||
super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false);
|
||||
super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false, false);
|
||||
} else {
|
||||
d.graphic_style_id(i);
|
||||
let g = d.graphic_styles[i].clone();
|
||||
for id in &ids {
|
||||
apply_style(d, *id, &g, chars);
|
||||
}
|
||||
super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false);
|
||||
super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -476,7 +476,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for nid in &follow {
|
||||
apply_style(d, *nid, &g, chars);
|
||||
}
|
||||
super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false);
|
||||
super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false, true);
|
||||
set_link(d, &[src], Some(id));
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -1604,9 +1604,11 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
anti_alias: Default::default(),
|
||||
system_layout: false,
|
||||
fractional_widths: true,
|
||||
list_count: 0,
|
||||
list_level_counts: Vec::new(),
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
@@ -1892,9 +1894,11 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
anti_alias: Default::default(),
|
||||
system_layout: t.system_layout,
|
||||
fractional_widths: t.fractional_widths,
|
||||
list_count: 0,
|
||||
list_level_counts: Vec::new(),
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -195,6 +195,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
|| optical != t.optical_margin
|
||||
|| vertical != t.vertical
|
||||
|| part.list_count != t.list_count
|
||||
|| part.list_level_counts != t.list_level_counts
|
||||
|| continues != t.first_para_continues
|
||||
{
|
||||
t.runs = next.runs;
|
||||
@@ -203,6 +204,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
t.optical_margin = optical;
|
||||
t.vertical = vertical;
|
||||
t.list_count = part.list_count;
|
||||
t.list_level_counts = part.list_level_counts;
|
||||
t.first_para_continues = continues;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
@@ -216,14 +218,15 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
}
|
||||
|
||||
/// Type that is not a frame of a thread is a story of its own: its numbered list paragraphs count
|
||||
/// from 1. Each such type object with another [`TextObject::list_count`] (a copy of a frame, a
|
||||
/// frame taken out of its thread, a frame converted to point type, which [`reflow`] takes out of
|
||||
/// its thread) gets 0 and is laid out again.
|
||||
/// from 1. Each such type object with another [`TextObject::list_count`] or
|
||||
/// [`TextObject::list_level_counts`] (a copy of a frame, a frame taken out of its thread, a frame
|
||||
/// converted to point type, which [`reflow`] takes out of its thread) gets 0 at every level and is
|
||||
/// laid out again.
|
||||
pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) {
|
||||
let mut stale = vec![];
|
||||
doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.list_count != 0
|
||||
&& (t.list_count != 0 || !t.list_level_counts.is_empty())
|
||||
{
|
||||
stale.push(n.id);
|
||||
}
|
||||
@@ -240,6 +243,7 @@ pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) {
|
||||
for id in stale {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.list_count = 0;
|
||||
t.list_level_counts.clear();
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
@@ -444,13 +448,13 @@ fn cut_to_shown(n: &mut Node, ids: &[NodeId]) {
|
||||
{
|
||||
// Split as `take_overflow` splits it: the first part is what the frame shows.
|
||||
let from: &TextObject = t;
|
||||
let parts = vectorcraft_text::thread::distribute_continuing(
|
||||
let parts = vectorcraft_text::thread::distribute_continuing_levels(
|
||||
vectorcraft_text::FontDb::global(),
|
||||
&[from, from],
|
||||
&from.runs,
|
||||
&from.paragraph_styles(),
|
||||
from.first_para_continues,
|
||||
from.list_count,
|
||||
from.list_counts(),
|
||||
);
|
||||
if let (Some(head), Some(_)) = (parts.first(), parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty()))) {
|
||||
t.runs = head.runs.clone();
|
||||
@@ -485,13 +489,13 @@ fn take_overflow(d: &mut Document, to: NodeId, from: &TextObject) {
|
||||
// `from`'s text split as if it flowed on into a second frame: the second part is what `from`
|
||||
// has no room for. Its share is measured as it is laid out, continuing a paragraph or not and
|
||||
// with its numbered list paragraphs counted on from the frames before it.
|
||||
let parts = vectorcraft_text::thread::distribute_continuing(
|
||||
let parts = vectorcraft_text::thread::distribute_continuing_levels(
|
||||
db,
|
||||
&[from, from],
|
||||
&from.runs,
|
||||
&from.paragraph_styles(),
|
||||
from.first_para_continues,
|
||||
from.list_count,
|
||||
from.list_counts(),
|
||||
);
|
||||
let Some(rest) = parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty())) else { return };
|
||||
let mut tail = from.clone();
|
||||
|
||||
@@ -981,6 +981,74 @@ fn outlining_a_numbered_last_frame_keeps_only_the_text_it_has_no_room_for() {
|
||||
assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]));
|
||||
}
|
||||
|
||||
/// As above for a list at level 2: the last frame's numbered paragraphs are counted on from the
|
||||
/// count the frames before it reached at that level.
|
||||
#[test]
|
||||
fn outlining_a_last_frame_numbered_at_a_deeper_level_keeps_only_the_text_it_has_no_room_for() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect();
|
||||
let story = format!("{items}{}", TEXT.repeat(4));
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap());
|
||||
let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap());
|
||||
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14})).unwrap();
|
||||
let shown = [a, c].map(|id| text(&s, id));
|
||||
assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{shown:?}");
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") };
|
||||
assert_eq!((t.list_count, t.list_level_counts.as_slice()), (0, &[7][..]));
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
assert_eq!(laid.markers.first().map(|m| m.text.as_str()), Some("VIII."));
|
||||
assert!(laid.overflow, "the last frame has more text than it shows");
|
||||
let end = laid.lines.last().map_or(0, |l| l.end);
|
||||
let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t });
|
||||
assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "counted from 1, the frame would show other text");
|
||||
let hidden = shown[1][end..].trim_start_matches(' ');
|
||||
s.execute("select.set", &json!({"ids": [c]})).unwrap();
|
||||
s.execute("type.createOutlines", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]));
|
||||
}
|
||||
|
||||
/// New Symbol and Redefine Symbol on a thread's last frame numbered at level 2: the symbol's
|
||||
/// art holds the text the frame shows, measured with the count the frames before it reached at
|
||||
/// that level, and the frame before it takes the rest.
|
||||
#[test]
|
||||
fn a_symbol_of_a_last_frame_numbered_at_a_deeper_level_holds_the_text_it_shows() {
|
||||
for command in ["symbol.new", "symbol.update"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect();
|
||||
let story = format!("{items}{}", TEXT.repeat(4));
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap());
|
||||
let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap());
|
||||
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14}))
|
||||
.unwrap();
|
||||
let shown = [a, c].map(|id| text(&s, id));
|
||||
assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{command}: {shown:?}");
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") };
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
assert!(laid.overflow, "{command}: the last frame has more text than it shows");
|
||||
let end = laid.lines.last().map_or(0, |l| l.end);
|
||||
let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t });
|
||||
assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "{command}: counted from 1, the frame would show other text");
|
||||
let hidden = shown[1][end..].trim_start_matches(' ');
|
||||
let name = if command == "symbol.update" {
|
||||
let mark = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [mark]})).unwrap();
|
||||
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
|
||||
"Mark"
|
||||
} else {
|
||||
"Story"
|
||||
};
|
||||
s.execute("select.set", &json!({"ids": [c]})).unwrap();
|
||||
s.execute(command, &json!({"name": name})).unwrap();
|
||||
let held = symbol_texts(&s, name);
|
||||
assert_eq!(held.iter().map(|t| t.trim_end()).collect::<Vec<_>>(), [shown[1][..end].trim_end()], "{command}: the symbol's art");
|
||||
assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]), "{command}: the frame before it takes the rest");
|
||||
}
|
||||
}
|
||||
|
||||
/// A story of one-line paragraphs that each start with a quotation mark, threaded from area
|
||||
/// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for.
|
||||
fn quoted_thread() -> (Session, u64, u64) {
|
||||
@@ -1066,6 +1134,65 @@ fn a_thread_takes_its_first_frames_optical_margin_alignment() {
|
||||
assert_eq!([a, b, c].map(|id| optical(&s, id).0), [true; 3]);
|
||||
}
|
||||
|
||||
/// The anti-aliasing method (`text.setFormat {antiAlias}`) belongs to each frame and is saved
|
||||
/// only when not Sharp. A range sets its frame in one undo step. Threading and opening preserve
|
||||
/// different methods on the frames of a thread.
|
||||
#[test]
|
||||
fn the_anti_aliasing_method_is_set_for_each_frame_of_a_thread() {
|
||||
use vectorcraft_doc::text::TypeAntiAlias::{self, Crisp, None as Hard, Sharp, Strong};
|
||||
let method = |s: &Session, id: u64| text_of(&s.doc().unwrap().doc, NodeId(id)).map(|t| t.anti_alias);
|
||||
let saved = |s: &Session, id: u64| serde_json::to_value(text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap()).unwrap().get("antiAlias").cloned();
|
||||
let (mut s, a, b) = quoted_thread();
|
||||
assert_eq!((method(&s, a), method(&s, b)), (Some(Sharp), Some(Sharp)));
|
||||
assert_eq!(saved(&s, a), None, "Sharp is not saved");
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
let err = s.execute("text.setFormat", &json!({"antiAlias": "smooth"})).unwrap_err().to_string();
|
||||
assert!(err.contains("`antiAlias` must be"), "{err}");
|
||||
s.execute("text.setFormat", &json!({"antiAlias": "crisp"})).unwrap();
|
||||
assert_eq!((method(&s, a), method(&s, b)), (Some(Sharp), Some(Crisp)), "only the selected frame takes it");
|
||||
assert_eq!(saved(&s, b), Some(json!("crisp")));
|
||||
// A range of characters sets its whole frame.
|
||||
s.execute("text.setFormat", &json!({"ids": [a], "start": 0, "end": 1, "antiAlias": "none"})).unwrap();
|
||||
assert_eq!((method(&s, a), method(&s, b)), (Some(Hard), Some(Crisp)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((method(&s, a), method(&s, b)), (Some(Sharp), Some(Crisp)), "one undo step");
|
||||
// A new frame does not change the methods of the existing frames.
|
||||
s.execute("text.setFormat", &json!({"ids": [b], "antiAlias": "strong"})).unwrap();
|
||||
let c = new_id(s.execute("text.thread.add", &json!({"from": b, "at": [400, 10]})).unwrap());
|
||||
assert_eq!([a, b, c].map(|id| method(&s, id)), [Some(Sharp), Some(Strong), Some(Strong)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Threading existing frames with different methods preserves both.
|
||||
let (mut joined, first_id, second_id) = quoted_thread();
|
||||
joined.execute("text.thread.remove", &json!({})).unwrap();
|
||||
joined.execute("text.setFormat", &json!({"ids": [first_id], "antiAlias": "crisp"})).unwrap();
|
||||
joined.execute("text.setFormat", &json!({"ids": [second_id], "antiAlias": "strong"})).unwrap();
|
||||
joined.execute("select.set", &json!({"ids": [first_id, second_id]})).unwrap();
|
||||
joined.execute("text.thread.create", &json!({})).unwrap();
|
||||
assert_eq!((method(&joined, first_id), method(&joined, second_id)), (Some(Crisp), Some(Strong)));
|
||||
joined.execute("text.editRange", &json!({"id": first_id, "start": 0, "end": 0, "insert": "New "})).unwrap();
|
||||
assert_eq!((method(&joined, first_id), method(&joined, second_id)), (Some(Crisp), Some(Strong)), "reflow keeps both methods");
|
||||
joined.execute("text.thread.remove", &json!({})).unwrap();
|
||||
assert_eq!((method(&joined, first_id), method(&joined, second_id)), (Some(Crisp), Some(Strong)), "unthreading keeps both methods");
|
||||
let first: [TypeAntiAlias; 2] = [Crisp, Hard];
|
||||
for (one, name) in [(first, "old.vectorcraft"), ([Hard, Crisp], "old.vectorcraft"), (first, "old.eps"), ([Hard, Crisp], "old.eps")] {
|
||||
for (id, m) in [(a, one[0]), (b, one[1])] {
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
|
||||
t.anti_alias = m;
|
||||
}
|
||||
let bytes = if name.ends_with(".eps") {
|
||||
let r = s.execute("document.exportEps", &json!({})).unwrap();
|
||||
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
|
||||
} else {
|
||||
vectorcraft_format::save(&s.doc().unwrap().doc, false)
|
||||
};
|
||||
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
|
||||
assert_eq!(r["restored"], name.ends_with(".eps"), "{r}");
|
||||
assert_eq!(threads(&s), [[a, b]], "{name}");
|
||||
assert_eq!((method(&s, a), method(&s, b)), (Some(one[0]), Some(one[1])), "{name}, frame methods");
|
||||
}
|
||||
}
|
||||
|
||||
/// A file saved with the frames of one thread set differently opens with the first frame's
|
||||
/// setting in every frame: a native file, and an EPS file restored from the document it carries.
|
||||
#[test]
|
||||
@@ -1325,6 +1452,41 @@ fn numbered_list_paragraphs_count_on_through_a_thread() {
|
||||
assert_eq!(numbers(&s, b), all(1), "the frame left holds the story");
|
||||
}
|
||||
|
||||
/// List levels in a thread: a deeper level's count runs on into the next frame, which saves
|
||||
/// it (`listLevelCounts`), and a pasted copy of that frame counts every level from 1.
|
||||
#[test]
|
||||
fn list_levels_count_on_through_a_thread() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
|
||||
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap();
|
||||
let frame = |s: &Session, id: u64| text_of(&s.doc().unwrap().doc, NodeId(id)).cloned().unwrap();
|
||||
let numbers =
|
||||
|t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>();
|
||||
let first = numbers(&frame(&s, a));
|
||||
assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}");
|
||||
let expected: Vec<String> = std::iter::once("1.".to_string()).chain((1..=9).map(|n| format!("{n}."))).collect();
|
||||
assert_eq!([first.clone(), numbers(&frame(&s, b))].concat(), expected);
|
||||
let b_counts = frame(&s, b).list_counts();
|
||||
assert_eq!(b_counts.parts(), (1, vec![first.len() as u32 - 1]));
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!((back.list_counts(), numbers(back)), (b_counts, numbers(&frame(&s, b))), "reopened");
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.objects.first().copied().unwrap();
|
||||
assert_ne!(copy, NodeId(b));
|
||||
let pasted = frame(&s, copy.0);
|
||||
assert_eq!((pasted.list_count, pasted.list_level_counts.as_slice()), (0, &[][..]));
|
||||
assert_eq!(numbers(&pasted).first().map(String::as_str), Some("1."), "a pasted copy counts from 1");
|
||||
}
|
||||
|
||||
/// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art
|
||||
/// looks as it did; Break Link puts the frames in no thread, and each counts from 1.
|
||||
#[test]
|
||||
|
||||
@@ -1279,7 +1279,7 @@ fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> {
|
||||
let cell = lay.frames.first()?;
|
||||
let line = lay.lines.first()?;
|
||||
let para = t.para_at(0);
|
||||
let avail = cell.width() - 2.0 * t.area.inset - para.left_indent - para.right_indent;
|
||||
let avail = cell.width() - 2.0 * t.area.inset - para.laid_out_left_indent() - para.right_indent;
|
||||
Some((line.x1 - line.x0, avail, line.end))
|
||||
}
|
||||
|
||||
|
||||
@@ -437,7 +437,7 @@ fn bulleted_lists_are_set_per_paragraph_saved_and_continued() {
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square"}));
|
||||
// A saved list reads the fields it leaves out as their defaults and ignores those it doesn't know.
|
||||
let read = |v: serde_json::Value| serde_json::from_value::<vectorcraft_doc::ParaStyle>(json!({ "list": v })).unwrap().list;
|
||||
assert_eq!((read(json!({})), read(json!({"bullet": "dash", "level": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash))));
|
||||
assert_eq!((read(json!({})), read(json!({"bullet": "dash", "restart": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash))));
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (square, dot, None));
|
||||
@@ -500,7 +500,7 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() {
|
||||
s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap();
|
||||
assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square)));
|
||||
s.execute("text.setFormat", &json!({"number": "lowerLetters", "start": 0, "end": 0})).unwrap();
|
||||
let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters) });
|
||||
let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters), ..ParaList::default() });
|
||||
assert_eq!(list(&s, 0), letters);
|
||||
assert_eq!(numbers(&s), ["a.", "2.", "3."]);
|
||||
assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0));
|
||||
@@ -536,6 +536,70 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() {
|
||||
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None));
|
||||
}
|
||||
|
||||
/// Bullets and Numbering… › Alignment: `text.setFormat {listAlign}` sets where the marker
|
||||
/// stands in the list paragraphs it touches, in one undo step, and refuses unknown values;
|
||||
/// a paragraph in no list gets no list from it. A list keeps its alignment when it changes
|
||||
/// between bullets and numbers, and a call that puts a paragraph in a list gives it the
|
||||
/// alignment too. The alignment is saved only when it is right, opens with the file, and
|
||||
/// paragraph styles carry it both ways.
|
||||
#[test]
|
||||
fn list_markers_are_aligned_per_paragraph_saved_and_styled() {
|
||||
use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let list = |s: &Session, i: usize| text(s).para_at(i).list;
|
||||
let align = |s: &Session| (0..3).map(|i| list(s, i).map(|l| l.align)).collect::<Vec<_>>();
|
||||
let (left, right) = (Some(ListAlign::Left), Some(ListAlign::Right));
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 4})).unwrap();
|
||||
assert_eq!(align(&s), [left, left, None]);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("text.setFormat", &json!({"listAlign": "right", "start": 0, "end": 0})).unwrap();
|
||||
assert_eq!(align(&s), [right, left, None]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(align(&s), [left, left, None]);
|
||||
s.execute("text.setFormat", &json!({"listAlign": "right"})).unwrap();
|
||||
assert_eq!(align(&s), [right, right, None], "the paragraph in no list stays out of one");
|
||||
for bad in [json!({"listAlign": "center"}), json!({"listAlign": "Right"}), json!({"listAlign": 1}), json!({"listAlign": true})] {
|
||||
assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(align(&s), [right, right, None], "an unknown value changes nothing");
|
||||
// Bullets and numbers keep it; a call that makes a list sets it there too.
|
||||
s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap();
|
||||
assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square).aligned(ListAlign::Right)));
|
||||
s.execute("text.setFormat", &json!({"list": "bullet", "listAlign": "right", "start": 8, "end": 8})).unwrap();
|
||||
assert_eq!(list(&s, 2), Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right)));
|
||||
s.execute("text.setFormat", &json!({"listAlign": "left", "start": 8, "end": 8})).unwrap();
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "align": "right"}));
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(2)).unwrap()["list"], json!({"bullet": "dot"}));
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!((back.para_at(0).list, back.para_at(1).list), (list(&s, 0), Some(ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right))));
|
||||
// A list turned off and on again starts aligned left.
|
||||
s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap();
|
||||
assert_eq!(align(&s)[1], left);
|
||||
// A style captured from a list aligned right gives other lists that alignment, and one
|
||||
// captured from a list aligned left takes it back.
|
||||
let other = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Other"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [other], "list": "number"})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Aligned left", "id": other})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Aligned right", "id": id})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Aligned right", "id": other})).unwrap();
|
||||
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(other)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!(t.para_at(0).list.map(|l| l.align), right);
|
||||
s.execute("paraStyle.apply", &json!({"name": "Aligned left", "id": id})).unwrap();
|
||||
assert_eq!(align(&s), [left, left, left]);
|
||||
}
|
||||
|
||||
/// The text object `id`.
|
||||
fn text_at(s: &Session, id: u64) -> vectorcraft_doc::text::TextObject {
|
||||
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
@@ -724,6 +788,56 @@ fn point_and_vertical_type_convert(s: &mut Session) {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
}
|
||||
|
||||
/// Convert to Text of a list whose markers are aligned right writes the same characters as for
|
||||
/// a list aligned left, the marker and then a tab, and gives the paragraphs the same tab stops:
|
||||
/// the text after the tab stays where it was, and the marker moves to the first line's start,
|
||||
/// where a list aligned left draws it. Numbers narrower than the hanging indent (their tab went
|
||||
/// to the left indent) and wider (their tab went to the next default stop), and bullets.
|
||||
#[test]
|
||||
fn convert_to_text_of_markers_aligned_right_keeps_the_text_in_place() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let text_glyphs = |s: &Session, id: u64| {
|
||||
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let milli = |v: f64| (v * 1000.0).round() as i64;
|
||||
lay.glyphs
|
||||
.iter()
|
||||
.filter(|g| g.len > 0 && !g.outline.elements().is_empty())
|
||||
.map(|g| (g.gid, milli(g.origin.x), milli(g.origin.y)))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.plain_text(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
|
||||
for (list, converted) in [
|
||||
("number", (1..=10).map(|i| format!("{i}.\tItem {i}")).collect::<Vec<_>>().join("\n")),
|
||||
("bullet", (1..=10).map(|i| format!("\u{2022}\tItem {i}")).collect::<Vec<_>>().join("\n")),
|
||||
] {
|
||||
let make = |s: &mut Session, align: &str| {
|
||||
let id = s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap()
|
||||
["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "list": list, "listAlign": align})).unwrap();
|
||||
id
|
||||
};
|
||||
let (left, right) = (make(&mut s, "left"), make(&mut s, "right"));
|
||||
let drawn_left = drawn_glyphs(&s, left);
|
||||
let text_before = text_glyphs(&s, right);
|
||||
assert_eq!(text_before, text_glyphs(&s, left), "{list}: the text starts at the same place with either alignment");
|
||||
assert_ne!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers aligned right are drawn elsewhere");
|
||||
s.execute("text.convertListToText", &json!({"ids": [left, right]})).unwrap();
|
||||
assert_eq!((plain(&s, left), plain(&s, right)), (converted.clone(), converted), "{list}: the marker, then a tab");
|
||||
let after = text_glyphs(&s, right);
|
||||
assert!(text_before.iter().all(|g| after.contains(g)), "{list}: the text stays where it was");
|
||||
assert_eq!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers start where a list aligned left draws them");
|
||||
assert_eq!(drawn_glyphs(&s, left), drawn_left, "{list}: aligned left, every glyph stays");
|
||||
}
|
||||
}
|
||||
|
||||
/// A right-to-left list paragraph converted to text starts with the number, its period and a
|
||||
/// tab, which are read first: the line draws its glyphs in the order it drew them with the
|
||||
/// marker, the number at the right with its period at its left. Letters and roman numerals
|
||||
@@ -907,6 +1021,130 @@ fn convert_to_text_moves_the_type_tools_selection() {
|
||||
assert_eq!(s.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected");
|
||||
}
|
||||
|
||||
/// Convert to Text of list paragraphs at deeper levels writes each level's own numbers, those
|
||||
/// a thread's earlier frames counted at each level included, and every glyph is drawn where it
|
||||
/// was: each converted paragraph's left indent takes its level's indent, with the plain left
|
||||
/// stop there.
|
||||
#[test]
|
||||
fn convert_to_text_keeps_deeper_list_levels_in_place() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let numbers = |s: &Session, id: u64| {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
|
||||
};
|
||||
let text_in = "One\nTwo\nThree\nFour\nFive";
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": text_in, "area": {"width": 400, "height": 400}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 2, "start": 4, "end": 9})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 3, "start": 14, "end": 14})).unwrap();
|
||||
let before = drawn_glyphs(&s, id);
|
||||
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(drawn_glyphs(&s, id), before, "every glyph is drawn where it was");
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.plain_text(), "1.\tOne\n1.\tTwo\n2.\tThree\n1.\tFour\n2.\tFive");
|
||||
let paras = (0..5)
|
||||
.map(|k| {
|
||||
let p = t.para_at(k);
|
||||
(p.list.is_none(), p.left_indent, p.tabs.iter().map(|tab| tab.position).collect::<Vec<_>>())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(paras, [18.0, 36.0, 36.0, 54.0, 18.0].map(|x| (true, x, vec![x])), "out of the list, at the level's indent");
|
||||
// A thread whose second frame goes on with the numbers of level 2.
|
||||
let items = std::iter::once("Head".to_string()).chain((1..=9).map(|i| format!("Item {i}"))).collect::<Vec<_>>().join("\n");
|
||||
let a =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 500, "text": items, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 500, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap();
|
||||
let first = numbers(&s, a).len();
|
||||
assert!((3..10).contains(&first) && text(&s, a).plain_text().ends_with('\n'), "both frames hold items: {first}");
|
||||
assert_eq!(numbers(&s, b).first(), Some(&format!("{first}.")), "the second frame counts level 2 on");
|
||||
let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b));
|
||||
s.execute("text.convertListToText", &json!({"ids": [a, b]})).unwrap();
|
||||
assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames");
|
||||
let story = std::iter::once("1.\tHead".to_string()).chain((1..=9).map(|i| format!("{i}.\tItem {i}"))).collect::<Vec<_>>().join("\n");
|
||||
assert_eq!(text(&s, a).plain_text() + &text(&s, b).plain_text(), story);
|
||||
}
|
||||
|
||||
/// List levels: `text.setFormat {listLevel}` sets the level of the list paragraphs it touches
|
||||
/// as one undo step, from 1 to 9, and refuses other values; bullets and numbers keep it, a
|
||||
/// list turned off and on starts at level 1 again, each level counts its own numbers, and the
|
||||
/// level is saved past level 1, reopened and carried by paragraph styles.
|
||||
#[test]
|
||||
fn list_levels_are_set_per_paragraph_saved_and_styled() {
|
||||
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree\nFour", "area": {"width": 300, "height": 200}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let levels = |s: &Session| (0..4).map(|i| text(s).para_at(i).list.map(|l| l.level)).collect::<Vec<_>>();
|
||||
let numbers = |s: &Session| {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
|
||||
};
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 9})).unwrap();
|
||||
assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("text.setFormat", &json!({"listLevel": 2, "start": 4, "end": 9})).unwrap();
|
||||
assert_eq!(levels(&s), [Some(1), Some(2), Some(2), None]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(numbers(&s), ["1.", "1.", "2."]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]);
|
||||
s.execute("text.setFormat", &json!({"listLevel": 2.0, "start": 4, "end": 4})).unwrap();
|
||||
assert_eq!(levels(&s)[1], Some(2));
|
||||
s.execute("text.setFormat", &json!({"listLevel": 9})).unwrap();
|
||||
assert_eq!(levels(&s), [Some(9), Some(9), Some(9), None], "the paragraph in no list stays out of one");
|
||||
let bad = [json!({"listLevel": 0}), json!({"listLevel": 10}), json!({"listLevel": 1.5}), json!({"listLevel": "2"}), json!({"listLevel": true})];
|
||||
for p in bad {
|
||||
assert!(s.execute("text.setFormat", &p).is_err(), "{p}");
|
||||
}
|
||||
let err = s.execute("text.setFormat", &json!({"listLevel": 10})).unwrap_err().to_string();
|
||||
assert!(err.contains("`listLevel` must be a whole number between 1 and 9 (got 10)"), "{err}");
|
||||
assert_eq!(levels(&s)[0], Some(9), "an unknown value changes nothing");
|
||||
// A deeper paragraph counts from 1, and one back at a shallower level goes on.
|
||||
s.execute("text.setFormat", &json!({"listLevel": 1, "start": 0, "end": 0})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"listLevel": 3, "start": 4, "end": 4})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"listLevel": 1, "start": 8, "end": 8})).unwrap();
|
||||
assert_eq!(numbers(&s), ["1.", "1.", "2."]);
|
||||
// Bullets and numbers keep the level; a call that makes a list sets it there too.
|
||||
s.execute("text.setFormat", &json!({"bullet": "square", "start": 4, "end": 4})).unwrap();
|
||||
assert_eq!(text(&s).para_at(1).list, Some(ParaList::bulleted(Bullet::Square).at_level(3)));
|
||||
s.execute("text.setFormat", &json!({"list": "bullet", "listLevel": 4, "start": 14, "end": 14})).unwrap();
|
||||
assert_eq!(text(&s).para_at(3).list, Some(ParaList::bulleted(Bullet::Dot).at_level(4)));
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(1)).unwrap()["list"], json!({"bullet": "square", "level": 3}));
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "dot", "number": "arabic"}));
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!((0..4).map(|i| back.para_at(i).list).collect::<Vec<_>>(), (0..4).map(|i| text(&s).para_at(i).list).collect::<Vec<_>>());
|
||||
// A list turned off and on again starts at level 1.
|
||||
s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap();
|
||||
assert_eq!(text(&s).para_at(1).list, Some(ParaList::numbered(NumberStyle::Arabic)));
|
||||
// A style captured from a paragraph at level 4 sets that level, and one captured at level 1
|
||||
// takes it back.
|
||||
s.execute("paraStyle.new", &json!({"name": "Level 4", "id": id, "start": 14, "end": 14})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Level 1", "id": id, "start": 0, "end": 0})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Level 4", "id": id, "start": 0, "end": 0})).unwrap();
|
||||
assert_eq!(levels(&s)[0], Some(4));
|
||||
s.execute("paraStyle.apply", &json!({"name": "Level 1", "id": id})).unwrap();
|
||||
assert_eq!(levels(&s), [Some(1); 4]);
|
||||
}
|
||||
|
||||
/// Type › Create Outlines makes a path of a list item's bullet as well as of its characters.
|
||||
#[test]
|
||||
fn create_outlines_makes_a_path_of_the_bullet() {
|
||||
@@ -1627,9 +1865,10 @@ fn variable_font_settings_are_a_character_attribute_saved_only_when_set() {
|
||||
s.execute("text.setStyle", &json!({"style": "Bold"})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty()));
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 75}})).unwrap();
|
||||
s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Inter"}})).unwrap();
|
||||
s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Source Sans 3"}})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty()));
|
||||
// A static font has no axes to set.
|
||||
// A static font has no axes to set (Source Sans 3, not Inter: an installed Inter
|
||||
// Variable would shadow the bundled static and give it axes).
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap();
|
||||
assert_eq!(saved(&s), None);
|
||||
// A missing font keeps them as given, for when it is installed.
|
||||
@@ -1651,7 +1890,7 @@ fn variable_font_settings_are_a_character_attribute_saved_only_when_set() {
|
||||
{"tag": "wdth", "name": "Width", "min": 75.0, "default": 100.0, "max": 100.0, "value": 100.0, "hidden": false},
|
||||
])
|
||||
);
|
||||
assert_eq!(s.execute("text.fontList", &json!({"family": "Inter"})).unwrap()["axes"], json!([]));
|
||||
assert_eq!(s.execute("text.fontList", &json!({"family": "Source Sans 3"})).unwrap()["axes"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -373,8 +373,10 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// The point origin sits where the alignment anchors the first line.
|
||||
let p0 = t.para_at(0);
|
||||
let (x0, x1) =
|
||||
lay.lines.first().map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.left_indent - p0.first_line_indent, l.avail.1 + p0.right_indent));
|
||||
let (x0, x1) = lay
|
||||
.lines
|
||||
.first()
|
||||
.map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.laid_out_left_indent() - p0.first_line_indent, l.avail.1 + p0.right_indent));
|
||||
let ox = match p0.justify {
|
||||
Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5,
|
||||
Justify::Right | Justify::JustifyRight => x1,
|
||||
|
||||
@@ -25,9 +25,10 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo
|
||||
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
|
||||
/// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the
|
||||
/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the artboards the
|
||||
/// Artboard tool had selected for its commands; the view's center the app passes to Edit Symbol);
|
||||
/// an action leaves them out ([`Session::journal_for_action`]).
|
||||
const REPLAY_ONLY: [(&str, &str); 22] = [
|
||||
/// Artboard tool had selected for its commands; the artboards and art Cut and Copy took with that
|
||||
/// tool chosen; the view's center the app passes to Edit Symbol); an action leaves them out
|
||||
/// ([`Session::journal_for_action`]).
|
||||
const REPLAY_ONLY: [(&str, &str); 26] = [
|
||||
("file.new", "created"),
|
||||
("edit.newDocumentFromState", "created"),
|
||||
("document.save", "modified"),
|
||||
@@ -46,6 +47,10 @@ const REPLAY_ONLY: [(&str, &str); 22] = [
|
||||
("artboard.cut", "selectedArtboards"),
|
||||
("artboard.rename", "selectedArtboards"),
|
||||
("artboard.switchOrientation", "selectedArtboards"),
|
||||
("edit.copy", "selectedArtboards"),
|
||||
("edit.copy", "art"),
|
||||
("edit.cut", "selectedArtboards"),
|
||||
("edit.cut", "art"),
|
||||
("select.allOnArtboard", "artboard"),
|
||||
("symbol.edit", "artboard"),
|
||||
("symbol.edit", "viewCenter"),
|
||||
|
||||
@@ -175,6 +175,9 @@ pub struct Session {
|
||||
/// The composite fonts (lowercase names) that the open documents defined when the font
|
||||
/// database was last brought in step with them ([`cmd::compositefonts::sync`]).
|
||||
pub(crate) composite_names: Vec<String>,
|
||||
/// Every composite font (lowercase name) this session has registered in the font database:
|
||||
/// type read for opening may have been laid out in one that is gone by the time it opens.
|
||||
pub(crate) composite_names_registered: std::collections::HashSet<String>,
|
||||
/// While a command that changes nothing in the document runs (a query, or one of
|
||||
/// [`command_sets::JOURNALED_READS`]): it reads the instance of an object selected in a symbol
|
||||
/// instance's art.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use vectorcraft_doc::{Node, TextObject};
|
||||
|
||||
use super::{
|
||||
Arc, Clipboard, Color, CommandInfo, DocState, Document, DrawMode, EngineError, GradientPaint, Journal, NodeKind, PaintDefaults, Prefs, Result,
|
||||
Session, ViewInfo, cmd, command_specs, file_access,
|
||||
@@ -84,6 +88,7 @@ impl Session {
|
||||
batch_steps: None,
|
||||
batch_paint_docs: Vec::new(),
|
||||
composite_names: Vec::new(),
|
||||
composite_names_registered: HashSet::new(),
|
||||
reading: false,
|
||||
place_hand_off: None,
|
||||
queue_hand_off: None,
|
||||
@@ -182,10 +187,8 @@ impl Session {
|
||||
self.reset_tool_for_doc_switch();
|
||||
}
|
||||
self.active = Some(index);
|
||||
// Type is drawn in the composite fonts of the document now in front, and measured again
|
||||
// when they differ from those of the document before.
|
||||
if switched && cmd::compositefonts::sync(self) {
|
||||
self.refresh_document_text_bounds(index);
|
||||
if switched {
|
||||
self.sync_front_composite_fonts();
|
||||
}
|
||||
true
|
||||
} else {
|
||||
@@ -193,6 +196,16 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the composite fonts of the document now in front ([`cmd::compositefonts::sync`]),
|
||||
/// after a switch or a close brought it there. Its type set in a composite font that differs
|
||||
/// from the one of that name before (added, changed or removed) is measured again, and type not
|
||||
/// measured yet (in a document replaced while in the back) is measured now.
|
||||
fn sync_front_composite_fonts(&mut self) {
|
||||
let changed: HashSet<String> = cmd::compositefonts::sync(self).into_iter().collect();
|
||||
let Some(index) = self.active else { return };
|
||||
self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() || sets_in_any(t, &changed));
|
||||
}
|
||||
|
||||
/// The name [`Session::next_untitled`] gives next (New Document's Name field).
|
||||
pub fn peek_untitled(&self) -> String {
|
||||
format!("Untitled-{}", self.untitled_counter + 1)
|
||||
@@ -211,19 +224,44 @@ impl Session {
|
||||
pub(crate) fn refresh_text_bounds(doc: &mut Document) {
|
||||
// Inline graphics in text take their size from their symbols' art (not saved either).
|
||||
doc.resolve_inline_art();
|
||||
let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art));
|
||||
let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art));
|
||||
for tree in trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) {
|
||||
refresh_tree_text_bounds(tree);
|
||||
let mut uncached = |t: &TextObject| t.cached_bounds.is_none();
|
||||
for tree in text_trees_mut(doc) {
|
||||
each_text_where(tree, &mut uncached, &mut cmd::typecmd::refresh_bounds_for_first_frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Session::refresh_text_bounds`] for open document `index`, as a change that is not an
|
||||
/// edit: a saved document stays saved, and its undo history keeps its documents as they are.
|
||||
fn refresh_document_text_bounds(&mut self, index: usize) {
|
||||
if let Some(st) = self.docs.get_mut(index) {
|
||||
st.change_unsaved(|doc| Self::refresh_text_bounds(Arc::make_mut(doc)));
|
||||
/// Measure the type in open document `index` that `stale` picks (in its trees as
|
||||
/// [`Session::refresh_text_bounds`] walks them), as a change that is not an edit: a saved
|
||||
/// document stays saved, and its undo history keeps its documents as they are. The document is
|
||||
/// left as it is when `stale` picks nothing.
|
||||
fn refresh_document_text_bounds(&mut self, index: usize, mut stale: impl FnMut(&TextObject) -> bool) {
|
||||
let Some(st) = self.docs.get_mut(index) else { return };
|
||||
if !text_trees(&st.doc).any(|n| has_text_where(n, &mut stale)) {
|
||||
return;
|
||||
}
|
||||
st.change_unsaved(|doc| {
|
||||
let doc = Arc::make_mut(doc);
|
||||
for tree in text_trees_mut(doc) {
|
||||
each_text_where(tree, &mut stale, &mut cmd::typecmd::refresh_bounds_for_first_frame);
|
||||
}
|
||||
// Inline graphics take their size from their symbols' art, whose type may just have
|
||||
// been measured again: the text holding one whose size changed is measured again too.
|
||||
if !doc.resolve_inline_art().is_empty() {
|
||||
for tree in text_trees_mut(doc) {
|
||||
each_text_where(tree, &mut |t| t.cached_bounds.is_none(), &mut cmd::typecmd::refresh_bounds_for_first_frame);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Register the composite fonts of open document `index`, which is in front, and measure its
|
||||
/// type not measured yet and its type set in a font of a name that a composite font of an open
|
||||
/// document or of the font database has, or had before: that type may have been laid out in
|
||||
/// another document's composite fonts (those registered while its file was read for opening).
|
||||
fn sync_opened_composite_fonts(&mut self, index: usize) {
|
||||
let mut names = cmd::compositefonts::names_in_use(self);
|
||||
names.extend(cmd::compositefonts::sync(self));
|
||||
self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() || sets_in_any(t, &names));
|
||||
}
|
||||
|
||||
/// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`,
|
||||
@@ -234,6 +272,8 @@ impl Session {
|
||||
// (importers and the native reader have seen to theirs already, with a warning).
|
||||
doc.limit_depth();
|
||||
cmd::compositefonts::filter_loaded(&mut doc);
|
||||
// Inline graphics in text take their size from their symbols' art (not saved).
|
||||
doc.resolve_inline_art();
|
||||
// Guides from files saved before guides had layers go on a layer.
|
||||
doc.adopt_guides();
|
||||
cmd::dimension::conform(&mut doc);
|
||||
@@ -244,8 +284,7 @@ impl Session {
|
||||
self.docs.push(st);
|
||||
let i = self.docs.len() - 1;
|
||||
self.active = Some(i);
|
||||
cmd::compositefonts::sync(self);
|
||||
self.refresh_document_text_bounds(i);
|
||||
self.sync_opened_composite_fonts(i);
|
||||
if self.depth == 0
|
||||
&& let Some(label) = self.explicit_undo_group.clone()
|
||||
{
|
||||
@@ -262,11 +301,26 @@ impl Session {
|
||||
}
|
||||
doc.limit_depth();
|
||||
cmd::compositefonts::filter_loaded(&mut doc);
|
||||
doc.resolve_inline_art();
|
||||
doc.adopt_guides();
|
||||
cmd::dimension::conform(&mut doc);
|
||||
// In the back, type set in a font named like a composite font is measured when the document
|
||||
// comes to the front and its own composite fonts are registered, not in the front
|
||||
// document's.
|
||||
let mut back_names = None;
|
||||
if self.active == Some(index) {
|
||||
// Pending tool work (typing, a drag) belongs to the content being thrown away.
|
||||
self.reset_tool_for_doc_switch();
|
||||
} else {
|
||||
let mut names = cmd::compositefonts::names_in_use(self);
|
||||
names.extend(doc.composite_fonts.iter().map(|f| f.name.to_lowercase()));
|
||||
for tree in text_trees_mut(&mut doc) {
|
||||
each_text_where(tree, &mut |t| t.cached_bounds.is_some() && sets_in_any(t, &names), &mut |t| {
|
||||
t.cached_bounds = None;
|
||||
t.cached_baselines.clear();
|
||||
});
|
||||
}
|
||||
back_names = Some(names);
|
||||
}
|
||||
let old = &self.docs[index];
|
||||
let mut st = DocState::new(doc, old.path.clone());
|
||||
@@ -297,12 +351,10 @@ impl Session {
|
||||
if let Some(stash) = &mut self.batch_stash {
|
||||
stash.push(old);
|
||||
}
|
||||
// Type in the composite fonts of a document in the back is measured again when it comes to
|
||||
// the front.
|
||||
if self.active == Some(index) {
|
||||
cmd::compositefonts::sync(self);
|
||||
match back_names {
|
||||
Some(names) => self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() && !sets_in_any(t, &names)),
|
||||
None => self.sync_opened_composite_fonts(index),
|
||||
}
|
||||
self.refresh_document_text_bounds(index);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -319,7 +371,7 @@ impl Session {
|
||||
stash.push(old);
|
||||
}
|
||||
self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) };
|
||||
cmd::compositefonts::sync(self);
|
||||
self.sync_front_composite_fonts();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -344,33 +396,56 @@ impl Session {
|
||||
true
|
||||
}
|
||||
|
||||
/// Commands with enablement (for menus, palette, MCP `list_commands`).
|
||||
/// Commands with enablement (for menus, palette, MCP `list_commands`), the installed plug-ins'
|
||||
/// commands last.
|
||||
pub fn commands(&self) -> Vec<CommandInfo> {
|
||||
command_specs().iter().map(|c| c.info(self)).collect()
|
||||
command_specs().iter().chain(crate::cmd::plugin::command_specs()).map(|c| c.info(self)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out the type in tree `n` (opacity mask art included) whose bounds aren't cached. Only the
|
||||
/// nodes on the way to such type are copied.
|
||||
fn refresh_tree_text_bounds(n: &mut Arc<vectorcraft_doc::Node>) {
|
||||
fn stale(n: &vectorcraft_doc::Node) -> bool {
|
||||
matches!(&n.kind, NodeKind::Text(t) if t.cached_bounds.is_none())
|
||||
|| n.extras().mask.as_deref().is_some_and(|m| stale(&m.art))
|
||||
|| n.children().is_some_and(|ch| ch.iter().any(|c| stale(c)))
|
||||
}
|
||||
if !stale(n) {
|
||||
/// Whether some of type `t` is set in a font named (in any case) one of `names`, lowercased.
|
||||
fn sets_in_any(t: &TextObject, names: &HashSet<String>) -> bool {
|
||||
!names.is_empty() && t.runs.iter().any(|r| names.contains(&r.style.font_family.to_lowercase()))
|
||||
}
|
||||
|
||||
/// The trees of `doc` that hold type: its layers, symbol definitions, graph designs and pattern
|
||||
/// tiles.
|
||||
fn text_trees(doc: &Document) -> impl Iterator<Item = &Arc<Node>> {
|
||||
let trees = doc.layers.iter().chain(doc.symbols.iter().map(|s| &s.art));
|
||||
let trees = trees.chain(doc.graph_designs.iter().map(|g| &g.art));
|
||||
trees.chain(doc.patterns.iter().flat_map(|p| p.art.iter()))
|
||||
}
|
||||
|
||||
/// [`text_trees`], to change.
|
||||
fn text_trees_mut(doc: &mut Document) -> impl Iterator<Item = &mut Arc<Node>> {
|
||||
let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art));
|
||||
let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art));
|
||||
trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
|
||||
}
|
||||
|
||||
/// Whether tree `n` (opacity mask art included) holds type that `pick` picks.
|
||||
fn has_text_where(n: &Node, pick: &mut dyn FnMut(&TextObject) -> bool) -> bool {
|
||||
matches!(&n.kind, NodeKind::Text(t) if pick(t))
|
||||
|| n.extras().mask.as_deref().is_some_and(|m| has_text_where(&m.art, pick))
|
||||
|| n.children().is_some_and(|ch| ch.iter().any(|c| has_text_where(c, pick)))
|
||||
}
|
||||
|
||||
/// Run `act` on the type in tree `n` (opacity mask art included) that `pick` picks. Only the nodes
|
||||
/// on the way to such type are copied.
|
||||
fn each_text_where(n: &mut Arc<Node>, pick: &mut dyn FnMut(&TextObject) -> bool, act: &mut dyn FnMut(&mut TextObject)) {
|
||||
if !has_text_where(n, pick) {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(n);
|
||||
if let NodeKind::Text(t) = &mut n.kind
|
||||
&& t.cached_bounds.is_none()
|
||||
&& pick(t)
|
||||
{
|
||||
cmd::typecmd::refresh_bounds_for_first_frame(t);
|
||||
act(t);
|
||||
}
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) {
|
||||
refresh_tree_text_bounds(&mut m.art);
|
||||
each_text_where(&mut m.art, pick, act);
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
refresh_tree_text_bounds(c);
|
||||
each_text_where(c, pick, act);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +76,13 @@ impl Session {
|
||||
cmd::ids_param(params, "ids", id)?;
|
||||
}
|
||||
// Clear's menu needs a selection; an agent supplying explicit targets only needs a
|
||||
// document. The command validates those targets before making any edit.
|
||||
// document. The command validates those targets before making any edit. Cut and Copy given
|
||||
// the artboards the journal recorded need only a document too, whatever is selected and
|
||||
// whichever tool is chosen.
|
||||
let given = given_ids(spec.params, params);
|
||||
let enabled = if id == "edit.clear" && params.get("ids").is_some() {
|
||||
let enabled = if (id == "edit.clear" && params.get("ids").is_some())
|
||||
|| (matches!(id, "edit.copy" | "edit.cut") && params.get("selectedArtboards").is_some())
|
||||
{
|
||||
cmd::has_doc(self)
|
||||
} else if let Some(ids) = given.clone() {
|
||||
// Objects given as `ids` (or `id`) stand in for the selection: the command is enabled
|
||||
@@ -113,7 +117,7 @@ impl Session {
|
||||
if let Some(ids) = &select_first {
|
||||
s.execute("select.set", &serde_json::json!({ "ids": ids }))?;
|
||||
}
|
||||
let r = (spec.run)(s, params);
|
||||
let r = cmd::run_spec(s, spec, params);
|
||||
if r.is_err()
|
||||
&& let Some((uid, selection, layer)) = selected_before
|
||||
&& let Some(st) = s.docs.iter_mut().find(|d| d.uid == uid)
|
||||
@@ -131,6 +135,9 @@ impl Session {
|
||||
let r = if self.depth == 0 { self.run_guarded(id, run) } else { self.run_nested(run) };
|
||||
if r.is_err() && self.depth == 0 {
|
||||
self.batch_paint_docs.clear();
|
||||
// A failed or panicked command's documents are put back: type is drawn in their
|
||||
// composite fonts again, not in those the command set.
|
||||
cmd::compositefonts::sync(self);
|
||||
}
|
||||
let r = r?;
|
||||
if self.depth == 0 {
|
||||
|
||||
@@ -2969,6 +2969,91 @@ fn image_trace_make_places_paths_over_the_image() {
|
||||
assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_keep_colors_traces_only_the_colors_listed() {
|
||||
// A light field with a black ring, a red square and a blue bar on it.
|
||||
let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| {
|
||||
let r = (f64::from(x) + 0.5 - 70.0).hypot(f64::from(y) + 0.5 - 60.0);
|
||||
if (34.0..=40.0).contains(&r) {
|
||||
[0, 0, 0, 255]
|
||||
} else if (130..170).contains(&x) && (30..70).contains(&y) {
|
||||
[200, 40, 50, 255]
|
||||
} else if (30..170).contains(&x) && (110..130).contains(&y) {
|
||||
[40, 80, 200, 255]
|
||||
} else {
|
||||
[240, 240, 220, 255]
|
||||
}
|
||||
});
|
||||
let traced = |keep: Value| {
|
||||
let mut s = session();
|
||||
add_image(&mut s, &art, Affine::IDENTITY);
|
||||
let params = json!({"params": {"mode": "color", "palette": "limited", "colors": 4, "keepColors": keep}});
|
||||
let r = s.execute("imageTrace.makeAndExpand", ¶ms)?;
|
||||
let g = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
let fills: Vec<Paint> = g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect();
|
||||
Ok::<_, EngineError>((r["colors"].clone(), fills))
|
||||
};
|
||||
let (black, red, blue, light) = (Color::rgb8(0, 0, 0), Color::rgb8(200, 40, 50), Color::rgb8(40, 80, 200), Color::rgb8(240, 240, 220));
|
||||
let has = |fills: &[Paint], c: Color| fills.contains(&Paint::solid(c));
|
||||
// Nothing listed: every color.
|
||||
let (colors, fills) = traced(json!([])).unwrap();
|
||||
assert_eq!(colors, 4);
|
||||
assert!([black, red, blue, light].into_iter().all(|c| has(&fills, c)), "{fills:?}");
|
||||
// The black and the red only.
|
||||
let (colors, fills) = traced(json!(["#000000", "#c8283c"])).unwrap();
|
||||
assert_eq!(colors, 2);
|
||||
assert!(has(&fills, black) && has(&fills, red), "{fills:?}");
|
||||
assert!(fills.iter().all(|f| *f == Paint::solid(black) || *f == Paint::solid(red)), "{fills:?}");
|
||||
// A color that is none is refused.
|
||||
let e = traced(json!(["#000000", "black"])).unwrap_err().to_string();
|
||||
assert!(e.contains("\"black\"") && e.contains("#rrggbb"), "{e}");
|
||||
assert!(find_command("imageTrace.make").unwrap().params.contains("keepColors?: [\"#rrggbb\"…]"));
|
||||
assert!(find_command("imageTrace.makeAndExpand").unwrap().params.contains("warnings: [text…] (as imageTrace.make)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_keep_colors_warns_about_colors_that_match_nothing() {
|
||||
// A light field with a black ring and a red square on it.
|
||||
let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| {
|
||||
let r = (f64::from(x) + 0.5 - 70.0).hypot(f64::from(y) + 0.5 - 60.0);
|
||||
if (34.0..=40.0).contains(&r) {
|
||||
[0, 0, 0, 255]
|
||||
} else if (130..170).contains(&x) && (30..70).contains(&y) {
|
||||
[200, 40, 50, 255]
|
||||
} else {
|
||||
[240, 240, 220, 255]
|
||||
}
|
||||
});
|
||||
let traced = |params: Value| {
|
||||
let mut s = session();
|
||||
add_image(&mut s, &art, Affine::IDENTITY);
|
||||
let r = s.execute("imageTrace.makeAndExpand", &json!({ "params": params })).unwrap();
|
||||
(r["paths"].as_u64().unwrap(), r["warnings"].clone())
|
||||
};
|
||||
let color = |keep: Value| traced(json!({"mode": "color", "palette": "limited", "colors": 3, "keepColors": keep}));
|
||||
// Every color listed is in the image: no warning.
|
||||
let (paths, warnings) = color(json!(["#000000", "#c8283c"]));
|
||||
assert!(paths > 0);
|
||||
assert_eq!(warnings, json!([]));
|
||||
// No color matches: nothing is traced, and the reply says why.
|
||||
assert_eq!(
|
||||
color(json!(["#ff00ff", "#00ffff"])),
|
||||
(0, json!(["Nothing was traced: none of the colors the trace found matches keepColors (#ff00ff, #00ffff)."]))
|
||||
);
|
||||
// One color matches nothing: the others are traced, and that one is named.
|
||||
let (paths, warnings) = color(json!(["#000000", "#00ffff"]));
|
||||
assert!(paths > 0);
|
||||
assert_eq!(warnings, json!(["keepColors: none of the colors the trace found matches #00ffff, so nothing of it is traced."]));
|
||||
// Ignore Color leaves out the only color listed.
|
||||
let ignored =
|
||||
traced(json!({"mode": "color", "palette": "limited", "colors": 3, "keepColors": ["#000000"], "ignoreWhite": true, "ignoreColor": "#000000"}));
|
||||
assert_eq!(ignored, (0, json!(["Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists."])));
|
||||
// Flat Logo mode does not use the list.
|
||||
let (paths, warnings) = traced(json!({"mode": "logo", "keepColors": ["#ff00ff"]}));
|
||||
assert!(paths > 0);
|
||||
assert_eq!(warnings, json!(["Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds."]));
|
||||
}
|
||||
|
||||
/// #3098: a trace made away from the command (off the UI thread) and handed in makes the command's
|
||||
/// own edit, as one undo step; Cancel stops it; once its image has changed it no longer applies.
|
||||
#[test]
|
||||
@@ -3012,6 +3097,42 @@ fn a_trace_run_hands_its_trace_to_the_command() {
|
||||
assert!((bb.center().x - 160.0).abs() < 1.5, "traced where the image is now: {bb:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_ignore_background_keeps_the_color_inside_the_art() {
|
||||
// A black field, a light panel on it and a black ring on the panel.
|
||||
let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| {
|
||||
let r = (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 75.0);
|
||||
if !((20..180).contains(&x) && (20..130).contains(&y)) || (37.0..=43.0).contains(&r) { [0, 0, 0, 255] } else { [240, 240, 220, 255] }
|
||||
});
|
||||
let traced = |background: bool| {
|
||||
let mut s = session();
|
||||
add_image(&mut s, &art, Affine::IDENTITY);
|
||||
let params = json!({"params": {"mode": "color", "palette": "limited", "colors": 2, "ignoreWhite": true, "ignoreColor": "#000000", "ignoreBackground": background}});
|
||||
let r = s.execute("imageTrace.makeAndExpand", ¶ms).unwrap();
|
||||
let g = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
let black: Vec<_> = g
|
||||
.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|n| n.appearance.fill_paint() == Paint::solid(Color::rgb8(0, 0, 0)))
|
||||
.map(|n| outline(n).0.bounds().unwrap())
|
||||
.collect();
|
||||
(r["colors"].clone(), black)
|
||||
};
|
||||
// The whole color: the ring goes with the field.
|
||||
let (colors, black) = traced(false);
|
||||
assert_eq!(colors, 1);
|
||||
assert!(black.is_empty(), "{black:?}");
|
||||
// The background only: the ring stays, and nothing black reaches the image's edge.
|
||||
let (colors, black) = traced(true);
|
||||
assert_eq!(colors, 2);
|
||||
assert_eq!(black.len(), 1, "{black:?}");
|
||||
let bb = black[0];
|
||||
assert!((bb.x0 - 57.0).abs() < 1.5 && (bb.x1 - 143.0).abs() < 1.5 && (bb.y0 - 32.0).abs() < 1.5 && (bb.y1 - 118.0).abs() < 1.5, "{bb:?}");
|
||||
// The params doc tells agents about the option.
|
||||
assert!(find_command("imageTrace.make").unwrap().params.contains("ignoreBackground: false"));
|
||||
}
|
||||
|
||||
/// A Document Library trace made away from its command no longer applies once the library's
|
||||
/// colours change: the settings' JSON leaves them out (#4605 review).
|
||||
#[test]
|
||||
|
||||
@@ -1440,17 +1440,17 @@ fn spiral_drags_with_alt_and_ctrl_change_its_segments_and_decay() {
|
||||
};
|
||||
s.select_tool("spiral", v).unwrap();
|
||||
let undo = undo_steps(&s);
|
||||
// Alt from a radius of 100 to 200: 10 segments become 14, one full wind of four quarter turns.
|
||||
// Alt from a radius of 100 to 200: preserving the inner end adds three full winds.
|
||||
let (anchors, outer) = spiral(&mut s, Mods { alt: true, ..Mods::default() }, (300.0, 100.0));
|
||||
assert_eq!(anchors, 15);
|
||||
assert_eq!(anchors, 23);
|
||||
assert!(near(outer, Point::new(300.0, 100.0)), "{outer:?}");
|
||||
assert_eq!(s.tool_options()["segments"], json!(14));
|
||||
// Ctrl/Cmd from 100 to 50: the decay rises from 80 % to 80 × 2^(1/14) %.
|
||||
assert_eq!(s.tool_options()["segments"], json!(22));
|
||||
// Ctrl/Cmd from 100 to 50: the decay rises from 80 % to 80 × 2^(4/22) %.
|
||||
let (anchors, outer) = spiral(&mut s, Mods { cmd: true, ..Mods::default() }, (150.0, 100.0));
|
||||
assert_eq!(anchors, 15);
|
||||
assert_eq!(anchors, 23);
|
||||
assert!(near(outer, Point::new(150.0, 100.0)), "{outer:?}");
|
||||
let decay = s.tool_options()["decay"].as_f64().unwrap();
|
||||
assert!((decay - 80.0 * 2f64.powf(1.0 / 14.0)).abs() < 1e-9, "{decay}");
|
||||
assert!((decay - 80.0 * 2f64.powf(4.0 / 22.0)).abs() < 1e-9, "{decay}");
|
||||
assert_eq!(undo_steps(&s) - undo, 2, "one undo step a spiral");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! Fill or Stroke changes it.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
@@ -107,6 +108,314 @@ fn static_symbols_and_bad_targets_are_refused() {
|
||||
assert_eq!(overrides(&s, inst), 0);
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// The frontmost object of the active layer.
|
||||
fn top(s: &Session) -> NodeId {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
d.node(d.default_layer().unwrap()).unwrap().children().unwrap().last().unwrap().id
|
||||
}
|
||||
|
||||
/// Every pixel of the page.
|
||||
fn page(s: &Session) -> Vec<[u8; 4]> {
|
||||
let r = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, true);
|
||||
(0..r.height).flat_map(|y| (0..r.width).map(move |x| (x, y))).map(|(x, y)| r.pixel(x, y)).collect()
|
||||
}
|
||||
|
||||
/// A dynamic symbol "Art" of the frontmost object, made in place: its instance.
|
||||
fn art_of_top(s: &mut Session) -> NodeId {
|
||||
let t = top(s);
|
||||
id(&s.execute("symbol.new", &json!({"name": "Art", "ids": [t.0], "type": "dynamic"})).unwrap())
|
||||
}
|
||||
|
||||
/// Put a red fill and stroke on the root of instance `inst`'s art directly, past the command.
|
||||
fn paint_root_red(s: &mut Session, inst: NodeId) {
|
||||
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
s.edit("paint", |d, _| {
|
||||
let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(red.clone()), stroke: Some(red), ..Default::default() };
|
||||
d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A 30×30 red image placed with its frame from (50, 20) to (80, 50).
|
||||
fn place_image(s: &mut Session) {
|
||||
let png = crate::tests_links::png(30, 30, [255, 0, 0]);
|
||||
let data = vectorcraft_format::base64_encode(&png);
|
||||
s.execute("file.place", &json!({"name": "red.png", "dataBase64": data, "at": [65, 35], "link": false})).unwrap();
|
||||
}
|
||||
|
||||
fn select_top(s: &mut Session, n: usize) {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let kids = d.node(d.default_layer().unwrap()).unwrap().children().unwrap();
|
||||
let ids: Vec<u64> = kids[kids.len() - n..].iter().map(|c| c.id.0).collect();
|
||||
s.execute("select.set", &json!({ "ids": ids })).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_that_draw_no_fill_or_stroke_of_their_own_refuse_one() {
|
||||
let folder = crate::tests_links::Folder::new("dynamic-symbol-kinds");
|
||||
let source = folder.file("placed.vectorcraft");
|
||||
let mut doc = vectorcraft_doc::Document::new(40.0, 40.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let look = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0);
|
||||
let rect = vectorcraft_doc::Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), look);
|
||||
doc.insert(Some(layer), 0, rect).unwrap();
|
||||
crate::tests_links::write(&source, &vectorcraft_format::save_file(&doc));
|
||||
type Make = Box<dyn Fn(&mut Session)>;
|
||||
let cases: Vec<(&str, &str, Make)> = vec![
|
||||
(
|
||||
"guide",
|
||||
"a guide",
|
||||
Box::new(|s| {
|
||||
square(s, 10.0);
|
||||
select_top(s, 1);
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
}),
|
||||
),
|
||||
(
|
||||
"blend",
|
||||
"a blend",
|
||||
Box::new(|s| {
|
||||
let (a, b) = (square(s, 10.0), square(s, 60.0));
|
||||
s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap();
|
||||
}),
|
||||
),
|
||||
(
|
||||
"envelope",
|
||||
"an envelope",
|
||||
Box::new(|s| {
|
||||
square(s, 10.0);
|
||||
select_top(s, 1);
|
||||
s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 50})).unwrap();
|
||||
}),
|
||||
),
|
||||
("radial Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.radial"))),
|
||||
("grid Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.grid"))),
|
||||
("mirror Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.mirror"))),
|
||||
(
|
||||
"Intertwine",
|
||||
"an Intertwine group",
|
||||
Box::new(|s| {
|
||||
square(s, 10.0);
|
||||
square(s, 30.0);
|
||||
select_top(s, 2);
|
||||
s.execute("object.intertwine.make", &json!({})).unwrap();
|
||||
}),
|
||||
),
|
||||
(
|
||||
"gradient mesh",
|
||||
"a gradient mesh",
|
||||
Box::new(|s| {
|
||||
let r = square(s, 10.0);
|
||||
s.execute("object.mesh.create", &json!({"ids": [r.0], "rows": 1, "cols": 1})).unwrap();
|
||||
}),
|
||||
),
|
||||
("image", "an image", Box::new(place_image)),
|
||||
(
|
||||
"placed document",
|
||||
"a placed document",
|
||||
Box::new(move |s| {
|
||||
let r = s.execute("file.place", &json!({"path": source, "at": [40, 40]})).unwrap();
|
||||
assert_eq!(r["linked"], true, "{r}");
|
||||
}),
|
||||
),
|
||||
];
|
||||
// Every case runs, and the failure lists each object that fails.
|
||||
let mut failures = Vec::new();
|
||||
for (label, kind, make) in cases {
|
||||
let mut s = session();
|
||||
make(&mut s);
|
||||
let inst = art_of_top(&mut s);
|
||||
if let Err(why) = refuses_own_paint(&mut s, inst, kind) {
|
||||
failures.push(format!("{label}: {why}"));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Check that the root of instance `inst`'s art, an object of `kind` that draws no fill or stroke
|
||||
/// of its own, refuses both, takes null, opacity and blend, and draws no stored fill or stroke.
|
||||
fn refuses_own_paint(s: &mut Session, inst: NodeId, kind: &str) -> std::result::Result<(), String> {
|
||||
let set = |s: &mut Session, p: &Value| s.execute("symbol.instance.setAppearance", p);
|
||||
let undo = undo_len(s);
|
||||
for key in ["fill", "stroke"] {
|
||||
for paint in [json!("#ff0000"), json!({"none": true})] {
|
||||
// With a valid opacity too: nothing of the call is applied.
|
||||
let p = json!({"id": inst.0, "path": [], key: paint, "opacity": 50});
|
||||
match set(s, &p) {
|
||||
Ok(_) => return Err(format!("{p} was taken")),
|
||||
Err(e) if !e.to_string().contains(&format!("{key} cannot be set on {kind}")) => return Err(format!("{p}: {e}")),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (overrides(s, inst), undo_len(s)) != (0, undo) {
|
||||
return Err("a refused call changed the instance or added an undo step".into());
|
||||
}
|
||||
// Null (taking a change back), opacity and blend are taken.
|
||||
let clear = json!({"id": inst.0, "path": [], "fill": null, "stroke": null});
|
||||
for p in [clear, json!({"id": inst.0, "path": [], "opacity": 50, "blend": "multiply"})] {
|
||||
set(s, &p).map_err(|e| format!("{p}: {e}"))?;
|
||||
}
|
||||
if overrides(s, inst) != 1 {
|
||||
return Err("opacity and blend were not stored".into());
|
||||
}
|
||||
// The renderer draws no fill or stroke of the object's own: a stored one changes nothing.
|
||||
let before = page(s);
|
||||
paint_root_red(s, inst);
|
||||
if page(s) != before {
|
||||
return Err("a stored fill and stroke changed the canvas".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn repeat(s: &mut Session, cmd: &str) {
|
||||
square(s, 10.0);
|
||||
select_top(s, 1);
|
||||
s.execute(cmd, &json!({})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_instance_in_the_art_takes_a_fill_that_stains_it_and_no_stroke() {
|
||||
let mut s = session();
|
||||
pair(&mut s, "static");
|
||||
let inst = art_of_top(&mut s);
|
||||
let undo = undo_len(&s);
|
||||
let e = s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": "#ff0000"})).unwrap_err().to_string();
|
||||
assert!(e.contains("stroke cannot be set on a symbol instance"), "{e}");
|
||||
assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo));
|
||||
// A stroke stored past the command draws nothing.
|
||||
let before = page(&s);
|
||||
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
s.edit("paint", |d, _| {
|
||||
let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], stroke: Some(red), ..Default::default() };
|
||||
d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(page(&s) == before, "a stroke on a symbol instance draws nothing");
|
||||
// The fill stains the nested instance's art.
|
||||
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap();
|
||||
assert_eq!(pixel(&s, 30, 30), RED, "the fill stains the art");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_instance_in_the_art_refuses_a_gradient_fill() {
|
||||
let mut s = session();
|
||||
pair(&mut s, "static");
|
||||
let inst = art_of_top(&mut s);
|
||||
let undo = undo_len(&s);
|
||||
let gradient = json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#00ff00"}]});
|
||||
let p = json!({"id": inst.0, "path": [], "fill": {"gradient": gradient}, "opacity": 50});
|
||||
let e = s.execute("symbol.instance.setAppearance", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("fill on a symbol instance must be a solid color or none"), "{e}");
|
||||
assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo), "nothing changes and no undo step");
|
||||
// A gradient fill stored past the command draws nothing.
|
||||
let before = page(&s);
|
||||
let paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
|
||||
s.edit("paint", |d, _| {
|
||||
let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(paint), ..Default::default() };
|
||||
d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(page(&s) == before, "a gradient fill on a symbol instance draws nothing");
|
||||
// A solid fill and none are taken.
|
||||
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": {"none": true}})).unwrap();
|
||||
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap();
|
||||
assert_eq!(pixel(&s, 30, 30), RED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_instance_in_the_art_refuses_a_pattern_fill() {
|
||||
let mut s = session();
|
||||
let tile = id(&s.execute("shape.rectangle", &json!({"x": 150, "y": 60, "width": 10, "height": 10})).unwrap());
|
||||
s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Dots", "edit": false})).unwrap();
|
||||
pair(&mut s, "static");
|
||||
let inst = art_of_top(&mut s);
|
||||
let undo = undo_len(&s);
|
||||
let p = json!({"id": inst.0, "path": [], "fill": {"swatch": "Dots"}, "opacity": 50});
|
||||
let e = s.execute("symbol.instance.setAppearance", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("fill on a symbol instance must be a solid color or none"), "{e}");
|
||||
assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo), "nothing changes and no undo step");
|
||||
// The pattern draws on a path.
|
||||
let probe = square(&mut s, 110.0);
|
||||
let before = page(&s);
|
||||
s.execute("paint.setFill", &json!({"ids": [probe.0], "swatch": "Dots"})).unwrap();
|
||||
assert!(page(&s) != before, "the pattern draws on a path");
|
||||
// A pattern fill stored on the nested instance past the command draws nothing.
|
||||
let before = page(&s);
|
||||
let paint = s.doc().unwrap().doc.swatch("Dots").unwrap().paint.clone();
|
||||
assert!(matches!(paint, Paint::Pattern { .. }), "{paint:?}");
|
||||
s.edit("paint", |d, _| {
|
||||
let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(paint), ..Default::default() };
|
||||
d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(page(&s) == before, "a pattern fill on a symbol instance draws nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_takes_back_an_old_change_an_object_does_not_draw() {
|
||||
let mut s = session();
|
||||
let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0));
|
||||
s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap();
|
||||
let inst = art_of_top(&mut s);
|
||||
// A change stored before the command refused one, as an older document may hold.
|
||||
paint_root_red(&mut s, inst);
|
||||
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": null})).unwrap();
|
||||
assert!(node(&s, inst).extras().symbol_overrides[0].fill.is_none());
|
||||
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": null})).unwrap();
|
||||
assert_eq!(overrides(&s, inst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_type_groups_and_shaped_image_paint_take_a_fill_and_stroke() {
|
||||
let set = |s: &mut Session, inst: NodeId, path: Value| {
|
||||
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": path, "fill": "#ff0000", "stroke": "#ff0000"})).unwrap()
|
||||
};
|
||||
// A group's own fill paints over its members.
|
||||
let mut s = session();
|
||||
let inst = pair(&mut s, "dynamic");
|
||||
assert_eq!(set(&mut s, inst, json!([]))["overrides"], 1);
|
||||
assert_eq!(pixel(&s, 30, 30), RED, "the group's own fill");
|
||||
// A compound path, type and a clipping path.
|
||||
let mut s = session();
|
||||
let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0));
|
||||
s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let inst = art_of_top(&mut s);
|
||||
set(&mut s, inst, json!([]));
|
||||
assert_eq!(pixel(&s, 30, 30), RED, "the compound path");
|
||||
let mut s = session();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap();
|
||||
let inst = art_of_top(&mut s);
|
||||
set(&mut s, inst, json!([]));
|
||||
let mut s = session();
|
||||
let (a, b) = (square(&mut s, 10.0), square(&mut s, 20.0));
|
||||
s.execute("object.clippingMask.make", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let inst = art_of_top(&mut s);
|
||||
assert!(matches!(&node(&s, inst).kind, NodeKind::SymbolInstance { .. }));
|
||||
set(&mut s, inst, json!([0]));
|
||||
set(&mut s, inst, json!([]));
|
||||
// An image whose fill has Convert to Shape: the fill paints 9 pt past its frame.
|
||||
let mut s = session();
|
||||
place_image(&mut s);
|
||||
s.execute("appearance.addFill", &json!({})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#00ff00", "item": 0})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "convertToShape.rectangle", "item": 0})).unwrap();
|
||||
assert_eq!(pixel(&s, 44, 35), [0, 255, 0, 255]);
|
||||
let inst = art_of_top(&mut s);
|
||||
let e = s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": "#ff0000"})).unwrap_err().to_string();
|
||||
assert!(e.contains("stroke cannot be set on an image without a Convert to Shape or Outline Object effect on it"), "{e}");
|
||||
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap();
|
||||
assert_eq!(pixel(&s, 44, 35), RED, "the shaped fill takes the instance's color");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -343,23 +343,139 @@ fn artboard_commands_read_the_recorded_selected_artboards() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `edit.copy` and `edit.cut` run the Artboard tool's copy and take no artboards, so their entries
|
||||
/// stay as given, apart from the locked-art preference the Artboard tool's copy notes.
|
||||
/// A Cut or Copy with the Artboard tool replays on the artboards it took in a new session, where
|
||||
/// another tool is chosen: with nothing selected there, and with an object selected that a Cut or
|
||||
/// Copy of objects would take. What it pastes is the same.
|
||||
#[test]
|
||||
fn edit_copy_and_cut_with_the_artboard_tool_record_their_params_as_given() {
|
||||
fn edit_copy_and_cut_with_the_artboard_tool_replay_on_its_artboards() {
|
||||
for id in ["edit.copy", "edit.cut"] {
|
||||
for select in ["select.all", "select.none"] {
|
||||
let mut s = Session::new();
|
||||
second_artboard_selected(&mut s);
|
||||
s.execute(select, &json!({})).unwrap();
|
||||
s.execute(id, &json!({})).unwrap();
|
||||
let boards = if id == "edit.cut" { 2 } else { 3 };
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), boards, "{id} after {select}");
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s), "{id} after {select}");
|
||||
let entry = s.journal.last().unwrap().clone();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), boards + 1, "{id} after {select}");
|
||||
let r = replayed(&s);
|
||||
assert_ne!(r.tool_id(), "artboard");
|
||||
assert_eq!(documents(&r), documents(&s), "{id} after {select}");
|
||||
assert_eq!(entry, (id.to_string(), json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false})), "{id} after {select}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Copy after a Paste takes the pasted artboards, which the Artboard tool selected, and so does its
|
||||
/// replay, though `edit.paste` selects nothing in a new session's tool.
|
||||
#[test]
|
||||
fn edit_copy_after_a_paste_with_the_artboard_tool_replays_on_the_pasted_artboards() {
|
||||
let mut s = Session::new();
|
||||
second_artboard_selected(&mut s);
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().1, json!({"selectedArtboards": [3], "art": true, "lockedAndHidden": false}));
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 5);
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s));
|
||||
}
|
||||
|
||||
/// A recorded action keeps `edit.copy` and `edit.cut` as given, so when it plays they follow the tool
|
||||
/// chosen then, and a batch step records and leaves out the same values. The locked-art preference
|
||||
/// that the Artboard tool's copy notes stays in the action.
|
||||
#[test]
|
||||
fn edit_copy_and_cut_with_the_artboard_tool_record_nothing_for_an_action() {
|
||||
for id in ["edit.copy", "edit.cut"] {
|
||||
let mut s = Session::new();
|
||||
second_artboard_selected(&mut s);
|
||||
let start = second_artboard_selected(&mut s);
|
||||
s.execute(id, &json!({})).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({"lockedAndHidden": false})), "{id}");
|
||||
let action = s.journal_for_action(start);
|
||||
assert_eq!(action, [(id.to_string(), json!({"lockedAndHidden": false}))], "{id}");
|
||||
let mut played = Session::new();
|
||||
played.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap();
|
||||
played.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
played.execute(id, &action[0].1).unwrap();
|
||||
assert_eq!(played.doc().unwrap().doc.artboards.len(), 1, "{id}");
|
||||
assert!(played.clipboard.artboards.is_empty(), "{id}");
|
||||
assert_eq!(played.clipboard.nodes.len(), 1, "{id}");
|
||||
|
||||
let mut s = Session::new();
|
||||
let start = second_artboard_selected(&mut s);
|
||||
let steps = json!([{"command": id, "params": {}}, {"command": "edit.paste", "params": {}}]);
|
||||
s.execute("command.batch", &json!({ "commands": steps })).unwrap();
|
||||
assert_eq!(
|
||||
s.journal.last().unwrap().1["commands"][0]["params"],
|
||||
json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}),
|
||||
"{id}"
|
||||
);
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s), "{id}");
|
||||
assert_eq!(s.journal_for_action(start)[0].1["commands"][0]["params"], json!({"lockedAndHidden": false}), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Without the Artboard tool, Cut and Copy of objects record nothing more than they did.
|
||||
#[test]
|
||||
fn edit_copy_and_cut_of_objects_record_their_params_as_given() {
|
||||
for id in ["edit.copy", "edit.cut"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
s.execute(id, &json!({})).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({})), "{id}");
|
||||
assert!(s.clipboard.artboards.is_empty(), "{id}");
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A recorded `selectedArtboards` needs only a document and is checked as `indices` is, as are `art`
|
||||
/// and the rule that a cut leaves one artboard. A failed call changes neither the document nor the
|
||||
/// clipboard.
|
||||
#[test]
|
||||
fn edit_copy_and_cut_read_the_recorded_artboards() {
|
||||
for id in ["edit.copy", "edit.cut"] {
|
||||
assert!(matches!(Session::new().execute(id, &json!({"selectedArtboards": [0]})), Err(crate::EngineError::Disabled(..))));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 300, "height": 100})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 1010, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..))), "{id}");
|
||||
let before = documents(&s);
|
||||
for bad in [
|
||||
json!({"selectedArtboards": [9]}),
|
||||
json!({"selectedArtboards": []}),
|
||||
json!({"selectedArtboards": "1"}),
|
||||
json!({"selectedArtboards": null}),
|
||||
json!({"selectedArtboards": [1], "art": "yes"}),
|
||||
] {
|
||||
let err = s.execute(id, &bad).unwrap_err().to_string();
|
||||
assert!(err.contains(id) || err.contains("no such artboard"), "{id} {bad}: {err}");
|
||||
assert_eq!(documents(&s), before, "{id} {bad}");
|
||||
assert!(s.clipboard.artboards.is_empty(), "{id} {bad}");
|
||||
}
|
||||
s.execute(id, &json!({"selectedArtboards": [1], "art": false})).unwrap();
|
||||
assert_eq!(s.clipboard.artboards.len(), 1, "{id}");
|
||||
assert!(s.clipboard.nodes.is_empty(), "{id}");
|
||||
assert_eq!(s.journal.last().unwrap().1, json!({"selectedArtboards": [1], "art": false}), "{id}");
|
||||
// Objects selected, `art` is still checked.
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let err = s.execute(id, &json!({"art": "yes"})).unwrap_err().to_string();
|
||||
assert!(err.contains(id), "{id}: {err}");
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap();
|
||||
assert!(s.execute("edit.cut", &json!({"selectedArtboards": [0]})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
|
||||
}
|
||||
|
||||
/// `artboard.duplicate` and the cut and copy record the tool's Move/Copy Artwork option: replayed in
|
||||
/// a new session, where the option is on, the art stays behind as it did.
|
||||
#[test]
|
||||
fn artboard_commands_record_the_tools_move_art_option() {
|
||||
for id in ["artboard.duplicate", "artboard.cut", "artboard.copy"] {
|
||||
for id in ["artboard.duplicate", "artboard.cut", "artboard.copy", "edit.cut", "edit.copy"] {
|
||||
let mut s = Session::new();
|
||||
second_artboard_selected(&mut s);
|
||||
s.set_tool_option("moveArt", &json!(false));
|
||||
|
||||
@@ -164,6 +164,48 @@ fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() {
|
||||
// The object's own effect, and the fill's (the first appearance item).
|
||||
for (effect, params, item) in [
|
||||
("warp.arc", json!({"bend": 40}), json!(null)),
|
||||
("warp.bulge", json!({"bend": 40, "orientation": "vertical"}), json!(null)),
|
||||
("warp.inflate", json!({"bend": 40}), json!(0)),
|
||||
] {
|
||||
let mut s = session();
|
||||
let id = gradient_rect(&mut s, Rect::new(40.0, 70.0, 160.0, 130.0), white_black(), false);
|
||||
// Placed from a quarter of the way across to the right edge.
|
||||
run(&mut s, "paint.setGradientGeom", json!({"start": [70, 100], "end": [160, 100], "stroke": false}));
|
||||
let placed = s.doc().unwrap().doc.node(id).and_then(|n| match n.appearance.fill_paint() {
|
||||
vectorcraft_color::Paint::Gradient(g) => g.geom,
|
||||
_ => None,
|
||||
});
|
||||
assert!(placed.is_some(), "the fill's gradient is placed");
|
||||
run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item}));
|
||||
let before = render(&s);
|
||||
// Fill alone: the gradient becomes strips inside the outline the warp makes.
|
||||
run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64}));
|
||||
let after = render(&s);
|
||||
let diff = mean_diff(&before, &after);
|
||||
assert!(diff < 1.0, "{effect} {params} on item {item}: mean difference {diff}");
|
||||
// The ramp's midpoint, 115 pt before the warp, moves and scales with the warped bounds.
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let b = doc.node(doc.layers[0].id).and_then(|l| l.geometric_bounds()).unwrap();
|
||||
let want = b.x0 + (115.0 - 40.0) / 120.0 * b.width();
|
||||
assert!((want - 115.0).abs() > 2.0, "{effect} {params}: the warp moves the midpoint ({b:?})");
|
||||
let red = |x: usize| f64::from(after.pixels[(100 * 200 + x) * 4]);
|
||||
// White turns dark once on row 100, and gradually, so the crossing is the ramp's midpoint
|
||||
// and not an edge of the outline.
|
||||
let crossings: Vec<usize> =
|
||||
(b.x0.ceil() as usize + 3..b.x1.floor() as usize - 4).filter(|&x| red(x) >= 127.5 && red(x + 1) < 127.5).collect();
|
||||
assert_eq!(crossings.len(), 1, "{effect} {params}: white turns dark once on row 100: {crossings:?}");
|
||||
let x = crossings[0];
|
||||
assert!(red(x - 2) - red(x + 3) < 100.0, "{effect} {params}: the ramp, not an edge: {} to {}", red(x - 2), red(x + 3));
|
||||
let half = x as f64 + 0.5 + (red(x) - 127.5) / (red(x) - red(x + 1));
|
||||
assert!((half - want).abs() < 1.0, "{effect} {params} on item {item}: the midpoint is at {half}, want {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_strips_match_the_gradient_samples() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Outline Stroke (`object.path.outlineStroke`) and the live Outline Stroke effect produce the
|
||||
//! stroke as the canvas paints it: dashes, arrowheads, width profiles, alignment, every stroke
|
||||
//! item in order, with the stroke's opacity and blend mode moved to the new fill.
|
||||
//! item in order, with the stroke's opacity and blend mode moved to the new fill. Outline Stroke,
|
||||
//! Expand's Stroke option and Flatten Transparency's Convert All Strokes to Outlines apply the
|
||||
//! object's geometry effects and each stroke's own to the path before outlining that stroke.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::BlendMode;
|
||||
@@ -180,6 +182,329 @@ fn the_live_effect_outlines_the_stroke_it_sits_on() {
|
||||
assert!(close(g.bounds().unwrap(), Rect::new(0.0, 49.0, 100.0, 51.0), 0.01), "{:?}", g.bounds());
|
||||
}
|
||||
|
||||
fn render(s: &Session) -> vectorcraft_render::Rendered {
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
vectorcraft_render::Renderer::new().render(&doc, 400, 300, vectorcraft_geom::Affine::IDENTITY, &opts)
|
||||
}
|
||||
|
||||
/// Pixels whose color differs by more than `tol` in any channel.
|
||||
fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, tol: u8) -> usize {
|
||||
a.pixels
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(b.pixels.as_chunks::<4>().0)
|
||||
.filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Does `n` or anything in it have a geometry effect, its own or a fill's or stroke's?
|
||||
fn has_geometry_effects(n: &Node) -> bool {
|
||||
let geometry = |e: &vectorcraft_doc::Effect| vectorcraft_render::effects::is_geometry(&e.id);
|
||||
let mut found = false;
|
||||
n.walk(&mut |m| found |= m.appearance.effects.iter().any(geometry) || m.appearance.items.iter().any(|i| i.effects().iter().any(geometry)));
|
||||
found
|
||||
}
|
||||
|
||||
/// A selected 100 pt square at (150, 100) with a 4 pt black stroke and fill `fill` (paint.setFill's),
|
||||
/// and `effects` (effect.apply's `effect` and `params`) on appearance item `item` (`null`: on the
|
||||
/// object).
|
||||
fn square_with(s: &mut Session, fill: Value, item: Value, effects: &[(&str, Value)]) {
|
||||
s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap();
|
||||
s.execute("paint.setFill", &fill).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
|
||||
for (effect, params) in effects {
|
||||
s.execute("effect.apply", &json!({"effect": effect, "params": params, "item": item})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The geometry effects the tests put on the stroke or on the object.
|
||||
fn geometry_effects() -> [(&'static str, Value); 2] {
|
||||
[("path.offsetPath", json!({"offset": 10})), ("distort.zigZag", json!({"size": 6, "ridges": 3}))]
|
||||
}
|
||||
|
||||
/// The commands that outline strokes: Outline Stroke, Expand with Stroke alone, and Flatten
|
||||
/// Transparency with Convert All Strokes to Outlines.
|
||||
fn outlining() -> [(&'static str, Value); 3] {
|
||||
[
|
||||
("object.path.outlineStroke", json!({})),
|
||||
("object.expand", json!({"object": false, "fill": false, "stroke": true})),
|
||||
("object.flattenTransparency", json!({"strokesToOutlines": true})),
|
||||
]
|
||||
}
|
||||
|
||||
/// What `command` with `params` leaves in place of the selected object.
|
||||
fn outlined_by(s: &mut Session, command: &str, params: &Value) -> Node {
|
||||
let r = s.execute(command, params).unwrap_or_else(|e| panic!("{command}: {e}"));
|
||||
let id = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
s.doc().unwrap().doc.node(id).cloned().unwrap()
|
||||
}
|
||||
|
||||
/// For each command in [`outlining`] and each of [`geometry_effects`] on `item` of a square filled
|
||||
/// with `fill`: the pixels that differ by more than 64 from the canvas before, and whether a
|
||||
/// geometry effect is left on the result.
|
||||
fn outlined_as_drawn(fill: Value, item: Value) -> Vec<(&'static str, &'static str, usize, bool)> {
|
||||
let mut results = vec![];
|
||||
for (command, p) in outlining() {
|
||||
for (effect, params) in geometry_effects() {
|
||||
let mut s = session();
|
||||
square_with(&mut s, fill.clone(), item.clone(), &[(effect, params)]);
|
||||
let before = render(&s);
|
||||
let n = outlined_by(&mut s, command, &p);
|
||||
results.push((command, effect, differing(&before, &render(&s), 64), has_geometry_effects(&n)));
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
/// What [`outlined_as_drawn`] gives when every outline looks as the canvas drew the object.
|
||||
fn as_drawn() -> Vec<(&'static str, &'static str, usize, bool)> {
|
||||
outlining().iter().flat_map(|(command, _)| geometry_effects().map(|(effect, _)| (*command, effect, 0, false))).collect()
|
||||
}
|
||||
|
||||
/// A stroke's own geometry effects reshape the path it strokes, as on the canvas: the outline is
|
||||
/// the stroke of the offset or zig-zagged path, not the stroke's outline offset or zig-zagged.
|
||||
#[test]
|
||||
fn a_geometry_effect_on_the_stroke_shapes_the_path_it_outlines() {
|
||||
assert_eq!(outlined_as_drawn(json!({"none": true}), json!(1)), as_drawn());
|
||||
// The stroke of the square offset by 10: 4 pt wide around a 120 pt square.
|
||||
let mut s = session();
|
||||
square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": 10}))]);
|
||||
let n = outline(&mut s);
|
||||
assert!(close(n.geometric_bounds().unwrap(), Rect::new(138.0, 88.0, 262.0, 212.0), 0.01), "{:?}", n.geometric_bounds());
|
||||
assert!(near(area(&n), 124.0 * 124.0 - 116.0 * 116.0, 2.0), "{}", area(&n));
|
||||
}
|
||||
|
||||
/// A stroke whose geometry effect leaves no path paints nothing on the canvas, and outlining it
|
||||
/// replaces the live stroke with an empty group.
|
||||
#[test]
|
||||
fn a_stroke_its_effects_empty_is_outlined_to_nothing() {
|
||||
for (command, p) in outlining() {
|
||||
let mut s = session();
|
||||
square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": -60}))]);
|
||||
let before = render(&s);
|
||||
let n = outlined_by(&mut s, command, &p);
|
||||
assert!(matches!(&n.kind, NodeKind::Group { children, .. } if children.is_empty()), "{command}: {n:?}");
|
||||
let mut stroked = false;
|
||||
for layer in &s.doc().unwrap().doc.layers {
|
||||
layer.walk(&mut |m| stroked |= m.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))));
|
||||
}
|
||||
assert!(!stroked, "{command} left a live stroke");
|
||||
assert_eq!(differing(&before, &render(&s), 64), 0, "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A drop shadow on a stroke with a geometry effect stays on the outline, which draws as the
|
||||
/// stroke did.
|
||||
#[test]
|
||||
fn the_outline_of_a_reshaped_stroke_keeps_its_drop_shadow() {
|
||||
let mut s = session();
|
||||
square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": 10})), ("stylize.dropShadow", json!({}))]);
|
||||
let before = render(&s);
|
||||
let n = outline(&mut s);
|
||||
let kept: Vec<&str> = n.appearance.items.iter().flat_map(|i| i.effects()).map(|e| e.id.as_str()).collect();
|
||||
assert_eq!(kept, ["stylize.dropShadow"]);
|
||||
assert_eq!(differing(&before, &render(&s), 64), 0, "the shadowed outline draws as the stroke did");
|
||||
}
|
||||
|
||||
/// The brush art of a brushed stroke with a geometry effect follows the path the effect makes.
|
||||
#[test]
|
||||
fn a_brush_draws_along_the_path_the_stroke_effects_make() {
|
||||
let mut s = session();
|
||||
square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": 10}))]);
|
||||
s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap();
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
let st = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().cloned().unwrap();
|
||||
assert!(st.brush.is_some() && st.effects.len() == 1, "{st:?}");
|
||||
let before = render(&s);
|
||||
let n = outline(&mut s);
|
||||
assert!(!vectorcraft_brush::has_brush(&n) && !has_geometry_effects(&n), "brush art: {n:?}");
|
||||
assert_eq!(differing(&before, &render(&s), 64), 0, "the brush art draws as the brushed stroke did");
|
||||
}
|
||||
|
||||
/// The object's geometry effects reshape the path its fills paint and its strokes outline, as on
|
||||
/// the canvas, and do not stay on the result.
|
||||
#[test]
|
||||
fn a_geometry_effect_on_the_object_shapes_the_path_it_outlines() {
|
||||
assert_eq!(outlined_as_drawn(json!({"color": "#33aa55"}), Value::Null), as_drawn());
|
||||
}
|
||||
|
||||
/// The canvas paints a gradient fill or stroke on each copy that the object's Transform effect
|
||||
/// makes by itself; the outlined result draws the same, with the gradient fitted to each copy.
|
||||
#[test]
|
||||
fn each_transform_copy_keeps_its_own_gradient() {
|
||||
let mut differ = vec![];
|
||||
for on_stroke in [false, true] {
|
||||
for (command, p) in outlining() {
|
||||
let mut s = session();
|
||||
let fill = if on_stroke { json!({"none": true}) } else { json!({"gradient": {"kind": "linear"}}) };
|
||||
square_with(&mut s, fill, Value::Null, &[("distort.transform", json!({"moveH": 120, "copies": 1}))]);
|
||||
if on_stroke {
|
||||
s.execute("paint.setStroke", &json!({"gradient": {"kind": "linear"}})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 12})).unwrap();
|
||||
}
|
||||
let before = render(&s);
|
||||
outlined_by(&mut s, command, &p);
|
||||
differ.push((on_stroke, command, differing(&before, &render(&s), 64)));
|
||||
}
|
||||
}
|
||||
let none_differ: Vec<_> = [false, true].into_iter().flat_map(|on_stroke| outlining().map(|(command, _)| (on_stroke, command, 0))).collect();
|
||||
assert_eq!(differ, none_differ);
|
||||
}
|
||||
|
||||
/// With Scale Strokes & Effects on, the canvas draws the stroke of a solid square that its
|
||||
/// Transform effect scales to 200% at 8 pt; the outlined result draws the same.
|
||||
#[test]
|
||||
fn a_transform_that_scales_strokes_outlines_the_scaled_stroke() {
|
||||
let scale = json!({"scaleH": 200, "scaleV": 200, "strokes": true});
|
||||
let mut differ = vec![];
|
||||
for (command, p) in outlining() {
|
||||
let mut s = session();
|
||||
square_with(&mut s, json!({"color": "#33aa55"}), Value::Null, &[("distort.transform", scale.clone())]);
|
||||
let before = render(&s);
|
||||
outlined_by(&mut s, command, &p);
|
||||
differ.push((command, differing(&before, &render(&s), 64)));
|
||||
}
|
||||
let none_differ: Vec<_> = outlining().map(|(command, _)| (command, 0)).into_iter().collect();
|
||||
assert_eq!(differ, none_differ);
|
||||
}
|
||||
|
||||
/// Random in a Transform effect takes its seed from the object's id. The outline draws as the
|
||||
/// canvas drew the object with Random in a Transform effect that copies a gradient square, in a
|
||||
/// Transform effect on the stroke of such a square, and in a Transform effect on the fill of a
|
||||
/// plain square.
|
||||
#[test]
|
||||
fn outlines_keep_the_random_transforms_the_canvas_drew() {
|
||||
let random = json!({"moveH": 30, "moveV": 30, "rotate": 40, "random": true});
|
||||
let cases = [
|
||||
("random copies", json!({"gradient": {"kind": "linear"}}), json!({"moveH": 120, "rotate": 30, "copies": 1, "random": true}), None),
|
||||
("copies, random stroke", json!({"gradient": {"kind": "linear"}}), json!({"moveH": 120, "copies": 1}), Some(1)),
|
||||
("random fill", json!({"color": "#33aa55"}), json!({}), Some(0)),
|
||||
];
|
||||
let mut differ = vec![];
|
||||
for (case, fill, object, item) in &cases {
|
||||
for (command, p) in outlining() {
|
||||
let mut s = session();
|
||||
square_with(&mut s, fill.clone(), Value::Null, &[]);
|
||||
if object != &json!({}) {
|
||||
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": object})).unwrap();
|
||||
}
|
||||
s.execute("stroke.set", &json!({"weight": 12})).unwrap();
|
||||
if let Some(item) = item {
|
||||
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": random, "item": item})).unwrap();
|
||||
}
|
||||
let before = render(&s);
|
||||
outlined_by(&mut s, command, &p);
|
||||
differ.push((*case, command, differing(&before, &render(&s), 64)));
|
||||
}
|
||||
}
|
||||
let none_differ: Vec<_> = cases.iter().flat_map(|(case, ..)| outlining().map(|(command, _)| (*case, command, 0))).collect();
|
||||
assert_eq!(differ, none_differ);
|
||||
}
|
||||
|
||||
/// For each command in [`outlining`] on a stroke-only square with `effects` on appearance item
|
||||
/// `item`: the pixels that differ by more than 64 from the canvas before, whether a geometry
|
||||
/// effect is left on the result, and (for Outline Stroke) the outline's box and area.
|
||||
fn outlined_square(item: Value, effects: &[(&str, Value)], hide: bool) -> (Vec<(&'static str, usize, bool)>, Rect, f64) {
|
||||
let mut results = vec![];
|
||||
let mut shape = (Rect::ZERO, 0.0);
|
||||
for (command, p) in outlining() {
|
||||
let mut s = session();
|
||||
square_with(&mut s, json!({"none": true}), item.clone(), effects);
|
||||
if hide {
|
||||
for index in 0..effects.len() {
|
||||
s.execute("effect.setParams", &json!({"index": index, "visible": false, "item": item})).unwrap();
|
||||
}
|
||||
}
|
||||
let before = render(&s);
|
||||
let n = outlined_by(&mut s, command, &p);
|
||||
if command == "object.path.outlineStroke" {
|
||||
shape = (n.geometric_bounds().unwrap(), area(&n));
|
||||
}
|
||||
results.push((command, differing(&before, &render(&s), 64), has_geometry_effects(&n)));
|
||||
}
|
||||
(results, shape.0, shape.1)
|
||||
}
|
||||
|
||||
/// What [`outlined_square`] gives for each command when the outline draws as the canvas did and
|
||||
/// keeps no geometry effect.
|
||||
fn drawn_plain() -> Vec<(&'static str, usize, bool)> {
|
||||
outlining().map(|(command, _)| (command, 0, false)).into_iter().collect()
|
||||
}
|
||||
|
||||
/// A path with no effects outlines its own stroke: 4 pt wide around the 100 pt square.
|
||||
#[test]
|
||||
fn a_path_without_effects_outlines_its_own_stroke() {
|
||||
let (results, bounds, a) = outlined_square(Value::Null, &[], false);
|
||||
assert_eq!(results, drawn_plain());
|
||||
assert!(close(bounds, Rect::new(148.0, 98.0, 252.0, 202.0), 0.01), "{bounds:?}");
|
||||
assert!(near(a, 104.0 * 104.0 - 96.0 * 96.0, 0.5), "{a}");
|
||||
}
|
||||
|
||||
/// Round Corners on the object rounds the path whose stroke is outlined, with the radius in
|
||||
/// points as the canvas draws it: corners of radius 12 outside and 8 inside.
|
||||
#[test]
|
||||
fn round_corners_on_the_object_round_the_stroke_it_outlines() {
|
||||
let (results, bounds, a) = outlined_square(Value::Null, &[("stylize.roundCorners", json!({"radius": 10}))], false);
|
||||
assert_eq!(results, drawn_plain());
|
||||
assert!(close(bounds, Rect::new(148.0, 98.0, 252.0, 202.0), 0.01), "{bounds:?}");
|
||||
let rounded = 104.0 * 104.0 - 96.0 * 96.0 - (4.0 - std::f64::consts::PI) * (12.0 * 12.0 - 8.0 * 8.0);
|
||||
assert!(near(a, rounded, 2.0), "{a}, rounded {rounded}");
|
||||
}
|
||||
|
||||
/// A hidden geometry effect, on the object or on the stroke, does not draw on the canvas and does
|
||||
/// not shape the outline.
|
||||
#[test]
|
||||
fn a_hidden_geometry_effect_does_not_shape_the_outline() {
|
||||
for item in [Value::Null, json!(1)] {
|
||||
let (results, bounds, a) = outlined_square(item.clone(), &geometry_effects(), true);
|
||||
let drawn: Vec<_> = results.iter().map(|(command, differ, _)| (*command, *differ)).collect();
|
||||
assert_eq!(drawn, drawn_plain().into_iter().map(|(command, differ, _)| (command, differ)).collect::<Vec<_>>(), "item {item}");
|
||||
assert!(close(bounds, Rect::new(148.0, 98.0, 252.0, 202.0), 0.01), "item {item}: {bounds:?}");
|
||||
assert!(near(a, 104.0 * 104.0 - 96.0 * 96.0, 0.5), "item {item}: {a}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An effect on the object that is not a geometry effect, such as a Gaussian Blur, stays on the
|
||||
/// result of Outline Stroke and Expand while the geometry effect shapes the path. Flatten
|
||||
/// Transparency renders raster effects into an image instead.
|
||||
#[test]
|
||||
fn an_effect_that_is_not_geometry_stays_on_the_result() {
|
||||
for (command, p) in outlining().into_iter().filter(|(command, _)| *command != "object.flattenTransparency") {
|
||||
let mut s = session();
|
||||
square_with(
|
||||
&mut s,
|
||||
json!({"color": "#33aa55"}),
|
||||
Value::Null,
|
||||
&[("distort.zigZag", json!({"size": 6, "ridges": 3})), ("blur.gaussian", json!({}))],
|
||||
);
|
||||
let n = outlined_by(&mut s, command, &p);
|
||||
let kept: Vec<&str> = n.appearance.effects.iter().map(|e| e.id.as_str()).collect();
|
||||
assert_eq!(kept, ["blur.gaussian"], "{command}");
|
||||
assert!(!has_geometry_effects(&n), "{command}: {n:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo after outlining a path with geometry effects on the object and on its stroke gives back
|
||||
/// the path as it was, effects included, and its selection.
|
||||
#[test]
|
||||
fn undo_gives_back_the_path_with_its_effects() {
|
||||
for (command, p) in outlining() {
|
||||
let mut s = session();
|
||||
square_with(&mut s, json!({"color": "#33aa55"}), Value::Null, &[("distort.zigZag", json!({"size": 6, "ridges": 3}))]);
|
||||
s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}, "item": 1})).unwrap();
|
||||
let before = s.doc().unwrap().doc.layers.clone();
|
||||
let selected = s.doc().unwrap().selection.objects.clone();
|
||||
outlined_by(&mut s, command, &p);
|
||||
assert_ne!(s.doc().unwrap().doc.layers, before, "{command} changed nothing");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.layers, before, "{command}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, selected, "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brushed_strokes_outline_to_their_brush_art() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -151,6 +151,42 @@ fn return_in_the_type_tool_continues_the_paragraph_it_splits() {
|
||||
assert_eq!(justs(&s, id), [Justify::Right, Justify::Center]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_at_the_start_of_a_list_item_moves_it_a_level_down_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let id = area(&mut s, "one\ntwo");
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap();
|
||||
s.select_tool("type", v).unwrap();
|
||||
// Click into the second line, then Home: the caret before "two".
|
||||
let t = text(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let l1 = &lay.lines[1];
|
||||
let at = t.xf * vectorcraft_geom::Point::new((l1.x0 + l1.x1) / 2.0, l1.baseline - 3.0);
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, at.x, at.y), v).unwrap();
|
||||
}
|
||||
s.tool_key(ToolKey::Home, Mods::default(), v).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.tool_key(ToolKey::Tab, Mods::default(), v).unwrap();
|
||||
let levels = |s: &Session| text(s, id).paragraph_styles().iter().map(|p| p.list.map(|l| l.level)).collect::<Vec<_>>();
|
||||
assert_eq!(levels(&s), [Some(1), Some(2)]);
|
||||
assert_eq!((text(&s, id).plain_text(), s.doc().unwrap().history.undo.len()), ("one\ntwo".to_string(), undo + 1));
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id));
|
||||
let starts: Vec<f64> = lay.markers.iter().map(|m| lay.glyphs[m.glyphs.start].origin.x).collect();
|
||||
assert!((starts[1] - starts[0] - 18.0).abs() < 1e-6, "the item moved 18 pt in: {starts:?}");
|
||||
assert_eq!(lay.markers.iter().map(|m| m.text.as_str()).collect::<Vec<_>>(), ["1.", "1."]);
|
||||
// Shift+Tab moves it back up, and typing then goes on at the caret.
|
||||
s.tool_key(ToolKey::Tab, Mods { shift: true, ..Mods::default() }, v).unwrap();
|
||||
assert_eq!(levels(&s), [Some(1), Some(1)]);
|
||||
s.tool_text("and ", v).unwrap();
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
assert_eq!(text(&s, id).plain_text(), "one\nand two");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((text(&s, id).plain_text(), levels(&s)), ("one\ntwo".to_string(), vec![Some(1), Some(2)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_style_applies_to_one_paragraph() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -64,7 +64,7 @@ fn an_unknown_key_is_named_with_the_nearest_accepted_one() {
|
||||
("file.new", json!(null)),
|
||||
// Keys listed by reference, and any key where the description says so.
|
||||
("file.newPresets.save", json!({"name": "a", "width": 300})),
|
||||
("document.export", json!({"format": "png", "anyFormatOption": 1})),
|
||||
("document.export", json!({"format": "png", "styling": "style"})),
|
||||
// Not a command: running it says so.
|
||||
("no.such", json!({"x": 1})),
|
||||
] {
|
||||
@@ -74,6 +74,109 @@ fn an_unknown_key_is_named_with_the_nearest_accepted_one() {
|
||||
assert!(check_keys("x", "{a, …as file.new}", &json!({"b": 1})).is_err());
|
||||
}
|
||||
|
||||
/// An export's options: a key no format reads is refused, at the top level and in the PDF objects,
|
||||
/// under each command that takes them.
|
||||
#[test]
|
||||
fn export_options_refuse_a_key_no_format_reads() {
|
||||
for id in ["document.export", "document.exportSelection", "document.serialize"] {
|
||||
assert_eq!(
|
||||
refusal(id, json!({"format": "pdf", "standard": "pdfX4", "noSuchOption": true})),
|
||||
"unknown parameter `noSuchOption`; no format takes it (see document.formats)"
|
||||
);
|
||||
assert_eq!(refusal(id, json!({"format": "pdf", "standardd": "pdfX4"})), "unknown parameter `standardd`; did you mean `standard`?");
|
||||
assert_eq!(
|
||||
refusal(id, json!({"format": "pdf", "options": {"standard": "pdfX4"}})),
|
||||
"unknown parameter `options`; no format takes it (see document.formats)"
|
||||
);
|
||||
}
|
||||
// A command that writes one format refuses another format's option, and says which format.
|
||||
for (id, why) in [
|
||||
("document.exportPdf", "PDF doesn't take it"),
|
||||
("document.pdfSettings", "PDF doesn't take it"),
|
||||
("pdf.preset.save", "PDF doesn't take it"),
|
||||
("document.exportEps", "EPS doesn't take it"),
|
||||
("document.exportDxf", "DXF doesn't take it"),
|
||||
] {
|
||||
for key in ["noSuchOption", "quality"] {
|
||||
assert_eq!(refusal(id, json!({key: 1})), format!("unknown parameter `{key}`; {why} (see document.formats)"));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
refusal("document.exportEps", json!({"flattener": {"balanse": 50}})),
|
||||
"unknown parameter `flattener.balanse`; did you mean `flattener.balance`?"
|
||||
);
|
||||
assert!(refusal("document.exportDxf", json!({"flattener": {}})).contains("DXF doesn't take it"));
|
||||
assert!(refusal("document.exportEps", json!({"marks": {}})).contains("EPS doesn't take it"));
|
||||
for id in ["document.pdfSettings", "pdf.preset.save"] {
|
||||
assert!(refusal(id, json!({"marks": {"useDocumentBleed": true}})).starts_with("unknown parameter `marks.useDocumentBleed`;"), "{id}");
|
||||
}
|
||||
for (given, message) in [
|
||||
(json!({"marks": {"useDocumentBleed": true}}), "unknown parameter `marks.useDocumentBleed`; `marks` takes:"),
|
||||
(json!({"bleed": {"useDocumen": true}}), "unknown parameter `bleed.useDocumen`; did you mean `bleed.useDocument`?"),
|
||||
(json!({"output": {"intent": "x"}}), "unknown parameter `output.intent`;"),
|
||||
(json!({"advanced": {"outlineTxt": true}}), "unknown parameter `advanced.outlineTxt`; did you mean `advanced.outlineText`?"),
|
||||
(json!({"security": {"openPasswrd": "x"}}), "unknown parameter `security.openPasswrd`; did you mean `security.openPassword`?"),
|
||||
(json!({"compression": {"color": {"dpi": 300}}}), "unknown parameter `compression.color.dpi`;"),
|
||||
(json!({"flattener": {"balanse": 50}}), "unknown parameter `flattener.balanse`; did you mean `flattener.balance`?"),
|
||||
] {
|
||||
for id in ["document.export", "document.exportPdf"] {
|
||||
let got = refusal(id, given.clone());
|
||||
assert!(got.starts_with(message), "{id} {given}: {got}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The check passes each key `document.formats` lists, each key a command's own description names,
|
||||
/// and each field of the PDF settings and the flattener options. A key an encoder reads that none
|
||||
/// of these names is outside this test.
|
||||
#[test]
|
||||
fn export_options_pass_every_listed_key() {
|
||||
use crate::cmd::fileio::{FORMATS, dxf, eps, pdf::OPTIONS};
|
||||
for f in FORMATS.iter().filter(|f| f.write) {
|
||||
for o in f.options {
|
||||
let params = json!({"format": f.id, o.name: null});
|
||||
assert!(check_params("document.export", ¶ms).is_ok(), "{params}");
|
||||
}
|
||||
}
|
||||
for (id, options) in [("document.exportEps", eps::OPTIONS), ("document.exportDxf", dxf::OPTIONS), ("document.exportPdf", OPTIONS)] {
|
||||
for o in options {
|
||||
assert!(check_params(id, &json!({o.name: null})).is_ok(), "{id} {}", o.name);
|
||||
}
|
||||
}
|
||||
for id in [
|
||||
"document.export",
|
||||
"document.exportSelection",
|
||||
"document.serialize",
|
||||
"document.exportPdf",
|
||||
"document.pdfSettings",
|
||||
"pdf.preset.save",
|
||||
"document.exportEps",
|
||||
"document.exportDxf",
|
||||
] {
|
||||
// An export reads these two when it writes over the file a lossy document came from. The
|
||||
// PDF settings and preset commands take them through document.exportPdf's description.
|
||||
check_params(id, &json!({"path": "a", "acknowledgeLoss": true})).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
let keys = doc_keys(find_command(id).unwrap().params).unwrap().keys;
|
||||
let refused: Vec<&str> = keys.into_iter().filter(|key| check_params(id, &json!({*key: null})).is_err()).collect();
|
||||
// `document.exportPdf`'s description explains the page boxes as `BleedBox = …` between its
|
||||
// keys, which reads as two more keys.
|
||||
let prose = if id == "document.exportPdf" { vec!["BleedBox", "MediaBox"] } else { vec![] };
|
||||
assert_eq!(refused, prose, "{id} names and refuses these");
|
||||
}
|
||||
let settings = serde_json::to_value(vectorcraft_pdf::PdfSettings::default()).unwrap();
|
||||
for key in settings.as_object().unwrap().keys() {
|
||||
assert!(OPTIONS.iter().any(|o| o.name == key), "document.formats doesn't list the PDF option `{key}`");
|
||||
}
|
||||
let mut every = settings;
|
||||
every["flattener"] = serde_json::to_value(crate::cmd::flatten::FlattenOptions::default()).unwrap();
|
||||
every["security"]["openPassword"] = json!("a");
|
||||
every["security"]["permissionsPassword"] = json!("b");
|
||||
every["preset"] = json!("High Quality Print");
|
||||
for id in ["document.export", "document.exportPdf", "document.pdfSettings", "pdf.preset.save"] {
|
||||
check_params(id, &every).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// `stroke.set` names every key it reads (its own, `item`, and `ids`/`id`/`target`), so a
|
||||
/// misnamed one is refused instead of answering `objects: 1` with nothing changed.
|
||||
#[test]
|
||||
|
||||
@@ -1055,6 +1055,225 @@ fn a_cancelled_filter_run_changes_nothing() {
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
/// The manifest of a filter `id` named "Cmd Test" of `kind` with `commands` (a JSON list).
|
||||
fn with_commands(id: &str, kind: &str, commands: &str) -> String {
|
||||
format!(r#"{{"id":"{id}","name":"Cmd Test","version":"1","kind":"{kind}","commands":{commands}}}"#)
|
||||
}
|
||||
|
||||
/// Two commands: Grow, with a parameter, and Tidy, whose `menu` names another menu.
|
||||
const GROW_AND_TIDY: &str = r#"[{"id":"grow","label":"Grow","params":{"by":{"type":"number","min":0,"max":10,"default":2}}},
|
||||
{"id":"tidy","label":"Tidy","menu":"File"}]"#;
|
||||
|
||||
/// A plug-in that adds a triangle when it runs as its command `grow` (`_context.command`) and
|
||||
/// returns its input unchanged otherwise.
|
||||
fn grower(id: &str) -> String {
|
||||
let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#;
|
||||
tpl::when_given(&with_commands(id, "filter", GROW_AND_TIDY), tpl::Given::Params, r#""command":"grow""#, triangle)
|
||||
}
|
||||
|
||||
/// The command listing's entry for `id`.
|
||||
fn listed(s: &Session, id: &str) -> Option<crate::cmd::CommandInfo> {
|
||||
s.commands().into_iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
fn objects(s: &Session) -> usize {
|
||||
s.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum()
|
||||
}
|
||||
|
||||
/// A plug-in's commands are commands of their own while it is installed: listed under Object ›
|
||||
/// Plug-ins › its name whatever menu the manifest names, run with their checked parameters and
|
||||
/// `_context.command` as one undo step named after them, and gone once it is removed or replaced
|
||||
/// by a module without them.
|
||||
#[test]
|
||||
fn plugin_commands_are_registered_run_and_removed() {
|
||||
let mut s = session();
|
||||
let id = "test.engine.cmds";
|
||||
let (grow, tidy) = ("plugin.test.engine.cmds.grow", "plugin.test.engine.cmds.tidy");
|
||||
let info = install_wat(&mut s, &grower(id));
|
||||
assert_eq!(info["commands"][0]["command"], grow);
|
||||
assert_eq!(info["commands"][1]["label"], "Tidy");
|
||||
for (cmd, label) in [(grow, "Grow"), (tidy, "Tidy")] {
|
||||
let c = listed(&s, cmd).unwrap_or_else(|| panic!("{cmd} is listed"));
|
||||
assert_eq!((c.label, c.menu.as_slice()), (label, &["Object", "Plug-ins", "Cmd Test"][..]), "{cmd}");
|
||||
assert!(c.enabled, "{cmd}");
|
||||
}
|
||||
assert!(crate::cmd::find_command(grow).is_some());
|
||||
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let (before, undo) = (objects(&s), s.doc().unwrap().history.undo.len());
|
||||
let r = run(&mut s, grow, json!({"by": 3}));
|
||||
assert_eq!((r["plugin"].as_str(), r["command"].as_str()), (Some(id), Some("grow")));
|
||||
assert_eq!(objects(&s), before + 1, "the command added its triangle");
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.history.undo.len(), st.history.undo.last().unwrap().label.as_str()), (undo + 1, "Grow"));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(objects(&s), before);
|
||||
// Its parameters are checked before it runs.
|
||||
let e = s.execute(grow, &json!({"by": "lots"})).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == grow), "{e}");
|
||||
assert_eq!(objects(&s), before);
|
||||
// Another command of the same plug-in is told apart by its id.
|
||||
run(&mut s, tidy, json!({}));
|
||||
assert_eq!(objects(&s), before, "tidy returns its input unchanged");
|
||||
|
||||
// Replaced by a module without commands, its commands are gone; installed again, back.
|
||||
install_wat(&mut s, &tpl::module(&tpl::manifest(id, "filter", "{}"), tpl::ECHO, "", 1));
|
||||
assert!(listed(&s, grow).is_none() && crate::cmd::find_command(grow).is_none());
|
||||
install_wat(&mut s, &grower(id));
|
||||
assert!(listed(&s, grow).is_some());
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
assert!(listed(&s, grow).is_none() && listed(&s, tidy).is_none());
|
||||
assert!(s.execute(grow, &json!({})).is_err(), "removed, it no longer runs");
|
||||
}
|
||||
|
||||
/// A command run is journaled like any other, so an action records it with its parameters and
|
||||
/// plays it back.
|
||||
#[test]
|
||||
fn actions_record_and_play_plugin_commands() {
|
||||
let mut s = session();
|
||||
let id = "test.engine.cmds.actions";
|
||||
let grow = crate::cmd::plugin::command_id(id, "grow");
|
||||
install_wat(&mut s, &grower(id));
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let before = objects(&s);
|
||||
let start = s.journal.len();
|
||||
run(&mut s, &grow, json!({"by": 4}));
|
||||
let steps = s.journal_for_action(start);
|
||||
assert_eq!(steps, [(grow.clone(), json!({"by": 4}))]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let commands: Vec<Value> = steps.iter().map(|(c, p)| json!({"command": c, "params": p})).collect();
|
||||
// The triangle a run adds is selected, and the next run would be given it: deselect between.
|
||||
let none = json!({"command": "select.none", "params": {}});
|
||||
run(&mut s, "command.batch", json!({"label": "Grow Twice", "commands": [commands[0].clone(), none, commands[0].clone()]}));
|
||||
assert_eq!(objects(&s), before + 2, "both steps ran");
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A command that traps leaves the document and its history as they were, and the error names
|
||||
/// the plug-in and the command.
|
||||
#[test]
|
||||
fn a_failing_plugin_command_changes_nothing_and_names_itself() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 10.0, "#ff0000");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let id = "test.engine.cmds.trap";
|
||||
install_wat(&mut s, &tpl::module(&with_commands(id, "filter", r#"[{"id":"boom","label":"Boom"}]"#), "unreachable", "", 1));
|
||||
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||||
let e = s.execute(&crate::cmd::plugin::command_id(id, "boom"), &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("“Cmd Test” › Boom: ") && e.contains("trapped"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A command that runs out of its instruction budget leaves the document and its history as they
|
||||
/// were, and the error names the plug-in and the command.
|
||||
#[test]
|
||||
fn a_plugin_command_out_of_budget_changes_nothing_and_names_itself() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 10.0, "#ff0000");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let id = "test.engine.cmds.spin";
|
||||
let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#;
|
||||
// More steps than a light budget pays for (as the plug-in crate's budget test counts them).
|
||||
install_wat(&mut s, &tpl::spinning(&with_commands(id, "filter", r#"[{"id":"spin","label":"Spin"}]"#), 100_000_000, triangle));
|
||||
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||||
let e = s.execute(&crate::cmd::plugin::command_id(id, "spin"), &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("“Cmd Test” › Spin: ") && e.contains("instruction budget"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// An `ids` that is not a list, or that holds an entry that is not an object id, is an error naming
|
||||
/// `ids` for a plug-in command and for `plugin.run`, and is never read as the selection; left out
|
||||
/// or null, `ids` is the selection.
|
||||
#[test]
|
||||
fn a_malformed_ids_is_refused_not_read_as_the_selection() {
|
||||
let mut s = session();
|
||||
let id = "test.engine.cmds.ids";
|
||||
let grow = crate::cmd::plugin::command_id(id, "grow");
|
||||
install_wat(&mut s, &grower(id));
|
||||
rect(&mut s, 10.0, "#ff0000");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||||
let cases = [
|
||||
(grow.as_str(), json!(5)),
|
||||
(grow.as_str(), json!([1, "x"])),
|
||||
(grow.as_str(), json!([-1])),
|
||||
("plugin.run", json!("all")),
|
||||
("plugin.run", json!([1.5])),
|
||||
];
|
||||
for (cmd, ids) in cases {
|
||||
let mut p = json!({"ids": ids});
|
||||
if cmd == "plugin.run" {
|
||||
p["id"] = json!(id);
|
||||
}
|
||||
let e = s.execute(cmd, &p).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::BadParams { cmd: c, .. } if c == cmd), "{cmd} {ids}: {e}");
|
||||
let said = e.to_string();
|
||||
assert!(
|
||||
said.contains("ids must be an array of non-negative integer object ids") || said.contains("not a non-negative integer object id"),
|
||||
"{cmd} {ids}: {e}"
|
||||
);
|
||||
assert_eq!(s.doc().unwrap().doc, before, "{cmd} {ids}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{cmd} {ids}");
|
||||
}
|
||||
// Left out or null, the command runs on the selection.
|
||||
for p in [json!({}), json!({"ids": null})] {
|
||||
let r = run(&mut s, &grow, p.clone());
|
||||
assert_eq!(r["command"], "grow", "{p}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap().doc, before, "{p}");
|
||||
}
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A live effect's commands are listed under Effect › Plug-ins and run under its permissions (with
|
||||
/// commands it gets `doc.write` unless its list leaves it out), and labels show bidirectional
|
||||
/// characters escaped. A malformed
|
||||
/// `commands` entry is refused at install, and the error names it.
|
||||
#[test]
|
||||
fn effect_commands_labels_and_malformed_entries() {
|
||||
let mut s = session();
|
||||
let id = "test.engine.cmds.effect";
|
||||
let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#;
|
||||
install_wat(&mut s, &tpl::returning(&with_commands(id, "effect", "[{\"id\":\"flip\",\"label\":\"Flip\u{202E}txt\"}]"), triangle));
|
||||
let c = listed(&s, &crate::cmd::plugin::command_id(id, "flip")).expect("listed");
|
||||
assert_eq!((c.label, c.menu.as_slice()), ("Flip\\u{202E}txt", &["Effect", "Plug-ins", "Cmd Test"][..]));
|
||||
run(&mut s, "select.none", json!({}));
|
||||
// Without `permissions`, a live effect with commands may change the document: its command adds
|
||||
// the triangle, as one undo step.
|
||||
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||||
let out = run(&mut s, &crate::cmd::plugin::command_id(id, "flip"), json!({}));
|
||||
assert_eq!(out["ids"].as_array().map(Vec::len), Some(1), "{out}");
|
||||
assert_ne!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
// One that lists its permissions without `doc.write` is refused the change, and the document
|
||||
// stays as it was.
|
||||
let listed = format!(
|
||||
r#"{{"id":"{id}","name":"Cmd Test","version":"1","kind":"effect","permissions":["doc.read","effect.render"],"commands":[{{"id":"flip","label":"Flip"}}]}}"#
|
||||
);
|
||||
let wasm = wat::parse_str(tpl::returning(&listed, triangle)).unwrap();
|
||||
let hash = vectorcraft_plugins::permissions::sha256_hex(&wasm);
|
||||
let grant = json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read", "effect.render"], "hash": hash});
|
||||
run(&mut s, "plugin.install", grant);
|
||||
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||||
let e = s.execute(&crate::cmd::plugin::command_id(id, "flip"), &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("wasn't granted `doc.write`"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
|
||||
let bad = id.replace("effect", "bad");
|
||||
let wasm = wat::parse_str(tpl::module(&with_commands(&bad, "filter", r#"[{"id":"ok","label":"A"},{"id":"Bad","label":"B"}]"#), tpl::ECHO, "", 1));
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm.unwrap())})).unwrap_err().to_string();
|
||||
assert!(e.contains("command `Bad`"), "{e}");
|
||||
assert!(vectorcraft_plugins::registry::get(&bad).is_none());
|
||||
}
|
||||
|
||||
/// A filter `id` of API level 2 that lists `settings` and `cache`, whose `vc_run` runs `first`
|
||||
/// (instructions that trap unless each storage call returns what it should), with the keys "zoom"
|
||||
/// at 0x2000 and "tile" at 0x2004 and the value "hello" at 0x2100.
|
||||
@@ -1086,10 +1305,11 @@ fn replace_storing(s: &mut Session, id: &str, first: &str) -> Value {
|
||||
|
||||
/// A plug-in's settings and cache are kept in the plug-in store under its id: the next session
|
||||
/// has them, replacing the plug-in keeps them, another plug-in can't read them, `plugin.clearCache`
|
||||
/// empties the cache alone and `plugin.remove` deletes both. The caps and the bytes each call is
|
||||
/// charged follow ([`plugin_storage_is_capped_and_survives_a_damaged_settings_file`],
|
||||
/// [`plugin_storage_calls_are_charged_the_files_they_read_and_write`]) in the same test, since
|
||||
/// clearing every plug-in's cache would empty theirs.
|
||||
/// with its id empties its cache alone and `plugin.remove` deletes both. The caps and the bytes
|
||||
/// each call is charged follow ([`plugin_storage_is_capped_and_survives_a_damaged_settings_file`],
|
||||
/// [`plugin_storage_calls_are_charged_the_files_they_read_and_write`]) in the same test.
|
||||
/// `plugin.clearCache` without an id empties the cache of every plug-in installed in the process,
|
||||
/// so it is tested in a binary of its own (`tests/plugin_clear_cache.rs`).
|
||||
#[test]
|
||||
fn plugins_keep_settings_and_a_cache_of_their_own_in_the_store() {
|
||||
let folder = crate::tests_links::Folder::new("plugin-storage");
|
||||
@@ -1116,16 +1336,13 @@ fn plugins_keep_settings_and_a_cache_of_their_own_in_the_store() {
|
||||
install_storing(&mut next, other, READS);
|
||||
assert!(next.execute("plugin.run", &json!({"id": other, "ids": []})).is_err());
|
||||
|
||||
// Clearing the cache leaves the settings.
|
||||
assert_eq!(run(&mut next, "plugin.clearCache", json!({"id": id})), json!({"cleared": [id], "bytesFreed": 3}));
|
||||
assert_eq!(run(&mut next, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0));
|
||||
run(&mut next, "plugin.run", json!({"id": id, "ids": []}));
|
||||
// Clearing the cache leaves the settings, and the other plug-in's cache.
|
||||
replace_storing(&mut next, other, WRITES);
|
||||
run(&mut next, "plugin.run", json!({"id": other, "ids": []}));
|
||||
let all = run(&mut next, "plugin.clearCache", json!({}));
|
||||
let cleared = all["cleared"].as_array().unwrap();
|
||||
assert!(cleared.contains(&json!(id)) && cleared.contains(&json!(other)) && all["bytesFreed"] == json!(3), "{all}");
|
||||
assert_eq!(run(&mut next, "plugin.info", json!({"id": other}))["cacheBytes"], json!(0));
|
||||
assert_eq!(run(&mut next, "plugin.clearCache", json!({"id": id})), json!({"cleared": [id], "bytesFreed": 3}));
|
||||
assert_eq!(run(&mut next, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0));
|
||||
assert_eq!(run(&mut next, "plugin.info", json!({"id": other}))["cacheBytes"], json!(3));
|
||||
run(&mut next, "plugin.run", json!({"id": id, "ids": []}));
|
||||
assert!(next.execute("plugin.clearCache", &json!({"id": "test.engine.storing.missing"})).is_err());
|
||||
|
||||
run(&mut next, "plugin.remove", json!({"id": id}));
|
||||
|
||||
@@ -461,6 +461,25 @@ fn svg_export_writes_the_filter_when_it_is_the_last_effect() {
|
||||
assert!(!out.contains("<feOffset dx=\"3\""), "{out}");
|
||||
}
|
||||
|
||||
/// A filter at the size limit whose written form, with the id SVG export gives it, would be longer
|
||||
/// than SVG import reads is exported as an image, which opens again as the canvas draws it.
|
||||
#[test]
|
||||
fn svg_export_draws_a_filter_too_long_to_read_back_as_an_image() {
|
||||
let (mut s, id) = blue_square();
|
||||
let (open, close) =
|
||||
(r##"<filter id="r"><feFlood flood-color="#ff0000" result=""##, r#""/><feComposite in2="SourceGraphic" operator="in"/></filter>"#);
|
||||
let xml = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len()));
|
||||
run(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [id.0], "params": {"xml": xml}}));
|
||||
assert_eq!(&render(&s).pixel(100, 100)[..3], &[255, 0, 0]);
|
||||
let out = svg(&mut s);
|
||||
assert!(out.contains("<image") && !out.contains("<filter"), "{} bytes", out.len());
|
||||
let (back, warnings) = vectorcraft_svg::import_with_report(&out).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let again = vectorcraft_render::Renderer::new().render_region(&back, vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
|
||||
let px = again.pixel(100, 100);
|
||||
assert!(px[0] > 240 && px[1] < 15 && px[2] < 15, "{px:?}");
|
||||
}
|
||||
|
||||
/// A filter SVG 1.1 can't hold (a blend mode SVG 1.1 lacks) is exported as an image, as the canvas
|
||||
/// draws it; an SVG 1.1 one stays a filter.
|
||||
#[test]
|
||||
|
||||
@@ -1276,6 +1276,45 @@ fn start_list_command_removes_the_typed_characters_and_starts_the_list() {
|
||||
assert!(s.execute("text.startList", &json!({"at": 3})).is_err(), "id required");
|
||||
}
|
||||
|
||||
/// A list typed with "1. " starts at level 1. Tab at the start of the item Return makes moves it
|
||||
/// to level 2, where it is numbered from 1, Return continues the list at that level, and
|
||||
/// Shift+Tab moves an item back up, where it goes on with level 1's count. "1. " typed at the
|
||||
/// start of an item at level 2 stays text.
|
||||
#[test]
|
||||
fn a_typed_list_takes_its_levels_from_tab_and_shift_tab() {
|
||||
use vectorcraft_doc::text::{NumberStyle, ParaList};
|
||||
let v = ViewInfo::default();
|
||||
let mut s = session();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 200.0, 200.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
let typed = |s: &mut Session, text: &str| {
|
||||
for c in text.chars() {
|
||||
s.tool_text(&c.to_string(), v).unwrap();
|
||||
}
|
||||
};
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
typed(&mut s, "1. Apples");
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
key(&mut s, ToolKey::Tab, Mods::default());
|
||||
typed(&mut s, "Pears");
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
typed(&mut s, "Plums");
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
key(&mut s, ToolKey::Tab, shift);
|
||||
typed(&mut s, "Figs");
|
||||
let arabic = ParaList::numbered(NumberStyle::Arabic);
|
||||
let t = obj(&s, id);
|
||||
assert_eq!(t.plain_text(), "Apples\nPears\nPlums\nFigs");
|
||||
assert_eq!((0..4).map(|i| t.para_at(i).list).collect::<Vec<_>>(), [arabic, arabic.at_level(2), arabic.at_level(2), arabic].map(Some));
|
||||
assert_eq!(list_markers(&s, id), ["1.", "1.", "2.", "2."]);
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
key(&mut s, ToolKey::Tab, Mods::default());
|
||||
typed(&mut s, "1. ");
|
||||
let t = obj(&s, id);
|
||||
assert_eq!((t.plain_text().as_str(), t.para_at(4).list), ("Apples\nPears\nPlums\nFigs\n1. ", Some(arabic.at_level(2))));
|
||||
}
|
||||
|
||||
/// A byte offset given as a negative or fractional number is an error, not the end of the text
|
||||
/// (`at`) or its start or end (`start`, `end`), and the text is left as it was. Null keeps the default.
|
||||
#[test]
|
||||
@@ -2318,6 +2357,18 @@ fn convert_area_point_keeps_text_in_place_for_every_alignment() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_area_to_point_keeps_a_deeper_list_item_in_place() {
|
||||
let mut s = session();
|
||||
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Item", "size": 18, "area": {"width": 150, "height": 200}})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet", "listLevel": 3})).unwrap();
|
||||
let before = first_glyph_doc_pos(&obj(&s, id));
|
||||
s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap();
|
||||
let after = first_glyph_doc_pos(&obj(&s, id));
|
||||
assert!((after - before).hypot() < 0.01, "{before:?} → {after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_are_listed_with_params() {
|
||||
let s = session();
|
||||
|
||||
@@ -4034,6 +4034,12 @@ proptest! {
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
|
||||
survive("mutated .ai symbols", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
survive("mutated .ai symbols, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?;
|
||||
// A symbol library: the same symbols and list, no layers.
|
||||
let library = mutate_text(&symbol_library_text(), cut, &edits);
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&library)].concat();
|
||||
survive("mutated .ai symbol library", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
let eps = vectorcraft_testkit::ai::eps(library.as_bytes(), vectorcraft_testkit::ai::page_ps());
|
||||
survive("mutated symbol library in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
|
||||
}
|
||||
|
||||
/// A `.ai` whose art styles, as the writer writes them, are damaged: the styles, the style
|
||||
@@ -4052,13 +4058,13 @@ proptest! {
|
||||
const STYLES_FUZZ_CHARS: usize = 64_000;
|
||||
|
||||
/// The editing data VectorCraft writes for a document with a drop shadow on a path, a gradient fill
|
||||
/// and a width profile on others, a stroked even-odd path with an opacity mask and Pathfinder › Add
|
||||
/// on a group.
|
||||
/// and a width profile on others, a stroked even-odd path with an opacity mask, Pathfinder › Add on
|
||||
/// a group and a graphic style of its own with effects on one of its fills.
|
||||
fn styles_text() -> &'static str {
|
||||
static TEXT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
TEXT.get_or_init(|| {
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, OpacityMask, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, GraphicStyle, Node, NodeKind, OpacityMask, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_geom::{FillRule, Rect, shapes};
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
@@ -4101,6 +4107,10 @@ fn styles_text() -> &'static str {
|
||||
let mut merged = Node::group(doc.alloc_id(), vec![std::sync::Arc::new(a), std::sync::Arc::new(b)]);
|
||||
merged.appearance = Appearance { effects: vec![fx("pathfinder.add")], ..Appearance::default() }.into();
|
||||
doc.insert(Some(layer), usize::MAX, merged).unwrap();
|
||||
doc.graphic_styles.push(GraphicStyle::new("Shaded", ap));
|
||||
let chained = FillLayer { opacity: 0.5, effects: vec![fx("distort.twist")], ..FillLayer::new(Paint::solid(Color::WHITE)) };
|
||||
let chained = Appearance { items: vec![AppearanceItem::Fill(chained)], ..Appearance::default() };
|
||||
doc.graphic_styles.push(GraphicStyle::new("Chained", chained));
|
||||
let out = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap();
|
||||
String::from_utf8(out.program).unwrap()
|
||||
})
|
||||
@@ -4112,14 +4122,46 @@ fn styles_text() -> &'static str {
|
||||
fn the_written_styles_fuzz_data_opens_live() {
|
||||
let text = styles_text();
|
||||
assert!(text.chars().count() < STYLES_FUZZ_CHARS, "{}", text.chars().count());
|
||||
for mark in ["%AI9_BeginArtStyles", "%AI5_BeginLayer", "\n6 () XW", "\n1 (Anon", "(BSLeftWidthsArray)", "0 /Bool (UseEvenOdd)", "Bb"] {
|
||||
assert!(text.contains(mark), "{mark}");
|
||||
for mark in [
|
||||
"%AI9_BeginArtStyles",
|
||||
"%AI9_BeginArtStyleList",
|
||||
"%AI5_BeginLayer",
|
||||
"\n6 () XW",
|
||||
"\n1 (Anon",
|
||||
"(BSLeftWidthsArray)",
|
||||
"0 /Bool (UseEvenOdd)",
|
||||
"(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter",
|
||||
"Bb",
|
||||
] {
|
||||
assert!(text.replace("\r\n", "\n").contains(mark), "{mark}");
|
||||
}
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat();
|
||||
let (d, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0);
|
||||
assert!(layered, "{warnings:?}");
|
||||
let art = d.layers[0].children().unwrap();
|
||||
assert_eq!(art.iter().map(|n| n.appearance.effects.len()).collect::<Vec<_>>(), [1, 1, 1, 1, 1]);
|
||||
assert!(d.graphic_style("Shaded").is_some() && d.graphic_style("Chained").is_some());
|
||||
}
|
||||
|
||||
/// [`symbols_text`] without its layer: a symbol library.
|
||||
fn symbol_library_text() -> String {
|
||||
let full = symbols_text();
|
||||
match (full.find("%AI5_BeginLayer"), full.find("%AI5_EndLayer--\n")) {
|
||||
(Some(a), Some(b)) if a < b => format!("{}{}", &full[..a], &full[b + "%AI5_EndLayer--\n".len()..]),
|
||||
_ => full,
|
||||
}
|
||||
}
|
||||
|
||||
/// The library [`ai_symbols_never_panic`] damages, as it is, opens with its symbols.
|
||||
#[test]
|
||||
fn the_symbol_library_fuzz_data_opens_with_its_symbols() {
|
||||
let text = symbol_library_text();
|
||||
assert!(!text.contains("%AI5_BeginLayer"));
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
|
||||
let (doc, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0);
|
||||
assert!(!layered, "{warnings:?}");
|
||||
let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, ["S2", "S1"], "{warnings:?}");
|
||||
}
|
||||
|
||||
/// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the
|
||||
@@ -4212,6 +4254,9 @@ fn ai_symbol_limits_never_panic() {
|
||||
ai::layer("L", &inst("(S1)", "1 0 0 -1 20 50"))
|
||||
),
|
||||
format!("{}{}", def("S1", &inst("(S2)", "1 0 0 -1 0 0")), def("S2", &inst("(S1)", "1 0 0 -1 0 0"))),
|
||||
// Symbol libraries without layers: definitions nested too deep and a long chain.
|
||||
nested,
|
||||
chain,
|
||||
];
|
||||
for body in cases {
|
||||
let data = ai::editing_data(200.0, 100.0, &body).replacen("%%EndComments", "%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n%%EndComments", 1);
|
||||
|
||||
@@ -109,6 +109,22 @@ fn a_journal_with_a_key_no_longer_read_still_replays() {
|
||||
assert!(s.execute("object.move", &json!({"dx": 5, "dyy": 5})).is_err());
|
||||
}
|
||||
|
||||
/// `Session::replay` still runs an export with an option that is no longer read, and a preset
|
||||
/// file that holds one still loads and exports. `Session::execute` refuses the same options.
|
||||
#[test]
|
||||
fn an_export_step_or_preset_with_a_key_no_longer_read_still_runs() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let old = json!({"format": "pdf", "noSuchOption": true, "marks": {"useDocumentBleed": true}});
|
||||
assert!(check_params("document.export", &old).is_err());
|
||||
assert!(s.execute("document.export", &old).is_err());
|
||||
s.replay("document.export", &old).unwrap();
|
||||
let file = json!({"format": "vcpdfpresets", "presets": [{"name": "Old", "settings": {"standard": "none", "noLongerRead": 1, "marks": {"retired": true}}}]});
|
||||
s.execute("pdf.preset.import", &json!({"data": file.to_string()})).unwrap();
|
||||
check_params("document.export", &json!({"format": "pdf", "preset": "Old"})).unwrap();
|
||||
s.execute("document.export", &json!({"format": "pdf", "preset": "Old"})).unwrap();
|
||||
}
|
||||
|
||||
/// So do an effect's own parameters (#1592): a journal or a file with a key the effect doesn't
|
||||
/// take, or a value of another type, replays and opens; the effect reads what it knows, clamped.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! `plugin.clearCache` without an id empties the cache of every installed plug-in and leaves their
|
||||
//! settings.
|
||||
//!
|
||||
//! A test binary of its own: installed plug-ins are process-wide, so clearing every plug-in's cache
|
||||
//! in the engine's other tests would empty the caches those tests check.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_plugins::wat as tpl;
|
||||
|
||||
/// Sets "zoom" to "hello" and writes "hel" to the cache entry "tile".
|
||||
const WRITES: &str = "(if (call $vc_settings_set (i32.const 0x2000) (i32.const 4) (i32.const 0x2100) (i32.const 5)) (then unreachable))
|
||||
(if (call $vc_cache_write (i32.const 0x2004) (i32.const 4) (i32.const 0x2100) (i32.const 3)) (then unreachable))";
|
||||
|
||||
/// Traps unless "zoom" is "hello".
|
||||
const READS: &str =
|
||||
"(if (i32.ne (call $vc_settings_get (i32.const 0x2000) (i32.const 4) (i32.const 0x3000) (i32.const 16)) (i32.const 5)) (then unreachable))
|
||||
(if (i32.ne (i32.load (i32.const 0x3000)) (i32.load (i32.const 0x2100))) (then unreachable))";
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
/// Installs a filter `id` of API level 2 that lists `settings` and `cache` and whose `vc_run` runs
|
||||
/// `first`, in place of any module installed with its id (`replace: true`).
|
||||
fn install_storing(s: &mut Session, id: &str, first: &str) {
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["settings","cache"]}}"#);
|
||||
let data = format!(r#"{} (data (i32.const 0x2000) "zoomtile") (data (i32.const 0x2100) "hello")"#, tpl::STORAGE_IMPORTS);
|
||||
let wasm = wat::parse_str(tpl::with_imports(&manifest, &data, first, r#"{"objects":[]}"#)).unwrap();
|
||||
let data = vectorcraft_format::base64_encode(&wasm);
|
||||
run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_every_cache_empties_each_plugins_cache_and_keeps_its_settings() {
|
||||
let folder = std::env::temp_dir().join(format!("vc-plugin-clear-cache-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let ids = ["test.clearcache.a", "test.clearcache.b"];
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
|
||||
s.attach_plugin_store(folder.clone());
|
||||
for id in ids {
|
||||
install_storing(&mut s, id, WRITES);
|
||||
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
|
||||
assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3));
|
||||
}
|
||||
|
||||
assert_eq!(run(&mut s, "plugin.clearCache", json!({})), json!({"cleared": ids, "bytesFreed": 6}));
|
||||
for id in ids {
|
||||
assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0));
|
||||
// The settings stay: a module that reads them runs.
|
||||
install_storing(&mut s, id, READS);
|
||||
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
|
||||
}
|
||||
// Nothing is left to clear.
|
||||
assert_eq!(run(&mut s, "plugin.clearCache", json!({})), json!({"cleared": ids, "bytesFreed": 0}));
|
||||
|
||||
for id in ids {
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
@@ -599,7 +599,7 @@ proptest! {
|
||||
let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes()));
|
||||
let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?;
|
||||
if let Ok(man) = &parsed {
|
||||
prop_assert!(man.permissions().iter().all(|p| p.applies(man.kind)), "{}", text);
|
||||
prop_assert!(man.permissions().iter().all(|p| p.applies(man.kind, !man.commands.is_empty())), "{}", text);
|
||||
let shown = vectorcraft_plugins::permissions::display_text(&man.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
|
||||
prop_assert!(!shown.chars().any(|c| c.is_control() || matches!(c, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown);
|
||||
prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
|
||||
@@ -617,6 +617,80 @@ proptest! {
|
||||
vectorcraft_plugins::registry::revoke("fuzz.permissions");
|
||||
}
|
||||
|
||||
/// Manifests with hostile `commands` (junk of every type, bad and repeated ids, labels with
|
||||
/// control and bidirectional override characters, a `menu` naming another menu, junk
|
||||
/// parameters) parse to an error or to commands whose menu labels show no hidden character and
|
||||
/// that sit under Plug-ins alone; installing one and running each of its commands ends as an
|
||||
/// error or an edit, never a panic, and the document then renders and exports.
|
||||
#[test]
|
||||
fn hostile_manifest_commands_never_crash(
|
||||
kind in prop::sample::select(vec!["filter", "effect", "exporter"]),
|
||||
entries in prop::collection::vec(
|
||||
prop_oneof![
|
||||
3 => (
|
||||
prop_oneof![Just(json!("grow")), Just(json!("a_1")), Just(json!("Grow")), Just(json!("")), Just(json!("a.b")), arb_junk()],
|
||||
prop_oneof![
|
||||
3 => "[a-zA-Z\u{202E}\u{2066}\u{200F} ]{0,70}".prop_map(Value::from),
|
||||
1 => "[a-z\u{0007}\n]{1,8}".prop_map(Value::from),
|
||||
1 => arb_junk(),
|
||||
],
|
||||
prop_oneof![Just(Value::Null), Just(json!("File")), arb_junk()],
|
||||
prop_oneof![Just(Value::Null), Just(json!({"by": {"type": "number", "min": 0, "max": 10, "default": 2}})), arb_junk()],
|
||||
)
|
||||
.prop_map(|(id, label, menu, params)| {
|
||||
let mut c = json!({"id": id, "label": label});
|
||||
if !menu.is_null() {
|
||||
c["menu"] = menu;
|
||||
}
|
||||
if !params.is_null() {
|
||||
c["params"] = params;
|
||||
}
|
||||
c
|
||||
}),
|
||||
1 => arb_junk(),
|
||||
],
|
||||
0..5,
|
||||
),
|
||||
junk in prop::option::weighted(0.1, arb_junk()),
|
||||
given in prop_oneof![Just(json!({})), Just(json!({"by": 3})), arb_junk()],
|
||||
) {
|
||||
let mut m = json!({"id": "fuzz.commands", "name": "Fuzz \u{202E}Commands", "kind": kind});
|
||||
if kind == "exporter" {
|
||||
m["extensions"] = json!(["fz"]);
|
||||
}
|
||||
m["commands"] = junk.unwrap_or(json!(entries));
|
||||
let text = m.to_string();
|
||||
let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes()));
|
||||
let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?;
|
||||
if let Ok(man) = &parsed {
|
||||
for c in &man.commands {
|
||||
let shown = c.shown_label();
|
||||
prop_assert!(!shown.chars().any(|ch| ch.is_control() || matches!(ch, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown);
|
||||
prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
|
||||
}
|
||||
}
|
||||
let mut s = rich_session();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let module = wat::parse_str(tpl::returning(&text, r#"{"objects":[]}"#)).unwrap();
|
||||
let r = catch_quiet(|| {
|
||||
let mut ran = vec![];
|
||||
if install(&mut s, &module).is_ok() {
|
||||
for c in s.commands().into_iter().filter(|c| c.id.starts_with("plugin.fuzz.commands.")) {
|
||||
ran.push((c.id, c.menu.clone()));
|
||||
let _ = s.execute(c.id, &given);
|
||||
}
|
||||
}
|
||||
ran
|
||||
});
|
||||
let ran = r.map_err(|msg| TestCaseError::fail(format!("panicked running {text}: {msg}")))?;
|
||||
prop_assert!(ran.is_empty() || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text);
|
||||
for (id, menu) in &ran {
|
||||
prop_assert!(menu.len() == 3 && menu[1] == "Plug-ins" && matches!(menu[0], "Object" | "Effect"), "{id}: {menu:?}");
|
||||
}
|
||||
renders(&s, "after hostile commands")?;
|
||||
vectorcraft_plugins::registry::remove("fuzz.commands");
|
||||
}
|
||||
|
||||
/// A plug-in of API level 2 that calls the host imports with hostile arguments (any progress
|
||||
/// counts, any log level, pointers and lengths in and out of its memory, text that isn't
|
||||
/// UTF-8) runs to an error or an output, never a panic, and its report stays within the caps.
|
||||
@@ -681,7 +755,17 @@ proptest! {
|
||||
use vectorcraft_plugins::host::STORAGE_BUDGET;
|
||||
use vectorcraft_plugins::storage::Meter;
|
||||
const FUNCS: [&str; 6] = ["vc_settings_get", "vc_settings_set", "vc_cache_read", "vc_cache_write", "vc_settings_delete", "vc_cache_clear"];
|
||||
let folder = std::env::temp_dir().join(format!("vectorcraft-plugin-fuzz-storage-{}", std::process::id()));
|
||||
// Each case starts with an empty store, and the store is deleted when the case ends, also
|
||||
// when it fails.
|
||||
struct Store(std::path::PathBuf);
|
||||
impl Drop for Store {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
let store = Store(std::env::temp_dir().join(format!("vectorcraft-plugin-fuzz-storage-{}", std::process::id())));
|
||||
let _ = std::fs::remove_dir_all(&store.0);
|
||||
let folder = store.0.clone();
|
||||
let mut s = Session::new();
|
||||
s.attach_plugin_store(folder.clone());
|
||||
let storing = |id: &str| format!(r#"{{"id":"{id}","name":"F","kind":"filter","api":2,"permissions":["settings","cache"]}}"#);
|
||||
@@ -704,8 +788,11 @@ proptest! {
|
||||
let text: String = key.iter().map(|b| format!("\\{b:02x}")).collect();
|
||||
let imports = format!(r#"{} (data (i32.const 0x2000) "{text}")"#, tpl::STORAGE_IMPORTS);
|
||||
let wasm = wat::parse_str(tpl::with_imports(&storing("fuzz.storage"), &imports, &format!("(local $r i32) {body}"), r#"{"objects":[]}"#)).unwrap();
|
||||
// A failed case leaves its module installed, and the cases that shrink it install others
|
||||
// under the same id.
|
||||
let r = catch_quiet(|| {
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant})).map_err(|e| e.to_string())?;
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant, "replace": true}))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let p = vectorcraft_plugins::registry::get("fuzz.storage").ok_or("not installed")?;
|
||||
p.run(b"{\"objects\":[]}", b"{}").map_err(|e| e.to_string())
|
||||
});
|
||||
|
||||
@@ -122,16 +122,17 @@
|
||||
//! definitions are at most the reader's 4,096. In a symbol's art, an instance's matrix and
|
||||
//! a gradient's take the art to the canvas's space turned y up about art space's origin there.
|
||||
//! - Art styles, as the reader reads them (`style.rs`, `read/ops.rs`): a path, compound path or
|
||||
//! group with live effects is written as its drawn look (`u`, its vector art as its geometry
|
||||
//! effects leave it, without shadows, glows or feathers, `U`, its transparency, `0 0 Xd`,
|
||||
//! `6 () XW`), then the object itself on `%_` lines (its points painted
|
||||
//! group with live effects or linked to a graphic style is written as its drawn look (`u`, its
|
||||
//! vector art as its geometry effects leave it, without shadows, glows or feathers, `U`, its
|
||||
//! transparency, `0 0 Xd`, `6 () XW`), then the object itself on `%_` lines (its points painted
|
||||
//! with nothing, or with its gradient fill; a group with its members) with its name and opacity
|
||||
//! mask, then `1 (style) XW`. The styles are written in a non-printing block after the symbols
|
||||
//! (`%AI9_BeginArtStyles`, each `/KnownStyle :` with its name and a filter tree: a chain of the
|
||||
//! stack of fills and strokes with the contents among them, the effects and the blend filter). A
|
||||
//! fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that fills by
|
||||
//! the even-odd rule is written as its drawn look. A style is written only when the reader reads it
|
||||
//! back as the object's appearance.
|
||||
//! stack of fills and strokes with the contents among them, the effects and the blend filter), the
|
||||
//! Graphic Styles panel's first; the panel's order follows the palette (`%AI9_BeginArtStyleList`).
|
||||
//! A fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that
|
||||
//! fills by the even-odd rule is written as its drawn look. A style is written only when the
|
||||
//! reader reads it back as the object's appearance (or the graphic style's).
|
||||
//!
|
||||
//! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at
|
||||
//! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the
|
||||
@@ -166,9 +167,10 @@
|
||||
//! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as
|
||||
//! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on
|
||||
//! layers and opacity masks nested too deep, and, on objects written without an art style, live
|
||||
//! effects other than geometry effects left out; graphic styles, links to them and every art style
|
||||
//! on a large canvas are left out; art past [`crate::import::MAX_EDITING_OBJECTS`] objects in all
|
||||
//! (the most the reader reads), counted as the reader counts them, is left out.
|
||||
//! effects other than geometry effects left out; graphic styles the reader wouldn't read, links to
|
||||
//! graphic styles on objects not written with them, and every art style on a large canvas, are left
|
||||
//! out; art past [`crate::import::MAX_EDITING_OBJECTS`] objects in all (the most the reader reads),
|
||||
//! counted as the reader counts them, is left out.
|
||||
//!
|
||||
//! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with
|
||||
//! the version numbers of the newest files users sent ([`private_data`]).
|
||||
@@ -255,7 +257,8 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re
|
||||
// The objects that may keep their effects in an art style, as they are before baking (not on
|
||||
// a large canvas, whose scale the styles' strokes and effects would need).
|
||||
let styled = writer::styled_objects(doc);
|
||||
let graphic_styles = writer::has_graphic_styles(doc);
|
||||
let own_styles = doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles;
|
||||
let links = writer::style_links(doc);
|
||||
// Live geometry effects as their result, as the PDF part draws them.
|
||||
let baked = baked(doc);
|
||||
let doc = baked.as_ref().unwrap_or(doc);
|
||||
@@ -290,15 +293,16 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re
|
||||
w.reserve_swatch_gradients();
|
||||
if scale == 1.0 {
|
||||
w.styled = styled;
|
||||
} else if !styled.is_empty() {
|
||||
w.panel_styles();
|
||||
} else if !styled.is_empty() || own_styles || links > 0 {
|
||||
w.warnings.push(writer::LARGE_CANVAS_STYLES.to_string());
|
||||
}
|
||||
if graphic_styles {
|
||||
w.warnings.push(writer::GRAPHIC_STYLES.to_string());
|
||||
}
|
||||
for layer in &doc.layers {
|
||||
w.layer(layer);
|
||||
}
|
||||
if scale == 1.0 {
|
||||
w.lost_links(links);
|
||||
}
|
||||
// The symbols no instance uses too; their art adds definitions.
|
||||
w.symbols();
|
||||
// The palette first: its gradient and pattern swatches add definitions.
|
||||
@@ -322,6 +326,7 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re
|
||||
w.symbol_definitions(&mut s);
|
||||
w.art_styles(&mut s);
|
||||
s.push_str(&palette);
|
||||
w.art_style_list(&mut s);
|
||||
w.symbol_list(&mut s);
|
||||
artboards(&mut s, doc, to_art, origin, &library);
|
||||
if !w.texts.is_empty() {
|
||||
|
||||
@@ -4887,6 +4887,48 @@ fn symbols_painting_patterns_through_brush_art_are_placed_as_art() {
|
||||
assert_eq!(instances(&back.layers[0]), 0, "the instance is its art");
|
||||
}
|
||||
|
||||
/// A symbol first defined from the drawn look of a group with a style, through a member's scatter
|
||||
/// brush, still warns that its art's own effects are left out, and so does a later instance of it.
|
||||
#[test]
|
||||
fn a_symbol_defined_inside_a_drawn_look_warns_of_its_own_effects() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let mut shadowed = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::BLACK);
|
||||
shadowed.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }.into();
|
||||
doc.symbols.push(Symbol { dynamic: false, name: "Shadowed".into(), art: Arc::new(shadowed) });
|
||||
let dot = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::BLACK);
|
||||
let placed = instance(&mut doc, "Shadowed", Affine::translate((8.0, 0.0)));
|
||||
let art = group(&mut doc, vec![dot, placed], false);
|
||||
let brush = vectorcraft_brush::Brush {
|
||||
name: "Scattered".into(),
|
||||
kind: vectorcraft_brush::BrushKind::Scatter(vectorcraft_brush::Scatter {
|
||||
art,
|
||||
size: (100.0, 100.0),
|
||||
spacing: (100.0, 100.0),
|
||||
scatter: (0.0, 0.0),
|
||||
rotation: (0.0, 0.0),
|
||||
colorization: vectorcraft_brush::Colorization::None,
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
vectorcraft_brush::store(&mut doc, &[brush]);
|
||||
let mut line =
|
||||
Node::path(doc.alloc_id(), shapes::line((10.0, 50.0).into(), (90.0, 50.0).into()), Appearance::basic(Paint::None, solid(Color::BLACK), 1.0));
|
||||
line.appearance.stroke_mut().unwrap().brush = Some("Scattered".into());
|
||||
let edge = square(&mut doc, Rect::new(10.0, 60.0, 30.0, 80.0), LEAF);
|
||||
let mut styled = group(&mut doc, vec![line, edge], false);
|
||||
styled.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into();
|
||||
put(&mut doc, named(styled, "Styled"));
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
let p = shown(&out.program);
|
||||
assert!(p.contains("%AI14_BeginSymbol") && p.contains("1 (Anon 1) XW"), "{p}");
|
||||
assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
|
||||
|
||||
let later = instance(&mut doc, "Shadowed", Affine::translate((150.0, 20.0)));
|
||||
put(&mut doc, later);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
|
||||
}
|
||||
|
||||
/// A symbol whose stroke is written as its outline (an outside stroke): a scaled instance, a scaled
|
||||
/// instance of a symbol holding it and a turned and mirrored instance are written as their art,
|
||||
/// which keeps the stroke the canvas draws; a moved instance stays an instance.
|
||||
@@ -5560,6 +5602,33 @@ fn styles_document() -> Document {
|
||||
let mut merged = group(&mut doc, vec![a, b], false);
|
||||
merged.appearance = Appearance { effects: vec![fx("pathfinder.add", serde_json::json!({}))], ..Appearance::default() }.into();
|
||||
add(&mut doc, named(merged, "Merged"));
|
||||
// A graphic style of its own, and an object linked to one.
|
||||
let mut fill_top = FillLayer::new(solid(Color::rgb(0.2, 0.2, 0.9)));
|
||||
fill_top.opacity = 0.5;
|
||||
fill_top.blend = BlendMode::Screen;
|
||||
fill_top.effects = vec![fx("distort.twist", serde_json::json!({"angle": 30.0}))];
|
||||
let style_ap = Appearance {
|
||||
items: vec![
|
||||
AppearanceItem::Fill(FillLayer::new(solid(Color::rgb(0.9, 0.9, 0.1)))),
|
||||
AppearanceItem::Fill(fill_top),
|
||||
AppearanceItem::Stroke(StrokeLayer::new(solid(Color::BLACK), 3.0)),
|
||||
],
|
||||
effects: vec![fx("warp.arc", serde_json::json!({"bend": 30.0})), shadow],
|
||||
// The contents over every fill and stroke.
|
||||
contents_index: Some(3),
|
||||
};
|
||||
let mut layered = vectorcraft_doc::GraphicStyle::new("Layered", style_ap);
|
||||
layered.id = 9;
|
||||
layered.opacity = 0.8;
|
||||
doc.graphic_styles.push(layered);
|
||||
let sunshine = doc.graphic_style("Sunshine").unwrap().clone();
|
||||
let mut linked_obj = Node::path(
|
||||
doc.alloc_id(),
|
||||
shapes::rectangle(Rect::new(170.0, 130.0, 220.0, 170.0)),
|
||||
sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned(),
|
||||
);
|
||||
linked_obj.extras_mut().graphic_style = Some(sunshine.id);
|
||||
add(&mut doc, named(linked_obj, "Sunny"));
|
||||
doc
|
||||
}
|
||||
|
||||
@@ -5683,12 +5752,24 @@ fn near_lines(got: &str, want: &str) {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_with_live_effects_are_read_back() {
|
||||
fn objects_with_live_effects_and_graphic_styles_are_read_back() {
|
||||
let doc = styles_document();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS || w == writer::PANEL_STYLES), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
same_styles(&back, &doc);
|
||||
// The panel's styles, in its order, with their appearance and transparency.
|
||||
let names = |d: &Document| d.graphic_styles.iter().map(|g| g.name.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&back), names(&doc));
|
||||
for (g, w) in back.graphic_styles.iter().zip(doc.graphic_styles.iter()) {
|
||||
assert!(writer::same_appearance(&g.appearance, &w.appearance), "{}:\n got {:?}\n want {:?}", w.name, g.appearance, w.appearance);
|
||||
assert_eq!(
|
||||
(r3(f64::from(g.opacity)), g.blend, g.isolate, g.knockout),
|
||||
(r3(f64::from(w.opacity)), w.blend, w.isolate, w.knockout),
|
||||
"{}",
|
||||
w.name
|
||||
);
|
||||
}
|
||||
// A group's members come back as they were.
|
||||
let members = |d: &Document, name: &str| {
|
||||
let mut out = vec![];
|
||||
@@ -5700,13 +5781,39 @@ fn objects_with_live_effects_are_read_back() {
|
||||
same_lines(&members(&back, "Merged"), &members(&doc, "Merged"));
|
||||
}
|
||||
|
||||
/// In a document with only a new document's graphic styles, an object linked to one of them keeps
|
||||
/// its link: the panel's styles are written.
|
||||
#[test]
|
||||
fn a_link_to_a_built_in_graphic_style_is_read_back() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let sunshine = doc.graphic_style("Sunshine").unwrap().clone();
|
||||
let ap = sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned();
|
||||
// Without a link, a new document's styles are not written.
|
||||
let mut plain = doc.clone();
|
||||
let unlinked = Node::path(plain.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap.clone());
|
||||
plain.insert(Some(layer), usize::MAX, unlinked).unwrap();
|
||||
assert!(!program(&plain).contains("%AI9_BeginArtStyleList"));
|
||||
let mut sunny = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap);
|
||||
sunny.extras_mut().graphic_style = Some(sunshine.id);
|
||||
doc.insert(Some(layer), usize::MAX, named(sunny, "Sunny")).unwrap();
|
||||
assert_eq!(doc.graphic_styles, Document::new(1.0, 1.0).graphic_styles);
|
||||
let back = read_back(&doc);
|
||||
same_styles(&back, &doc);
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
assert_eq!(n.extras().graphic_style.and_then(|id| back.graphic_style_by_id(id)).map(|g| g.name.as_str()), Some("Sunshine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_styles_are_written_in_their_forms() {
|
||||
let p = program_shown(&styles_document());
|
||||
// The styles in a non-printing block after the symbols' and before the palette.
|
||||
let styles = p.find("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n/KnownStyle :\n(Anon 1) /Name ,\n/ActiveStyle :\n").unwrap();
|
||||
// The styles in a non-printing block after the symbols' and before the palette; the panel's in
|
||||
// its order first, then the objects' own.
|
||||
let styles = p.find("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n/KnownStyle :\n(Default Graphic Style) /Name ,\n/ActiveStyle :\n").unwrap();
|
||||
assert!(styles < p.find("%AI5_BeginPalette").unwrap(), "{p}");
|
||||
assert!(!p.contains("%AI9_BeginArtStyleList"), "{p}");
|
||||
let list = "%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyleList\n(Default Graphic Style)\n(Black Outline)\n(Heavy Ink)\n(Sunshine)\n(Layered)\n%AI9_EndArtStyleList\n%AI5_End_NonPrinting--\n";
|
||||
let at = p.find(list).unwrap();
|
||||
assert!(p.find("%AI5_EndPalette").unwrap() < at && at < p.find("%AI9_BeginDocumentData").unwrap(), "{p}");
|
||||
// A drop shadow on the object, after its stack and before its transparency.
|
||||
assert!(
|
||||
p.contains(
|
||||
@@ -5720,6 +5827,8 @@ fn art_styles_are_written_in_their_forms() {
|
||||
assert!(p.contains("U\n0 1 0 2 0 Xy\n0 0 Xd\n6 () XW\n%_0 XR\n%_0 1 0 0 0 Xy\n%_10 -10 m\n"), "{p}");
|
||||
assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Shadowed) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon 1) XW\n"), "{p}");
|
||||
assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Offset) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}");
|
||||
// The object linked to a graphic style names it.
|
||||
assert!(p.contains("%_/XMLUID : (Sunny) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Sunshine) XW\n"), "{p}");
|
||||
// A group's members on `%_` lines, then its name and style.
|
||||
assert!(p.contains("6 () XW\n%_u\n"), "{p}");
|
||||
assert!(p.contains("%_U\n%_/ArtDictionary :\n%_/XMLUID : (Merged) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}");
|
||||
@@ -5739,8 +5848,9 @@ fn art_styles_are_written_in_their_forms() {
|
||||
}
|
||||
|
||||
/// Objects whose appearance the reader wouldn't read with their style are written as their
|
||||
/// drawn look, with the warnings they were written with before; and a document without effects has
|
||||
/// no art styles.
|
||||
/// drawn look, with the warnings they were written with before; graphic styles it wouldn't read
|
||||
/// are left out with a warning; and a document without effects or styles of its own has no art
|
||||
/// styles.
|
||||
#[test]
|
||||
fn appearances_the_reader_wouldnt_read_are_their_drawn_look() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
@@ -5768,10 +5878,41 @@ fn appearances_the_reader_wouldnt_read_are_their_drawn_look() {
|
||||
assert!(!p.contains("%AI9_BeginArtStyles") && !p.lines().any(|l| l.starts_with("1 (")), "{p}");
|
||||
let back = read_back(&doc);
|
||||
assert!(back.layers[0].children().unwrap().iter().all(|n| n.appearance.effects.is_empty()));
|
||||
// A graphic style with a gradient: left out, the others listed.
|
||||
let ramp = Gradient { stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)], ..Gradient::default() };
|
||||
let graded = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(ramp))), Paint::None, 0.0);
|
||||
doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Graded", graded));
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
|
||||
let p = String::from_utf8(out.program).unwrap().replace("\r\n", "\n");
|
||||
assert!(p.contains("(Sunshine)\n%AI9_EndArtStyleList") && !p.contains("(Graded)"), "{p}");
|
||||
// Nothing styled: no art styles.
|
||||
assert!(!program(&sample()).contains("ArtStyle"));
|
||||
}
|
||||
|
||||
/// A graphic style's opacity is read back within the written number's precision: the style and an
|
||||
/// object's link to it stay.
|
||||
#[test]
|
||||
fn a_graphic_styles_opacity_is_compared_at_the_written_precision() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let ap = Appearance::basic(solid(Color::BLACK), Paint::None, 1.0);
|
||||
let mut g = vectorcraft_doc::GraphicStyle::new("Fine", ap.clone());
|
||||
g.id = 1;
|
||||
g.opacity = 0.123456;
|
||||
*doc.graphic_styles = vec![g];
|
||||
let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap);
|
||||
n.opacity = 0.123456;
|
||||
n.extras_mut().graphic_style = Some(1);
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
assert_eq!(back.graphic_styles.len(), 1);
|
||||
assert!((back.graphic_styles[0].opacity - 0.123456).abs() < 1e-3);
|
||||
assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, Some(back.graphic_styles[0].id));
|
||||
}
|
||||
|
||||
/// A compound shape draws as one area, which an art style's object wouldn't keep: it and a group
|
||||
/// holding one are written as their drawn look, with a warning.
|
||||
#[test]
|
||||
@@ -5802,6 +5943,45 @@ fn compound_shapes_are_not_written_with_an_art_style() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Graphic styles the reader's style list wouldn't hold (a name over its limit, styles past its
|
||||
/// count) are left out with a warning, and an object linked to one has no link.
|
||||
#[test]
|
||||
fn graphic_styles_past_the_lists_limits_are_left_out() {
|
||||
let plain = || Appearance::basic(solid(Color::BLACK), Paint::None, 1.0);
|
||||
let styled = |name: String, id: u32| {
|
||||
let mut g = vectorcraft_doc::GraphicStyle::new(name, plain());
|
||||
g.id = id;
|
||||
g
|
||||
};
|
||||
// A name one byte over the limit, among others.
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let long = "n".repeat(1025);
|
||||
*doc.graphic_styles = vec![styled("Before".into(), 1), styled(long.clone(), 2), styled("After".into(), 3)];
|
||||
let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), plain());
|
||||
linked.extras_mut().graphic_style = Some(2);
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, linked).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
|
||||
assert!(!shown(&out.program).contains(&long));
|
||||
let back = read_back(&doc);
|
||||
let names: Vec<&str> = back.graphic_styles.iter().map(|g| g.name.as_str()).collect();
|
||||
assert_eq!(names, ["Before", "After"]);
|
||||
assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None);
|
||||
// A name at the limit is kept.
|
||||
let at_limit = "n".repeat(1024);
|
||||
doc.graphic_styles[1].name = at_limit.clone();
|
||||
assert!(read_back(&doc).graphic_styles.iter().any(|g| g.name == at_limit));
|
||||
// Styles past the count.
|
||||
let mut many = Document::new(200.0, 100.0);
|
||||
*many.graphic_styles = (1..=4097).map(|i| styled(format!("Style {i}"), i)).collect();
|
||||
let out = export_ai_editing(&many, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
|
||||
let back = read_back(&many);
|
||||
assert_eq!(back.graphic_styles.len(), 4096);
|
||||
assert_eq!(back.graphic_styles.last().map(|g| g.name.as_str()), Some("Style 4096"));
|
||||
}
|
||||
|
||||
/// A member of a group whose Transform effect moves it is written where baking put it, not with
|
||||
/// its own effects where it was.
|
||||
#[test]
|
||||
@@ -5953,7 +6133,8 @@ fn read_one(make: impl FnOnce(&mut Document) -> Node) -> (Arc<Node>, Vec<String>
|
||||
|
||||
/// A filled path or compound path with live effects that fills by the even-odd rule is written as its
|
||||
/// drawn look with a warning, since a style's fill fills by the non-zero rule. A stroke-only object
|
||||
/// keeps its style and its own fill rule.
|
||||
/// keeps its style and its own fill rule. A filled even-odd object linked to a graphic style isn't
|
||||
/// written with that style, and its link is left out with a warning.
|
||||
#[test]
|
||||
fn a_filled_even_odd_object_is_written_as_its_drawn_look() {
|
||||
let mut doc = Document::new(300.0, 100.0);
|
||||
@@ -5982,9 +6163,28 @@ fn a_filled_even_odd_object_is_written_as_its_drawn_look() {
|
||||
let back = read_back(&doc);
|
||||
same_styles(&back, &doc);
|
||||
assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. }));
|
||||
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let ap = Appearance::basic(black_fill(), Paint::None, 0.0);
|
||||
let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone());
|
||||
g.id = 7;
|
||||
doc.graphic_styles.push(g);
|
||||
let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap);
|
||||
n.extras_mut().graphic_style = Some(7);
|
||||
if let NodeKind::Path { rule, .. } = &mut n.kind {
|
||||
*rule = FillRule::EvenOdd;
|
||||
}
|
||||
put(&mut doc, n);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
assert!(matches!(n.kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }), "{n:?}");
|
||||
assert_eq!(n.extras().graphic_style, None);
|
||||
}
|
||||
|
||||
/// A compound path filled by the non-zero rule keeps its effects and its gradient fill.
|
||||
/// A compound path filled by the non-zero rule keeps its effects, its gradient fill and its link to
|
||||
/// a graphic style.
|
||||
#[test]
|
||||
fn a_compound_path_keeps_its_style() {
|
||||
let mut doc = Document::new(300.0, 100.0);
|
||||
@@ -6002,8 +6202,16 @@ fn a_compound_path_keeps_its_style() {
|
||||
let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(graded, Paint::None, 0.0) };
|
||||
let n = named(non_zero(ring(&mut doc, ap)), "Graded");
|
||||
put(&mut doc, n);
|
||||
let sunshine = doc.graphic_style("Sunshine").unwrap().clone();
|
||||
let ap = sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned();
|
||||
let mut linked_ring = named(ring(&mut doc, ap), "Linked");
|
||||
if let NodeKind::Compound { rule, .. } = &mut linked_ring.kind {
|
||||
*rule = FillRule::NonZero;
|
||||
}
|
||||
linked_ring.extras_mut().graphic_style = Some(sunshine.id);
|
||||
put(&mut doc, linked_ring);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS || w == writer::STYLE_LINKS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
same_styles(&back, &doc);
|
||||
assert!(back.layers[0].children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Compound { .. })));
|
||||
@@ -6029,36 +6237,136 @@ fn a_group_holding_an_image_is_written_as_its_drawn_look() {
|
||||
assert!(im.link.is_some());
|
||||
}
|
||||
|
||||
/// Graphic styles and links to them are left out with a warning: an object linked to a style
|
||||
/// keeps its effects in a style of its own. A new document's styles without a link don't warn.
|
||||
/// A link to a graphic style that isn't written warns; one that is written doesn't.
|
||||
#[test]
|
||||
fn graphic_styles_and_links_are_left_out_with_a_warning() {
|
||||
let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::GRAPHIC_STYLES);
|
||||
assert!(!warns(&Document::new(200.0, 100.0)));
|
||||
// A style of the document's own.
|
||||
let mut own = Document::new(200.0, 100.0);
|
||||
own.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Mine", Appearance::basic(black_fill(), Paint::None, 0.0)));
|
||||
assert!(warns(&own));
|
||||
assert!(!shown(&export_ai_editing(&own, &opts()).unwrap().program).contains("ArtStyle"));
|
||||
assert_eq!(read_back(&own).graphic_style("Mine"), None);
|
||||
// A link to a new document's style, on an object with an effect.
|
||||
let mut linked = Document::new(200.0, 100.0);
|
||||
let id = linked.graphic_style("Sunshine").unwrap().id;
|
||||
let mut n = plain_rect(&mut linked, "Linked", vec![drop_shadow()]);
|
||||
n.extras_mut().graphic_style = Some(id);
|
||||
put(&mut linked, n);
|
||||
assert!(warns(&linked));
|
||||
let back = read_back(&linked);
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
assert_eq!(n.extras().graphic_style, None);
|
||||
assert_eq!(n.appearance.effects.len(), 1);
|
||||
fn a_link_left_out_warns() {
|
||||
let warns = |make: &dyn Fn(&mut Document, u32) -> Node| {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let ap = Appearance {
|
||||
items: vec![AppearanceItem::Fill(FillLayer::new(black_fill())), AppearanceItem::Fill(FillLayer::new(solid(Color::WHITE)))],
|
||||
..Appearance::default()
|
||||
};
|
||||
let mut layered = vectorcraft_doc::GraphicStyle::new("Layered", ap);
|
||||
layered.id = 7;
|
||||
doc.graphic_styles.push(layered);
|
||||
let mut plain = vectorcraft_doc::GraphicStyle::new("Plain", Appearance::basic(black_fill(), Paint::None, 0.0));
|
||||
plain.id = 8;
|
||||
doc.graphic_styles.push(plain);
|
||||
let n = make(&mut doc, 7);
|
||||
put(&mut doc, n);
|
||||
let warned = export_ai_editing(&doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::STYLE_LINKS);
|
||||
(warned, read_back(&doc))
|
||||
};
|
||||
let linked = |mut n: Node, id: u32| {
|
||||
n.extras_mut().graphic_style = Some(id);
|
||||
n
|
||||
};
|
||||
// A style of two fills: a path isn't read with it.
|
||||
let (warned, back) = warns(&|doc, id| {
|
||||
let ap = doc.graphic_style_by_id(id).unwrap().appearance.clone();
|
||||
linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap), id)
|
||||
});
|
||||
assert!(warned);
|
||||
assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None);
|
||||
assert!(back.graphic_style("Layered").is_some());
|
||||
// An image, a symbol instance and a knockout shape are never written with a style.
|
||||
let (warned, _) = warns(&|doc, _| {
|
||||
let art = square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK);
|
||||
doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) });
|
||||
linked(Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) }), 8)
|
||||
});
|
||||
assert!(warned);
|
||||
let (warned, _) = warns(&|doc, _| {
|
||||
let mut n = plain_rect(doc, "Out", vec![]);
|
||||
n.knockout_shape = true;
|
||||
linked(n, 8)
|
||||
});
|
||||
assert!(warned);
|
||||
// A group with a member the style can't hold.
|
||||
let (warned, _) = warns(&|doc, _| {
|
||||
let member = plain_rect(doc, "Member", vec![drop_shadow()]);
|
||||
linked(group(doc, vec![member], false), 8)
|
||||
});
|
||||
assert!(warned);
|
||||
// A group with an effect and a member linked to a style: the group keeps its effect, and the
|
||||
// member's link is left out.
|
||||
let (warned, back) = warns(&|doc, _| {
|
||||
let member = linked(plain_rect(doc, "Member", vec![]), 8);
|
||||
let mut g = group(doc, vec![member], false);
|
||||
g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into();
|
||||
g
|
||||
});
|
||||
assert!(warned);
|
||||
let g = &back.layers[0].children().unwrap()[0];
|
||||
assert_eq!(g.appearance.effects.len(), 1, "{g:?}");
|
||||
assert_eq!(g.children().unwrap()[0].extras().graphic_style, None);
|
||||
// A group whose link isn't written: its member keeps its own.
|
||||
let (warned, back) = warns(&|doc, id| {
|
||||
let plain = doc.graphic_style_by_id(id + 1).unwrap().appearance.clone();
|
||||
let member = linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), plain), id + 1);
|
||||
linked(group(doc, vec![member], false), id)
|
||||
});
|
||||
assert!(warned);
|
||||
let g = &back.layers[0].children().unwrap()[0];
|
||||
assert_eq!(g.extras().graphic_style, None);
|
||||
let member = g.children().unwrap()[0].extras().graphic_style.unwrap();
|
||||
assert_eq!(back.graphic_style_by_id(member).map(|s| s.name.as_str()), Some("Plain"));
|
||||
// A link in a symbol's art.
|
||||
let mut in_symbol = Document::new(200.0, 100.0);
|
||||
let id = in_symbol.graphic_style("Sunshine").unwrap().id;
|
||||
let mut art = square(&mut in_symbol, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK);
|
||||
art.extras_mut().graphic_style = Some(id);
|
||||
in_symbol.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) });
|
||||
assert!(warns(&in_symbol));
|
||||
let (warned, _) = warns(&|doc, _| {
|
||||
let art = linked(square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK), 8);
|
||||
doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) });
|
||||
Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) })
|
||||
});
|
||||
assert!(warned);
|
||||
// A link that is kept.
|
||||
let (warned, back) = warns(&|doc, id| {
|
||||
let plain = doc.graphic_style_by_id(id + 1).unwrap().appearance.clone();
|
||||
linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), plain), id + 1)
|
||||
});
|
||||
assert!(!warned);
|
||||
let id = back.layers[0].children().unwrap()[0].extras().graphic_style.unwrap();
|
||||
assert_eq!(back.graphic_style_by_id(id).map(|g| g.name.as_str()), Some("Plain"));
|
||||
}
|
||||
|
||||
/// A panel with no style written warns that a new document's styles come back.
|
||||
#[test]
|
||||
fn an_empty_graphic_styles_panel_warns() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
doc.graphic_styles.clear();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PANEL_EMPTY), "{:?}", out.warnings);
|
||||
assert!(!shown(&out.program).contains("ArtStyle"));
|
||||
assert_eq!(read_back(&doc).graphic_styles, Document::new(1.0, 1.0).graphic_styles);
|
||||
// Every style left out.
|
||||
let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)];
|
||||
let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||||
doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Graded", Appearance::basic(graded, Paint::None, 0.0)));
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PANEL_EMPTY) && out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
|
||||
// A new document's styles aren't written and don't warn.
|
||||
let out = export_ai_editing(&Document::new(200.0, 100.0), &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::PANEL_EMPTY), "{:?}", out.warnings);
|
||||
}
|
||||
|
||||
/// A second style of one name is left out with a warning, and an object linked to it has no link.
|
||||
#[test]
|
||||
fn a_graphic_style_of_a_name_another_has_is_left_out() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let style = |id: u32, c: Color| {
|
||||
let mut g = vectorcraft_doc::GraphicStyle::new("Twin", Appearance::basic(solid(c), Paint::None, 0.0));
|
||||
g.id = id;
|
||||
g
|
||||
};
|
||||
*doc.graphic_styles = vec![style(1, Color::BLACK), style(2, Color::WHITE)];
|
||||
let mut n = styled_rect(&mut doc, "Second", Rect::new(10.0, 10.0, 50.0, 40.0), solid(Color::WHITE), Paint::None, vec![]);
|
||||
n.extras_mut().graphic_style = Some(2);
|
||||
put(&mut doc, n);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES) && out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
assert_eq!(back.graphic_styles.len(), 1);
|
||||
assert_eq!(back.graphic_styles[0].appearance.fill().and_then(|f| f.paint.color()), Some(Color::BLACK));
|
||||
assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None);
|
||||
}
|
||||
|
||||
/// A path or compound path with no points writes nothing, with or without a style; a group with no
|
||||
@@ -6183,6 +6491,93 @@ fn every_effect_setting_written_on_an_object_is_read_back() {
|
||||
assert!(matches!(&spot.appearance.fill().unwrap().paint, Paint::Solid { swatch: Some(s), .. } if s == "Ink"), "{spot:?}");
|
||||
}
|
||||
|
||||
/// Each effect a graphic style can hold, in a style of its own, reads back with its settings, whatever
|
||||
/// the writer's conversions to the file's units.
|
||||
#[test]
|
||||
fn every_effect_a_graphic_style_holds_is_read_back() {
|
||||
use serde_json::json;
|
||||
let mut cases = vec![
|
||||
(
|
||||
"distort.transform",
|
||||
json!({"scaleH": 50.0, "scaleV": 150.0, "moveH": 12.5, "moveV": -7.5, "rotate": 30.0, "copies": 2, "reference": 8, "reflectX": true, "objects": false, "strokes": true, "patterns": true}),
|
||||
),
|
||||
("distort.freeDistort", json!({"corners": [[0.1, 0.05], [0.9, 0.1], [1.0, 1.0], [0.0, 0.8]]})),
|
||||
("distort.puckerBloat", json!({"amount": -40.0})),
|
||||
("distort.puckerBloat", json!({"amount": 60.0})),
|
||||
("distort.twist", json!({"angle": -45.0})),
|
||||
("distort.zigZag", json!({"size": 7.5, "relative": false, "ridges": 6, "points": "corner"})),
|
||||
("distort.zigZag", json!({"size": 25.0, "relative": true, "ridges": 3, "points": "smooth"})),
|
||||
("blur.gaussian", json!({"radius": 2.5})),
|
||||
];
|
||||
let warps = [
|
||||
"arc",
|
||||
"arcLower",
|
||||
"arcUpper",
|
||||
"arch",
|
||||
"bulge",
|
||||
"shellLower",
|
||||
"shellUpper",
|
||||
"flag",
|
||||
"wave",
|
||||
"fish",
|
||||
"rise",
|
||||
"fisheye",
|
||||
"inflate",
|
||||
"squeeze",
|
||||
"twist",
|
||||
];
|
||||
let ids: Vec<String> = warps.iter().map(|w| format!("warp.{w}")).collect();
|
||||
for id in &ids {
|
||||
cases.push((id, json!({"bend": 30.0, "horizontal": -10.0, "vertical": 20.0, "orientation": "vertical"})));
|
||||
cases.push((id, json!({"bend": -30.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"})));
|
||||
}
|
||||
for (id, params) in cases {
|
||||
let effect = fx(id, params.clone());
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let mut g = vectorcraft_doc::GraphicStyle::new(
|
||||
"Held",
|
||||
Appearance { effects: vec![effect.clone()], ..Appearance::basic(black_fill(), Paint::None, 0.0) },
|
||||
);
|
||||
g.id = 3;
|
||||
*doc.graphic_styles = vec![g];
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{id} {params}: {:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let got = &back.graphic_styles[0].appearance.effects;
|
||||
assert_eq!(got.len(), 1, "{id} {params}");
|
||||
assert_eq!(got[0].id, effect.id);
|
||||
let (g, w) = (vectorcraft_effects::merged_params(&got[0].id, &got[0].params), vectorcraft_effects::merged_params(&effect.id, &effect.params));
|
||||
for (key, value) in w.as_object().unwrap() {
|
||||
// Random isn't written, and a style with it isn't read; its seed is the default.
|
||||
let read = g.get(key).unwrap_or(&serde_json::Value::Null);
|
||||
match value {
|
||||
serde_json::Value::Array(rows) => {
|
||||
let got = read.as_array().unwrap();
|
||||
assert_eq!(got.len(), rows.len(), "{id}");
|
||||
for (r, w) in got.iter().zip(rows) {
|
||||
for (r, w) in r.as_array().unwrap().iter().zip(w.as_array().unwrap()) {
|
||||
assert!((r.as_f64().unwrap() - w.as_f64().unwrap()).abs() < 6e-3, "{id} {key}: {read} for {value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::Value::Number(v) => assert!((read.as_f64().unwrap() - v.as_f64().unwrap()).abs() < 6e-3, "{id} {key}: {read} for {v}"),
|
||||
serde_json::Value::String(v) => assert_eq!(read.as_str().map(str::to_ascii_lowercase), Some(v.to_ascii_lowercase()), "{id} {key}"),
|
||||
_ => assert_eq!(read, value, "{id} {key}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// A Zig Zag with its 0 to 1 settings written under the size's own key, and a turn in radians.
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let effects = vec![
|
||||
fx("distort.zigZag", json!({"size": 7.5, "relative": false, "ridges": 6, "points": "corner"})),
|
||||
fx("distort.transform", json!({"moveV": -7.5, "rotate": 30.0})),
|
||||
];
|
||||
doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Forms", Appearance { effects, ..Appearance::default() }));
|
||||
let p = program_shown(&doc);
|
||||
assert!(p.contains("7.5 /Real (amount) ,") && p.contains("0 /Real (roundness) ,"), "{p}");
|
||||
assert!(p.contains("7.5 /Real (moveV_Pts) ,") && p.contains("0.5236 /Real (rotate_Radians) ,"), "{p}");
|
||||
}
|
||||
|
||||
/// Every kind of appearance a style isn't written for is written as its drawn look, with a warning.
|
||||
#[test]
|
||||
fn appearances_written_as_their_drawn_look() {
|
||||
@@ -6324,6 +6719,60 @@ fn appearances_written_as_their_drawn_look() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A link to a graphic style whose Transform moves the outline is kept on a path the reader reads
|
||||
/// that style on, and left out with a warning on a path with an opacity mask, which keeps its mask.
|
||||
#[test]
|
||||
fn a_link_to_a_style_that_moves_the_outline_is_kept_where_the_reader_reads_it() {
|
||||
let make = |masked: bool| {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let moved = fx("distort.transform", serde_json::json!({"copies": 0, "scaleH": 50.0}));
|
||||
let ap = Appearance { effects: vec![moved], ..Appearance::basic(black_fill(), Paint::None, 0.0) };
|
||||
let mut g = vectorcraft_doc::GraphicStyle::new("Moved", ap.clone());
|
||||
g.id = 9;
|
||||
doc.graphic_styles.push(g);
|
||||
let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap);
|
||||
n.extras_mut().graphic_style = Some(9);
|
||||
if masked {
|
||||
let art = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 25.0), Color::gray(0.5));
|
||||
n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }.into());
|
||||
}
|
||||
put(&mut doc, n);
|
||||
doc
|
||||
};
|
||||
let doc = make(false);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
assert_eq!(n.extras().graphic_style.and_then(|id| back.graphic_style_by_id(id)).map(|g| g.name.as_str()), Some("Moved"));
|
||||
|
||||
let doc = make(true);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
assert_eq!(n.extras().graphic_style, None);
|
||||
assert!(n.extras().mask.is_some(), "{n:?}");
|
||||
}
|
||||
|
||||
/// Twist and Transform on an object that isn't linked to a graphic style are not written as a style
|
||||
/// of its own: the object reads back without live effects.
|
||||
#[test]
|
||||
fn distort_effects_on_an_unlinked_object_write_no_style() {
|
||||
let cases = [fx("distort.twist", serde_json::json!({"angle": 30.0})), fx("distort.transform", serde_json::json!({"copies": 0, "moveH": 5.0}))];
|
||||
for e in cases {
|
||||
let what = e.id.clone();
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let n = plain_rect(&mut doc, "D", vec![e]);
|
||||
put(&mut doc, n);
|
||||
let p = shown(&export_ai_editing(&doc, &opts()).unwrap().program);
|
||||
assert!(!p.contains("%AI9_BeginArtStyles") && !p.contains("(Anon 1) XW"), "{what}:\n{p}");
|
||||
let back = read_back(&doc);
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
assert!(n.appearance.effects.is_empty(), "{what}: {n:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient fill that fits its object is placed on the outline its geometry effects draw: an
|
||||
/// object with Offset Path reads back with the gradient a plain object of the drawn size has.
|
||||
#[test]
|
||||
@@ -6622,14 +7071,26 @@ fn a_styled_objects_gradient_is_placed_as_a_plain_objects_is() {
|
||||
}
|
||||
}
|
||||
|
||||
/// On a large canvas an object with an effect warns and comes back as its drawn look at its place.
|
||||
/// On a large canvas a document with only graphic styles, an object linked to one and an object with
|
||||
/// an effect all warn, and the object comes back as its drawn look at its place.
|
||||
#[test]
|
||||
fn a_large_canvas_writes_every_object_as_its_look() {
|
||||
let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::LARGE_CANVAS_STYLES);
|
||||
let mut styles_only = Document::new(20000.0, 100.0);
|
||||
styles_only.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Mine", Appearance::basic(black_fill(), Paint::None, 0.0)));
|
||||
assert!(warns(&styles_only));
|
||||
assert_eq!(read_back(&styles_only).graphic_style("Mine"), None);
|
||||
let mut plain = Document::new(20000.0, 100.0);
|
||||
let n = plain_rect(&mut plain, "None", vec![]);
|
||||
put(&mut plain, n);
|
||||
assert!(!warns(&plain));
|
||||
let mut linked_doc = Document::new(20000.0, 100.0);
|
||||
let id = linked_doc.graphic_style("Sunshine").unwrap().id;
|
||||
let mut n = plain_rect(&mut linked_doc, "Linked", vec![]);
|
||||
n.extras_mut().graphic_style = Some(id);
|
||||
put(&mut linked_doc, n);
|
||||
assert!(warns(&linked_doc));
|
||||
assert_eq!(read_back(&linked_doc).layers[0].children().unwrap()[0].extras().graphic_style, None);
|
||||
let mut shaded = Document::new(20000.0, 100.0);
|
||||
let n = plain_rect(&mut shaded, "Shaded", vec![drop_shadow()]);
|
||||
put(&mut shaded, n);
|
||||
|
||||
@@ -22,9 +22,9 @@ mod symbols;
|
||||
mod texts;
|
||||
mod variables;
|
||||
|
||||
pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects};
|
||||
#[cfg(test)]
|
||||
pub(super) use styles::same_appearance;
|
||||
pub(super) use styles::{GRAPHIC_STYLES, LARGE_CANVAS_STYLES, has_graphic_styles, styled_objects};
|
||||
pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, same_appearance};
|
||||
|
||||
pub(super) use images::PAYLOAD;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
//! Art styles: objects with live effects a plain path can't hold, written as their drawn look, the
|
||||
//! object itself on `%_` lines and `1 (style) XW`, and the styles they name in
|
||||
//! `%AI9_BeginArtStyles` (see [`crate::ai_editing`] for the form). Each style is read back before it
|
||||
//! is written ([`crate::import::read_art_style`]: the `.ai` reader's style parser and its reading of
|
||||
//! a graphic style, with the object's own gradient): one that reads back differently is not
|
||||
//! written, and the object is written as its drawn look alone.
|
||||
//! Art styles: objects whose appearance a plain path can't hold, written as their drawn look, the
|
||||
//! object itself on `%_` lines and `1 (style) XW`; the styles they name and the Graphic Styles
|
||||
//! panel's styles in `%AI9_BeginArtStyles`, and the panel's order in `%AI9_BeginArtStyleList` (see
|
||||
//! [`crate::ai_editing`] for the form). Each style is read back before it is written
|
||||
//! ([`crate::import::read_art_style`]: the `.ai` reader's style parser and its reading of a graphic
|
||||
//! style, with the object's own gradient): one that reads back differently is not written, and the
|
||||
//! object is written as its drawn look alone.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Knockout, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, GraphicStyle, Knockout, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, FillRule, Rect, Shape};
|
||||
|
||||
use super::{Writer, Xy, blend_code, compound_path, hide};
|
||||
@@ -19,33 +20,35 @@ use crate::ai_ops;
|
||||
use crate::ps;
|
||||
|
||||
pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = "on a large canvas, no art styles or graphic styles are written: objects with live effects are written with their geometry effects applied to the artwork, where they can no longer be edited, and their other effects are left out of the editing data for other apps";
|
||||
pub(in crate::ai_editing) const GRAPHIC_STYLES: &str = "graphic styles and links to them are left out of the editing data for other apps";
|
||||
pub(in crate::ai_editing) const PANEL_STYLES: &str = "graphic styles with gradient or pattern fills, with fills, strokes or effects the editing data can't hold as a graphic style, with a name another style has, with names over 1,024 bytes, or past 4,096 styles, are left out of the editing data for other apps";
|
||||
pub(in crate::ai_editing) const PANEL_EMPTY: &str =
|
||||
"a Graphic Styles panel with no style written opens from the editing data with a new document's styles";
|
||||
pub(in crate::ai_editing) const STYLE_LINKS: &str = "links to graphic styles are left out of the editing data for other apps on type, images, symbol instances, knockout shapes and other objects that aren't written with their graphic style";
|
||||
|
||||
/// Does `doc` have graphic styles other than a new document's, or an object in its layers, opacity
|
||||
/// masks or symbols linked to a graphic style?
|
||||
pub(in crate::ai_editing) fn has_graphic_styles(doc: &Document) -> bool {
|
||||
fn linked(n: &Node) -> bool {
|
||||
n.extras().graphic_style.is_some()
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| linked(&m.art))
|
||||
|| n.children().is_some_and(|c| c.iter().any(|m| linked(m)))
|
||||
/// How many objects of `doc`'s layers, opacity masks and symbols are linked to a graphic style.
|
||||
pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize {
|
||||
fn count(n: &Node) -> usize {
|
||||
let own = usize::from(n.extras().graphic_style.is_some());
|
||||
let mask = n.extras().mask.as_ref().map_or(0, |m| count(&m.art));
|
||||
own + mask + n.children().map_or(0, |c| c.iter().map(|m| count(m)).sum())
|
||||
}
|
||||
doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles || doc.layers.iter().any(|l| linked(l)) || doc.symbols.iter().any(|s| linked(&s.art))
|
||||
doc.layers.iter().map(|l| count(l)).sum::<usize>() + doc.symbols.iter().map(|s| count(&s.art)).sum::<usize>()
|
||||
}
|
||||
|
||||
/// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]),
|
||||
/// by id: paths and compound paths with points and groups, with live effects, without Opacity &
|
||||
/// Mask Define Knockout Shape (the reader takes an object's transparency from its look, which
|
||||
/// doesn't carry it). A group's members are paths, compound paths or such groups, none with effects
|
||||
/// or anything baking changes.
|
||||
/// by id: paths and compound paths with points and groups, with live effects or linked to a graphic
|
||||
/// style, without Opacity & Mask Define Knockout Shape (the reader takes an object's transparency
|
||||
/// from its look, which doesn't carry it). A group's members are paths, compound paths or such
|
||||
/// groups, none with effects or anything baking changes.
|
||||
pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap<NodeId, Arc<Node>> {
|
||||
fn walk(nodes: &[Arc<Node>], out: &mut HashMap<NodeId, Arc<Node>>) {
|
||||
for n in nodes {
|
||||
if candidate(n) {
|
||||
out.insert(n.id, n.clone());
|
||||
continue;
|
||||
}
|
||||
// Baking a container with effects of its own changes its members, which keep their ids:
|
||||
// they are written as baking left them.
|
||||
// A group's members are written with a style of their own only when the group is written
|
||||
// without one. Baking a container with effects of its own changes its members, which
|
||||
// keep their ids: they are written as baking left them.
|
||||
if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &n.kind
|
||||
&& !bakes_members(n)
|
||||
{
|
||||
@@ -69,7 +72,8 @@ fn bakes_members(n: &Node) -> bool {
|
||||
|
||||
/// Whether `n` may be written with an art style ([`styled_objects`]).
|
||||
fn candidate(n: &Node) -> bool {
|
||||
if n.appearance.effects.is_empty() || n.knockout_shape {
|
||||
let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some();
|
||||
if !styled || n.knockout_shape {
|
||||
return false;
|
||||
}
|
||||
match &n.kind {
|
||||
@@ -96,25 +100,39 @@ fn plain_member(n: &Node) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The art styles objects name, each with its text after its name (`/ActiveStyle :` to `/Def ;`).
|
||||
/// The art styles written: the Graphic Styles panel's (listed, in its order) and those objects
|
||||
/// name, each with its text after its name (`/ActiveStyle :` to `/Def ;`).
|
||||
#[derive(Default)]
|
||||
pub(in crate::ai_editing) struct Styles {
|
||||
/// Every style, in the order written: its name and text.
|
||||
known: Vec<(String, String)>,
|
||||
/// The name of each style by its text.
|
||||
/// The panel's styles written, by the graphic style's id.
|
||||
panel: HashMap<u32, String>,
|
||||
/// The panel's styles written, in its order.
|
||||
listed: Vec<String>,
|
||||
/// The names styles have, and the name of each object style by its text.
|
||||
names: HashSet<String>,
|
||||
by_text: HashMap<String, String>,
|
||||
next_anon: u64,
|
||||
/// How many objects were written with the graphic style they are linked to.
|
||||
pub(super) links_written: usize,
|
||||
}
|
||||
|
||||
impl Styles {
|
||||
/// The name of an object style of `text`: a style with the same text already written, else a
|
||||
/// new one named `Anon` and a number.
|
||||
/// new one named `Anon` and a number no other style has.
|
||||
fn object_style(&mut self, text: String) -> String {
|
||||
if let Some(name) = self.by_text.get(&text) {
|
||||
return name.clone();
|
||||
}
|
||||
self.next_anon += 1;
|
||||
let name = format!("Anon {}", self.next_anon);
|
||||
let name = loop {
|
||||
self.next_anon += 1;
|
||||
let name = format!("Anon {}", self.next_anon);
|
||||
if !self.names.contains(&name) {
|
||||
break name;
|
||||
}
|
||||
};
|
||||
self.names.insert(name.clone());
|
||||
self.by_text.insert(text.clone(), name.clone());
|
||||
self.known.push((name.clone(), text));
|
||||
name
|
||||
@@ -286,16 +304,30 @@ impl Writer<'_> {
|
||||
(!line.is_empty()).then_some(line)
|
||||
}
|
||||
|
||||
/// An appearance item's basic filter. `None` for an item with transparency or effects of its
|
||||
/// own, which the reader doesn't read on an object.
|
||||
/// An appearance item's filter: its basic filter, or a chain of it, its effects and its
|
||||
/// transparency.
|
||||
fn item_filter(&mut self, s: &mut String, item: &AppearanceItem, on: Option<(&BezPath, FillRule)>) -> Option<()> {
|
||||
if item.opacity() < 1.0 || item.blend() != BlendMode::Normal || !item.effects().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let plain = item.opacity() >= 1.0 && item.blend() == BlendMode::Normal && item.effects().is_empty();
|
||||
let mut basic_text = String::new();
|
||||
// In a chain, the chain holds the item's visibility.
|
||||
let visible = item.visible() || !plain;
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => self.fill_filter(s, f, item.visible(), on),
|
||||
AppearanceItem::Stroke(st) => self.stroke_filter(s, st, item.visible()),
|
||||
AppearanceItem::Fill(f) => self.fill_filter(&mut basic_text, f, visible, on)?,
|
||||
AppearanceItem::Stroke(st) => self.stroke_filter(&mut basic_text, st, visible)?,
|
||||
}
|
||||
if plain {
|
||||
s.push_str(&basic_text);
|
||||
return Some(());
|
||||
}
|
||||
let _ = write!(s, "/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n{} /Visible ,\n", u8::from(item.visible()));
|
||||
s.push_str(&basic_text);
|
||||
for e in item.effects() {
|
||||
s.push_str(&self.effect_filter(e)?);
|
||||
}
|
||||
let opacity = if item.opacity().is_finite() { item.opacity().clamp(0.0, 1.0) } else { 1.0 };
|
||||
blend_filter(s, Xy { blend: item.blend(), opacity, ..Xy::OPAQUE });
|
||||
s.push_str(";\n /Part ,\n");
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The text of a style of appearance `ap` and transparency `xy` (after its name): the chain
|
||||
@@ -337,7 +369,13 @@ impl Writer<'_> {
|
||||
let p = vectorcraft_effects::merged_params(&e.id, &e.params);
|
||||
let mut d = Entries::default();
|
||||
let id = e.id.as_str();
|
||||
let name = if id.starts_with("stylize.") { self.stylize_entries(id, &p, &mut d)? } else { Self::path_entries(id, &p, &mut d)? };
|
||||
let name = if id.starts_with("stylize.") {
|
||||
self.stylize_entries(id, &p, &mut d)?
|
||||
} else if id.starts_with("warp.") || id.starts_with("distort.") || id.starts_with("blur.") {
|
||||
Self::distort_entries(id, &p, &mut d)?
|
||||
} else {
|
||||
Self::path_entries(id, &p, &mut d)?
|
||||
};
|
||||
let mut s = String::new();
|
||||
basic(&mut s, name, true, e.visible);
|
||||
s.push_str("/Dictionary : /NotRecorded ,\n");
|
||||
@@ -460,9 +498,140 @@ impl Writer<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The style object `orig` (`n` before baking) is written with; `None` when the reader wouldn't
|
||||
/// read the object as it is with it. A gradient fill is placed on `n`'s outline, as the object
|
||||
/// draws it. A style that isn't written leaves no gradient definition or warning behind.
|
||||
/// A Warp, Distort & Transform or Gaussian Blur effect's entries ([`Self::effect_filter`]) and
|
||||
/// its filter's name.
|
||||
fn distort_entries(id: &str, p: &Value, d: &mut Entries) -> Option<&'static str> {
|
||||
Some(match id {
|
||||
id if id.starts_with("warp.") => {
|
||||
let style = crate::import::warp_number(id.strip_prefix("warp.")?)?;
|
||||
d.int("DeformStyle", style);
|
||||
d.real("DeformValue", num(p, "bend")? / 100.0);
|
||||
d.real("DeformHoriz", num(p, "horizontal")? / 100.0);
|
||||
d.real("DeformVert", num(p, "vertical")? / 100.0);
|
||||
d.bool("Rotate", word(p, "orientation").as_deref() == Some("vertical"));
|
||||
"Adobe Deform" // brand-ok: the filter's name in the files
|
||||
}
|
||||
"distort.transform" => {
|
||||
d.real("scaleH_Factor", num(p, "scaleH")? / 100.0);
|
||||
d.real("scaleV_Factor", num(p, "scaleV")? / 100.0);
|
||||
d.real("moveH_Pts", num(p, "moveH")?);
|
||||
// Art space has y up.
|
||||
d.real("moveV_Pts", 0.0 - num(p, "moveV")?);
|
||||
d.real("rotate_Radians", num(p, "rotate")?.to_radians());
|
||||
d.int("numCopies", num(p, "copies")? as i64);
|
||||
d.int("pinPoint", num(p, "reference")? as i64);
|
||||
d.bool("reflectX", flag(p, "reflectX")?);
|
||||
d.bool("reflectY", flag(p, "reflectY")?);
|
||||
d.bool("transformObjects", flag(p, "objects")?);
|
||||
d.bool("scaleLines", flag(p, "strokes")?);
|
||||
d.bool("transformPatterns", flag(p, "patterns")?);
|
||||
d.bool("randomize", false);
|
||||
"Adobe Transform" // brand-ok: the filter's name in the files
|
||||
}
|
||||
"distort.freeDistort" => {
|
||||
let corners = p.get("corners")?.as_array()?;
|
||||
let corner = |i: usize| -> Option<(f64, f64)> {
|
||||
let c = corners.get(i)?.as_array()?;
|
||||
Some((c.first()?.as_f64()?, c.get(1)?.as_f64()?))
|
||||
};
|
||||
// The source frame is the unit square (y up); destination corners 0, 1, 3, 2 are
|
||||
// the catalog's top left, top right, bottom right, bottom left (y down).
|
||||
for (i, (h, v)) in [(0.0, 1.0), (1.0, 1.0), (0.0, 0.0), (1.0, 0.0)].into_iter().enumerate() {
|
||||
d.real(&format!("src{i}h"), h);
|
||||
d.real(&format!("src{i}v"), v);
|
||||
}
|
||||
for (i, from) in [0, 1, 3, 2].into_iter().enumerate() {
|
||||
let (x, y) = corner(from)?;
|
||||
d.real(&format!("dst{i}h"), x);
|
||||
d.real(&format!("dst{i}v"), 1.0 - y);
|
||||
}
|
||||
"Adobe Free Distort" // brand-ok: the filter's name in the files
|
||||
}
|
||||
"distort.puckerBloat" => {
|
||||
d.real("d_factor", num(p, "amount")?);
|
||||
"Adobe Punk and Bloat" // brand-ok: the filter's name in the files
|
||||
}
|
||||
"distort.twist" => {
|
||||
d.real("angle", num(p, "angle")?);
|
||||
"Adobe Twirl" // brand-ok: the filter's name in the files
|
||||
}
|
||||
"distort.zigZag" => {
|
||||
let relative = flag(p, "relative")?;
|
||||
d.real("absoluteness", if relative { 0.0 } else { 1.0 });
|
||||
d.real(if relative { "relAmount" } else { "amount" }, num(p, "size")?);
|
||||
d.real("ridges", num(p, "ridges")?);
|
||||
d.real("roundness", if word(p, "points").as_deref() == Some("smooth") { 1.0 } else { 0.0 });
|
||||
"Adobe Zigzag" // brand-ok: the filter's name in the files
|
||||
}
|
||||
"blur.gaussian" => {
|
||||
d.real("blur", num(p, "radius")?);
|
||||
"Adobe PSL Gaussian Blur" // brand-ok: the filter's name in the files
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The Graphic Styles panel's styles that the reader reads as they are: each checked, and
|
||||
/// the others left out with a warning. Their names are taken before the art's styles are.
|
||||
pub(in crate::ai_editing) fn panel_styles(&mut self) {
|
||||
let doc = self.doc;
|
||||
// A new document's styles are what the reader gives a file without a style list; they are
|
||||
// still written when an object is linked to one of them, so that it keeps its link.
|
||||
let linked = self.styled.values().any(|n| n.extras().graphic_style.is_some());
|
||||
if !linked && doc.graphic_styles == Document::new(1.0, 1.0).graphic_styles {
|
||||
return;
|
||||
}
|
||||
for g in doc.graphic_styles.iter() {
|
||||
self.styles.names.insert(g.name.clone());
|
||||
}
|
||||
let mut readable: Vec<(&GraphicStyle, String)> = vec![];
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
for g in doc.graphic_styles.iter() {
|
||||
if readable.len() >= crate::import::MAX_LISTED || g.name.len() > crate::import::MAX_LISTED_NAME || seen.contains(g.name.as_str()) {
|
||||
self.warn(PANEL_STYLES);
|
||||
continue;
|
||||
}
|
||||
let xy = Xy { blend: g.blend, opacity: g.opacity.clamp(0.0, 1.0), isolate: g.isolate, knockout: g.knockout, shape: false };
|
||||
let text = self.style_text(&g.appearance, xy, None).filter(|t| panel_reads(g, t));
|
||||
let Some(text) = text else {
|
||||
self.warn(PANEL_STYLES);
|
||||
continue;
|
||||
};
|
||||
seen.insert(g.name.as_str());
|
||||
readable.push((g, text));
|
||||
}
|
||||
// The list as the reader reads it: names too long, and styles past its count, aren't.
|
||||
let list: String = readable.iter().map(|(g, _)| ps::bytes(g.name.as_bytes()) + "\n").collect();
|
||||
let listed: HashSet<String> = crate::import::read_style_list(list.as_bytes()).into_iter().collect();
|
||||
for (g, text) in readable {
|
||||
if !listed.contains(&g.name) {
|
||||
self.warn(PANEL_STYLES);
|
||||
continue;
|
||||
}
|
||||
if g.id != 0 {
|
||||
self.styles.panel.insert(g.id, g.name.clone());
|
||||
}
|
||||
self.styles.listed.push(g.name.clone());
|
||||
self.styles.known.push((g.name.clone(), text));
|
||||
}
|
||||
// The reader gives a file without a style list a new document's styles.
|
||||
if self.styles.listed.is_empty() {
|
||||
self.warn(PANEL_EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
/// Warn when fewer objects were written with their graphic style than `links` are linked to one.
|
||||
pub(in crate::ai_editing) fn lost_links(&mut self, links: usize) {
|
||||
if self.styles.links_written < links {
|
||||
self.warn(STYLE_LINKS);
|
||||
}
|
||||
}
|
||||
|
||||
/// The style object `orig` (`n` before baking) is written with: the graphic style it is linked
|
||||
/// to when it has that style's appearance and transparency and the panel's style is written,
|
||||
/// else a style of its own; `None` when the reader wouldn't read the object as it is with
|
||||
/// either. A gradient fill is placed on `n`'s outline, as the object draws it. A style of its own
|
||||
/// that isn't written leaves no gradient definition or warning behind.
|
||||
pub(super) fn object_style(&mut self, n: &Node, orig: &Node) -> Option<String> {
|
||||
if let NodeKind::Group { children, .. } = &orig.kind
|
||||
&& bakes_members(orig)
|
||||
@@ -470,6 +639,21 @@ impl Writer<'_> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let doc = self.doc;
|
||||
if let Some(id) = orig.extras().graphic_style
|
||||
&& let Some(name) = self.styles.panel.get(&id).cloned()
|
||||
&& let Some(g) = doc.graphic_style_by_id(id)
|
||||
&& g.transparency_matches(orig)
|
||||
&& g.appearance_on(orig).approx_eq(&orig.appearance)
|
||||
&& let Some(text) = self.styles.known.iter().find(|(n, _)| *n == name).map(|(_, t)| t.clone())
|
||||
&& object_reads(orig, &text)
|
||||
{
|
||||
self.styles.links_written += 1;
|
||||
return Some(name);
|
||||
}
|
||||
if orig.appearance.effects.is_empty() || !own_style_holds(&orig.appearance) {
|
||||
return None;
|
||||
}
|
||||
let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_)));
|
||||
let saved = (graded.then(|| self.gradient_mark()), self.warnings.len());
|
||||
let path = drawn_outline(n, orig);
|
||||
@@ -597,6 +781,20 @@ impl Writer<'_> {
|
||||
}
|
||||
s.push_str("%AI9_EndArtStyles\n%AI5_End_NonPrinting--\n");
|
||||
}
|
||||
|
||||
/// The panel's styles written, in its order, in a non-printing block:
|
||||
/// `%AI9_BeginArtStyleList`, a name on each line, `%AI9_EndArtStyleList`.
|
||||
pub(in crate::ai_editing) fn art_style_list(&self, s: &mut String) {
|
||||
if self.styles.listed.is_empty() {
|
||||
return;
|
||||
}
|
||||
s.push_str("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyleList\n");
|
||||
for name in &self.styles.listed {
|
||||
s.push_str(&ps::bytes(name.as_bytes()));
|
||||
s.push('\n');
|
||||
}
|
||||
s.push_str("%AI9_EndArtStyleList\n%AI5_End_NonPrinting--\n");
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline a path's or compound path's style places a gradient fill on.
|
||||
@@ -623,15 +821,38 @@ fn object_rule(n: &Node) -> FillRule {
|
||||
}
|
||||
}
|
||||
|
||||
/// Can appearance `ap` be written as a style of an object's own: fills and strokes that paint without
|
||||
/// transparency or effects of their own, and no Warp, Distort & Transform or Blur effect? Those
|
||||
/// are written only in the Graphic Styles panel's styles.
|
||||
fn own_style_holds(ap: &Appearance) -> bool {
|
||||
let plain = |i: &AppearanceItem| i.opacity() >= 1.0 && i.blend() == BlendMode::Normal && i.effects().is_empty();
|
||||
let panel_only = |e: &Effect| ["warp.", "distort.", "blur."].iter().any(|p| e.id.starts_with(p));
|
||||
ap.items.iter().filter(|i| !i.paint().is_none()).all(plain) && !ap.effects.iter().any(panel_only)
|
||||
}
|
||||
|
||||
/// Does the reader read graphic style `g` from style text `text` as it is?
|
||||
fn panel_reads(g: &GraphicStyle, text: &str) -> bool {
|
||||
let section = known_style("x", text);
|
||||
let Some(read) = crate::import::read_art_style(section.as_bytes(), "x", None) else { return false };
|
||||
// The opacity within the written number's precision.
|
||||
read.transparency.is_some_and(|t| (t.0 - g.opacity).abs() <= 1e-4 && (t.1, t.2, t.3) == (g.blend, g.isolate, g.knockout))
|
||||
&& same_appearance(&read.appearance, &g.appearance)
|
||||
}
|
||||
|
||||
/// Does the reader read object `n` written with style text `text` as it is: a path or compound
|
||||
/// path with a style it reads on any object, filled by the non-zero rule when the style fills it,
|
||||
/// or a group with no fills or strokes of its own?
|
||||
/// or a group with no fills or strokes of its own, with effects that move the outline only where the
|
||||
/// reader reads them ([`crate::import::style_moves_with`])?
|
||||
fn object_reads(n: &Node, text: &str) -> bool {
|
||||
let section = known_style("x", text);
|
||||
let own = n.appearance.fill().map(|f| &f.paint).filter(|p| matches!(p, Paint::Gradient(_)));
|
||||
let Some(mut read) = crate::import::read_art_style(section.as_bytes(), "x", own) else { return false };
|
||||
let group = matches!(n.kind, NodeKind::Group { .. });
|
||||
if !read.simple || (group && !read.appearance.items.is_empty()) || (!group && read.pathfinder) {
|
||||
if !read.simple
|
||||
|| (group && !read.appearance.items.is_empty())
|
||||
|| (!group && read.pathfinder)
|
||||
|| !crate::import::style_moves_with(section.as_bytes(), "x", n)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !group {
|
||||
|
||||
@@ -204,6 +204,8 @@ impl Writer<'_> {
|
||||
if sym.dynamic || doc.symbol_export_type(name) != SymbolExportType::default() || doc.symbol_nine_slice(name).is_some() {
|
||||
self.warn(SYMBOL_OPTIONS);
|
||||
}
|
||||
// The art's effects are its own, not those of a drawn look that first places it.
|
||||
let in_look = std::mem::take(&mut self.in_look);
|
||||
self.defining_symbols.push(name.to_string());
|
||||
// The objects of this definition and of those it defines first.
|
||||
let before = self.charges();
|
||||
@@ -220,6 +222,7 @@ impl Writer<'_> {
|
||||
self.defining_symbols.pop();
|
||||
self.keep_since(before);
|
||||
self.warn(SYMBOL_COUNT);
|
||||
self.in_look = in_look;
|
||||
return false;
|
||||
}
|
||||
// The group that holds the art is an object of the definition ([`Self::take_objects`]);
|
||||
@@ -305,6 +308,7 @@ impl Writer<'_> {
|
||||
(self.wrote_fixed, self.inner_scale),
|
||||
) = saved;
|
||||
self.doc = page_doc;
|
||||
self.in_look = in_look;
|
||||
let art = std::mem::replace(&mut self.body, art);
|
||||
self.defining_symbols.pop();
|
||||
if self.left_out_since(art_start) {
|
||||
|
||||
@@ -75,7 +75,10 @@ use obj::PsError;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use ai::objects_read;
|
||||
pub(crate) use ai::{MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_SYMBOLS, read_art_style, symbol_art_member};
|
||||
pub(crate) use ai::{
|
||||
MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with,
|
||||
symbol_art_member, warp_number,
|
||||
};
|
||||
pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES};
|
||||
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from};
|
||||
pub(crate) use native::{MAX_TEXT, scale_document};
|
||||
@@ -308,7 +311,8 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result<
|
||||
/// The document of what `out` drew: one layer, clipped art in clipping groups (grouped art in
|
||||
/// groups), the spot inks as spot swatches, in CMYK when most process colours were.
|
||||
fn finish(mut out: Out) -> Imported {
|
||||
let drawn = std::mem::take(&mut out.drawn);
|
||||
let mut drawn = std::mem::take(&mut out.drawn);
|
||||
graphics::merge_strips(&mut drawn, &mut out.shadings);
|
||||
let mut children = vectorcraft_doc::clipnest::nest(&mut out.doc, drawn);
|
||||
if !out.shadings.is_empty() {
|
||||
graphics::collapse(&mut children, &out.shadings);
|
||||
|
||||
@@ -45,14 +45,16 @@ pub(crate) use read::objects_read;
|
||||
#[cfg(test)]
|
||||
pub(crate) use read::with_image_limits;
|
||||
pub use read::{
|
||||
BindingRef, Brushes, PaletteSwatches, PlacedPage, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, read,
|
||||
read_beside, variables,
|
||||
BindingRef, Brushes, PaletteSwatches, PlacedPage, Structure, Symbols, active_artboard, artboards, bindings_note, brushes, graphic_styles,
|
||||
palette, read, read_beside, symbols, variables,
|
||||
};
|
||||
pub(crate) use read::{
|
||||
MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, slot_frame, slot_of, symbol_art_member,
|
||||
MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with,
|
||||
symbol_art_member,
|
||||
};
|
||||
|
||||
pub(crate) use read::is_look_slot;
|
||||
pub(crate) use style::{MAX_LISTED, MAX_LISTED_NAME, warp_number};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -142,10 +142,11 @@ mod types;
|
||||
pub(crate) use art::join_and;
|
||||
use art::preview_png;
|
||||
pub use document::{
|
||||
BindingRef, Brushes, PaletteSwatches, VariableRead, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, variables,
|
||||
BindingRef, Brushes, PaletteSwatches, Symbols, VariableRead, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette,
|
||||
symbols, variables,
|
||||
};
|
||||
pub(crate) use document::{slot_frame, slot_of};
|
||||
pub(crate) use ops::read_art_style;
|
||||
pub(crate) use ops::{read_art_style, read_style_list, style_moves_with};
|
||||
#[cfg(test)]
|
||||
pub(crate) use raster::with_image_limits;
|
||||
pub(crate) use types::MAX_NODES;
|
||||
|
||||
@@ -503,7 +503,15 @@ impl<'a> Reader<'a> {
|
||||
let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
let art: Vec<Arc<Node>> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect();
|
||||
let art: Vec<Arc<Node>> = frame
|
||||
.children
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let mut kept = self.doc.reid(n);
|
||||
self.reid_masks(&mut kept, 0);
|
||||
Arc::new(kept)
|
||||
})
|
||||
.collect();
|
||||
// The objects of the art linked to graphic styles, by their ids in the document.
|
||||
if !sub.style_links.is_empty() {
|
||||
let mut ids = HashMap::new();
|
||||
@@ -671,7 +679,7 @@ impl<'a> Reader<'a> {
|
||||
named
|
||||
}
|
||||
|
||||
/// Give the art of the opacity masks in `n` (a copy of a symbol's art or of a clipping path with ids of this
|
||||
/// Give the art of the opacity masks in `n` (a copy of a symbol's, a pattern's or a brush's art, or of a clipping path, with ids of this
|
||||
/// document, [`vectorcraft_doc::Document::reid`], which leaves masks as they are) ids of this
|
||||
/// document too.
|
||||
pub(super) fn reid_masks(&mut self, n: &mut Node, depth: usize) {
|
||||
@@ -783,7 +791,15 @@ impl<'a> Reader<'a> {
|
||||
&& sub.doc.images.is_empty();
|
||||
let Some(frame) = sub.frames.pop().filter(|f| whole && !f.children.is_empty()) else { return };
|
||||
self.brush_art_looks.insert(name.clone(), sub.drawn_looks);
|
||||
let mut art: Vec<Arc<Node>> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect();
|
||||
let mut art: Vec<Arc<Node>> = frame
|
||||
.children
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let mut kept = self.doc.reid(n);
|
||||
self.reid_masks(&mut kept, 0);
|
||||
Arc::new(kept)
|
||||
})
|
||||
.collect();
|
||||
let node = match art.len() {
|
||||
1 => art.pop().map(Arc::unwrap_or_clone),
|
||||
_ => Some(Node::group(self.doc.alloc_id(), art)),
|
||||
|
||||
@@ -405,6 +405,73 @@ pub fn graphic_styles(data: &[u8], large_canvas: bool) -> (Vec<GraphicStyle>, f6
|
||||
(r.graphic_styles().0, r.canvas_scale)
|
||||
}
|
||||
|
||||
/// The symbols of a `.ai` file's editing data, as [`symbols`] reads them.
|
||||
pub struct Symbols {
|
||||
/// The symbols in the Symbols panel's order, each with its Symbol Options › Registration.
|
||||
pub symbols: Vec<(Symbol, vectorcraft_doc::SymbolRegistration)>,
|
||||
/// The image blobs the symbols' art shows, by key.
|
||||
pub images: std::collections::BTreeMap<String, ImageBlob>,
|
||||
/// The global and spot colors the symbols' art is linked to, as swatches, as [`read`] adds
|
||||
/// them to a document read from the layers.
|
||||
pub swatches: Vec<Swatch>,
|
||||
}
|
||||
|
||||
/// The symbols of the editing data `data`, for a file whose layers aren't read (a symbol library
|
||||
/// has none), as [`read`] makes them: in the symbol list's order, without those that hold an
|
||||
/// instance of a symbol the data doesn't define, and with each object of their art that is linked
|
||||
/// to a listed graphic style linked to it by the id [`graphic_styles`] gives that style. The
|
||||
/// symbols are those defined before reading stops and before the first layer; `None` when there
|
||||
/// are none.
|
||||
pub fn symbols(data: &[u8]) -> Option<Symbols> {
|
||||
crate::import::ai::find(data, b"BeginSymbol")?;
|
||||
let mut r = Reader::with_names(data);
|
||||
// The symbols come before the art, so an error in the art leaves them read.
|
||||
let _ = r.run();
|
||||
r.check_symbols();
|
||||
r.order_symbols();
|
||||
if r.doc.symbols.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (styles, ids) = r.graphic_styles();
|
||||
let links: HashMap<NodeId, u32> = r.style_links.iter().filter_map(|(node, name)| Some((*node, *ids.get(name)?))).collect();
|
||||
if !links.is_empty() {
|
||||
for symbol in &mut r.doc.symbols {
|
||||
link_styles(std::slice::from_mut(&mut symbol.art), &links, &styles, 0);
|
||||
}
|
||||
}
|
||||
let mut shown = Document::new(1.0, 1.0);
|
||||
shown.symbols = std::mem::take(&mut r.doc.symbols);
|
||||
let mut images = std::collections::BTreeMap::new();
|
||||
shown.visit_images(|_, im| {
|
||||
if let Some(blob) = r.doc.images.get(&im.key) {
|
||||
images.insert(im.key.clone(), blob.clone());
|
||||
}
|
||||
});
|
||||
// The colors the art is linked to that the file defines, once each.
|
||||
let mut linked = std::collections::HashSet::new();
|
||||
for s in &mut shown.symbols {
|
||||
vectorcraft_doc::swatches::map_node_solid_paints(Arc::make_mut(&mut s.art), &mut |_, link, _| {
|
||||
linked.extend(link.clone());
|
||||
false
|
||||
});
|
||||
}
|
||||
let mut swatches: Vec<Swatch> = vec![];
|
||||
let colors = r.spots.iter().chain(&r.stop_spots).map(|n| (n, n.spot)).chain(r.custom_colors.iter().map(|n| (n, true)));
|
||||
for (n, spot) in colors {
|
||||
if linked.remove(&n.name) {
|
||||
swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot });
|
||||
}
|
||||
}
|
||||
let symbols = std::mem::take(&mut shown.symbols)
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let reg = r.doc.symbol_registration(&s.name);
|
||||
(s, reg)
|
||||
})
|
||||
.collect();
|
||||
Some(Symbols { symbols, images, swatches })
|
||||
}
|
||||
|
||||
/// [`artboards`] as far as reading `data` from its start gets: those read before an error, and
|
||||
/// those of a `/Document` still open when it stopped (its art can be what stopped it).
|
||||
fn artboards_read(data: &[u8]) -> (Vec<(String, Rect)>, Option<usize>) {
|
||||
|
||||
@@ -1182,6 +1182,13 @@ pub(crate) struct StyleRead {
|
||||
pub simple: bool,
|
||||
/// It has Effect › Pathfinder effects, which only a group with no fills or strokes is read with.
|
||||
pub pathfinder: bool,
|
||||
/// The opacity, blending mode, isolation and knockout its blend filter gives.
|
||||
pub transparency: Option<(f32, BlendMode, bool, Knockout)>,
|
||||
}
|
||||
|
||||
/// The names a style list section gives graphic styles, as the reader reads them.
|
||||
pub(crate) fn read_style_list(section: &[u8]) -> Vec<String> {
|
||||
crate::import::ai::style::art_style_list(section).iter().map(|raw| obj::text(raw)).filter(|name| !name.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Art style `name` of `section` as the reader reads it ([`StyleRead`]): [`appearance_from_art_style`],
|
||||
@@ -1200,7 +1207,14 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P
|
||||
}
|
||||
}
|
||||
let appearance = appearance_from_art_style(&st)?;
|
||||
Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder })
|
||||
Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder, transparency: st.transparency })
|
||||
}
|
||||
|
||||
/// Is art style `name` of `section` read on `object` as far as an effect that moves the outline
|
||||
/// goes: the style has none, or `object` is one [`moves_with`] reads it on, as [`styled`] checks?
|
||||
/// `false` when the section doesn't define it.
|
||||
pub(crate) fn style_moves_with(section: &[u8], name: &str, object: &Node) -> bool {
|
||||
crate::import::ai::style::art_styles(section).remove(name).is_some_and(|st| !st.moves_outline() || moves_with(object, &st))
|
||||
}
|
||||
|
||||
/// A style's stroke as an appearance item (`None` for a fill).
|
||||
|
||||
@@ -122,6 +122,12 @@ pub(super) fn warp_style(n: i64) -> Option<&'static str> {
|
||||
id.strip_prefix("warp.")
|
||||
}
|
||||
|
||||
/// The number `DeformStyle` gives warp style `style` (`arc`, `arcLower`, …), from 1.
|
||||
pub(crate) fn warp_number(style: &str) -> Option<i64> {
|
||||
let at = WARPS.iter().position(|id| id.strip_prefix("warp.") == Some(style))?;
|
||||
i64::try_from(at + 1).ok()
|
||||
}
|
||||
|
||||
/// The Effect › Pathfinder effects by the `Command` number of their filter. `Command` 0 is Add
|
||||
/// (https://community.adobe.com/questions-652/liveeffect-xml-effect-is-added-under-the-object-795137);
|
||||
/// the numbers 1 to 11 are VectorCraft's own reader rules, learned from saved files. Trap (12) isn't
|
||||
@@ -326,9 +332,9 @@ impl Default for Filter {
|
||||
const MAX_FILTERS: usize = 4096;
|
||||
|
||||
/// Most entries read from a style list.
|
||||
const MAX_LISTED: usize = 4096;
|
||||
pub(crate) const MAX_LISTED: usize = 4096;
|
||||
/// The longest style name read from a style list, in bytes.
|
||||
const MAX_LISTED_NAME: usize = 1024;
|
||||
pub(crate) const MAX_LISTED_NAME: usize = 1024;
|
||||
|
||||
/// The style list (`%AI9_BeginArtStyleList`, between its markers): one literal string per style,
|
||||
/// the Graphic Styles panel's order. Each name is its string's bytes, escapes undone; other tokens,
|
||||
|
||||
@@ -1366,6 +1366,15 @@ fn convert_to_shape_settings_that_arent_read_keep_the_drawn_look() {
|
||||
assert!(!art_styles(data.as_bytes())["Odd"].plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_numbers_are_the_dialogs_order() {
|
||||
for n in 1..=15 {
|
||||
let style = warp_style(n).unwrap();
|
||||
assert_eq!(warp_number(style), Some(n), "{style}");
|
||||
}
|
||||
assert_eq!((warp_style(0), warp_style(16), warp_number("arcs"), warp_number("")), (None, None, None, None));
|
||||
}
|
||||
|
||||
/// A style as a writer checks it: the reader's appearance, with a gradient fill as the object's own,
|
||||
/// and nothing for styles it doesn't read.
|
||||
#[test]
|
||||
@@ -1379,6 +1388,7 @@ fn a_style_is_read_as_a_writer_checks_it() {
|
||||
let got = read(&data, Some(&own)).unwrap();
|
||||
assert_eq!(got.appearance.items.len(), 1);
|
||||
assert_eq!(got.appearance.items[0].paint(), &own);
|
||||
assert_eq!(got.transparency.map(|t| (t.0, t.1)), Some((0.5, BlendMode::Multiply)));
|
||||
assert!(got.simple && !got.pathfinder);
|
||||
// A gradient that isn't the object's own, a pattern, another name, two gradients: nothing.
|
||||
assert!(read(&data, None).is_none());
|
||||
|
||||
@@ -5974,6 +5974,37 @@ fn an_object_in_a_brushs_art_with_a_style_this_reads_comes_in_as_itself() {
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// The art of an opacity mask in a brush's art takes ids of the document, as the rest of the art
|
||||
/// does: no object of the brush's art shares an id with an object of the layers.
|
||||
#[test]
|
||||
fn a_brushs_opacity_mask_art_takes_ids_of_the_document() {
|
||||
fn ids(n: &Node, out: &mut Vec<vectorcraft_doc::NodeId>) {
|
||||
out.push(n.id);
|
||||
if let Some(m) = n.extras().mask.as_ref() {
|
||||
ids(&m.art, out);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
ids(c, out);
|
||||
}
|
||||
}
|
||||
let square = rect_path(0.0, 0.0, 10.0, 10.0);
|
||||
let mask_art: String = format!("1 g\n{square}f\n").lines().map(|l| format!("%_{l}\n")).collect();
|
||||
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n");
|
||||
let section = format!("%AI8_BeginBrushPattern\n(Dot art)\n0 A\n{RED}{square}f\n{mask}%AI8_EndBrushPattern\n");
|
||||
let shapes: String = (0..12).map(|i| format!("{RED}{}f\n", rect_path(f64::from(i) * 20.0, 0.0, f64::from(i) * 20.0 + 10.0, 10.0))).collect();
|
||||
let r = read_ok(&format!("{section}{}{}", plugin_object(SCATTER, "Spray", SPRAY), layer(&shapes)));
|
||||
let lib = vectorcraft_brush::library(&r.doc);
|
||||
let spray = lib.iter().find(|b| b.name == "Spray").unwrap_or_else(|| panic!("{:?}", r.warnings));
|
||||
let art = spray.art();
|
||||
assert!(art.iter().any(|n| n.extras().mask.is_some()), "the mask is read: {art:#?}");
|
||||
let mut all = Vec::new();
|
||||
for n in r.doc.layers.iter().map(|l| &**l).chain(art) {
|
||||
ids(n, &mut all);
|
||||
}
|
||||
let unique: std::collections::HashSet<_> = all.iter().copied().collect();
|
||||
assert_eq!(unique.len(), all.len(), "{all:?}");
|
||||
}
|
||||
|
||||
/// An art styles section inside a symbol's, a pattern's or a brush's art is skipped: the file's own
|
||||
/// art styles still style the layers' objects read after the definition.
|
||||
#[test]
|
||||
@@ -6184,6 +6215,50 @@ fn an_object_in_a_symbols_art_with_a_listed_style_is_linked() {
|
||||
assert_eq!(art.extras().graphic_style, Some(1), "{art:#?}");
|
||||
}
|
||||
|
||||
/// Editing data with symbols and no layers (a symbol library) gives its symbols: in the symbol
|
||||
/// list's order with the unlisted one after them, with their registration points and the images
|
||||
/// their art shows, without the one holding an instance of a symbol the data doesn't define, and
|
||||
/// with the object linked to a listed graphic style linked to it by its id.
|
||||
#[test]
|
||||
fn a_symbol_library_without_layers_gives_its_symbols() {
|
||||
let styles = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]);
|
||||
// The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242).
|
||||
let nested = instance("Dot", [1.0, 0.0, 0.0, -1.0, -8081.0, 8237.0]);
|
||||
let lost = instance("Ghost", [1.0, 0.0, 0.0, -1.0, -8081.0, 8237.0]);
|
||||
let m = "[ 5 0 0 5 -8096 8244.5 ]";
|
||||
let raster = format!(
|
||||
"%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n{m} 2 1 0 Xh\n{m} 0 0 2 1 2 1 8 3 0 0 1 0 4 4 0 0\n\
|
||||
%%BeginData: 6\rXI\nABCDEF%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\n"
|
||||
);
|
||||
let defs = symbols(
|
||||
&(symbol("Dot", &dot(), 1)
|
||||
+ &symbol("Pic", &raster, 5)
|
||||
+ &symbol("Pair", &nested, 5)
|
||||
+ &symbol("Tile", &styled_square("Blue"), 5)
|
||||
+ &symbol("Lost", &lost, 5)),
|
||||
);
|
||||
let list = "%AI24_BeginSymbolList\n(Tile)\n(Pair)\n(Lost)\n(Dot)\n%AI24_EndSymbolList\n";
|
||||
let body = format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{styles}{defs}{list}");
|
||||
let read = super::symbols(stream(&body).as_bytes()).expect("the library's symbols");
|
||||
let names: Vec<&str> = read.symbols.iter().map(|(s, _)| s.name.as_str()).collect();
|
||||
assert_eq!(names, ["Tile", "Pair", "Dot", "Pic"]);
|
||||
let registrations: Vec<u8> = read.symbols.iter().map(|(_, r)| r.0).collect();
|
||||
let center = vectorcraft_doc::SymbolRegistration::CENTER.0;
|
||||
assert_eq!(registrations, [center, center, 0, center]);
|
||||
let Some(((tile, _), (pair, _))) = read.symbols.first().zip(read.symbols.get(1)) else { panic!("two symbols") };
|
||||
assert_eq!(tile.art.extras().graphic_style, Some(1), "{:#?}", tile.art);
|
||||
// Dot sits at (10, 5) in Pair's art.
|
||||
let mut doc = Document::new(1.0, 1.0);
|
||||
doc.symbols = read.symbols.iter().map(|(s, _)| s.clone()).collect();
|
||||
assert_eq!(pair.art.visual_bounds_with(&doc.symbol_boxes()), Some(Rect::new(8.0, 3.0, 12.0, 7.0)));
|
||||
let keys: Vec<String> =
|
||||
doc.symbols.iter().filter_map(|s| if let NodeKind::Image(im) = &s.art.kind { Some(im.key.clone()) } else { None }).collect();
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert_eq!(read.images.keys().cloned().collect::<Vec<_>>(), keys);
|
||||
// Without symbols there are none to give.
|
||||
assert!(super::symbols(stream(&styles).as_bytes()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_in_a_patterns_art_with_a_listed_style_is_linked() {
|
||||
let defs = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]);
|
||||
@@ -6196,6 +6271,35 @@ fn an_object_in_a_patterns_art_with_a_listed_style_is_linked() {
|
||||
assert_eq!(square.extras().graphic_style, Some(1), "{square:#?}");
|
||||
}
|
||||
|
||||
/// The art of an opacity mask in a pattern's art takes ids of the document, as the rest of the art
|
||||
/// does: no object of the pattern's art shares an id with an object of the layers.
|
||||
#[test]
|
||||
fn a_patterns_opacity_mask_art_takes_ids_of_the_document() {
|
||||
fn ids(n: &Node, out: &mut Vec<vectorcraft_doc::NodeId>) {
|
||||
out.push(n.id);
|
||||
if let Some(m) = n.extras().mask.as_ref() {
|
||||
ids(&m.art, out);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
ids(c, out);
|
||||
}
|
||||
}
|
||||
let square = rect_path(0.0, 0.0, 10.0, 10.0);
|
||||
let mask_art: String = format!("1 g\n{square}f\n").lines().map(|l| format!("%_{l}\n")).collect();
|
||||
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n");
|
||||
let pattern = format!("%AI3_BeginPattern: (P)\n(P) 0 0 10 10\n0 A\n0 Xw\n{RED}{square}f\n{mask}E\n%AI3_EndPattern\n");
|
||||
let shapes: String = (0..12).map(|i| format!("{RED}{}f\n", rect_path(f64::from(i) * 20.0, 0.0, f64::from(i) * 20.0 + 10.0, 10.0))).collect();
|
||||
let r = read_ok(&format!("{pattern}{}", layer(&shapes)));
|
||||
let def = r.doc.pattern("P").unwrap_or_else(|| panic!("{:?}", r.warnings));
|
||||
assert!(def.art.iter().any(|n| n.extras().mask.is_some()), "the mask is read: {:#?}", def.art);
|
||||
let mut all = Vec::new();
|
||||
for n in r.doc.layers.iter().chain(&def.art) {
|
||||
ids(n, &mut all);
|
||||
}
|
||||
let unique: std::collections::HashSet<_> = all.iter().copied().collect();
|
||||
assert_eq!(unique.len(), all.len(), "{all:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_in_a_version_7_patterns_tile_with_a_listed_style_is_linked() {
|
||||
let defs = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]);
|
||||
|
||||
@@ -21,9 +21,11 @@ mod ops;
|
||||
mod paint;
|
||||
mod path;
|
||||
mod state;
|
||||
mod strips;
|
||||
|
||||
pub(crate) use paint::collapse;
|
||||
pub(crate) use state::{GState, Out, Space, process};
|
||||
pub(crate) use strips::merge_strips;
|
||||
|
||||
/// Most objects one file makes.
|
||||
const MAX_NODES: usize = 1 << 20;
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Gradients a program paints as a row of abutting strips, each in one colour (the way many
|
||||
//! PostScript writers draw a smooth shading at Level 2, which has no `shfill`), read as one
|
||||
//! linear gradient.
|
||||
//!
|
||||
//! Drawn one by one, abutting strips leave seams: a renderer that anti-aliases each fill lets the
|
||||
//! art below show through along every shared edge (PDF 32000-1:2008 §10.6.4 notes the effect,
|
||||
//! "conflation", for adjacent areas painted separately), which reads as thin stripes across the
|
||||
//! gradient, and as a tint of the colour below over the whole of it where the strips are only a
|
||||
//! pixel or two wide. A run of them under the same clips becomes one object filled with a linear
|
||||
//! gradient through the strips' colours, which has no seams and stays editable.
|
||||
//!
|
||||
//! Only strips that clearly are a gradient merge: at least [`MIN_STRIPS`] parallelograms of one
|
||||
//! length, side by side in one direction with no gap or overlap, each a small step in colour from
|
||||
//! the one before ([`MAX_STEP`]). Abutting blocks of distinct colours (a bar chart, a row of
|
||||
//! swatches) stay as they are.
|
||||
|
||||
use super::{
|
||||
Affine, Appearance, AppearanceItem, BezPath, Color, Drawn, FillLayer, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, HashSet,
|
||||
MAX_STITCH_STOPS, Node, NodeId, NodeKind, Paint, PathData, PathEl, Point, Vec2,
|
||||
};
|
||||
|
||||
/// Fewest strips (that paint something) a gradient is read from.
|
||||
const MIN_STRIPS: usize = 6;
|
||||
/// Largest change of any colour component (0 to 1) from one strip to the next.
|
||||
const MAX_STEP: f32 = 0.08;
|
||||
/// How far apart corners may be and still meet (points).
|
||||
const EPS: f64 = 0.01;
|
||||
/// Largest change of any colour component a stop left out may make to the gradient.
|
||||
const TOLERANCE: f32 = 0.5 / 255.0;
|
||||
|
||||
/// A filled parallelogram in one process colour.
|
||||
struct Strip {
|
||||
corners: [Point; 4],
|
||||
model: u8,
|
||||
comps: [f32; 4],
|
||||
color: Color,
|
||||
overprint: bool,
|
||||
}
|
||||
|
||||
/// The parallelogram `n` fills, in one process colour: `None` for anything else.
|
||||
fn strip_of(n: &Node) -> Option<Strip> {
|
||||
let NodeKind::Path { path, live: None, clipping: false, guide: false, .. } = &n.kind else { return None };
|
||||
if n.appearance.items.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() else { return None };
|
||||
if f.opacity != 1.0 || !f.visible || !f.effects.is_empty() || f.blend != vectorcraft_color::BlendMode::Normal {
|
||||
return None;
|
||||
}
|
||||
let Paint::Solid { color, swatch: None, .. } = &f.paint else { return None };
|
||||
let (model, comps) = match *color {
|
||||
Color::Rgb { r, g, b } => (0, [r, g, b, 0.0]),
|
||||
Color::Cmyk { c, m, y, k } => (1, [c, m, y, k]),
|
||||
Color::Gray { k } => (2, [k, 0.0, 0.0, 0.0]),
|
||||
Color::Lab { .. } => return None,
|
||||
};
|
||||
let [sp] = path.subpaths.as_slice() else { return None };
|
||||
let mut pts: Vec<Point> = Vec::with_capacity(5);
|
||||
for a in &sp.anchors {
|
||||
if a.h_in != a.p || a.h_out != a.p {
|
||||
return None;
|
||||
}
|
||||
pts.push(a.p);
|
||||
}
|
||||
if pts.len() == 5 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.distance(*b) <= EPS) {
|
||||
pts.pop();
|
||||
}
|
||||
let [a, b, c, d] = pts.as_slice() else { return None };
|
||||
// Opposite corners share a midpoint.
|
||||
if (a.to_vec2() + c.to_vec2() - b.to_vec2() - d.to_vec2()).hypot() > EPS {
|
||||
return None;
|
||||
}
|
||||
Some(Strip { corners: [*a, *b, *c, *d], model, comps, color: *color, overprint: f.overprint })
|
||||
}
|
||||
|
||||
/// The edge two strips share, as it runs along the first: `(k, from, to)`, from its corner `k`.
|
||||
fn shared_edge(a: &Strip, b: &Strip) -> Option<(usize, Point, Point)> {
|
||||
let near = |p: Point, q: Point| p.distance(q) <= EPS;
|
||||
for k in 0..4 {
|
||||
let (p, q) = (a.corners[k], a.corners[(k + 1) % 4]);
|
||||
if p.distance(q) <= EPS {
|
||||
continue;
|
||||
}
|
||||
for j in 0..4 {
|
||||
let (r, s) = (b.corners[j], b.corners[(j + 1) % 4]);
|
||||
if (near(p, r) && near(q, s)) || (near(p, s) && near(q, r)) {
|
||||
return Some((k, p, q));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Coordinates along a run of strips: a point is `o + s·w + t·v`, `v` along the strips (their
|
||||
/// shared edges run from `t` = 0 to 1), `w` across them.
|
||||
struct Axes {
|
||||
o: Point,
|
||||
w: Vec2,
|
||||
v: Vec2,
|
||||
inv: Affine,
|
||||
}
|
||||
|
||||
impl Axes {
|
||||
fn of(a: &Strip, b: &Strip) -> Option<Self> {
|
||||
let (k, from, to) = shared_edge(a, b)?;
|
||||
let w = a.corners[(k + 2) % 4] - a.corners[(k + 1) % 4];
|
||||
let v = to - from;
|
||||
let m = Affine::new([w.x, w.y, v.x, v.y, from.x, from.y]);
|
||||
if w.hypot() <= EPS || m.determinant().abs() < 1e-9 * w.hypot() * v.hypot() {
|
||||
return None;
|
||||
}
|
||||
Some(Self { o: from, w, v, inv: m.inverse() })
|
||||
}
|
||||
|
||||
fn at(&self, s: f64, t: f64) -> Point {
|
||||
self.o + self.w * s + self.v * t
|
||||
}
|
||||
|
||||
/// Does `p` lie within the run's length, and across it from `lo` to `hi`?
|
||||
fn holds(&self, p: Point, lo: f64, hi: f64) -> bool {
|
||||
let (ts, ss) = (EPS / self.v.hypot(), EPS / self.w.hypot());
|
||||
let l = self.inv * p;
|
||||
l.x >= lo - ss && l.x <= hi + ss && l.y >= -ts && l.y <= 1.0 + ts
|
||||
}
|
||||
|
||||
/// The strip's extent across the run (`s` from, to), when it spans the run's length exactly.
|
||||
fn band(&self, st: &Strip) -> Option<(f64, f64)> {
|
||||
let (ts, ss) = (EPS / self.v.hypot(), EPS / self.w.hypot());
|
||||
let local: Vec<Point> = st.corners.iter().map(|p| self.inv * *p).collect();
|
||||
let lo = local.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
|
||||
let hi = local.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max);
|
||||
let fits = local
|
||||
.iter()
|
||||
.all(|p| ((p.y).abs() <= ts || (p.y - 1.0).abs() <= ts) && ((p.x - lo).abs() <= ss || (p.x - hi).abs() <= ss) && p.x.is_finite());
|
||||
// Each end of the strip touches both edges of the run.
|
||||
let ends = [0.0, 1.0].iter().all(|t| local.iter().filter(|p| (p.y - t).abs() <= ts).count() == 2);
|
||||
(fits && ends).then_some((lo, hi))
|
||||
}
|
||||
}
|
||||
|
||||
/// A run of strips found at one place in what was drawn: where it ends (exclusive), the object
|
||||
/// it becomes, and whether that object covers the clip around it (so the clip path filled with
|
||||
/// the gradient paints no more than the strips did).
|
||||
struct Run {
|
||||
start: usize,
|
||||
end: usize,
|
||||
node: Node,
|
||||
covers_clip: bool,
|
||||
}
|
||||
|
||||
/// Is `s` (from, to) too thin to paint anything?
|
||||
fn thin(b: (f64, f64), axes: &Axes) -> bool {
|
||||
(b.1 - b.0) * axes.w.hypot() <= EPS
|
||||
}
|
||||
|
||||
/// The run of strips starting at `i`, if one does.
|
||||
fn run_at(drawn: &Drawn, strips: &[Option<Strip>], i: usize) -> Option<Run> {
|
||||
let chain = |k: usize| drawn.get(k).map(|(c, _)| c.iter().map(|c| c.id).collect::<Vec<_>>());
|
||||
let here = chain(i)?;
|
||||
let strip = |k: usize| strips.get(k).and_then(Option::as_ref).filter(|_| chain(k).as_ref() == Some(&here));
|
||||
let first = strip(i)?;
|
||||
// The run's axes come from its first two strips that paint something; a line before them
|
||||
// (a strip of no width, as some writers draw first) joins the run where it lies on its edge.
|
||||
let (lead, a, b) = match (strip(i + 1), strip(i + 2)) {
|
||||
(Some(b), _) if Axes::of(first, b).is_some_and(|ax| ax.band(first).is_some_and(|f| !thin(f, &ax))) => (None, first, b),
|
||||
(Some(a), Some(b)) => (Some(first), a, b),
|
||||
_ => return None,
|
||||
};
|
||||
let axes = Axes::of(a, b)?;
|
||||
let (ba, bb) = (axes.band(a)?, axes.band(b)?);
|
||||
if thin(ba, &axes) || thin(bb, &axes) {
|
||||
return None;
|
||||
}
|
||||
let ss = EPS / axes.w.hypot();
|
||||
let forward = if (bb.0 - ba.1).abs() <= ss {
|
||||
true
|
||||
} else if (bb.1 - ba.0).abs() <= ss {
|
||||
false
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let mut edge = if forward { ba.0 } else { ba.1 };
|
||||
if let Some(l) = lead {
|
||||
let lb = axes.band(l)?;
|
||||
if !thin(lb, &axes) || (lb.0 - edge).abs() > ss {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let at = i + usize::from(lead.is_some());
|
||||
let mut bands: Vec<(f64, f64, &Strip)> = vec![];
|
||||
let mut end = at;
|
||||
while bands.len() < MAX_STITCH_STOPS {
|
||||
let Some(st) = strip(end) else { break };
|
||||
let Some(band) = axes.band(st) else { break };
|
||||
let meets = if forward { (band.0 - edge).abs() <= ss } else { (band.1 - edge).abs() <= ss };
|
||||
if !meets || st.overprint != a.overprint {
|
||||
break;
|
||||
}
|
||||
if !thin(band, &axes) {
|
||||
if let Some((_, _, prev)) = bands.last()
|
||||
&& (prev.model != st.model || prev.comps.iter().zip(&st.comps).any(|(x, y)| (x - y).abs() > MAX_STEP))
|
||||
{
|
||||
break;
|
||||
}
|
||||
bands.push((band.0, band.1, st));
|
||||
}
|
||||
edge = if forward { band.1 } else { band.0 };
|
||||
end += 1;
|
||||
}
|
||||
if bands.len() < MIN_STRIPS {
|
||||
return None;
|
||||
}
|
||||
let lo = bands.iter().map(|b| b.0).fold(f64::INFINITY, f64::min);
|
||||
let hi = bands.iter().map(|b| b.1).fold(f64::NEG_INFINITY, f64::max);
|
||||
if !(hi - lo).is_finite() || hi - lo <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
bands.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||||
let stops: Vec<(f32, &Strip)> = bands.iter().map(|(s0, s1, st)| ((((s0 + s1) / 2.0 - lo) / (hi - lo)) as f32, *st)).collect();
|
||||
let stops: Vec<GradientStop> = simplify(&stops).into_iter().map(|(t, st)| GradientStop::new(t, st.color)).collect();
|
||||
// The gradient runs across the strips, square to them, from the run's edge at `lo`.
|
||||
let len = axes.v.hypot();
|
||||
let mut n = Vec2::new(-axes.v.y / len, axes.v.x / len);
|
||||
let across = axes.w.dot(n) * (hi - lo);
|
||||
if across < 0.0 {
|
||||
n = -n;
|
||||
}
|
||||
let start = axes.at(lo, 0.0);
|
||||
let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
g.geom = Some(GradientGeom { start, end: start + n * across.abs(), aspect: 1.0, focal: None });
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(axes.at(lo, 0.0));
|
||||
bp.line_to(axes.at(hi, 0.0));
|
||||
bp.line_to(axes.at(hi, 1.0));
|
||||
bp.line_to(axes.at(lo, 1.0));
|
||||
bp.close_path();
|
||||
let item = AppearanceItem::Fill(FillLayer { overprint: a.overprint, ..FillLayer::new(Paint::Gradient(Box::new(g))) });
|
||||
let (clips, first_node) = drawn.get(i)?;
|
||||
// Every point of a clip's path, control points included, inside the run: its curves are too
|
||||
// (each lies in the hull of its points), and the run's area is convex.
|
||||
let covers_clip = clips.last().and_then(|c| c.region.as_ref()).is_some_and(|(clip, _)| {
|
||||
clip.elements().iter().all(|el| match *el {
|
||||
PathEl::MoveTo(p) | PathEl::LineTo(p) => axes.holds(p, lo, hi),
|
||||
PathEl::QuadTo(a, p) => axes.holds(a, lo, hi) && axes.holds(p, lo, hi),
|
||||
PathEl::CurveTo(a, b, p) => axes.holds(a, lo, hi) && axes.holds(b, lo, hi) && axes.holds(p, lo, hi),
|
||||
PathEl::ClosePath => true,
|
||||
})
|
||||
});
|
||||
let node = Node::path(first_node.id, PathData::from_bezpath(&bp), Appearance { items: vec![item], ..Appearance::default() });
|
||||
Some(Run { start: i, end, node, covers_clip })
|
||||
}
|
||||
|
||||
/// The stops `stops` (offset, strip) needs: those the gradient between the stops kept on either
|
||||
/// side wouldn't come within [`TOLERANCE`] of are kept.
|
||||
fn simplify<'a>(stops: &[(f32, &'a Strip)]) -> Vec<(f32, &'a Strip)> {
|
||||
let mut kept: Vec<(f32, &Strip)> = vec![];
|
||||
let Some(&first) = stops.first() else { return kept };
|
||||
kept.push(first);
|
||||
let mut from = 0;
|
||||
while from + 1 < stops.len() {
|
||||
// The furthest stop reachable from `from` with every stop between on the line.
|
||||
let mut to = from + 1;
|
||||
while to + 1 < stops.len() && on_line(stops, from, to + 1) {
|
||||
to += 1;
|
||||
}
|
||||
if let Some(&s) = stops.get(to) {
|
||||
kept.push(s);
|
||||
}
|
||||
from = to;
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// Do the stops between `a` and `b` lie on the blend between those two?
|
||||
fn on_line(stops: &[(f32, &Strip)], a: usize, b: usize) -> bool {
|
||||
let (Some((ta, sa)), Some((tb, sb))) = (stops.get(a), stops.get(b)) else { return false };
|
||||
let span = tb - ta;
|
||||
stops.get(a + 1..b).unwrap_or_default().iter().all(|(t, s)| {
|
||||
let f = if span > 0.0 { (t - ta) / span } else { 0.0 };
|
||||
sa.comps.iter().zip(&sb.comps).zip(&s.comps).all(|((x, y), z)| (x + (y - x) * f - z).abs() <= TOLERANCE)
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs of strips in `drawn` become one gradient-filled object each; one that covers its clip is
|
||||
/// marked in `shadings`, so a clipping group of nothing else is its clip path filled with the
|
||||
/// gradient (a clip larger than the strips keeps clipping the gradient to them).
|
||||
pub(crate) fn merge_strips(drawn: &mut Drawn, shadings: &mut HashSet<NodeId>) {
|
||||
let strips: Vec<Option<Strip>> = drawn.iter().map(|(_, n)| strip_of(n)).collect();
|
||||
if strips.iter().filter(|s| s.is_some()).count() < MIN_STRIPS {
|
||||
return;
|
||||
}
|
||||
let mut runs: Vec<Run> = vec![];
|
||||
let mut i = 0;
|
||||
while i < drawn.len() {
|
||||
match run_at(drawn, &strips, i) {
|
||||
Some(r) => {
|
||||
i = r.end.max(i + 1);
|
||||
runs.push(r);
|
||||
}
|
||||
None => i += 1,
|
||||
}
|
||||
}
|
||||
if runs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let old = std::mem::take(drawn);
|
||||
let mut runs = runs.into_iter().peekable();
|
||||
let mut skip_to = 0;
|
||||
for (k, item) in old.into_iter().enumerate() {
|
||||
if k < skip_to {
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = runs.next_if(|r| r.start == k) {
|
||||
skip_to = r.end;
|
||||
if r.covers_clip {
|
||||
shadings.insert(r.node.id);
|
||||
}
|
||||
drawn.push((item.0, r.node));
|
||||
continue;
|
||||
}
|
||||
drawn.push(item);
|
||||
}
|
||||
}
|
||||
@@ -1192,7 +1192,7 @@ fn keep_differing(
|
||||
}
|
||||
|
||||
/// Copy page drawing IDs, including mask art outside the ordinary child tree.
|
||||
fn reid_page_art(doc: &mut Document, source: &Node) -> Node {
|
||||
pub(super) fn reid_page_art(doc: &mut Document, source: &Node) -> Node {
|
||||
let mut copy = doc.reid(source);
|
||||
if let Some(mask) = &mut copy.extras_mut().mask {
|
||||
mask.art = Arc::new(reid_page_art(doc, &mask.art));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user